From d2dc9b59e4ff29dcec57c5942abc6c543df043d3 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 13:38:07 +0300 Subject: [PATCH 01/75] =?UTF-8?q?docs(design):=20tray=20glance=20=E2=80=94?= =?UTF-8?q?=20activity,=20clients,=2024h=20histogram=20in=20the=20macOS=20?= =?UTF-8?q?menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for a compact glance section at the top of the macOS tray menu: five most recent tool calls, active MCP clients, and a calls-per-hour histogram for the last 24h behind a submenu. Key decisions and their reasons: - NSMenu with SwiftUI views in NSMenuItem.view (CodexBar's approach), not a popover — a popover penalises the common quick-status case. - Histogram plots calls per hour, not tokens: no hourly token series exists (usage buckets carry calls/errors/resp_bytes; token_source is "bytes"), and real tokenisation only accumulates per session. - Fixes two feed bugs in scope: CoreProcessManager matches a literal 'activity' SSE event the core never emits (activity is 30s-polled today), and APIClient decodes last_active where the API emits last_activity. - Glance items and their hosting views are cached and re-inserted on rebuild, since rebuildMenu() calls removeAllItems() without dismissing the menu; extracted into Menu/Glance/ rather than growing the 1120-line MCPProxyApp.swift. Menu open stays network-free (spec 048 invariant); the histogram fetch happens on submenu open only. --- .../specs/2026-07-29-tray-glance-design.md | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-29-tray-glance-design.md 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 00000000..6c151a91 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-tray-glance-design.md @@ -0,0 +1,122 @@ +# Tray Glance — compact activity, clients, and 24h histogram in the macOS menu + +**Status:** Design (2026-07-29) +**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 sections 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 at a glance, 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 (on demand) + +Design constraints, in priority order: never irritate (the menu stays a menu — fast, keyboard-navigable, native), 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 | `NSMenu` with SwiftUI views hosted in `NSMenuItem.view` | CodexBar's approach (verified: 31 files use `NSMenuItem`, zero `NSPopover`/`MenuBarExtra`; charts via SwiftUI Charts). A popover on every icon click penalises the common case — quick status check or profile switch. The tray already uses `NSHostingView` (`MCPProxyApp.swift:231,277`), so this is not a new pattern. | +| What is visible on open | Activity + clients; histogram in a submenu | The histogram needs a separate `/activity/usage` fetch. Putting it on the open path would undo spec 048's zero-network menu open. | +| Histogram metric | Calls per hour (errors highlighted) | Hourly token series does not exist. `UsageTimeBucket` carries `calls`, `errors`, `total_resp_bytes`; the endpoint reports `token_source: "bytes"` — a size proxy. Real tokenisation (`cl100k_base`) exists but only accumulates 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 | Fix the SSE feed as part of this work | `CoreProcessManager.swift:683` matches `case "activity"`, which the core never emits (real names: `activity.tool_call.completed`, `activity.internal_tool_call.completed`, …), so activity is 30s-polled today. A section promising "what's happening now" on a 30-second delay is a section users stop trusting. Spec 048 already set a ≤50 ms SSE-reactivity target that activity silently falls outside of. | +| Integration | Cached views + extract into a component | `rebuildMenu()` (`MCPProxyApp.swift:523`, ~300 lines) calls `removeAllItems()` on every state change without dismissing the menu. Plain text items tolerate this; a hosted SwiftUI view would be destroyed and rebuilt under the user's cursor — flicker and lost hover state, and more often once SSE makes updates frequent. | + +## Architecture + +New component under `native/macos/MCPProxy/MCPProxy/Menu/Glance/`: + +- **`GlanceSection.swift`** — the only thing `MCPProxyApp` talks to. Interface: `func items(for state: AppState) -> [NSMenuItem]`. Constructs its `NSMenuItem`s and `NSHostingView`s once, stores them, and returns the same instances on every rebuild; SwiftUI re-renders them from `@Published` state. This is what makes `removeAllItems()` harmless for hosted views. +- **`GlanceView.swift`** — SwiftUI layout for the two sections: five activity rows, then the client list. +- **`ActivityHistogramView.swift`** — SwiftUI Charts bar chart, calls per hour over 24h, errors as a distinct segment. Built only when its submenu opens. +- **`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 deleted afterwards. + +Changes to existing files are deliberately small: + +- `MCPProxyApp.swift` — `rebuildMenu()` inserts `glance.items(for: appState)` after the status header and before the "Needs Attention" block, plus one submenu item for the histogram. Roughly five lines; no new layout code in an already 1120-line file. +- `CoreProcessManager.swift:683` — match the `activity.` event-name prefix instead of the literal `activity`. +- `APIClient.swift:341,353` — decode `last_activity` (the field the API emits) instead of `last_active`. + +Data already exists in `AppState`: `recentActivity: [ActivityEntry]`, `recentSessions: [MCPSession]`, `tokenMetrics`, `activityVersion`. `ActivityEntry` already decodes `type`, `serverName`, `toolName`, `status`, `errorMessage`, `durationMs`, `timestamp`, `hasSensitiveData`. This work is rendering plus two feed fixes, not new plumbing. + +## Layout + +``` +MCPProxy v0.52.0 ● +12 calls in the last 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 + colour), `server:tool` for upstream calls or the built-in's name for discovery/execution calls, and relative age right-aligned. A failed row appends the first clause of `errorMessage`, truncated to fit one line; the untruncated message is in the tooltip. + +UI strings are English, matching the rest of the menu. Rows follow the macOS design guide already in the repo (`docs/superpowers/specs/macos-design-guide.md`): semantic colours, SF Symbols, no colour-only status encoding — the status icon carries shape as well as colour. + +## Data flow + +**Menu open performs zero network requests.** Everything renders from `AppState`. + +**Activity** arrives by SSE push once the `activity.` prefix match is fixed; the existing 30-second poll stays as a safety net for missed events. The poll narrows to `GET /api/v1/activity?type=tool_call,internal_tool_call&limit=10`. Note the API already suppresses successful `call_tool_read/write/destructive` wrapper records by default (`ExcludeCallToolSuccess`) so they do not double-count the paired upstream record — the row shows `github:create_issue`, not its wrapper. + +The `internal_tool_call` type also covers management built-ins (`upstream_servers`, `quarantine_security`) that the user considers internal noise, and the API cannot filter by tool-name set. The component therefore keeps a three-entry display allowlist (`retrieve_tools`, `code_execution`, `describe_tool`) and takes the first five matching records out of the ten fetched; if fewer than five match, it shows what matched rather than padding the list. This is presentation policy, not tray-held state, so it stays within the "tray holds no state" rule (CLAUDE.md). + +**The header summary line** ("12 calls in the last hour") comes from `GET /api/v1/activity/summary?period=1h` — a single pre-aggregated response — added to the existing 30-second background refresh loop, never to the menu-open path. The client count beside it is the length of the filtered active-session list, so header and Clients section always agree. + +**Clients** come from the existing `GET /api/v1/sessions?limit=5` poll, filtered client-side to `status == "active"` (no server-side status parameter exists). Documented caveat: per spec 082, handshake-only sessions are not persisted, so an idle background agent does not appear until its first call. + +**Histogram** is fetched only when its submenu opens, via that submenu's delegate — never on the main menu open path. It refetches on every submenu open rather than caching client-side; the endpoint (`GET /api/v1/activity/usage?window=24h`) serves an in-memory snapshot behind a short TTL cache and never scans the log, so repeated opens are cheap and always current. While the first response is in flight the submenu shows a muted loading row. Its timeline buckets are global by contract (not filtered by server/tool/status), so the label is "Activity (24h)" with no implication of filtering. + +**Clicks**: an activity row opens the Web UI activity log filtered by that record's `request_id`; a client row opens it filtered by that session. URL construction only — no backend work. + +## States + +| Situation | Behaviour | +|---|---| +| Core stopped or disconnected | The whole glance block is hidden. Stale numbers presented as current are worse than nothing; the menu already shows the error and a Retry item. | +| 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". | +| Histogram fetch fails | Muted error row inside the submenu; the rest of the menu is unaffected. | +| 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: + +- **Regression, SSE naming**: `activity.tool_call.completed` reaches the activity handler (fails today). +- **Regression, session decoding**: `last_activity` decodes into the model (nil today). +- Formatting: relative time, row label composition, middle truncation. +- Display allowlist: management built-ins excluded, the three discovery/execution built-ins included, upstream `tool_call` records always included. +- Visibility rules: block hidden when the core is stopped; empty-state rows for no activity / no clients. +- **Invariant, spec 048**: a stub API client counting requests records zero calls when the main menu opens. + +Visual verification by screenshotting the menu with `mcpproxy-ui-test` per `docs/development/macos-tray.md` (requires Screen Recording permission). + +## 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. Web UI changes. 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 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. From adc18778d179fb34989b4b9b1e9ea9ca217f85cf Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 14:44:04 +0300 Subject: [PATCH 02/75] docs(design): revise tray glance design after cross-model review Codex reviewed the design against the actual code and found nine issues; all were verified against the repo before acting on them. Substantive changes: - Rows are plain NSMenuItems, not hosted SwiftUI views. Apple documents that custom menu-item views get mouse but not keyboard events, so SwiftUI rows would not be keyboard-selectable and would need hand-rolled accessibility. Only the histogram stays a hosted view, in its submenu. This also removes the view-caching scheme, which was unnecessary for plain items and would not have prevented churn anyway. - Activity selection now keeps failed call_tool_* wrapper records. The backend intentionally retains them because a failed wrapper has no corresponding upstream tool_call record; the previous built-in allowlist would have discarded the only trace of a failed call. Page size raised 10 -> 25 so noise cannot starve the five rows. - Header count now derives from /activity/usage (in-memory snapshot), not /activity/summary, which sets Limit=0 and scans every record in the window and counts all activity types, not just calls. Same fetch feeds the histogram, so the submenu needs no fetch of its own. - SSE fix consumes activity.* event payloads directly instead of calling refreshActivity(), which would have fired a REST GET per event. - Sessions page size raised to 25 before the active filter, since closed sessions can crowd out older still-active ones. - Deep links use ?session=, the query the Activity view actually reads; request_id would have required a Web UI change, an explicit non-goal. - Chart stacks calls-errors plus errors (calls already includes errors) and synthesises zero buckets for missing hours. - Added an honest scope inventory and the protocol extraction the zero-network test requires. --- .../specs/2026-07-29-tray-glance-design.md | 101 +++++++++++------- 1 file changed, 64 insertions(+), 37 deletions(-) diff --git a/docs/superpowers/specs/2026-07-29-tray-glance-design.md b/docs/superpowers/specs/2026-07-29-tray-glance-design.md index 6c151a91..f755b223 100644 --- a/docs/superpowers/specs/2026-07-29-tray-glance-design.md +++ b/docs/superpowers/specs/2026-07-29-tray-glance-design.md @@ -1,8 +1,8 @@ # Tray Glance — compact activity, clients, and 24h histogram in the macOS menu -**Status:** Design (2026-07-29) +**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 sections hosted inside a plain `NSMenu` +**Reference:** [CodexBar](https://github.com/steipete/CodexBar) — rich SwiftUI content hosted inside a plain `NSMenu` ## Problem @@ -10,40 +10,40 @@ The macOS tray menu shows what MCPProxy *is* (servers, profiles, version, update ## Goal -A compact glance section at the top of the tray menu answering three questions at a glance, without turning the menu into a dashboard: +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 (on demand) +3. How busy has it been? — calls per hour over the last 24h (in a submenu) -Design constraints, in priority order: never irritate (the menu stays a menu — fast, keyboard-navigable, native), 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). +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 | `NSMenu` with SwiftUI views hosted in `NSMenuItem.view` | CodexBar's approach (verified: 31 files use `NSMenuItem`, zero `NSPopover`/`MenuBarExtra`; charts via SwiftUI Charts). A popover on every icon click penalises the common case — quick status check or profile switch. The tray already uses `NSHostingView` (`MCPProxyApp.swift:231,277`), so this is not a new pattern. | -| What is visible on open | Activity + clients; histogram in a submenu | The histogram needs a separate `/activity/usage` fetch. Putting it on the open path would undo spec 048's zero-network menu open. | -| Histogram metric | Calls per hour (errors highlighted) | Hourly token series does not exist. `UsageTimeBucket` carries `calls`, `errors`, `total_resp_bytes`; the endpoint reports `token_source: "bytes"` — a size proxy. Real tokenisation (`cl100k_base`) exists but only accumulates 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 | Fix the SSE feed as part of this work | `CoreProcessManager.swift:683` matches `case "activity"`, which the core never emits (real names: `activity.tool_call.completed`, `activity.internal_tool_call.completed`, …), so activity is 30s-polled today. A section promising "what's happening now" on a 30-second delay is a section users stop trusting. Spec 048 already set a ≤50 ms SSE-reactivity target that activity silently falls outside of. | -| Integration | Cached views + extract into a component | `rebuildMenu()` (`MCPProxyApp.swift:523`, ~300 lines) calls `removeAllItems()` on every state change without dismissing the menu. Plain text items tolerate this; a hosted SwiftUI view would be destroyed and rebuilt under the user's cursor — flicker and lost hover state, and more often once SSE makes updates frequent. | +| 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`, `duration_ms` (`internal/runtime/event_bus.go:430`) — everything a row renders. | +| Integration | Extract a `Menu/Glance/` component; no view caching needed | 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 it would not have prevented churn anyway (items are still removed and re-inserted during menu tracking). The histogram view lives in a submenu that main-menu rebuilds do not touch. | ## Architecture New component under `native/macos/MCPProxy/MCPProxy/Menu/Glance/`: -- **`GlanceSection.swift`** — the only thing `MCPProxyApp` talks to. Interface: `func items(for state: AppState) -> [NSMenuItem]`. Constructs its `NSMenuItem`s and `NSHostingView`s once, stores them, and returns the same instances on every rebuild; SwiftUI re-renders them from `@Published` state. This is what makes `removeAllItems()` harmless for hosted views. -- **`GlanceView.swift`** — SwiftUI layout for the two sections: five activity rows, then the client list. -- **`ActivityHistogramView.swift`** — SwiftUI Charts bar chart, calls per hour over 24h, errors as a distinct segment. Built only when its submenu opens. -- **`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 deleted afterwards. +- **`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 are deliberately small: +Changes to existing files: -- `MCPProxyApp.swift` — `rebuildMenu()` inserts `glance.items(for: appState)` after the status header and before the "Needs Attention" block, plus one submenu item for the histogram. Roughly five lines; no new layout code in an already 1120-line file. -- `CoreProcessManager.swift:683` — match the `activity.` event-name prefix instead of the literal `activity`. +- `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`. - -Data already exists in `AppState`: `recentActivity: [ActivityEntry]`, `recentSessions: [MCPSession]`, `tokenMetrics`, `activityVersion`. `ActivityEntry` already decodes `type`, `serverName`, `toolName`, `status`, `errorMessage`, `durationMs`, `timestamp`, `hasSensitiveData`. This work is rendering plus two feed fixes, not new plumbing. +- `APIClient.swift` — add a usage-aggregate call and its response model; widen `recentActivity` to accept a type filter and a larger limit. +- `AppState.swift` — add `@Published var usageTimeline: [UsageBucket]` and `lastHourCalls: Int` alongside the existing `recentActivity` / `recentSessions`. ## Layout @@ -70,25 +70,46 @@ Profile: default ▸ … ``` -Row anatomy: status icon (shape + colour), `server:tool` for upstream calls or the built-in's name for discovery/execution calls, and relative age right-aligned. A failed row appends the first clause of `errorMessage`, truncated to fit one line; the untruncated message is in the tooltip. - -UI strings are English, matching the rest of the menu. Rows follow the macOS design guide already in the repo (`docs/superpowers/specs/macos-design-guide.md`): semantic colours, SF Symbols, no colour-only status encoding — the status icon carries shape as well as colour. +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`. +**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 existing 30-second poll reconciles that optimistic list with the server's canonical records (storage-assigned ids and the asynchronously-computed sensitive-data flags), narrowed to `GET /api/v1/activity?type=tool_call,internal_tool_call&limit=25`. + +**Which records qualify** (implemented in `GlanceSelection`, applied identically to polled records and SSE payloads): + +- `type == "tool_call"` — always. These are the real upstream calls. +- `type == "internal_tool_call"` — when the tool is `retrieve_tools`, `code_execution`, or `describe_tool`, **or when the record's status is not `success`**. + +The second half of that rule matters more than it looks. The backend deliberately keeps *failed* `call_tool_*` wrapper records while suppressing successful ones, precisely because a failed wrapper has no corresponding upstream `tool_call` record (`internal/storage/activity_models.go:255`). A naive built-in allowlist would therefore discard the only trace of a call that never reached its server — hiding exactly the failures this section exists to surface. + +Management built-ins (`upstream_servers`, `quarantine_security`) are excluded in both states: they are proxy administration, which is what "exclude internal mcpproxy events" asks for. -**Activity** arrives by SSE push once the `activity.` prefix match is fixed; the existing 30-second poll stays as a safety net for missed events. The poll narrows to `GET /api/v1/activity?type=tool_call,internal_tool_call&limit=10`. Note the API already suppresses successful `call_tool_read/write/destructive` wrapper records by default (`ExcludeCallToolSuccess`) so they do not double-count the paired upstream record — the row shows `github:create_issue`, not its wrapper. +The poll fetches 25 records and the section shows the first five that qualify, so unrelated records cannot starve the list the way a 10-record page could. If fewer than five qualify, it shows what it has. -The `internal_tool_call` type also covers management built-ins (`upstream_servers`, `quarantine_security`) that the user considers internal noise, and the API cannot filter by tool-name set. The component therefore keeps a three-entry display allowlist (`retrieve_tools`, `code_execution`, `describe_tool`) and takes the first five matching records out of the ten fetched; if fewer than five match, it shows what matched rather than padding the list. This is presentation policy, not tray-held state, so it stays within the "tray holds no state" rule (CLAUDE.md). +This selection is presentation policy, not tray-held state, so it stays within the "tray holds no state" rule (CLAUDE.md). -**The header summary line** ("12 calls in the last hour") comes from `GET /api/v1/activity/summary?period=1h` — a single pre-aggregated response — added to the existing 30-second background refresh loop, never to the menu-open path. The client count beside it is the length of the filtered active-session list, so header and Clients section always agree. +### Header summary and histogram -**Clients** come from the existing `GET /api/v1/sessions?limit=5` poll, filtered client-side to `status == "active"` (no server-side status parameter exists). Documented caveat: per spec 082, handshake-only sessions are not persisted, so an idle background agent does not appear until its first call. +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's "calls in the last hour" is the most recent bucket; the histogram is the same timeline. -**Histogram** is fetched only when its submenu opens, via that submenu's delegate — never on the main menu open path. It refetches on every submenu open rather than caching client-side; the endpoint (`GET /api/v1/activity/usage?window=24h`) serves an in-memory snapshot behind a short TTL cache and never scans the log, so repeated opens are cheap and always current. While the first response is in flight the submenu shows a muted loading row. Its timeline buckets are global by contract (not filtered by server/tool/status), so the label is "Activity (24h)" with no implication of filtering. +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. -**Clicks**: an activity row opens the Web UI activity log filtered by that record's `request_id`; a client row opens it filtered by that session. URL construction only — no backend work. +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 + +`GET /api/v1/sessions?limit=25` on the existing poll, filtered client-side to `status == "active"` (the API has no status parameter), showing up to five. The larger page matters: sessions are returned most-recently-active first regardless of status, so a burst of closed sessions could otherwise crowd out an older but still-active client. Documented caveat: per spec 082, handshake-only sessions are not persisted, so an idle background agent does not appear until its first call. + +### Clicks + +An activity row and a client row both open the Web UI activity log filtered by that record's **session** — `/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 explicitly excludes. ## States @@ -97,25 +118,31 @@ The `internal_tool_call` type also covers management built-ins (`upstream_server | Core stopped or disconnected | The whole glance block is hidden. Stale numbers presented as current are worse than nothing; the menu already shows the error and a Retry item. | | 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". | -| Histogram fetch fails | Muted error row inside the submenu; the rest of the menu is unaffected. | +| Usage data not loaded yet | The header omits the call count (clients only); the histogram submenu shows a muted "Loading…" row. | | 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: +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). -- Formatting: relative time, row label composition, middle truncation. -- Display allowlist: management built-ins excluded, the three discovery/execution built-ins included, upstream `tool_call` records always included. -- Visibility rules: block hidden when the core is stopped; empty-state rows for no activity / no clients. -- **Invariant, spec 048**: a stub API client counting requests records zero calls when the main menu opens. +- **Selection**: upstream `tool_call` always included; the three discovery/execution built-ins included; management built-ins excluded; **a failed `call_tool_write` record is included** (the finding above, pinned by a test); five qualifying records selected from a 25-record page containing noise. +- **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 -Visual verification by screenshotting the menu with `mcpproxy-ui-test` per `docs/development/macos-tray.md` (requires Screen Recording permission). +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 instead of refetching, two field-level bug fixes, a new API client method plus response model for the usage aggregate, two new `AppState` fields, a data-source protocol extraction for testability, deletion of a dead 511-line file, 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. Web UI changes. 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. +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 this unblocks From 29e217b79d6f574ee752338f78a5fa94a05c367a Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 14:49:49 +0300 Subject: [PATCH 03/75] docs(design): tray glance round-2 review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six more findings, all verified against the code first: - Do not narrow AppState.recentActivity: the native Dashboard renders the full activity log from it on purpose (security scans, quarantine, OAuth) so a quiet proxy still shows something (DashboardView.swift:572). The glance section gets its own filtered feed instead. - Header now reads 'calls this hour' and selects the bucket matching the current UTC hour, showing zero when absent. Buckets are UTC-hour aligned and sparse (usage_aggregate.go:229), so 'the most recent bucket' could have displayed an hours-old count as current. - Selection rules were self-contradictory (include all failed internal calls vs exclude management built-ins). Precedence is now explicit: management names are excluded first, then tool_call, then discovery built-ins or non-success internal calls. - Sessions page raised to the API maximum: storage truncates newest-first by START time before the runtime sorts by last activity (manager.go:1355), so a long-lived active session falls outside any small page. Server-side status filter recorded as a follow-up. - Deep links go through an app-delegate action, not a URL built inside the component: a first-time browser session needs the API key that openWebUI() fetches from the core manager (MCPProxyApp.swift:985). - Usage state fields are optional so 'not loaded' stays distinguishable from 'loaded and idle' — a valid usage response can be empty. --- .../specs/2026-07-29-tray-glance-design.md | 47 +++++++++++++------ 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/specs/2026-07-29-tray-glance-design.md b/docs/superpowers/specs/2026-07-29-tray-glance-design.md index f755b223..72b0cc47 100644 --- a/docs/superpowers/specs/2026-07-29-tray-glance-design.md +++ b/docs/superpowers/specs/2026-07-29-tray-glance-design.md @@ -42,8 +42,10 @@ 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; widen `recentActivity` to accept a type filter and a larger limit. -- `AppState.swift` — add `@Published var usageTimeline: [UsageBucket]` and `lastHourCalls: Int` alongside the existing `recentActivity` / `recentSessions`. +- `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]`, `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. + +`AppState.recentActivity` is **not** narrowed. The native Dashboard deliberately renders the full activity log from that property — security scans, quarantine changes, OAuth events — precisely so a quiet proxy still shows something (`Views/DashboardView.swift:572`). Filtering it for the tray's benefit would silently gut that view, so the glance section gets its own feed instead. ## Layout @@ -78,16 +80,15 @@ Row anatomy: status icon (shape *and* colour, never colour alone), `server:tool` ### 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 existing 30-second poll reconciles that optimistic list with the server's canonical records (storage-assigned ids and the asynchronously-computed sensitive-data flags), narrowed to `GET /api/v1/activity?type=tool_call,internal_tool_call&limit=25`. - -**Which records qualify** (implemented in `GlanceSelection`, applied identically to polled records and SSE payloads): +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. 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=25`. This is a fourth call on the existing refresh cycle rather than a reuse of the Dashboard's feed, for the reason given above. -- `type == "tool_call"` — always. These are the real upstream calls. -- `type == "internal_tool_call"` — when the tool is `retrieve_tools`, `code_execution`, or `describe_tool`, **or when the record's status is not `success`**. +**Which records qualify** — evaluated in this order, in `GlanceSelection`, identically for polled records and SSE payloads: -The second half of that rule matters more than it looks. The backend deliberately keeps *failed* `call_tool_*` wrapper records while suppressing successful ones, precisely because a failed wrapper has no corresponding upstream `tool_call` record (`internal/storage/activity_models.go:255`). A naive built-in allowlist would therefore discard the only trace of a call that never reached its server — hiding exactly the failures this section exists to surface. +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`. -Management built-ins (`upstream_servers`, `quarantine_security`) are excluded in both states: they are proxy administration, which is what "exclude internal mcpproxy events" asks for. +Rule 3's second clause matters more than it looks. The backend deliberately keeps *failed* `call_tool_*` wrapper records while suppressing successful ones, precisely because a failed wrapper has no corresponding upstream `tool_call` record (`internal/storage/activity_models.go:255`). A plain built-in allowlist would discard the only trace of a call that never reached its server — hiding exactly the failures this section exists to surface. Rule 1 is stated first because management built-ins fail like any other internal call, and their failures are still administration noise. The poll fetches 25 records and the section shows the first five that qualify, so unrelated records cannot starve the list the way a 10-record page could. If fewer than five qualify, it shows what it has. @@ -95,7 +96,9 @@ This selection is presentation policy, not tray-held state, so it stays within t ### 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's "calls in the last hour" is the most recent bucket; the histogram is the same timeline. +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. @@ -105,11 +108,17 @@ Chart semantics: a bucket's `calls` **includes** its `errors`, so the stacked ba ### Clients -`GET /api/v1/sessions?limit=25` on the existing poll, filtered client-side to `status == "active"` (the API has no status parameter), showing up to five. The larger page matters: sessions are returned most-recently-active first regardless of status, so a burst of closed sessions could otherwise crowd out an older but still-active client. Documented caveat: per spec 082, handshake-only sessions are not persisted, so an idle background agent does not appear until its first call. +`GET /api/v1/sessions?limit=100` (the API maximum) on the existing poll, filtered client-side to `status == "active"`, showing up to five. + +The page size is deliberately the maximum rather than a tidy 25. 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`). A long-lived session that started days ago but is active right now therefore falls outside any small page — the tray would show "no connected clients" while a client is actively calling tools. Session records are compact scalars, so a 100-record page every 30 seconds is an acceptable cost for correctness. If that payload ever becomes a concern, the right fix is a server-side `status` filter on `/api/v1/sessions`, recorded here as a follow-up rather than smuggled into this work. + +Documented caveat: per spec 082, handshake-only sessions are not persisted, so an idle background agent does not appear until its first call. ### Clicks -An activity row and a client row both open the Web UI activity log filtered by that record's **session** — `/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 explicitly excludes. +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. ## States @@ -118,7 +127,8 @@ An activity row and a client row both open the Web UI activity log filtered by t | Core stopped or disconnected | The whole glance block is hidden. Stale numbers presented as current are worse than nothing; the menu already shows the error and a Retry item. | | 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 | The header omits the call count (clients only); the histogram submenu shows a muted "Loading…" row. | +| 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 @@ -128,7 +138,9 @@ Swift unit tests in the existing `swift-test` CI job. `GlanceSelection` and `Gla - **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; management built-ins excluded; **a failed `call_tool_write` record is included** (the finding above, pinned by a test); five qualifying records selected from a 25-record page containing noise. +- **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 25-record page containing noise. +- **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 after the glance feed is added. - **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. @@ -138,12 +150,17 @@ Visual verification by screenshotting the menu with `mcpproxy-ui-test` per `docs ## 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 instead of refetching, two field-level bug fixes, a new API client method plus response model for the usage aggregate, two new `AppState` fields, a data-source protocol extraction for testability, deletion of a dead 511-line file, and the test suite above. +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 instead of refetching, two field-level bug fixes, two new API client methods plus response models (usage aggregate, filtered glance activity), three new `AppState` fields, a data-source protocol extraction for testability, deletion of a dead 511-line file, 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 + +- Server-side `status` filter on `/api/v1/sessions`, which would let the tray stop fetching a 100-session page to find the active ones. +- Real token histogram (see Non-goals). + ## 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. From b85362d85ad2fc39ea5a4d9fc0c52f9681520e5e Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 14:54:38 +0300 Subject: [PATCH 04/75] docs(design): tray glance round-3 review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No P1s this round; three P2s and two P3s, all verified against the code: - rebuildMenu() gains a tracking guard. Live SSE rows make the debounced objectWillChange -> rebuildMenu() sink fire during active traffic, and rebuildMenu() calls removeAllItems() without dismissing the menu — so a busy agent could restructure the menu under the user's cursor and disturb an open histogram submenu. While tracking, rows update titles in place and structural changes are deferred to menuDidClose. - Glance state is explicitly cleared on disconnect. Optional fields only make a cleared state expressible; without an actual reset the menu can show the previous core's numbers, since the connection path flips to .connected before the first refresh completes. - Glance page size 25 -> 50. Management built-ins match the server-side type filter and are excluded client-side, and storage filters before truncating, so a burst of upstream_servers calls could fill a small page and starve real tool calls. - Mockup label corrected to 'calls this hour', matching the load-bearing bucket semantics. - SSE payload field is error_message, not error; reading the wrong key would drop failure text from optimistic rows until reconciliation. --- .../specs/2026-07-29-tray-glance-design.md | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-07-29-tray-glance-design.md b/docs/superpowers/specs/2026-07-29-tray-glance-design.md index 72b0cc47..ae00d76d 100644 --- a/docs/superpowers/specs/2026-07-29-tray-glance-design.md +++ b/docs/superpowers/specs/2026-07-29-tray-glance-design.md @@ -25,8 +25,8 @@ Constraints, in priority order: never irritate (the menu stays a menu — fast, | 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`, `duration_ms` (`internal/runtime/event_bus.go:430`) — everything a row renders. | -| Integration | Extract a `Menu/Glance/` component; no view caching needed | 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 it would not have prevented churn anyway (items are still removed and re-inserted during menu tracking). The histogram view lives in a submenu that main-menu rebuilds do not touch. | +| 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 @@ -51,7 +51,7 @@ Changes to existing files: ``` MCPProxy v0.52.0 ● -12 calls in the last hour · 2 clients +12 calls this hour · 2 clients ───────────────────────────────────── Recent ✓ github:create_issue 12s @@ -80,7 +80,7 @@ Row anatomy: status icon (shape *and* colour, never colour alone), `server:tool` ### 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. 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=25`. This is a fourth call on the existing refresh cycle rather than a reuse of the Dashboard's feed, for the reason given above. +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. 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: @@ -90,7 +90,7 @@ Live rows come from SSE. On `activity.tool_call.completed` / `activity.internal_ Rule 3's second clause matters more than it looks. The backend deliberately keeps *failed* `call_tool_*` wrapper records while suppressing successful ones, precisely because a failed wrapper has no corresponding upstream `tool_call` record (`internal/storage/activity_models.go:255`). A plain built-in allowlist would discard the only trace of a call that never reached its server — hiding exactly the failures this section exists to surface. Rule 1 is stated first because management built-ins fail like any other internal call, and their failures are still administration noise. -The poll fetches 25 records and the section shows the first five that qualify, so unrelated records cannot starve the list the way a 10-record page could. If fewer than five qualify, it shows what it has. +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). @@ -120,11 +120,17 @@ An activity row and a client row both open the Web UI activity log filtered by t `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 — setting `attributedTitle` on 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. + ## States | Situation | Behaviour | |---|---| -| Core stopped or disconnected | The whole glance block is hidden. Stale numbers presented as current are worse than nothing; the menu already shows the error and a Retry item. | +| Core stopped or disconnected | The whole glance block is hidden **and its state is cleared** — `glanceActivity` 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. | @@ -141,6 +147,9 @@ Swift unit tests in the existing `swift-test` CI job. `GlanceSelection` and `Gla - **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 25-record page containing noise. - **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 after the glance feed is added. +- **Open-menu stability**: with the menu tracking, a burst of SSE events updates row titles and performs no structural rebuild; the deferred rebuild runs once on close. +- **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. From f89a59ca1b1996eba9a9d7e38e8eb9f0f4e86da4 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 14:58:56 +0300 Subject: [PATCH 05/75] docs(design): tray glance round-4 review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No P1s; three P2s and one P3, all verified against the code: - Specify the SSE payload adapter. The wire format is an envelope with a Unix-seconds timestamp, and internal events send internal_tool_name plus optional target_server rather than tool_name/server_name (event_bus.go:552). Feeding raw payloads into the selection rules would have silently dropped every internal row. - Fix the sessions gap properly instead of papering over it. Storage truncates newest-by-start-time before the sort, and the handler parses offset without using it, so no page size guarantees active clients are found; a 100-record page was still a page. Adds a status filter to GET /api/v1/sessions applied during the cursor walk — a small Go change now in scope, because the alternative is a section that reports 'no clients' while a client is working. - In-place row updates must rewrite the whole row identity — title, image, tooltip, accessibility label, and the representedObject that carries the session for the click action. Updating only the title would leave a row whose click opens the previous record's session. - Selection test now pins the 50-record page the production feed uses. --- .../specs/2026-07-29-tray-glance-design.md | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-07-29-tray-glance-design.md b/docs/superpowers/specs/2026-07-29-tray-glance-design.md index ae00d76d..1a4f525d 100644 --- a/docs/superpowers/specs/2026-07-29-tray-glance-design.md +++ b/docs/superpowers/specs/2026-07-29-tray-glance-design.md @@ -80,7 +80,9 @@ Row anatomy: status icon (shape *and* colour, never colour alone), `server:tool` ### 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. 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. +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`, which the reconciling poll then replaces with the storage-assigned one. 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: @@ -108,11 +110,11 @@ Chart semantics: a bucket's `calls` **includes** its `errors`, so the stacked ba ### Clients -`GET /api/v1/sessions?limit=100` (the API maximum) on the existing poll, filtered client-side to `status == "active"`, showing up to five. +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. -The page size is deliberately the maximum rather than a tidy 25. 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`). A long-lived session that started days ago but is active right now therefore falls outside any small page — the tray would show "no connected clients" while a client is actively calling tools. Session records are compact scalars, so a 100-record page every 30 seconds is an acceptable cost for correctness. If that payload ever becomes a concern, the right fix is a server-side `status` filter on `/api/v1/sessions`, recorded here as a follow-up rather than smuggled into this work. +So: add a `status` query parameter to `GET /api/v1/sessions`, applied during the storage cursor walk (before truncation), and have the tray request `?status=active&limit=25`. This is roughly twenty lines in the handler and storage filter, plus the usual `make swagger` regeneration and a handler test. It is in scope precisely because the alternative is a section that under-reports without saying so. -Documented caveat: per spec 082, handshake-only sessions are not persisted, so an idle background agent does not appear until its first call. +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 @@ -124,7 +126,9 @@ An activity row and a client row both open the Web UI activity log filtered by t 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 — setting `attributedTitle` on 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. +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 @@ -144,10 +148,12 @@ Swift unit tests in the existing `swift-test` CI job. `GlanceSelection` and `Gla - **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 25-record page containing noise. +- **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 after the glance feed is added. -- **Open-menu stability**: with the menu tracking, a burst of SSE events updates row titles and performs no structural rebuild; the deferred rebuild runs once on close. +- **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. @@ -159,7 +165,7 @@ Visual verification by screenshotting the menu with `mcpproxy-ui-test` per `docs ## 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 instead of refetching, two field-level bug fixes, two new API client methods plus response models (usage aggregate, filtered glance activity), three new `AppState` fields, a data-source protocol extraction for testability, deletion of a dead 511-line file, and the test suite above. +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, two new API client methods plus response models (usage aggregate, filtered glance activity), three 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 @@ -167,8 +173,8 @@ Real token histogram (separate roadmap item: accumulate `TokenMetrics` into hour ## Follow-ups -- Server-side `status` filter on `/api/v1/sessions`, which would let the tray stop fetching a 100-session page to find the active ones. - 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 From b2642a577ad87ad2976cb7a449885dffb04aeb73 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 15:02:42 +0300 Subject: [PATCH 06/75] docs(design): tray glance round-5 review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No P1s; three P2s, all verified against the code: - Add a deduplication rule. A failed upstream call emits BOTH activity.tool_call.completed and activity.internal_tool_call.completed under one request id and both are persisted (mcp.go:2142,2146), so the previous rules would render one failure as two rows. Records sharing a request id now collapse to the tool_call one, which names the real server:tool; wrappers that failed before dispatch have no paired record and still render. - Provisional SSE ids are request_id + event type. ActivityEntry derives identity from id alone (Models.swift:536) and the paired events share a request id, so a bare request_id would collide two distinct records before the dedup rule could choose between them. - Give the glance section its own glanceSessions feed. recentSessions is shared: ActivityView resolves session ids to client names through it and DashboardView falls back to it for Recent Sessions, so pointing that feed at status=active would silently narrow both. Same mistake this design already avoided for recentActivity — now stated as a rule: a shared AppState feed the glance wants differently gets its own field, never a narrowed shared one. --- .../specs/2026-07-29-tray-glance-design.md | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-07-29-tray-glance-design.md b/docs/superpowers/specs/2026-07-29-tray-glance-design.md index 1a4f525d..db59709a 100644 --- a/docs/superpowers/specs/2026-07-29-tray-glance-design.md +++ b/docs/superpowers/specs/2026-07-29-tray-glance-design.md @@ -43,9 +43,9 @@ Changes to existing files: - `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]`, `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. +- `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. -`AppState.recentActivity` is **not** narrowed. The native Dashboard deliberately renders the full activity log from that property — security scans, quarantine changes, OAuth events — precisely so a quiet proxy still shows something (`Views/DashboardView.swift:572`). Filtering it for the tray's benefit would silently gut that view, so the glance section gets its own feed instead. +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 @@ -82,15 +82,16 @@ Row anatomy: status icon (shape *and* colour, never colour alone), `server:tool` 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`, which the reconciling poll then replaces with the storage-assigned one. 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. +**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 matters more than it looks. The backend deliberately keeps *failed* `call_tool_*` wrapper records while suppressing successful ones, precisely because a failed wrapper has no corresponding upstream `tool_call` record (`internal/storage/activity_models.go:255`). A plain built-in allowlist would discard the only trace of a call that never reached its server — hiding exactly the failures this section exists to surface. Rule 1 is stated first because management built-ins fail like any other internal call, and their failures are still administration noise. +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. @@ -112,7 +113,9 @@ Chart semantics: a bucket's `calls` **includes** its `errors`, so the stacked ba 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 tray request `?status=active&limit=25`. This is roughly twenty lines in the handler and storage filter, plus the usual `make swagger` regeneration and a handler test. It is in scope precisely because the alternative is a section that under-reports without saying so. +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. @@ -134,7 +137,7 @@ So `rebuildMenu()` gains a tracking guard: while the menu is open it does **not* | Situation | Behaviour | |---|---| -| Core stopped or disconnected | The whole glance block is hidden **and its state is cleared** — `glanceActivity` 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. | +| 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. | @@ -150,7 +153,8 @@ Swift unit tests in the existing `swift-test` CI job. `GlanceSelection` and `Gla - **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 after the glance feed is added. +- **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. @@ -165,7 +169,7 @@ Visual verification by screenshotting the menu with `mcpproxy-ui-test` per `docs ## 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, two new API client methods plus response models (usage aggregate, filtered glance activity), three 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. +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 From 8e9ce53ce35040da90d430761760592495c38312 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 16:09:12 +0300 Subject: [PATCH 07/75] docs(plan): tray glance implementation plan (8 tasks, TDD, no placeholders) Produced by a 21-agent workflow: four recon agents mapped the exact Swift and Go surfaces, eight drafting agents wrote one task each with real code, and each task was then adversarially verified against the repo by its own agent (all eight came back corrected, not clean). Tasks: (1) Go status filter on GET /api/v1/sessions applied during the cursor walk; (2) Swift API client methods, usage models, last_activity coding-key fix, glance data-source protocol; (3) AppState glance fields, refresh wiring, disconnect reset; (4) SSE payload adapter replacing the dead activity case; (5) GlanceSelection + GlanceFormatting pure logic; (6) GlanceSection plain NSMenuItems with whole-identity in-place updates; (7) MCPProxyApp integration, open-menu rebuild guard, session deep link, TrayMenu.swift deletion; (8) SwiftUI Charts histogram in the submenu. 156 checkbox steps, each with real code or a real command and expected output. Self-review passed: no placeholders, no Claude attribution in the commit templates, symbol names consistent across tasks, and every load-bearing spec detail present (request_id collapse, current-UTC-hour bucket, calls-includes-errors stacking, representedObject rewrites, shared feeds never narrowed). --- .../plans/2026-07-29-tray-glance.md | 6597 +++++++++++++++++ 1 file changed, 6597 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-29-tray-glance.md 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 00000000..1cbb91d0 --- /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`. From e9ea15df18070ab783522fb7236ade857354c7d2 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 16:14:09 +0300 Subject: [PATCH 08/75] chore: git-ignore .superpowers/ SDD scratch workspace --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index c06cbcd5..2b559c5f 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/ From 3cb39717229dac4ce9f7593b94460c4b57775533 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 16:18:41 +0300 Subject: [PATCH 09/75] 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. --- docs/api/rest-api.md | 18 ++- internal/httpapi/contracts_test.go | 2 +- internal/httpapi/security_test.go | 2 +- internal/httpapi/server.go | 18 ++- internal/httpapi/sessions_handlers_test.go | 142 +++++++++++++++++++++ internal/runtime/runtime.go | 10 +- internal/server/server.go | 7 +- internal/storage/manager.go | 48 +++++-- internal/storage/sessions_filter_test.go | 69 ++++++++++ oas/docs.go | 2 +- oas/swagger.yaml | 14 ++ 11 files changed, 310 insertions(+), 22 deletions(-) create mode 100644 internal/httpapi/sessions_handlers_test.go create mode 100644 internal/storage/sessions_filter_test.go diff --git a/docs/api/rest-api.md b/docs/api/rest-api.md index aa3ee7b5..9ba753fc 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/internal/httpapi/contracts_test.go b/internal/httpapi/contracts_test.go index 585caf24..d55a50a6 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 dac4e82f..e2f0945d 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 126685e2..b4ce9645 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 00000000..4420e794 --- /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 f867c853..82bbae44 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 a6284072..046c1300 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 301010de..51be2522 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 00000000..8aca662a --- /dev/null +++ b/internal/storage/sessions_filter_test.go @@ -0,0 +1,69 @@ +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) + } + }) +} diff --git a/oas/docs.go b/oas/docs.go index 81314b02..78209ad6 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"}}],"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 f9b207d1..b3dea712 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -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": content: @@ -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": content: application/json: From 23b6b8c10f4090369d8b7f177b8a442231d4967b Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 16:25:45 +0300 Subject: [PATCH 10/75] test(storage): pin limit truncation on the filtered session walk The status-filter suite never made `limit` binding on the filtered path: limit=2 had one match and limit=10 had four, so deleting the `if len(sessions) < limit` guard in GetRecentSessions still passed the whole suite. The filtered walk could have silently ignored limit. Add GetRecentSessions(2, "closed") over four closed sessions, asserting the page truncates to 2 while total still reports 4, and pinning the two returned IDs so the newest-first order of the filtered walk is covered too. Verified by removing the guard: only this sub-test fails. --- internal/storage/sessions_filter_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/storage/sessions_filter_test.go b/internal/storage/sessions_filter_test.go index 8aca662a..770cbb22 100644 --- a/internal/storage/sessions_filter_test.go +++ b/internal/storage/sessions_filter_test.go @@ -57,6 +57,19 @@ func TestGetRecentSessions_StatusFilterAppliedBeforeTruncation(t *testing.T) { 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) From ca2b93e1d49b2d3212aa2099e990fa419a4eee43 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 16:30:35 +0300 Subject: [PATCH 11/75] 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. --- .../MCPProxy/MCPProxy/API/APIClient.swift | 9 ++ .../macos/MCPProxy/MCPProxy/API/Models.swift | 91 +++++++++++++++++++ .../MCPProxyTests/APIClientGlanceTests.swift | 67 ++++++++++++++ .../MCPProxyTests/GlanceStubURLProtocol.swift | 63 +++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/GlanceStubURLProtocol.swift diff --git a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift index 33e8a44d..fdda787f 100644 --- a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift +++ b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift @@ -375,6 +375,15 @@ actor APIClient { 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") diff --git a/native/macos/MCPProxy/MCPProxy/API/Models.swift b/native/macos/MCPProxy/MCPProxy/API/Models.swift index 7a9e909f..5e09d5cc 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/MCPProxyTests/APIClientGlanceTests.swift b/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift new file mode 100644 index 00000000..db46c030 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift @@ -0,0 +1,67 @@ +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) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceStubURLProtocol.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceStubURLProtocol.swift new file mode 100644 index 00000000..4b268f5d --- /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() {} +} From 4de61321b6ff6aa0ed1cb6da596c4b2503888a55 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 16:31:08 +0300 Subject: [PATCH 12/75] 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. --- .../MCPProxy/MCPProxy/API/APIClient.swift | 14 ++++++++ .../MCPProxyTests/APIClientGlanceTests.swift | 34 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift index fdda787f..1eba6518 100644 --- a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift +++ b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift @@ -375,6 +375,20 @@ 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 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 + } + /// Fetch the usage aggregate from `GET /api/v1/activity/usage`. /// /// Served from an in-memory snapshot behind a short TTL cache — never a log diff --git a/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift b/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift index db46c030..d1ce6ffc 100644 --- a/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift @@ -64,4 +64,38 @@ final class APIClientGlanceTests: XCTestCase { // passes even when the fractional-seconds branch is dropped. XCTAssertEqual(bucket.start.timeIntervalSince1970, 1785330000.123, accuracy: 0.001) } + + // 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") + } } From b2c1efe360144200a59eb766a9f325abb7c3fc72 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 16:31:55 +0300 Subject: [PATCH 13/75] fix(tray): decode last_activity, add active-session API call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../MCPProxy/MCPProxy/API/APIClient.swift | 19 +++++++++-- .../MCPProxy/Views/DashboardView.swift | 10 +++--- .../MCPProxyTests/APIClientGlanceTests.swift | 32 +++++++++++++++++++ 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift index 1eba6518..4240852b 100644 --- a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift +++ b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift @@ -338,7 +338,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 @@ -350,7 +353,7 @@ actor APIClient { case toolCallCount = "tool_call_count" case totalTokens = "total_tokens" case startTime = "start_time" - case lastActive = "last_active" + case lastActivity = "last_activity" } } @@ -367,6 +370,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`. diff --git a/native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift b/native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift index aeae963f..943ea20a 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 index d1ce6ffc..9d9841fd 100644 --- a/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift @@ -98,4 +98,36 @@ final class APIClientGlanceTests: XCTestCase { XCTAssertEqual(entries.first?.toolName, "get_issue") XCTAssertEqual(entries.first?.errorMessage, "auth failed") } + + // 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) + } } From de3a5d73537d9ad5c7ab72b0c96d192271bd345f Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 16:32:42 +0300 Subject: [PATCH 14/75] refactor(tray): extract GlanceDataSource protocol over APIClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Menu/Glance/GlanceDataSource.swift | 27 ++++++++++++ .../MCPProxyTests/APIClientGlanceTests.swift | 25 +++++++++++ .../CountingGlanceDataSource.swift | 43 +++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceDataSource.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/CountingGlanceDataSource.swift 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 00000000..2ece2090 --- /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/MCPProxyTests/APIClientGlanceTests.swift b/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift index 9d9841fd..1e322029 100644 --- a/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift @@ -130,4 +130,29 @@ final class APIClientGlanceTests: XCTestCase { XCTAssertEqual(session.clientName, "Claude Code") XCTAssertEqual(session.toolCallCount, 8) } + + // 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/CountingGlanceDataSource.swift b/native/macos/MCPProxy/MCPProxyTests/CountingGlanceDataSource.swift new file mode 100644 index 00000000..3808e59e --- /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 + } +} From 0d1dfd24169be3e3b1501c7af930966ada0b8739 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 16:47:16 +0300 Subject: [PATCH 15/75] test(tray): cover the non-2xx error path of the three glance calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stub's statusCode knob existed but nothing drove it, leaving the error path of all three new calls unasserted. Pins that a non-2xx surfaces as APIClientError.httpError keyed off the status code — the 400 case is what the live core returns for ?status=bogus, and its error body carries a request_id that must not disturb message extraction. Verified by mutation: neutralising the 200...299 guard in performRequest fails all three, the 503 case by degrading to a decodingError. --- .../MCPProxyTests/APIClientGlanceTests.swift | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift b/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift index 1e322029..0d4ba904 100644 --- a/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift @@ -131,6 +131,72 @@ final class APIClientGlanceTests: XCTestCase { 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: - Data-source seam func testAPIClientConformsToGlanceDataSource() async throws { From 0f312e5bfd3e6be19db09f1cd3a86310cb4c4936 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 16:48:36 +0300 Subject: [PATCH 16/75] fix(tray): stop fetchWrapped masking a payload's own decoding error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the body is enveloped and a model's decoder throws, the bare-decode fallback re-fails on the top-level shape and that second error was the one reported — so UsageBucket's "Not an RFC 3339 timestamp" surfaced as "no value associated with key window", naming the wrong cause. Both decode errors are now kept and the informative one is chosen by probing the body for the envelope. Unwrapped endpoints keep reporting the bare error exactly as before; success paths are untouched. --- .../MCPProxy/MCPProxy/API/APIClient.swift | 19 ++++- .../MCPProxyTests/APIClientGlanceTests.swift | 78 +++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift index 4240852b..134fba7d 100644 --- a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift +++ b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift @@ -640,6 +640,11 @@ actor APIClient { // MARK: - Private Helpers + /// Detects the standard response envelope without caring about its payload. + 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") @@ -652,12 +657,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/MCPProxyTests/APIClientGlanceTests.swift b/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift index 0d4ba904..0caec167 100644 --- a/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift @@ -197,6 +197,84 @@ final class APIClientGlanceTests: XCTestCase { 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 { From f07a2e6f093554a5931dd641857104dda5bc1ec0 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 16:57:15 +0300 Subject: [PATCH 17/75] 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. --- .../MCPProxy/MCPProxy/State/AppState.swift | 57 ++++++++++++++ .../MCPProxyTests/AppStateGlanceTests.swift | 74 +++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift diff --git a/native/macos/MCPProxy/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index 76fd1ec5..c6c19bf3 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -72,6 +72,26 @@ 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? + // MARK: Token metrics (from status response) @Published var tokenMetrics: TokenMetrics? @@ -257,4 +277,41 @@ 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. + /// + /// 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 } + } } diff --git a/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift b/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift new file mode 100644 index 00000000..2edc347b --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift @@ -0,0 +1,74 @@ +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)!) + } +} From 8f274da770b1eb7bc5b87b895df41c8d0544d4e2 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 16:58:20 +0300 Subject: [PATCH 18/75] feat(tray): clear glance state whenever the core leaves .connected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../MCPProxy/MCPProxy/State/AppState.swift | 56 ++++++++- .../MCPProxyTests/AppStateGlanceTests.swift | 107 ++++++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) diff --git a/native/macos/MCPProxy/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index c6c19bf3..21770cc6 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -34,7 +34,19 @@ 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 != .connected { clearGlanceState() } + } + } /// Who owns the core process. @Published var ownership: CoreOwnership = .trayManaged @@ -314,4 +326,46 @@ final class AppState: ObservableObject { let calls = AppState.callsInCurrentHour(timeline, now: now) if callsThisHour != calls { callsThisHour = calls } } + + /// 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 } + } } diff --git a/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift b/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift index 2edc347b..d0b0c71f 100644 --- a/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift @@ -43,6 +43,113 @@ final class AppStateGlanceTests: XCTestCase { 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.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"]) + } + // MARK: - Helpers private static func date(_ iso: String) -> Date { From 7575cdf95cffdaba608b9cf41671475c783e005e Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 16:58:48 +0300 Subject: [PATCH 19/75] 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). --- .../MCPProxy/Core/CoreProcessManager.swift | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift index 4badda95..e9fe6d64 100644 --- a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift +++ b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift @@ -751,6 +751,9 @@ actor CoreProcessManager { private func refreshState() async { await refreshActivity() await refreshSessions() + await refreshGlanceActivity() + await refreshGlanceSessions() + await refreshUsage() await refreshTokenMetrics() await refreshSecurityStatus() await refreshProfiles() @@ -863,6 +866,44 @@ 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. + 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 + } + } + /// Fetch token metrics from the status endpoint and update appState. private func refreshTokenMetrics() async { guard let apiClient else { return } From 3908e695b3593e814ebf2b3036a1ddbeb927efaa Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 17:08:24 +0300 Subject: [PATCH 20/75] fix(tray): ignore glance updates unless the core is connected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clearGlanceState() alone left a window: a fetch already past its `guard let apiClient` when the core leaves .connected resolves after the reset and writes the dead core's data back over the cleared fields. shutdown() transitions to .shuttingDown before it cancels refreshTask, and cancelling would not close the window anyway — a suspended fetch still resumes and still runs the update that follows it. These are the one field set whose stated purpose is never showing a previous core's numbers as live, so the three updaters now drop writes that arrive while disconnected. --- .../MCPProxy/MCPProxy/State/AppState.swift | 16 ++++++++ .../MCPProxyTests/AppStateGlanceTests.swift | 39 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/native/macos/MCPProxy/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index 21770cc6..dd575482 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -315,6 +315,9 @@ final class AppState: ObservableObject { /// 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 @@ -322,6 +325,7 @@ final class AppState: ObservableObject { /// 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 } @@ -329,6 +333,14 @@ final class AppState: ObservableObject { /// 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 @@ -336,6 +348,7 @@ final class AppState: ObservableObject { /// glance rows actually render instead — still cheap, still churn-free. @MainActor func updateGlanceActivity(_ entries: [ActivityEntry]) { + guard coreState == .connected else { return } func fingerprint(_ list: [ActivityEntry]) -> [String] { list.map { "\($0.id)|\($0.status)|\($0.hasSensitiveData == true)" } } @@ -345,8 +358,11 @@ final class AppState: ObservableObject { } /// Replace the glance (active-only) session feed. Leaves `recentSessions` untouched. + /// + /// Ignored unless the core is `.connected` — see `updateGlanceActivity`. @MainActor func updateGlanceSessions(_ sessions: [APIClient.MCPSession]) { + guard coreState == .connected else { return } if sessions.map(\.id) != glanceSessions.map(\.id) { glanceSessions = sessions } diff --git a/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift b/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift index d0b0c71f..bf56bf0d 100644 --- a/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift @@ -38,6 +38,7 @@ final class AppStateGlanceTests: XCTestCase { ] let state = AppState() + state.coreState = .connected state.updateUsage(timeline: timeline, now: now) XCTAssertEqual(state.callsThisHour, 12) @@ -49,6 +50,7 @@ final class AppStateGlanceTests: XCTestCase { 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) @@ -107,6 +109,42 @@ final class AppStateGlanceTests: XCTestCase { 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 @@ -117,6 +155,7 @@ final class AppStateGlanceTests: XCTestCase { /// 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")]) From 4ef390ee88318528a701c3d3d0f95c1ec1e04cc9 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 17:19:35 +0300 Subject: [PATCH 21/75] 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. --- .../Menu/Glance/GlanceFormatting.swift | 108 ++++++++++++++++++ .../MCPProxyTests/GlanceFormattingTests.swift | 103 +++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceFormatting.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/GlanceFormattingTests.swift 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 00000000..eebb27c8 --- /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/MCPProxyTests/GlanceFormattingTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceFormattingTests.swift new file mode 100644 index 00000000..90f0c622 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceFormattingTests.swift @@ -0,0 +1,103 @@ +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) + } +} From 55507b0dd2049180b7e048c23ecf0b2c6871ce10 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 17:20:41 +0300 Subject: [PATCH 22/75] 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. --- .../Menu/Glance/GlanceSelection.swift | 39 +++++++++ .../MCPProxyTests/GlanceSelectionTests.swift | 83 +++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/GlanceSelectionTests.swift 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 00000000..bce73679 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift @@ -0,0 +1,39 @@ +// 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 + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionTests.swift new file mode 100644 index 00000000..0900f514 --- /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) + } +} From c3f393dbf28b23bc578d997aa7b151a75b4d4bca Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 17:21:54 +0300 Subject: [PATCH 23/75] 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. --- .../Menu/Glance/GlanceSelection.swift | 53 +++++++++ .../GlanceSelectionCollapseTests.swift | 106 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift index bce73679..1d59877e 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift @@ -16,6 +16,9 @@ enum GlanceSelection { /// 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. @@ -36,4 +39,54 @@ enum GlanceSelection { 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)) + } } diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift new file mode 100644 index 00000000..0cceb806 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift @@ -0,0 +1,106 @@ +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) + } +} From 76dcd0b6be4f5f3aef409addc010f9d0b2255de1 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 17:36:09 +0300 Subject: [PATCH 24/75] test(tray): pin rowLabel's tool_call type guard and truncation bounds A failed call_tool_* wrapper is persisted with both a tool_name and a server_name, so dropping the type check would render it as server:call_tool_read instead of the bare built-in name. Adds that case plus the at-limit and zero-limit truncation boundaries. Also documents what would invalidate rule 4: nested code_execution calls all share one request id, and are invisible here only because the nested caller writes to the legacy history without emitting an activity event. --- .../Menu/Glance/GlanceSelection.swift | 20 ++++++++++++++++++- .../MCPProxyTests/GlanceFormattingTests.swift | 17 ++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift index 1d59877e..df373bad 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift @@ -46,7 +46,25 @@ enum GlanceSelection { /// /// 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. + /// 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 { diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceFormattingTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceFormattingTests.swift index 90f0c622..fff06260 100644 --- a/native/macos/MCPProxy/MCPProxyTests/GlanceFormattingTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceFormattingTests.swift @@ -28,6 +28,14 @@ final class GlanceFormattingTests: XCTestCase { 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") @@ -47,6 +55,15 @@ final class GlanceFormattingTests: XCTestCase { 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}") } From 220cfca6a4a58efb0440c61421d0d31fb011363b Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 17:41:55 +0300 Subject: [PATCH 25/75] feat(tray): adapt activity SSE payloads into ActivityEntry rows --- .../MCPProxy/Menu/Glance/GlanceEvent.swift | 90 ++++++++++ .../MCPProxyTests/GlanceEventTests.swift | 158 ++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceEvent.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/GlanceEventTests.swift 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 00000000..a1c45347 --- /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/MCPProxyTests/GlanceEventTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceEventTests.swift new file mode 100644 index 00000000..fa7f8c7d --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceEventTests.swift @@ -0,0 +1,158 @@ +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) + } +} From 4b8520912b5aa0ead821875e747ddd78defaf07f Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 17:43:07 +0300 Subject: [PATCH 26/75] feat(tray): bounded prepend for the glance activity feed --- .../MCPProxy/MCPProxy/State/AppState.swift | 26 ++++++++++ .../MCPProxyTests/GlanceEventTests.swift | 48 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/native/macos/MCPProxy/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index dd575482..0332fe27 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -357,6 +357,32 @@ final class AppState: ObservableObject { } } + /// 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. + /// + /// Guarded on `coreState == .connected` for the same reason as the three + /// `update*` helpers: SSE teardown is asynchronous, so an event already in + /// flight when the core goes away would otherwise write a dead core's row + /// back over state `clearGlanceState()` had just emptied. + /// + /// Deliberately without the id-list 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. + @MainActor + func prependGlanceActivity(_ entry: ActivityEntry) { + guard coreState == .connected else { return } + glanceActivity.insert(entry, at: 0) + if glanceActivity.count > AppState.glanceActivityCap { + glanceActivity.removeLast(glanceActivity.count - AppState.glanceActivityCap) + } + } + /// Replace the glance (active-only) session feed. Leaves `recentSessions` untouched. /// /// Ignored unless the core is `.connected` — see `updateGlanceActivity`. diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceEventTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceEventTests.swift index fa7f8c7d..78acee8d 100644 --- a/native/macos/MCPProxy/MCPProxyTests/GlanceEventTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceEventTests.swift @@ -155,4 +155,52 @@ final class GlanceEventTests: XCTestCase { 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) + } + + 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) + + XCTAssertTrue(state.glanceActivity.isEmpty, + "a row arriving after the core dropped must not repopulate the cleared feed") + } } From f140c77e8fe2649db05e2a1c83c61a8b82443d28 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 17:43:57 +0300 Subject: [PATCH 27/75] fix(tray): consume activity.*.completed SSE events without refetching --- .../MCPProxy/Core/CoreProcessManager.swift | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift index e9fe6d64..5c4e5f20 100644 --- a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift +++ b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift @@ -680,24 +680,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) case "active_profile.changed": // Profiles v2 T5: the server-level default active profile was switched From 33e29ef2aa254510900fbdd3a87be4d97123cb1b Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 17:56:52 +0300 Subject: [PATCH 28/75] =?UTF-8?q?feat(tray):=20glance=20section=20skeleton?= =?UTF-8?q?=20=E2=80=94=20visibility=20rule=20and=20header=20summary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MCPProxy/Menu/Glance/GlanceSection.swift | 86 +++++++++++++ .../MCPProxyTests/GlanceSectionTests.swift | 120 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift 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 00000000..bb1548f8 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift @@ -0,0 +1,86 @@ +// 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 + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift new file mode 100644 index 00000000..e68e7820 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift @@ -0,0 +1,120 @@ +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) + } +} From 8714a65c5e42b274ae9edab66e710d49f007a669 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 17:57:46 +0300 Subject: [PATCH 29/75] feat(tray): glance Recent rows with full row identity --- .../MCPProxy/Menu/Glance/GlanceSection.swift | 77 +++++++++++++++++++ .../MCPProxyTests/GlanceSectionTests.swift | 53 +++++++++++++ 2 files changed, 130 insertions(+) diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift index bb1548f8..d82804bc 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift @@ -31,9 +31,15 @@ final class GlanceSection { private weak var clickTarget: AnyObject? private let clickAction: Selector + // 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] = [] init(target: AnyObject?, action: Selector) { self.clickTarget = target @@ -54,16 +60,81 @@ final class GlanceSection { /// array when the block is hidden. func items(for state: AppState, now: Date = Date()) -> [NSMenuItem] { 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 } + // 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 + } + // MARK: Header private func summaryTitle(for state: AppState) -> String { @@ -83,4 +154,10 @@ final class GlanceSection { 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/MCPProxyTests/GlanceSectionTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift index e68e7820..9ef2a36a 100644 --- a/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift @@ -40,6 +40,59 @@ final class GlanceSectionTests: XCTestCase { XCTAssertEqual(section.items(for: state, now: Self.now).first?.title, "1 client") } + // 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)) + } + // MARK: - Helpers private final class ClickStub: NSObject { From 8b1f3ab23254b3c7eb9b46931684cff3163e8019 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 17:58:49 +0300 Subject: [PATCH 30/75] feat(tray): glance Clients rows and 24h histogram submenu item --- .../MCPProxy/Menu/Glance/GlanceSection.swift | 84 +++++++++++++++++++ .../MCPProxyTests/GlanceSectionTests.swift | 69 +++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift index d82804bc..7928a9f9 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift @@ -31,6 +31,11 @@ final class GlanceSection { private weak var clickTarget: AnyObject? private let clickAction: Selector + /// 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)? + // MARK: Configuration /// Character budget for a row label before middle truncation kicks in. @@ -40,6 +45,11 @@ final class GlanceSection { 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? init(target: AnyObject?, action: Selector) { self.clickTarget = target @@ -61,6 +71,8 @@ final class GlanceSection { func items(for state: AppState, now: Date = Date()) -> [NSMenuItem] { summaryItem = nil activityRows = [] + clientRows = [] + histogramItem = nil guard isVisible(for: state) else { return [] } var items: [NSMenuItem] = [] @@ -88,6 +100,26 @@ final class GlanceSection { 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 } @@ -135,6 +167,58 @@ final class GlanceSection { return clause.isEmpty ? trimmed : clause } + /// 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 + } + // MARK: Header private func summaryTitle(for state: AppState) -> String { diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift index 9ef2a36a..8c18b298 100644 --- a/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift @@ -93,6 +93,75 @@ final class GlanceSectionTests: XCTestCase { XCTAssertNil(GlanceSection.firstClause(of: nil)) } + // 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)", + "—" + ]) + } + // MARK: - Helpers private final class ClickStub: NSObject { From ab31eb17cab9ea13213c907ea7932d418428475c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 17:59:45 +0300 Subject: [PATCH 31/75] feat(tray): in-place glance row updates with structural-change guard --- .../MCPProxy/Menu/Glance/GlanceSection.swift | 45 +++++++++++++++- .../MCPProxyTests/GlanceSectionTests.swift | 53 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift index 7928a9f9..3911a36a 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift @@ -46,6 +46,13 @@ final class GlanceSection { private var summaryItem: NSMenuItem? private var activityRows: [NSMenuItem] = [] 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 + /// 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. @@ -73,7 +80,10 @@ final class GlanceSection { activityRows = [] clientRows = [] histogramItem = nil - guard isVisible(for: state) else { return [] } + hasBuilt = true + builtVisible = isVisible(for: state) + builtWithTimeline = state.usageTimeline != nil + guard builtVisible else { return [] } var items: [NSMenuItem] = [] @@ -124,6 +134,39 @@ final class GlanceSection { 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, 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 + } + // MARK: Row rendering /// Rewrite an activity row so its title, icon, tooltip, accessibility label diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift index 8c18b298..f58bdcd1 100644 --- a/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift @@ -162,6 +162,59 @@ final class GlanceSectionTests: XCTestCase { ]) } + // 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)) + } + // MARK: - Helpers private final class ClickStub: NSObject { From 4c406a77d9504de937400feed87d874580f1cda9 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 18:02:41 +0300 Subject: [PATCH 32/75] feat(tray): encode glance status by shape and colour, not colour alone --- .../MCPProxy/Menu/Glance/GlanceSection.swift | 64 +++++++++++++++++-- .../MCPProxyTests/GlanceSectionTests.swift | 27 ++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift index 3911a36a..0a00c364 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift @@ -183,11 +183,10 @@ final class GlanceSection { item.setAccessibilityLabel("\(fullLabel), failed: \(detail), \(age) ago") } else { item.title = "\(label) — \(age)" - item.setAccessibilityLabel("\(fullLabel), \(failed ? "failed" : "succeeded"), \(age) ago") + item.setAccessibilityLabel("\(fullLabel), \(Self.outcomeDescription(for: entry)), \(age) ago") } - item.image = NSImage(systemSymbolName: GlanceFormatting.statusSymbolName(for: entry), - accessibilityDescription: failed ? "failed" : "succeeded") + item.image = Self.statusImage(for: entry) if let message = entry.errorMessage, !message.isEmpty { item.toolTip = "\(fullLabel)\n\(message)" @@ -210,6 +209,63 @@ final class GlanceSection { 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 + } + /// 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" @@ -225,7 +281,7 @@ final class GlanceSection { item.setAccessibilityLabel("\(name), \(callText)") } - item.image = NSImage(systemSymbolName: "circle.fill", accessibilityDescription: "connected") + item.image = Self.symbolImage(named: "circle.fill", tint: .systemGreen, description: "connected") if let version = session.clientVersion, !version.isEmpty { item.toolTip = "\(name) \(version)" diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift index f58bdcd1..a0fb0f46 100644 --- a/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift @@ -215,6 +215,33 @@ final class GlanceSectionTests: XCTestCase { XCTAssertFalse(Self.makeSection().updateInPlace(for: Self.busyState(), now: Self.now)) } + // 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 { From 79482d9da9c81283923e289658f7bdeffa0dabe1 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 18:04:13 +0300 Subject: [PATCH 33/75] feat(tray): key glance row updates on request id, not the reconcile-churned id --- .../MCPProxy/Menu/Glance/GlanceSection.swift | 89 +++++++++++++++---- .../MCPProxyTests/GlanceSectionTests.swift | 75 +++++++++++++++- 2 files changed, 146 insertions(+), 18 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift index 0a00c364..2dbfac29 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift @@ -43,8 +43,20 @@ final class GlanceSection { // 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: [NSMenuItem] = [] + private var activityRows: [ActivityRow] = [] private var clientRows: [NSMenuItem] = [] /// Snapshot of the structure the current items were built from, so an @@ -98,10 +110,10 @@ final class GlanceSection { items.append(disabledItem(titled: "No tool calls yet")) } else { for entry in entries { - let row = actionableItem() - apply(entry, to: row, now: now) + var row = ActivityRow(item: actionableItem()) + apply(entry, to: &row, now: now) activityRows.append(row) - items.append(row) + items.append(row.item) } } @@ -161,40 +173,85 @@ final class GlanceSection { 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) } + let summary = summaryTitle(for: state) + if summaryItem?.title != summary { summaryItem?.title = summary } + for index in activityRows.indices { apply(entries[index], 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. + private static func recordKey(for entry: ActivityEntry) -> String { + if let requestId = entry.requestId, !requestId.isEmpty { return requestId } + return entry.id + } + /// 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) { + /// + /// 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 { - item.title = "\(label) · \(detail) — \(age)" - item.setAccessibilityLabel("\(fullLabel), failed: \(detail), \(age) ago") + title = "\(label) · \(detail) — \(age)" + accessibility = "\(fullLabel), failed: \(detail), \(age) ago" } else { - item.title = "\(label) — \(age)" - item.setAccessibilityLabel("\(fullLabel), \(Self.outcomeDescription(for: entry)), \(age) ago") + title = "\(label) — \(age)" + accessibility = "\(fullLabel), \(Self.outcomeDescription(for: entry)), \(age) ago" } - item.image = Self.statusImage(for: entry) - + let toolTip: String if let message = entry.errorMessage, !message.isEmpty { - item.toolTip = "\(fullLabel)\n\(message)" + toolTip = "\(fullLabel)\n\(message)" } else { - item.toolTip = fullLabel + 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 } - item.representedObject = entry.sessionId + row.recordKey = key } /// First clause of an error message — everything up to the first newline, diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift index a0fb0f46..aca0c36c 100644 --- a/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift @@ -215,6 +215,74 @@ final class GlanceSectionTests: XCTestCase { XCTAssertFalse(Self.makeSection().updateInPlace(for: Self.busyState(), now: Self.now)) } + // MARK: - Rows are keyed on request id, not id + + /// A row rendered from a live SSE event carries a provisional id + /// (`":"`); 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: - Status is carried by shape AND colour func testStatusIsEncodedByShapeAndColourNotColourAlone() { @@ -282,14 +350,17 @@ final class GlanceSectionTests: XCTestCase { status: String = "success", error: String? = nil, timestamp: String, - session: String? = nil + session: String? = nil, + request: String? = nil ) -> ActivityEntry { var json: [String: Any] = [ "id": id, "type": type, "status": status, "timestamp": timestamp, - "request_id": "req-\(id)" + // 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 } From ca9c6146eaf81925510fadb09a9042839b22133d Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 18:05:25 +0300 Subject: [PATCH 34/75] fix(tray): republish glance sessions when a live call count moves --- .../MCPProxy/MCPProxy/API/APIClient.swift | 7 +++- .../MCPProxy/MCPProxy/State/AppState.swift | 8 +++- .../MCPProxyTests/AppStateGlanceTests.swift | 37 ++++++++++++++++++- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift index 134fba7d..412baed9 100644 --- a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift +++ b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift @@ -328,7 +328,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? diff --git a/native/macos/MCPProxy/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index 0332fe27..905fc955 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -386,10 +386,16 @@ final class AppState: ObservableObject { /// 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.map(\.id) != glanceSessions.map(\.id) { + if sessions != glanceSessions { glanceSessions = sessions } } diff --git a/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift b/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift index bf56bf0d..396fa6c3 100644 --- a/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift @@ -1,4 +1,5 @@ import XCTest +import Combine @testable import MCPProxy /// Tray Glance: the four `AppState` glance fields, the current-UTC-hour @@ -189,6 +190,38 @@ final class AppStateGlanceTests: XCTestCase { XCTAssertEqual(state.glanceSessions.map(\.id), ["s1"]) } + // MARK: - Session republish guard + + /// The tray's Clients rows render a live per-session call count + /// ("Claude Code — 8 calls · 1m"), so a session whose count moved has to + /// reach the menu. Guarding on ids alone froze that number at whatever the + /// first poll returned for as long as the session list's membership held. + func testUpdateGlanceSessionsRepublishesWhenACallCountMoves() throws { + let state = AppState() + state.coreState = .connected + state.updateGlanceSessions([try Self.session(id: "s1", status: "active", calls: 3)]) + + state.updateGlanceSessions([try Self.session(id: "s1", status: "active", calls: 40)]) + + XCTAssertEqual(state.glanceSessions.first?.toolCallCount, 40) + } + + /// …but the guard must still exist: an identical poll every 30s must not + /// publish, or the debounced `objectWillChange → rebuildMenu()` sink rebuilds + /// the menu forever on an idle proxy. + func testUpdateGlanceSessionsIgnoresAnIdenticalPoll() throws { + let state = AppState() + state.coreState = .connected + state.updateGlanceSessions([try Self.session(id: "s1", status: "active", calls: 3)]) + + var published = 0 + let sink = state.objectWillChange.sink { _ in published += 1 } + state.updateGlanceSessions([try Self.session(id: "s1", status: "active", calls: 3)]) + sink.cancel() + + XCTAssertEqual(published, 0) + } + // MARK: - Helpers private static func date(_ iso: String) -> Date { @@ -210,9 +243,9 @@ final class AppStateGlanceTests: XCTestCase { return try JSONDecoder().decode(ActivityEntry.self, from: json.data(using: .utf8)!) } - private static func session(id: String, status: String) throws -> APIClient.MCPSession { + 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":3} + {"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)!) From bfdae773f5a6352902beba596dfc96e07c904ba6 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 18:06:07 +0300 Subject: [PATCH 35/75] docs(tray): clarify when updateInPlace rewrites a row's whole identity --- .../MCPProxy/Menu/Glance/GlanceSection.swift | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift index 2dbfac29..3d72df0b 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift @@ -155,12 +155,15 @@ final class GlanceSection { /// 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. + /// 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 — 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 } From ecdd37de00b1e1f2c052b98bfcbce4e8e5ea459c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 18:19:06 +0300 Subject: [PATCH 36/75] perf(tray): stop rewriting and re-imaging client rows on an unchanged poll --- .../MCPProxy/Menu/Glance/GlanceSection.swift | 37 +++++++++++---- .../MCPProxyTests/GlanceSectionTests.swift | 47 +++++++++++++++++++ 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift index 3d72df0b..15f62d52 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift @@ -326,30 +326,49 @@ final class GlanceSection { 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 { - item.title = "\(name) — \(callText) · \(age)" - item.setAccessibilityLabel("\(name), \(callText), last active \(age) ago") + title = "\(name) — \(callText) · \(age)" + accessibility = "\(name), \(callText), last active \(age) ago" } else { - item.title = "\(name) — \(callText)" - item.setAccessibilityLabel("\(name), \(callText)") + title = "\(name) — \(callText)" + accessibility = "\(name), \(callText)" } - item.image = Self.symbolImage(named: "circle.fill", tint: .systemGreen, description: "connected") - + let toolTip: String if let version = session.clientVersion, !version.isEmpty { - item.toolTip = "\(name) \(version)" + toolTip = "\(name) \(version)" } else { - item.toolTip = name + toolTip = name } - item.representedObject = session.id + 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 diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift index aca0c36c..e46d6d3c 100644 --- a/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift @@ -283,6 +283,53 @@ final class GlanceSectionTests: XCTestCase { 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() { From c7adce20935db525657a226286e486e38433bf59 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 18:24:44 +0300 Subject: [PATCH 37/75] feat(tray): add MenuRebuildGuard so an open menu never restructures --- .../Menu/Glance/MenuRebuildGuard.swift | 60 +++++++++++++++++++ .../MCPProxyTests/MenuRebuildGuardTests.swift | 43 +++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/MenuRebuildGuardTests.swift 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 00000000..6bad6863 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift @@ -0,0 +1,60 @@ +// 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 + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/MenuRebuildGuardTests.swift b/native/macos/MCPProxy/MCPProxyTests/MenuRebuildGuardTests.swift new file mode 100644 index 00000000..01f6a6f2 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/MenuRebuildGuardTests.swift @@ -0,0 +1,43 @@ +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) + } +} From d6d3eff01d8286f1669a2225cdf0313b012efa6c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 18:25:22 +0300 Subject: [PATCH 38/75] feat(tray): build the session-filtered Web UI activity link --- .../MCPProxy/Menu/Glance/GlanceLinks.swift | 30 +++++++++++++ .../MCPProxyTests/GlanceLinksTests.swift | 43 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceLinks.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/GlanceLinksTests.swift 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 00000000..1fdd2c12 --- /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/MCPProxyTests/GlanceLinksTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceLinksTests.swift new file mode 100644 index 00000000..6bf1dd44 --- /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" + ) + } +} From caeb28eff3913a755c906a13570ba0fb342c70b7 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 18:27:35 +0300 Subject: [PATCH 39/75] test(tray): pin glance row click routing and the unfiltered-log fallback --- .../MCPProxyTests/GlanceRowRoutingTests.swift | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 native/macos/MCPProxy/MCPProxyTests/GlanceRowRoutingTests.swift diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceRowRoutingTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceRowRoutingTests.swift new file mode 100644 index 00000000..53b1e60f --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceRowRoutingTests.swift @@ -0,0 +1,140 @@ +import XCTest +import AppKit +@testable import MCPProxy + +/// Click routing for glance rows: every clickable row must carry BOTH a target +/// and an action, and the session id it hands the Web UI link must survive the +/// trip through `representedObject`. +@MainActor +final class GlanceRowRoutingTests: XCTestCase { + + static let now = GlanceFormatting.parseTimestamp("2027-01-15T08:00:00Z")! + + private final class ClickSpy: NSObject { + /// One entry per click, holding the session id the row handed over. + var clicks: [String?] = [] + @objc func openGlanceRow(_ sender: NSMenuItem) { + clicks.append(sender.representedObject as? String) + } + } + + /// A menu item with an action but no target routes up the responder chain + /// and is dropped — in a status-bar menu the row simply does nothing, with + /// no error anywhere. Pin both halves of the pair, and prove the pair + /// actually dispatches rather than only comparing fields. + func testEveryClickableRowCarriesBothTargetAndAction() { + let spy = ClickSpy() + let section = GlanceSection(target: spy, action: #selector(ClickSpy.openGlanceRow(_:))) + let items = section.items(for: Self.busyState(), now: Self.now) + + // A submenu item is not a glance click row: assigning `submenu` + // makes AppKit install its own `submenuAction:` and open the submenu + // itself, so the histogram row has an action but no target by design. + let clickable = items.filter { $0.action != nil && $0.submenu == nil } + XCTAssertEqual(clickable.count, 4, + "two activity rows, Open Activity…, and one client row") + + for item in clickable { + XCTAssertEqual(item.action, #selector(ClickSpy.openGlanceRow(_:))) + XCTAssertTrue(item.target === spy, + "a nil target makes the row silently do nothing") + } + + for item in clickable { + let sent = NSApplication.shared.sendAction(item.action!, to: item.target, from: item) + XCTAssertTrue(sent, "row '\(item.title)' did not dispatch") + } + XCTAssertEqual(spy.clicks, ["sess-a", nil, nil, "sess-a"], + "each row hands over its own session id") + } + + /// Two producers make `representedObject` nil: a record the core never + /// attributed to a session, and the "Open Activity…" row itself. Both mean + /// the same thing downstream — open the unfiltered log. + func testNilRepresentedObjectOpensTheUnfilteredLog() throws { + let state = Self.busyState() + state.glanceActivity = [ + Self.entry(id: "a", server: "github", tool: "create_issue", + timestamp: "2027-01-15T07:59:30Z", session: nil) + ] + let spy = ClickSpy() + let section = GlanceSection(target: spy, action: #selector(ClickSpy.openGlanceRow(_:))) + let items = section.items(for: state, now: Self.now) + + let unattributedRow = try XCTUnwrap(items.first { $0.title.hasPrefix("github:create_issue") }) + let openActivityRow = try XCTUnwrap(items.first { $0.title == "Open Activity…" }) + + XCTAssertNil(unattributedRow.representedObject, + "a record with no session_id must not carry a stale id") + XCTAssertNil(openActivityRow.representedObject, + "the Open Activity… row is deliberately unfiltered") + + for row in [unattributedRow, openActivityRow] { + XCTAssertEqual( + activityURLString(baseURL: "http://127.0.0.1:8080", + apiKey: "", + sessionID: row.representedObject as? String), + "http://127.0.0.1:8080/ui/activity" + ) + } + } + + // MARK: - Fixtures + + private static func busyState() -> AppState { + let state = AppState() + 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 + } + + private 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) + } + + private 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) + } +} From d27bd1cb475e2d2d723357ed7d305e70397c642d Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 18:29:40 +0300 Subject: [PATCH 40/75] 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. --- .../macos/MCPProxy/MCPProxy/MCPProxyApp.swift | 94 ++++ .../MCPProxy/MCPProxy/Menu/TrayMenu.swift | 511 ------------------ 2 files changed, 94 insertions(+), 511 deletions(-) delete mode 100644 native/macos/MCPProxy/MCPProxy/Menu/TrayMenu.swift diff --git a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift index e1e41353..476081a2 100644 --- a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift +++ b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift @@ -24,6 +24,19 @@ 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. + private lazy var glance = GlanceSection( + target: self, + action: #selector(openActivityForSession(_:)) + ) + + /// Suppresses structural rebuilds while the menu is on screen. + private var rebuildGuard = MenuRebuildGuard() + func applicationWillFinishLaunching(_ notification: Notification) { // Prevent focus steal on launch — no Dock icon, no Cmd+Tab entry NSApp.setActivationPolicy(.prohibited) @@ -190,10 +203,37 @@ 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 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() + } } func applicationWillTerminate(_ notification: Notification) { @@ -521,6 +561,33 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS /// Clears and rebuilds in-place to avoid replacing the menu object /// (which would close an already-open menu and lose the delegate). private func rebuildMenu() { + // Tray Glance owns live NSMenuItems and rewrites them in place, which is + // only safe on the thread AppKit draws them on. GlanceSection is + // deliberately not @MainActor (AppController is not actor-isolated, so + // annotating it would make this method fail to compile), so nothing at + // the type level enforces that — this does. Every caller is already on + // the main thread: the objectWillChange sink debounces on RunLoop.main + // and the rest are AppKit callbacks, so this is a regression tripwire, + // not a live constraint. + dispatchPrecondition(condition: .onQueue(.main)) + + // Tray Glance: while the menu is on screen its structure must not move + // under the cursor. Ask the glance section to rewrite its rows in place; + // it reports false — having mutated nothing — when the block's structure + // changed, and that rebuild then waits for menuDidClose. Non-glance + // sections are frozen for the same reason; menuWillOpen rebuilds the + // whole menu before it is drawn, so the next open is never stale. + if rebuildGuard.isMenuOpen { + // decide() only answers .rebuild for a closed menu, which the check + // above has already excluded; both open-menu outcomes stop here. + switch rebuildGuard.decide(structureChanged: !glance.updateInPlace(for: appState)) { + case .updateInPlace, .deferUntilClose: + return + case .rebuild: + break + } + } + let menu: NSMenu if let existing = statusItem.menu { existing.removeAllItems() @@ -590,6 +657,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 +1071,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")) diff --git a/native/macos/MCPProxy/MCPProxy/Menu/TrayMenu.swift b/native/macos/MCPProxy/MCPProxy/Menu/TrayMenu.swift deleted file mode 100644 index 8c90c4b9..00000000 --- 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() - } - } -} From c6f577f7be86044ddc68043ebc3d57549f2af9f9 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 18:31:50 +0300 Subject: [PATCH 41/75] refactor(tray): test the open-menu rebuild policy without an NSStatusItem Moves the 'while open, update rows in place; defer anything structural' decision out of rebuildMenu() into MenuRebuildGuard.decide(refreshing:from:), so the policy AppController actually runs is the one under test. --- .../macos/MCPProxy/MCPProxy/MCPProxyApp.swift | 23 ++--- .../Menu/Glance/MenuRebuildGuard.swift | 22 +++++ .../MCPProxyTests/GlanceFixtures.swift | 70 ++++++++++++++ .../MCPProxyTests/GlanceMenuPolicyTests.swift | 96 +++++++++++++++++++ .../MCPProxyTests/GlanceRowRoutingTests.swift | 71 +------------- 5 files changed, 202 insertions(+), 80 deletions(-) create mode 100644 native/macos/MCPProxy/MCPProxyTests/GlanceFixtures.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/GlanceMenuPolicyTests.swift diff --git a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift index 476081a2..ed28dcf8 100644 --- a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift +++ b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift @@ -572,20 +572,15 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS dispatchPrecondition(condition: .onQueue(.main)) // Tray Glance: while the menu is on screen its structure must not move - // under the cursor. Ask the glance section to rewrite its rows in place; - // it reports false — having mutated nothing — when the block's structure - // changed, and that rebuild then waits for menuDidClose. Non-glance - // sections are frozen for the same reason; menuWillOpen rebuilds the - // whole menu before it is drawn, so the next open is never stale. - if rebuildGuard.isMenuOpen { - // decide() only answers .rebuild for a closed menu, which the check - // above has already excluded; both open-menu outcomes stop here. - switch rebuildGuard.decide(structureChanged: !glance.updateInPlace(for: appState)) { - case .updateInPlace, .deferUntilClose: - return - case .rebuild: - break - } + // 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 diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift index 6bad6863..19d8212c 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift @@ -58,3 +58,25 @@ struct MenuRebuildGuard { 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`. + 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/MCPProxyTests/GlanceFixtures.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceFixtures.swift new file mode 100644 index 00000000..8c066f18 --- /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/GlanceMenuPolicyTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceMenuPolicyTests.swift new file mode 100644 index 00000000..ecb4a72a --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceMenuPolicyTests.swift @@ -0,0 +1,96 @@ +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) + + var guardState = MenuRebuildGuard() + guardState.menuWillOpen() + + // A third call arrives: the Recent list grows by a row. + 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") + 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/GlanceRowRoutingTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceRowRoutingTests.swift index 53b1e60f..57a53382 100644 --- a/native/macos/MCPProxy/MCPProxyTests/GlanceRowRoutingTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceRowRoutingTests.swift @@ -8,8 +8,6 @@ import AppKit @MainActor final class GlanceRowRoutingTests: XCTestCase { - static let now = GlanceFormatting.parseTimestamp("2027-01-15T08:00:00Z")! - private final class ClickSpy: NSObject { /// One entry per click, holding the session id the row handed over. var clicks: [String?] = [] @@ -25,7 +23,7 @@ final class GlanceRowRoutingTests: XCTestCase { func testEveryClickableRowCarriesBothTargetAndAction() { let spy = ClickSpy() let section = GlanceSection(target: spy, action: #selector(ClickSpy.openGlanceRow(_:))) - let items = section.items(for: Self.busyState(), now: Self.now) + let items = section.items(for: GlanceFixtures.connectedState(), now: GlanceFixtures.now) // A submenu item is not a glance click row: assigning `submenu` // makes AppKit install its own `submenuAction:` and open the submenu @@ -52,14 +50,14 @@ final class GlanceRowRoutingTests: XCTestCase { /// attributed to a session, and the "Open Activity…" row itself. Both mean /// the same thing downstream — open the unfiltered log. func testNilRepresentedObjectOpensTheUnfilteredLog() throws { - let state = Self.busyState() + let state = GlanceFixtures.connectedState() state.glanceActivity = [ - Self.entry(id: "a", server: "github", tool: "create_issue", - timestamp: "2027-01-15T07:59:30Z", session: nil) + GlanceFixtures.entry(id: "a", server: "github", tool: "create_issue", + timestamp: "2027-01-15T07:59:30Z", session: nil) ] let spy = ClickSpy() let section = GlanceSection(target: spy, action: #selector(ClickSpy.openGlanceRow(_:))) - let items = section.items(for: state, now: Self.now) + let items = section.items(for: state, now: GlanceFixtures.now) let unattributedRow = try XCTUnwrap(items.first { $0.title.hasPrefix("github:create_issue") }) let openActivityRow = try XCTUnwrap(items.first { $0.title == "Open Activity…" }) @@ -78,63 +76,4 @@ final class GlanceRowRoutingTests: XCTestCase { ) } } - - // MARK: - Fixtures - - private static func busyState() -> AppState { - let state = AppState() - 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 - } - - private 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) - } - - private 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) - } } From f0bec649dfbd6edcf655c5b257021f916017a32e Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 18:37:49 +0300 Subject: [PATCH 42/75] refactor(tray): enforce the menu's main-thread invariant at compile time Replaces the dispatchPrecondition tripwire in rebuildMenu() with @MainActor on the three members that touch live menu items: rebuildMenu, the rebuild-policy seam, and GlanceSection.updateInPlace. A threading mistake becomes a compile error rather than a release-build crash. Isolation is pinned to members, not types: @MainActor on GlanceSection itself makes its initializer isolated too, which AppController's lazy stored property cannot satisfy without isolating AppController as a whole. --- .../macos/MCPProxy/MCPProxy/MCPProxyApp.swift | 21 ++++++++++--------- .../MCPProxy/Menu/Glance/GlanceSection.swift | 20 ++++++++++++++---- .../Menu/Glance/MenuRebuildGuard.swift | 15 +++++++++++++ 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift index ed28dcf8..d8db6826 100644 --- a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift +++ b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift @@ -560,17 +560,17 @@ 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 RunLoop.main — 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 owns live NSMenuItems and rewrites them in place, which is - // only safe on the thread AppKit draws them on. GlanceSection is - // deliberately not @MainActor (AppController is not actor-isolated, so - // annotating it would make this method fail to compile), so nothing at - // the type level enforces that — this does. Every caller is already on - // the main thread: the objectWillChange sink debounces on RunLoop.main - // and the rest are AppKit callbacks, so this is a regression tripwire, - // not a live constraint. - dispatchPrecondition(condition: .onQueue(.main)) - // 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 @@ -1094,6 +1094,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/GlanceSection.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift index 15f62d52..23fb84b0 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift @@ -17,10 +17,16 @@ // 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. +// Deliberately NOT @MainActor as a *type*: AppController (the +// NSApplicationDelegate that calls this, MCPProxyApp.swift:15) is not +// actor-isolated, and this SDK does not infer MainActor from +// NSApplicationDelegate conformance. Isolating the type makes even the +// initializer main-actor-only, which its `private lazy var glance = ...` stored +// property cannot satisfy ("call to main actor-isolated initializer +// 'init(target:action:)' in a synchronous nonisolated context") — so it would +// force AppController itself to be isolated, far beyond the glance code. +// Instead the isolation is pinned to `updateInPlace`, the one member that +// mutates menu items already on screen. import AppKit @@ -164,7 +170,13 @@ final class GlanceSection { /// 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. + /// + /// `@MainActor` on the member rather than the type: this is the one call + /// that mutates menu items already on screen, and isolating the whole type + /// would force `AppController` — which is not actor-isolated — to change + /// well beyond the glance code (see the file header). @discardableResult + @MainActor func updateInPlace(for state: AppState, now: Date = Date()) -> Bool { guard hasBuilt else { return false } guard isVisible(for: state) == builtVisible else { return false } diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift index 19d8212c..134f6188 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift @@ -73,6 +73,21 @@ extension MenuRebuildGuard { /// /// This lives here, rather than inline in `rebuildMenu()`, so the policy can /// be tested without an `NSStatusItem`. + /// + /// `@MainActor` because this is the one call that mutates menu items already + /// on screen. `GlanceSection` is deliberately not actor-isolated as a type — + /// annotating it would force `AppController`, which is not isolated either, + /// to change well beyond the glance code — so the isolation is pinned to the + /// two members that actually touch live AppKit state: this seam and + /// `GlanceSection.updateInPlace`. + /// + /// 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 { From 365cf96a1e3200ae81949f4ee1e831713028b49d Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 18:47:22 +0300 Subject: [PATCH 43/75] test(tray): kill the half-update and stale-dirty mutants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .deferUntilClose design leans on updateInPlace being atomic — refusing an update must change nothing. The defer test pinned only an activity row, so a version that wrote the summary header before the row-count guard survived the suite, leaving the header describing one set of rows and the rows another. Pin the header under a changed call count. testReopeningClearsAStaleDirtyFlag was vacuous: it cleared the flag via menuDidClose before reopening, so deleting the clear from menuWillOpen kept the suite green. Model the only way the flag can actually be stale — a menuDidClose that never arrived. Also records the invariant the whole design rests on: menuWillOpen rebuilds unconditionally before every display, so the guard governs only what happens between an open and a close. --- .../MCPProxy/Menu/Glance/MenuRebuildGuard.swift | 11 +++++++++++ .../MCPProxyTests/GlanceMenuPolicyTests.swift | 11 ++++++++++- .../MCPProxyTests/MenuRebuildGuardTests.swift | 12 ++++++++++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift index 134f6188..5147bcde 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift @@ -25,6 +25,17 @@ enum MenuRebuildDecision: Equatable { /// 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 diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceMenuPolicyTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceMenuPolicyTests.swift index ecb4a72a..b2f410c6 100644 --- a/native/macos/MCPProxy/MCPProxyTests/GlanceMenuPolicyTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceMenuPolicyTests.swift @@ -61,10 +61,17 @@ final class GlanceMenuPolicyTests: XCTestCase { 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. + // 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"), @@ -75,6 +82,8 @@ final class GlanceMenuPolicyTests: XCTestCase { .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") } diff --git a/native/macos/MCPProxy/MCPProxyTests/MenuRebuildGuardTests.swift b/native/macos/MCPProxy/MCPProxyTests/MenuRebuildGuardTests.swift index 01f6a6f2..e6984c7c 100644 --- a/native/macos/MCPProxy/MCPProxyTests/MenuRebuildGuardTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/MenuRebuildGuardTests.swift @@ -32,12 +32,20 @@ final class MenuRebuildGuardTests: XCTestCase { 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) - _ = guardState.menuDidClose() + XCTAssertTrue(guardState.isDirty) + + // No menuDidClose in between. guardState.menuWillOpen() - XCTAssertFalse(guardState.isDirty) + 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") } } From 056ce7bb6e212da23e00596459cfbe982880c667 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 18:53:56 +0300 Subject: [PATCH 44/75] refactor(tray): isolate GlanceSection as a type, not member by member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @MainActor on the type covers the ~10 members that build live NSMenuItems or read AppState — items(for:), isVisible, summaryTitle, makeHistogramItem, the item factories and both apply overloads — which until now were safe only transitively, because rebuildMenu() happened to be the sole route to them. Corrects the previous claim that annotating the type would force AppController to be isolated. It does not: one @MainActor on the stored property that constructs the section is enough, and AppController is otherwise untouched. The alternative of a nonisolated initializer is rejected in the header, with the reason — it warns on mutating the isolated clickTarget and is an error under the Swift 6 language mode. --- .../macos/MCPProxy/MCPProxy/MCPProxyApp.swift | 6 +++ .../MCPProxy/Menu/Glance/GlanceSection.swift | 38 +++++++++++-------- .../Menu/Glance/MenuRebuildGuard.swift | 11 +++--- 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift index d8db6826..9618e6a3 100644 --- a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift +++ b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift @@ -29,6 +29,12 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS /// 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(_:)) diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift index 23fb84b0..d4083ba1 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift @@ -17,19 +17,31 @@ // 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 as a *type*: AppController (the -// NSApplicationDelegate that calls this, MCPProxyApp.swift:15) is not -// actor-isolated, and this SDK does not infer MainActor from -// NSApplicationDelegate conformance. Isolating the type makes even the -// initializer main-actor-only, which its `private lazy var glance = ...` stored -// property cannot satisfy ("call to main actor-isolated initializer -// 'init(target:action:)' in a synchronous nonisolated context") — so it would -// force AppController itself to be isolated, far beyond the glance code. -// Instead the isolation is pinned to `updateInPlace`, the one member that -// mutates menu items already on screen. +// @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 @@ -170,13 +182,7 @@ final class GlanceSection { /// 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. - /// - /// `@MainActor` on the member rather than the type: this is the one call - /// that mutates menu items already on screen, and isolating the whole type - /// would force `AppController` — which is not actor-isolated — to change - /// well beyond the glance code (see the file header). @discardableResult - @MainActor func updateInPlace(for state: AppState, now: Date = Date()) -> Bool { guard hasBuilt else { return false } guard isVisible(for: state) == builtVisible else { return false } diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift index 5147bcde..e877232b 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift @@ -85,12 +85,11 @@ extension MenuRebuildGuard { /// This lives here, rather than inline in `rebuildMenu()`, so the policy can /// be tested without an `NSStatusItem`. /// - /// `@MainActor` because this is the one call that mutates menu items already - /// on screen. `GlanceSection` is deliberately not actor-isolated as a type — - /// annotating it would force `AppController`, which is not isolated either, - /// to change well beyond the glance code — so the isolation is pinned to the - /// two members that actually touch live AppKit state: this seam and - /// `GlanceSection.updateInPlace`. + /// `@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 From 6d10bab0f4201fbb2dba285e8fc32c3f5240607f Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 19:01:25 +0300 Subject: [PATCH 45/75] 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. --- .../Menu/Glance/ActivityHistogramView.swift | 82 ++++++++++++++++++ .../ActivityHistogramTests.swift | 84 +++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift 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 00000000..5a2fa2e4 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift @@ -0,0 +1,82 @@ +// 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.. 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) + } +} From 8939b520dbd154aef400f17d76e758cd69045a78 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 19:01:50 +0300 Subject: [PATCH 46/75] 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. --- .../Menu/Glance/ActivityHistogramView.swift | 24 +++++++++++++++ .../ActivityHistogramTests.swift | 30 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift index 5a2fa2e4..58734304 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift @@ -79,4 +79,28 @@ enum ActivityHistogram { ) } } + + /// 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." + } } diff --git a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift index d9d41490..5d3ae60d 100644 --- a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift @@ -82,3 +82,33 @@ final class ActivityHistogramBarsTests: XCTestCase { 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." + ) + } +} From 608be3881b2e43d3b6a20f13ac0e38761bbcef6d Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 19:02:46 +0300 Subject: [PATCH 47/75] feat(tray): distinguish a failed usage refresh from "not loaded yet" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Guarded on .connected like the other glance updaters, so a dead core's failure cannot outlive it. --- .../MCPProxy/MCPProxy/State/AppState.swift | 25 ++++++++ .../ActivityHistogramTests.swift | 59 +++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/native/macos/MCPProxy/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index 905fc955..1949ae7d 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -104,6 +104,13 @@ final class AppState: ObservableObject { /// 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? + // MARK: Token metrics (from status response) @Published var tokenMetrics: TokenMetrics? @@ -329,6 +336,23 @@ final class AppState: ObservableObject { if usageTimeline != timeline { usageTimeline = timeline } let calls = AppState.callsInCurrentHour(timeline, now: now) 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. + /// + /// 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 } } /// Replace the glance activity feed. Leaves `recentActivity` untouched. @@ -415,5 +439,6 @@ final class AppState: ObservableObject { if !glanceSessions.isEmpty { glanceSessions = [] } if usageTimeline != nil { usageTimeline = nil } if callsThisHour != nil { callsThisHour = nil } + if usageError != nil { usageError = nil } } } diff --git a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift index 5d3ae60d..5c7aeb88 100644 --- a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift @@ -112,3 +112,62 @@ final class ActivityHistogramAccessibilityTests: XCTestCase { ) } } + +@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) + } +} From 1641149b90527e524d0f787c73132ce0c42a03a0 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 19:03:14 +0300 Subject: [PATCH 48/75] 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. --- .../Menu/Glance/ActivityHistogramView.swift | 84 ++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift index 58734304..27b352c5 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift @@ -8,7 +8,9 @@ // The chart renders from `AppState.usageTimeline` only — opening the submenu // performs no network request (spec 048 invariant). -import Foundation +import SwiftUI +import Charts +import AppKit // MARK: - Bar model @@ -104,3 +106,83 @@ enum ActivityHistogram { + "Busiest hour \(formatter.string(from: peak.hourStart)) with \(peak.total) calls." } } + +// 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 + } +} From ee9a9a9ff6ba38ddafb58da0e9be55b8b67e1d78 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 19:04:20 +0300 Subject: [PATCH 49/75] feat(tray): resolve the histogram's loading, failed and loaded states A loaded timeline beats a recorded failure; an empty error message is not a failure; an idle timeline is a flat axis, not a loading row. --- .../Menu/Glance/ActivityHistogramView.swift | 30 ++++++++++++++ .../ActivityHistogramTests.swift | 39 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift index 27b352c5..1c6dea2c 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift @@ -33,6 +33,23 @@ struct HistogramBar: Identifiable, Equatable { 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. @@ -105,6 +122,19 @@ enum ActivityHistogram { 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 diff --git a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift index 5c7aeb88..411f0e6b 100644 --- a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift @@ -113,6 +113,45 @@ final class ActivityHistogramAccessibilityTests: XCTestCase { } } +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 { From 24bbef5c09ef4974f8eebc7fd70d31f5e1e8a9b1 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 19:06:02 +0300 Subject: [PATCH 50/75] feat(tray): build the Activity (24h) submenu when it opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Menu/Glance/ActivityHistogramView.swift | 91 +++++++++++++++ .../ActivityHistogramTests.swift | 108 ++++++++++++++++++ 2 files changed, 199 insertions(+) diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift index 1c6dea2c..b7019c5e 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift @@ -216,3 +216,94 @@ extension ActivityHistogram { return item } } + +// 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. +/// +/// This is a separate `NSObject` rather than a method on `GlanceSection` +/// because `NSMenuDelegate` requires `NSObjectProtocol`, which `GlanceSection` +/// (a plain `@MainActor final class`) does not conform to. +/// +/// `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 submenu-structure tests are independent + /// of how the chart itself renders. + 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 + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift index 411f0e6b..770ffe3f 100644 --- a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift @@ -210,3 +210,111 @@ final class AppStateUsageErrorTests: XCTestCase { XCTAssertNil(state.usageError) } } + +@MainActor +final class ActivityHistogramSubmenuTests: XCTestCase { + + /// A connected core — every glance field on `AppState` is ignored otherwise. + private func connectedState() -> AppState { + let state = AppState() + state.coreState = .connected + return state + } + + /// The chart item is stubbed so these tests assert on submenu structure + /// alone, independent of how the chart itself renders. + 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. `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 submenu = makeSubmenu(connectedState()) + + XCTAssertEqual(submenu.menuItem.title, "Activity (24h)") + XCTAssertEqual(submenu.menuItem.submenu?.numberOfItems, 0) + } + + func testLoadingRowWhileTheTimelineIsNil() { + let submenu = makeSubmenu(connectedState()) + 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 = connectedState() + 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 = connectedState() + 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 = connectedState() + 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") + } + + /// The real chart item, not the stub: `chartItemSize` is otherwise an + /// unverified constant, and a mismatch with the view's own size shows up as + /// a band of dead space under the chart. + func testRealChartItemIsSizedAndLabelled() { + let bars = ActivityHistogram.bars(from: [], now: Fixture.now) + + let item = ActivityHistogram.chartMenuItem(bars: bars) + + XCTAssertEqual(item.view?.frame.size, ActivityHistogram.chartItemSize) + XCTAssertEqual(item.view?.fittingSize, ActivityHistogram.chartItemSize, + "the hosting view must fit its frame exactly, or the row grows dead space") + XCTAssertEqual(item.view?.accessibilityLabel(), + "Activity over the last 24 hours: no tool calls.") + XCTAssertFalse(item.isEnabled) + } +} From fefaac0fae438be3af5c805ab3235ea7630b6006 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 19:15:09 +0300 Subject: [PATCH 51/75] =?UTF-8?q?feat(tray):=20make=20the=20histogram=20re?= =?UTF-8?q?al=20=E2=80=94=20one=20lazy,=20state-aware=20submenu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GlanceSection keeps sole ownership of the Activity (24h) item, but its submenu now fills in on open via its own HistogramSubmenuDelegate rather than being built inside items(for:). rebuildMenu() runs on every debounced objectWillChange, menu open or closed, so an eagerly built chart would render a SwiftUI Chart nobody is looking at; the submenu also carries its own delegate so opening it cannot rebuild the tray menu under the cursor. The row is now state-aware: the 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 refresh failed. Replaces the optional histogramViewBuilder seam, which nothing in production ever set — so the tray shipped a text fallback and never a chart — with a non-optional factory defaulting to the real chart. Drops the standalone ActivityHistogramSubmenu in favour of this, so there is exactly one histogram submenu. --- .../macos/MCPProxy/MCPProxy/MCPProxyApp.swift | 14 +- .../Menu/Glance/ActivityHistogramView.swift | 66 +++---- .../MCPProxy/Menu/Glance/GlanceSection.swift | 55 +++--- .../ActivityHistogramTests.swift | 177 ++++++++++++++---- .../MCPProxyTests/GlanceSectionTests.swift | 43 +++-- 5 files changed, 246 insertions(+), 109 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift index 9618e6a3..f4b024fe 100644 --- a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift +++ b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift @@ -210,12 +210,16 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS 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 + // 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 diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift index b7019c5e..c15ede62 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift @@ -217,54 +217,43 @@ extension ActivityHistogram { } } -// MARK: - Submenu +// MARK: - Submenu delegate -/// Owns the tray's "Activity (24h)" item and rebuilds its single row when the -/// submenu opens. +/// Builds the single row of `GlanceSection`'s "Activity (24h)" submenu when +/// that 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. +/// 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. /// -/// This is a separate `NSObject` rather than a method on `GlanceSection` -/// 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. /// -/// `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 +/// 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 now: () -> Date private let chartItemFactory: ([HistogramBar]) -> NSMenuItem - /// - Parameters: - /// - now: injected clock, so the 24-hour axis is deterministic in tests. - /// - chartItemFactory: injected so submenu-structure tests are independent - /// of how the chart itself renders. + /// - 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, - 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 @@ -277,11 +266,16 @@ final class ActivityHistogramSubmenu: NSObject, NSMenuDelegate { // MARK: Rows /// 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: now() + now: Date() ) { case .loading: return Self.mutedItem("Loading…") diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift index d4083ba1..c50397b8 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift @@ -49,10 +49,11 @@ final class GlanceSection { private weak var clickTarget: AnyObject? private let clickAction: Selector - /// 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)? + /// 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 @@ -88,6 +89,12 @@ final class GlanceSection { /// 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 @@ -391,25 +398,31 @@ final class GlanceSection { // 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() - - 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…")) - } + 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 diff --git a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift index 770ffe3f..e8fd14aa 100644 --- a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift @@ -211,44 +211,112 @@ final class AppStateUsageErrorTests: XCTestCase { } } + +/// 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 ActivityHistogramSubmenuTests: XCTestCase { +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] = [] - /// A connected core — every glance field on `AppState` is ignored otherwise. + 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 } - /// The chart item is stubbed so these tests assert on submenu structure - /// alone, independent of how the chart itself renders. - private func makeSubmenu(_ state: AppState) -> ActivityHistogramSubmenu { - ActivityHistogramSubmenu( - appState: state, - now: { Fixture.now }, - chartItemFactory: { bars in - NSMenuItem(title: "CHART:\(bars.count)", action: nil, keyEquivalent: "") - } - ) + /// 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 — that is the whole point of - /// hanging the chart off the submenu delegate. `rebuildMenu()` runs on every + /// 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 submenu = makeSubmenu(connectedState()) + 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()) - XCTAssertEqual(submenu.menuItem.title, "Activity (24h)") - XCTAssertEqual(submenu.menuItem.submenu?.numberOfItems, 0) + 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 submenu = makeSubmenu(connectedState()) - let menu = submenu.menuItem.submenu! + let menu = histogramItem(makeSection(), connectedState()).submenu! - submenu.menuNeedsUpdate(menu) + open(menu) XCTAssertEqual(menu.numberOfItems, 1) XCTAssertEqual(menu.items[0].title, "Loading…") @@ -260,10 +328,9 @@ final class ActivityHistogramSubmenuTests: XCTestCase { func testErrorRowWhenTheFetchFailedBeforeAnyTimelineArrived() { let state = connectedState() state.recordUsageFailure("connection refused") - let submenu = makeSubmenu(state) - let menu = submenu.menuItem.submenu! + let menu = histogramItem(makeSection(), state).submenu! - submenu.menuNeedsUpdate(menu) + open(menu) XCTAssertEqual(menu.numberOfItems, 1) XCTAssertEqual(menu.items[0].title, "Usage unavailable") @@ -278,28 +345,52 @@ final class ActivityHistogramSubmenuTests: XCTestCase { let state = connectedState() state.recordUsageFailure("connection refused") state.usageTimeline = [Fixture.bucket(start: Fixture.currentHour, calls: 3, errors: 1)] - let submenu = makeSubmenu(state) - let menu = submenu.menuItem.submenu! + let menu = histogramItem(makeSection(), state).submenu! - submenu.menuNeedsUpdate(menu) + open(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() { + /// The row is read from AppState at OPEN time, not at build time — so a + /// timeline that arrives while the menu sits closed is shown on the next + /// open, and reopening replaces the row instead of appending to it. + func testReopeningRereadsStateAndReplacesTheRow() { let state = connectedState() - let submenu = makeSubmenu(state) - let menu = submenu.menuItem.submenu! + let menu = histogramItem(makeSection(), state).submenu! + + open(menu) + XCTAssertEqual(menu.items[0].title, "Loading…") - submenu.menuNeedsUpdate(menu) state.usageTimeline = [] - submenu.menuNeedsUpdate(menu) + open(menu) - XCTAssertEqual(menu.numberOfItems, 1) - XCTAssertEqual(menu.items[0].title, "CHART:24") + XCTAssertEqual(menu.numberOfItems, 1, "reopening replaces the row, never appends") + XCTAssertEqual(menu.items[0].title, "CHART:24", "an idle timeline is a flat axis, not a loading row") + } + + /// Opening the submenu must not restructure the menu it hangs from. The + /// whole point of the lazy build is that it touches the submenu and nothing + /// else — a parent that grew, shrank or re-created its rows while the user + /// had it open is the irritation `MenuRebuildGuard` exists to prevent. + func testOpeningTheSubmenuDoesNotRestructureTheParentMenu() { + let section = makeSection() + let state = connectedState() + state.usageTimeline = [Fixture.bucket(start: Fixture.currentHour, calls: 3, errors: 1)] + + let parent = NSMenu() + for item in section.items(for: state, now: Fixture.now) { parent.addItem(item) } + let countBefore = parent.numberOfItems + let itemsBefore = parent.items + + open(parent.items.first { $0.title == "Activity (24h)" }!.submenu!) + + XCTAssertEqual(parent.numberOfItems, countBefore) + XCTAssertTrue(zip(parent.items, itemsBefore).allSatisfy { $0 === $1 }, + "opening the submenu must not replace any row of the parent") + XCTAssertTrue(section.updateInPlace(for: state, now: Fixture.now), + "and must not make the block look structurally different afterwards") } /// The real chart item, not the stub: `chartItemSize` is otherwise an @@ -317,4 +408,20 @@ final class ActivityHistogramSubmenuTests: XCTestCase { "Activity over the last 24 hours: no tool calls.") XCTAssertFalse(item.isEnabled) } + + /// With no factory injected the submenu must still show a REAL chart. The + /// seam this replaced was optional and nothing in production ever set it, + /// so the shipped tray showed a text fallback and never a chart; a default + /// that already works cannot fail that way. + func testTheDefaultFactoryProducesTheRealChart() { + let state = connectedState() + state.usageTimeline = [Fixture.bucket(start: Fixture.currentHour, calls: 3, errors: 1)] + let menu = histogramItem(makeBareSection(), state).submenu! + + open(menu) + + XCTAssertEqual(menu.numberOfItems, 1) + XCTAssertEqual(menu.items[0].view?.frame.size, ActivityHistogram.chartItemSize) + XCTAssertNotNil(menu.items[0].view?.accessibilityLabel()) + } } diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift index e46d6d3c..66d220c0 100644 --- a/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift @@ -113,10 +113,21 @@ final class GlanceSectionTests: XCTestCase { XCTAssertFalse(row.isEnabled) } + /// The submenu's row is built by its delegate when it opens, so these two + /// tests fire `menuNeedsUpdate` where they previously read the row straight + /// out of `items(for:)`. What they assert is unchanged. + private func open(_ menu: NSMenu?) { + guard let menu, let delegate = menu.delegate else { + return XCTFail("the histogram submenu has no delegate, so opening it builds nothing") + } + delegate.menuNeedsUpdate?(menu) + } + func testHistogramSubmenuShowsLoadingUntilUsageArrives() { let section = Self.makeSection() let histogram = section.items(for: Self.busyState(), now: Self.now)[10] XCTAssertEqual(histogram.title, "Activity (24h)") + open(histogram.submenu) XCTAssertEqual(histogram.submenu?.item(at: 0)?.title, "Loading…") } @@ -124,23 +135,31 @@ final class GlanceSectionTests: XCTestCase { let state = Self.busyState() state.usageTimeline = [UsageBucket(start: Self.now, calls: 12, errors: 1, totalRespBytes: 0)] let section = Self.makeSection() - section.histogramViewBuilder = { buckets in + // The seam now takes the shaped 24-hour axis and returns the whole item, + // rather than taking raw buckets and returning a view — so the count + // below is the axis width, not the timeline length. + section.histogramChartItemFactory = { bars in + let item = NSMenuItem(title: "", action: nil, keyEquivalent: "") let view = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: 90)) - view.setAccessibilityLabel("\(buckets.count) buckets") - return view + view.setAccessibilityLabel("\(bars.count) bars") + item.view = view + return item } - let chart = section.items(for: state, now: Self.now)[10].submenu?.item(at: 0) + let submenu = section.items(for: state, now: Self.now)[10].submenu + open(submenu) + let chart = submenu?.item(at: 0) XCTAssertNotNil(chart?.view) - XCTAssertEqual(chart?.view?.accessibilityLabel(), "1 buckets") + XCTAssertEqual(chart?.view?.accessibilityLabel(), "24 bars") } - 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)") - } + // `testHistogramSubmenuFallsBackToTextWithoutABuilder` was REMOVED here, not + // ported: it asserted the text row shown when no view builder was injected, + // and there is no longer a builder-less mode to assert. The seam is + // non-optional and defaults to the real chart, precisely because the old + // optional seam was never set in production and the tray therefore shipped + // that text fallback and never a chart. Its successor — + // "with nothing injected, the submenu shows a real chart" — is + // `GlanceHistogramSubmenuTests.testTheDefaultFactoryProducesTheRealChart`. func testBlockLayoutOrder() { let section = Self.makeSection() From 385aa43213f1b059402052b4639eefaba6133b88 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 19:19:37 +0300 Subject: [PATCH 52/75] fix(tray): record a failed usage refresh instead of swallowing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catch in CoreProcessManager.refreshUsage dropped the error and left the header count and histogram submenu on "Loading…" forever, describing a fetch that is never coming back as one still in flight. usageError existed but had no production caller, so the "Usage unavailable" row was unreachable. The refresh moves onto AppState behind GlanceDataSource so the failure path is reachable from a test — untested wiring is how this state came to be unreachable in the first place. --- .../MCPProxy/Core/CoreProcessManager.swift | 11 ++- .../MCPProxy/MCPProxy/State/AppState.swift | 29 +++++++ .../ActivityHistogramTests.swift | 75 +++++++++++++++++++ 3 files changed, 109 insertions(+), 6 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift index 5c4e5f20..00e5c62c 100644 --- a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift +++ b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift @@ -893,14 +893,13 @@ actor CoreProcessManager { /// 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 } - 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 - } + await appState.refreshUsage(from: apiClient) } /// Fetch token metrics from the status endpoint and update appState. diff --git a/native/macos/MCPProxy/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index 1949ae7d..31e2e78b 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -355,6 +355,35 @@ final class AppState: ObservableObject { if usageError != message { usageError = 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 { + do { + let usage = try await source.usageAggregate(window: "24h", top: 1) + updateUsage(timeline: usage.timeline) + } catch { + 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 + } + /// Replace the glance activity feed. Leaves `recentActivity` untouched. /// /// Ignored unless the core is `.connected`. `clearGlanceState()` alone is not diff --git a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift index e8fd14aa..853547d0 100644 --- a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift @@ -425,3 +425,78 @@ final class GlanceHistogramSubmenuTests: XCTestCase { XCTAssertNotNil(menu.items[0].view?.accessibilityLabel()) } } + +/// The refresh path that makes `usageError` reachable. Without these, the +/// failure state is code nobody can ever see: the submenu would sit on +/// "Loading…" forever after a fetch that never recovers. +@MainActor +final class UsageRefreshWiringTests: XCTestCase { + + /// A data source that fails on demand. + private final class StubSource: GlanceDataSource { + var error: Error? + var timeline: [UsageBucket] = [] + + func usageAggregate(window: String, top: Int) async throws -> 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") + } + } +} From afb0f19a60d574ea0e3bc757767b3e9a2074a2bc Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 19:22:50 +0300 Subject: [PATCH 53/75] fix(tray): stop encoding chart outcomes by colour alone statusTint's rule for the rows above is that colour is never the only channel; the chart broke it. Errors now differ from successes in lightness and hue (muted neutral vs saturated red), which survives greyscale and colour-vision deficiency, and the legend is visible so the two series are named rather than guessed. Drops Color.accentColor: it follows the user's system accent, so anyone who had set theirs to red saw two near-identical segments. The item grows to 288x132 to give the legend its own space instead of taking it from 24 bars that are already only ~10pt wide. --- .../Menu/Glance/ActivityHistogramView.swift | 34 ++++++++--- .../ActivityHistogramTests.swift | 59 +++++++++++++++++++ 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift index c15ede62..81f4c1be 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift @@ -163,13 +163,27 @@ struct ActivityHistogramView: View { .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.accentColor, - "Errors": Color.red + "Succeeded": Color.secondary, + "Errors": Color(nsColor: .systemRed) ]) - // The legend would double the item's height for two self-evident - // colours; the accessibility label names both series instead. - .chartLegend(.hidden) + .chartLegend(.visible) .chartYAxis { AxisMarks(position: .leading, values: .automatic(desiredCount: 3)) } @@ -179,7 +193,10 @@ struct ActivityHistogramView: View { AxisValueLabel(format: .dateTime.hour()) } } - .frame(width: 260, height: 96) + // 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 @@ -194,8 +211,9 @@ 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) + /// 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. /// diff --git a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift index 853547d0..dae6b564 100644 --- a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift @@ -1,5 +1,6 @@ import XCTest import AppKit +import SwiftUI @testable import MCPProxy /// Shared anchors. All times are UTC: the backend aligns buckets with @@ -500,3 +501,61 @@ final class UsageRefreshWiringTests: XCTestCase { } } } + +/// The chart's visual encoding. `GlanceSection.statusTint` states the rule these +/// pin: colour is never the only channel separating two outcomes. +@MainActor +final class ActivityHistogramEncodingTests: XCTestCase { + + /// Fraction of drawn pixels that read as saturated red, plus how much was + /// drawn at all. Returns nil where nothing can be rasterised, so a machine + /// that cannot draw skips instead of failing. + private func redShare(of bars: [HistogramBar]) -> Double? { + let host = NSHostingView(rootView: ActivityHistogramView(bars: bars, accessibilitySummary: "")) + host.frame = NSRect(origin: .zero, size: ActivityHistogram.chartItemSize) + host.layoutSubtreeIfNeeded() + guard let rep = host.bitmapImageRepForCachingDisplay(in: host.bounds) else { return nil } + host.cacheDisplay(in: host.bounds, to: rep) + + var drawn = 0 + var red = 0 + for x in stride(from: 0, to: Int(rep.size.width), by: 2) { + for y in stride(from: 0, to: Int(rep.size.height), by: 2) { + guard let colour = rep.colorAt(x: x, y: y), colour.alphaComponent > 0.5 else { continue } + drawn += 1 + if colour.redComponent > 0.5, + colour.greenComponent < 0.45, + colour.blueComponent < 0.45 { + red += 1 + } + } + } + guard drawn > 100 else { return nil } + return Double(red) / Double(drawn) + } + + /// 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. + func testErrorSegmentsAreDrawnDistinctlyFromSuccesses() throws { + let hour = Fixture.currentHour + guard let withErrors = redShare(of: [HistogramBar(hourStart: hour, succeeded: 7, errors: 3)]), + let withoutErrors = redShare(of: [HistogramBar(hourStart: hour, succeeded: 10, errors: 0)]) + else { + throw XCTSkip("this machine cannot rasterise the chart") + } + + XCTAssertGreaterThan( + withErrors, withoutErrors + 0.05, + "an hour with errors must paint materially more of the error colour than one without" + ) + } + + // A companion test — "the success fill does not follow the system accent" + // — was attempted and DROPPED: `NSColor.currentControlTint` is get-only and + // the accent is not settable from a test process, so there is no honest way + // to render under a red accent. The guarantee is instead structural: the + // view no longer references `Color.accentColor` at all. Verified by + // inspection, not by the suite. +} From 20ce241565c3381e4cd7b836d8900d927eb11ec7 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 19:36:53 +0300 Subject: [PATCH 54/75] test(tray): pin the accent gap by chroma, and stop the colour test skipping itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The accent cannot be set from a test process, but the property is observable from the other side: Color.secondary is achromatic, an accent is not, so a success-only chart must paint no chroma at all. Shipped renders 0 of 4452 sampled pixels chromatic; with Color.accentColor restored, 4407. The failure direction is safe — a Graphite accent gives a false pass, never a false fail. A low pixel count no longer folds into XCTSkip. Skipping on a failed precondition reports green while asserting nothing, and does it precisely in CI where nobody is watching; a blank render now fails loudly. Skip is reserved for there being no bitmap to inspect at all. --- .../ActivityHistogramTests.swift | 84 +++++++++++++------ 1 file changed, 58 insertions(+), 26 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift index dae6b564..0b779ffc 100644 --- a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift @@ -507,31 +507,52 @@ final class UsageRefreshWiringTests: XCTestCase { @MainActor final class ActivityHistogramEncodingTests: XCTestCase { - /// Fraction of drawn pixels that read as saturated red, plus how much was - /// drawn at all. Returns nil where nothing can be rasterised, so a machine - /// that cannot draw skips instead of failing. - private func redShare(of bars: [HistogramBar]) -> Double? { + /// What a rasterised chart actually painted. + private struct Pixels { + /// Sample points with meaningful alpha — i.e. how much was drawn at all. + 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 + } + + /// Rasterise the chart and count pixels. + /// + /// Returns nil ONLY when there is genuinely no bitmap 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]) -> Pixels? { let host = NSHostingView(rootView: ActivityHistogramView(bars: bars, accessibilitySummary: "")) host.frame = NSRect(origin: .zero, size: ActivityHistogram.chartItemSize) host.layoutSubtreeIfNeeded() guard let rep = host.bitmapImageRepForCachingDisplay(in: host.bounds) else { return nil } host.cacheDisplay(in: host.bounds, to: rep) - var drawn = 0 - var red = 0 + var counts = Pixels() for x in stride(from: 0, to: Int(rep.size.width), by: 2) { for y in stride(from: 0, to: Int(rep.size.height), by: 2) { guard let colour = rep.colorAt(x: x, y: y), colour.alphaComponent > 0.5 else { continue } - drawn += 1 - if colour.redComponent > 0.5, - colour.greenComponent < 0.45, - colour.blueComponent < 0.45 { - red += 1 - } + 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 } } } - guard drawn > 100 else { return nil } - return Double(red) / Double(drawn) + return counts + } + + private func measure(_ bars: [HistogramBar]) throws -> Pixels { + guard let counts = pixels(of: bars) else { + throw XCTSkip("this machine cannot rasterise the chart at all") + } + // Asserted, never skipped: a blank render must fail loudly. + XCTAssertGreaterThan(counts.drawn, 100, "the chart rendered blank; the counts below mean nothing") + return counts } /// Errors must be drawn in a visually distinct fill, and that fill must be @@ -540,22 +561,33 @@ final class ActivityHistogramEncodingTests: XCTestCase { /// colour as successes. func testErrorSegmentsAreDrawnDistinctlyFromSuccesses() throws { let hour = Fixture.currentHour - guard let withErrors = redShare(of: [HistogramBar(hourStart: hour, succeeded: 7, errors: 3)]), - let withoutErrors = redShare(of: [HistogramBar(hourStart: hour, succeeded: 10, errors: 0)]) - else { - throw XCTSkip("this machine cannot rasterise the chart") - } + let withErrors = try measure([HistogramBar(hourStart: hour, succeeded: 7, errors: 3)]) + let withoutErrors = try measure([HistogramBar(hourStart: hour, succeeded: 10, errors: 0)]) + + let withShare = Double(withErrors.red) / Double(withErrors.drawn) + let withoutShare = Double(withoutErrors.red) / Double(withoutErrors.drawn) XCTAssertGreaterThan( - withErrors, withoutErrors + 0.05, + withShare, withoutShare + 0.05, "an hour with errors must paint materially more of the error colour than one without" ) } - // A companion test — "the success fill does not follow the system accent" - // — was attempted and DROPPED: `NSColor.currentControlTint` is get-only and - // the accent is not settable from a test process, so there is no honest way - // to render under a red accent. The guarantee is instead structural: the - // view no longer references `Color.accentColor` at all. Verified by - // inspection, not by the suite. + /// 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 chart 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. + func testSuccessFillCarriesNoChromaAndSoCannotBeTheAccentColour() throws { + let counts = try measure([HistogramBar(hourStart: Fixture.currentHour, + succeeded: 10, errors: 0)]) + + XCTAssertEqual( + counts.chromatic, 0, + "a chart with no errors must paint nothing coloured; \(counts.chromatic) of \(counts.drawn) sampled pixels carried chroma" + ) + } } From 2242de12d6133c6159b76c18c74cc317b17575e9 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 20:07:39 +0300 Subject: [PATCH 55/75] fix(tray): route the activity and sessions refreshes through GlanceDataSource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the three glance fetches lived in CoreProcessManager, called the concrete APIClient, and dropped their errors with a bare `// Non-fatal`. A permanently failing activity fetch therefore rendered "No tool calls yet" and a failing sessions fetch "No connected clients" — indis- tinguishable from a genuinely idle proxy, which is the same quiet lie `usageError` was added to stop the third feed telling. Both now fetch on AppState behind GlanceDataSource, like refreshUsage, and record their failures. The record is a per-feed consecutive-failure streak plus the last message: per-feed because the three fetch independently, and one shared counter that any success reset would report a permanently failing feed as healthy every 30 seconds. `recordGlanceFailure` carries the same `.connected` guard as every other publish helper here — a fetch already in flight when the core goes away resolves into its catch block after clearGlanceState(), and a dead core's failure must not outlive it. Nothing renders the streak yet; that is the next commit. --- .../MCPProxy/Core/CoreProcessManager.swift | 18 +- .../MCPProxy/MCPProxy/State/AppState.swift | 82 +++++++++ .../GlanceRefreshFailureTests.swift | 172 ++++++++++++++++++ 3 files changed, 260 insertions(+), 12 deletions(-) create mode 100644 native/macos/MCPProxy/MCPProxyTests/GlanceRefreshFailureTests.swift diff --git a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift index 00e5c62c..4a804ccd 100644 --- a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift +++ b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift @@ -868,14 +868,13 @@ 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 } - do { - let entries = try await apiClient.glanceActivity(limit: 50) - await appState.updateGlanceActivity(entries) - } catch { - // Non-fatal; we'll retry on the next refresh - } + await appState.refreshGlanceActivity(from: apiClient) } /// Tray Glance: fetch active-only sessions for the menu's "Clients" rows. @@ -883,12 +882,7 @@ actor CoreProcessManager { /// 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 - } + await appState.refreshGlanceSessions(from: apiClient) } /// Tray Glance: fetch the 24h usage aggregate that backs both the header diff --git a/native/macos/MCPProxy/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index 31e2e78b..61a756d2 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -111,6 +111,20 @@ final class AppState: ObservableObject { /// 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? + // MARK: Token metrics (from status response) @Published var tokenMetrics: TokenMetrics? @@ -337,6 +351,7 @@ final class AppState: ObservableObject { 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 @@ -353,6 +368,7 @@ final class AppState: ObservableObject { 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 @@ -384,6 +400,70 @@ final class AppState: ObservableObject { 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 { + do { + let entries = try await source.glanceActivity(limit: AppState.glanceActivityPageSize) + updateGlanceActivity(entries) + clearGlanceFailure(.activity) + } catch { + 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 { + do { + let sessions = try await source.activeSessions(limit: AppState.glanceSessionsPageSize) + updateGlanceSessions(sessions) + clearGlanceFailure(.sessions) + } catch { + 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 } + } + + /// 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 + } + } + + /// Records requested per glance activity poll. + static let glanceActivityPageSize = 50 + + /// 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 @@ -469,5 +549,7 @@ final class AppState: ObservableObject { if usageTimeline != nil { usageTimeline = nil } if callsThisHour != nil { callsThisHour = nil } if usageError != nil { usageError = nil } + if !glanceFailureStreak.isEmpty { glanceFailureStreak = [:] } + if glanceError != nil { glanceError = nil } } } diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceRefreshFailureTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceRefreshFailureTests.swift new file mode 100644 index 00000000..e8d18cf8 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceRefreshFailureTests.swift @@ -0,0 +1,172 @@ +import XCTest +@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: - The success path still publishes + + func testASuccessfulSessionsRefreshPublishesTheSessions() async throws { + let state = connectedState() + let source = StubSource() + source.sessions = [try Self.session(id: "s1")] + + await state.refreshGlanceSessions(from: source) + + XCTAssertEqual(state.glanceSessions.map(\.id), ["s1"]) + } +} From 69739c6afa511af5605397cde0f2daeeaadbc527 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 20:09:46 +0300 Subject: [PATCH 56/75] fix(tray): reject a glance publish from a previous core connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three glance fetches capture their client before suspending, and their publish guards asked only whether the tray is `.connected` right now. A response issued to a core that then died and was replaced resolves after reconnection has restored `.connected`, so the guard admits it and the dead core's rows, clients and usage overwrite the new core's. `testLateFetchDoesNotRepopulateAfterDisconnect` leaves the state disconnected, so it never saw this. AppState now carries a connection generation, bumped on every real coreState change. Each refresh captures it before the await and checks `isCurrentConnection(_:)` before publishing — on both the success and the failure path, since a dead core's error recorded against the new connection would mark a healthy core as failing. Re-assigning an unchanged coreState deliberately does not start a new generation: discarding a good in-flight response over a redundant publish would cost liveness for nothing. --- .../MCPProxy/MCPProxy/State/AppState.swift | 30 +++ .../GlanceReconnectGenerationTests.swift | 176 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 native/macos/MCPProxy/MCPProxyTests/GlanceReconnectGenerationTests.swift diff --git a/native/macos/MCPProxy/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index 61a756d2..d0b198fc 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -44,10 +44,31 @@ final class AppState: ObservableObject { /// 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 @@ -384,10 +405,13 @@ final class AppState: ObservableObject { /// 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)) } } @@ -411,11 +435,14 @@ final class AppState: ObservableObject { /// 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)) } } @@ -425,11 +452,14 @@ final class AppState: ObservableObject { /// 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)) } } diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceReconnectGenerationTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceReconnectGenerationTests.swift new file mode 100644 index 00000000..8e6aae2c --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceReconnectGenerationTests.swift @@ -0,0 +1,176 @@ +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) + } + + /// 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"]) + } +} From 15465aa80d4a441eb332597c4759862b23b1f99e Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 20:12:27 +0300 Subject: [PATCH 57/75] fix(tray): merge the reconciling poll into live rows instead of replacing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppState is reached through await and the poll suspends on the network, so an SSE event landing inside that window prepends a row the in-flight response cannot know about. Replacing the list wholesale erased it, and the call was missing from the menu until the next poll — up to 30 seconds of a row appearing and then vanishing. updateGlanceActivity now merges on GlanceSelection.recordKey (the requestId), retaining any existing row that is both absent from the page and newer than everything the page carries. The second condition is what keeps the feed from becoming append-only: a row the poll omits from within its own window is the poll's business. recordKey moves to GlanceSelection so the merge, rule 4's collapse and GlanceSection's row diff share one definition of "the same call" rather than three that can drift; GlanceSection delegates to it. The replace-vs-merge choice is now recorded on the function, which previously documented only the fingerprint guard. --- .../MCPProxy/Menu/Glance/GlanceSection.swift | 7 +- .../Menu/Glance/GlanceSelection.swift | 16 +++ .../MCPProxy/MCPProxy/State/AppState.swift | 41 +++++++- .../MCPProxyTests/AppStateGlanceTests.swift | 98 ++++++++++++++++++- 4 files changed, 156 insertions(+), 6 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift index c50397b8..0a2a6a3f 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift @@ -220,9 +220,12 @@ final class GlanceSection { /// `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 { - if let requestId = entry.requestId, !requestId.isEmpty { return requestId } - return entry.id + GlanceSelection.recordKey(for: entry) } /// Rewrite an activity row so its title, icon, tooltip, accessibility label diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift index df373bad..fd435b59 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift @@ -92,6 +92,22 @@ enum GlanceSelection { 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. diff --git a/native/macos/MCPProxy/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index d0b198fc..2ec0bc46 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -512,14 +512,51 @@ final class AppState: ObservableObject { @MainActor func updateGlanceActivity(_ entries: [ActivityEntry]) { guard coreState == .connected else { return } + let merged = AppState.mergeGlanceActivity(polled: entries, into: glanceActivity) func fingerprint(_ list: [ActivityEntry]) -> [String] { list.map { "\($0.id)|\($0.status)|\($0.hasSensitiveData == true)" } } - if fingerprint(entries) != fingerprint(glanceActivity) { - glanceActivity = entries + if fingerprint(merged) != fingerprint(glanceActivity) { + glanceActivity = merged } } + /// 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. + /// + /// A row is retained only when it is BOTH absent from the page and newer + /// than everything the page carries. The second half is what stops the feed + /// becoming append-only: a row the poll omits from within its own window — + /// collapsed, retention-pruned, filtered out — is the poll's business and is + /// dropped. A page whose timestamps cannot be parsed retains nothing, since + /// "newer than" is then unanswerable and the server's list is the better + /// answer. + static func mergeGlanceActivity(polled: [ActivityEntry], into existing: [ActivityEntry]) -> [ActivityEntry] { + // 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() + guard let newestPolled else { return Array(polled.prefix(glanceActivityCap)) } + + let polledKeys = Set(polled.map(GlanceSelection.recordKey)) + let retained = existing.filter { entry in + guard !polledKeys.contains(GlanceSelection.recordKey(for: entry)) else { return false } + guard let stamp = GlanceFormatting.parseTimestamp(entry.timestamp) else { return false } + return stamp > newestPolled + } + return Array((retained + polled).prefix(glanceActivityCap)) + } + /// 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. diff --git a/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift b/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift index 396fa6c3..586c37c9 100644 --- a/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift @@ -163,6 +163,95 @@ final class AppStateGlanceTests: XCTestCase { 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")) + // …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")) + + 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"]) + } + + /// 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")) + } + let page = try (0.. ActivityEntry { + 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":"2026-07-29T11:00:00Z"} + {"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)!) From b2d657b296d56d5907b5fd16f4d928283fc176ec Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 20:14:29 +0300 Subject: [PATCH 58/75] fix(tray): request the glance page at the endpoint's maximum depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rules 1-3 run client-side, after the page arrives, so the five rows were only ever as deep as one 50-record page. 46 management calls at the head of the log left the menu saying "No tool calls yet" while real calls sat just below the fold — reproduced by widening the existing 40-record noise fixture, which is boundary-shaped at exactly the point that still passes. Fixed by taking one deeper page rather than paging, decided on the Go side's behaviour: * ActivityFilter.Validate clamps limit to 100, so 100 is the ceiling for a single request — asking for more is silently served as 100. * offset does work end-to-end here (storage/activity.go honours it, TestListActivities_Pagination pins it), so paging is possible. * But Manager.ListActivities walks and unmarshals the WHOLE activity bucket on every call — it never breaks early, because it counts `total` — and that bucket holds up to activity_max_records (100k). Request cost is therefore set by the bucket, not by limit: a deeper page costs 50 more struct copies, a second page costs another 100k walk every 30 seconds. The residual is documented rather than papered over, and pinned by testRowsGoNoDeeperThanOnePage: five rows are guaranteed only when five qualify within the newest 100 records matching the type filter. --- .../MCPProxy/MCPProxy/API/APIClient.swift | 9 ++-- .../MCPProxy/MCPProxy/State/AppState.swift | 36 +++++++++++--- .../MCPProxyTests/APIClientGlanceTests.swift | 10 ++-- .../GlanceSelectionCollapseTests.swift | 47 +++++++++++++++++++ 4 files changed, 90 insertions(+), 12 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift index 412baed9..7fa04924 100644 --- a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift +++ b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift @@ -400,9 +400,12 @@ actor APIClient { /// 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] { + /// 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. + 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)" ) diff --git a/native/macos/MCPProxy/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index 2ec0bc46..1334bcbc 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -488,8 +488,33 @@ final class AppState: ObservableObject { } } - /// Records requested per glance activity poll. - static let glanceActivityPageSize = 50 + /// 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: five rows are + /// guaranteed only when five qualify within the newest 100 records matching + /// the type filter. `testRowsGoNoDeeperThanOnePage` pins that boundary. + static let glanceActivityPageSize = 100 /// Active sessions requested per poll. static let glanceSessionsPageSize = 25 @@ -557,10 +582,9 @@ final class AppState: ObservableObject { return Array((retained + polled).prefix(glanceActivityCap)) } - /// 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 + /// 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 diff --git a/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift b/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift index 0caec167..e8e17d70 100644 --- a/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift @@ -67,9 +67,13 @@ final class APIClientGlanceTests: XCTestCase { // MARK: - Glance activity - func testGlanceActivityRequestsToolCallTypesWithOversizedPage() async throws { + /// 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":50,"offset":0} + {"activities":[],"total":0,"limit":100,"offset":0} """) let client = GlanceStubURLProtocol.makeClient() @@ -77,7 +81,7 @@ final class APIClientGlanceTests: XCTestCase { XCTAssertEqual( GlanceStubURLProtocol.requestedURLs, - ["http://127.0.0.1:8080/api/v1/activity?type=tool_call,internal_tool_call&limit=50"] + ["http://127.0.0.1:8080/api/v1/activity?type=tool_call,internal_tool_call&limit=100"] ) } diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift index 0cceb806..79eacc85 100644 --- a/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift @@ -72,6 +72,53 @@ final class GlanceSelectionCollapseTests: XCTestCase { 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"]) + } + + /// 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") From 51cfff54bbc793cbad98aca8f1e9220c3962e59a Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 20:15:45 +0300 Subject: [PATCH 59/75] refactor(tray): delete builtWithTimeline, which had stopped protecting anything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard was right when Task 6 wrote it: items(for:) built the submenu's contents eagerly, so whether the timeline had loaded genuinely changed the built structure. Task 8 made the submenu lazy — its single row is built by HistogramSubmenuDelegate at open time from live AppState — and makeHistogramItem now returns an identical item either way. What was left was a spurious .deferUntilClose whenever the first usage fetch landed while the menu was open. Only items(for:) reset the flag, and the deferral suppresses exactly that call, so the glance rows stopped updating live for the REST of that menu session: ages froze, the header count froze, recovering only on close and reopen. Opening the menu shortly after launch or reconnect hit it. The test that pinned the old refusal is replaced by one that pins the behaviour that matters — rows keep updating when the timeline arrives, and are still updating a cycle later. --- .../MCPProxy/Menu/Glance/GlanceSection.swift | 17 +++++++-------- .../MCPProxyTests/GlanceSectionTests.swift | 21 ++++++++++++++++--- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift index 0a2a6a3f..e336a018 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift @@ -82,7 +82,6 @@ final class GlanceSection { /// in-place update can detect that a full rebuild is required instead. private var hasBuilt = false private var builtVisible = false - private var builtWithTimeline = false /// Held only so ownership of the submenu is explicit; `updateInPlace` /// deliberately never touches it (re-creating it would disturb an open @@ -119,7 +118,6 @@ final class GlanceSection { histogramItem = nil hasBuilt = true builtVisible = isVisible(for: state) - builtWithTimeline = state.usageTimeline != nil guard builtVisible else { return [] } var items: [NSMenuItem] = [] @@ -176,9 +174,9 @@ final class GlanceSection { /// 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. + /// 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 @@ -186,15 +184,16 @@ final class GlanceSection { /// 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 — re-creating it would - /// disturb an open submenu — so a change in its loaded-ness reports - /// structural instead. + /// 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 } - guard (state.usageTimeline != nil) == builtWithTimeline else { return false } let entries = GlanceSelection.activityRows(from: state.glanceActivity) let clients = GlanceSelection.activeClients(from: state.glanceSessions) diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift index 66d220c0..2af63518 100644 --- a/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift @@ -220,14 +220,29 @@ final class GlanceSectionTests: XCTestCase { XCTAssertEqual(items[3].title, "github:create_issue — 30s", "rows must be left untouched") } - func testUpdateInPlaceRefusesWhenHistogramLoadednessFlips() { + /// The first usage fetch landing while the menu is open must NOT report + /// structural. The submenu is filled in by its delegate when it opens, so a + /// timeline that arrives afterwards changes nothing about the built + /// structure — and reporting structural here cost the user live rows for + /// the whole menu session, because the deferral it triggered suppressed the + /// one call (`items(for:)`) that could clear it. Opening the menu shortly + /// after launch or reconnect hit exactly that. + func testTheTimelineArrivingWhileTheMenuIsOpenKeepsRowsUpdating() { let state = Self.busyState() let section = Self.makeSection() - _ = section.items(for: state, now: Self.now) + let items = section.items(for: state, now: Self.now) state.usageTimeline = [UsageBucket(start: Self.now, calls: 12, errors: 1, totalRespBytes: 0)] + state.callsThisHour = 13 + + XCTAssertTrue(section.updateInPlace(for: state, now: Self.now)) + XCTAssertEqual(items[0].title, "13 calls this hour · 1 client") - XCTAssertFalse(section.updateInPlace(for: state, now: Self.now)) + // And it is still updating a cycle later: the freeze was for the rest + // of the session, not for one tick. + state.callsThisHour = 14 + XCTAssertTrue(section.updateInPlace(for: state, now: Self.now)) + XCTAssertEqual(items[0].title, "14 calls this hour · 1 client") } func testUpdateInPlaceBeforeFirstBuildReportsStructural() { From cdd8268ece1e64dd45a7e5554e2e54966672a85b Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 20:18:18 +0300 Subject: [PATCH 60/75] feat(tray): say so when the glance feeds stop arriving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failing refresh was recorded and then never rendered. The submenu charts a loaded timeline in preference to a recorded failure — right for a blip, wrong for a core that is never coming back — so in the realistic sequence (core alive, glance populated, core dies) the failure was written every 30 seconds and shown never. And because refreshState bumps activityVersion whether or not anything succeeded, the block kept rebuilding with a fresh clock: a dead core's last five calls presented as a live, ticking display. A frozen menu is a hint that something is wrong; a ticking one is not. Three consecutive failures of any one feed — 90 seconds at the poll cadence — now flip AppState.glanceStale, which adds "· not updating" to the header and a muted "Not updating" row above the chart, carrying the last error as its tooltip. The data the block does have stays on screen; a single blip still passes unremarked. The marker is one @Published flip, not a write per failed cycle, so a core that has been gone for an hour does not rebuild the menu twice a minute forever. It is suppressed when the row below is already "Usage unavailable". --- .../Menu/Glance/ActivityHistogramView.swift | 27 +++++- .../MCPProxy/Menu/Glance/GlanceSection.swift | 10 +++ .../MCPProxy/MCPProxy/State/AppState.swift | 24 ++++++ .../ActivityHistogramTests.swift | 50 +++++++++++ .../GlanceRefreshFailureTests.swift | 85 +++++++++++++++++++ .../MCPProxyTests/GlanceSectionTests.swift | 31 +++++++ 6 files changed, 225 insertions(+), 2 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift index 81f4c1be..00b7028f 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift @@ -278,11 +278,34 @@ final class HistogramSubmenuDelegate: NSObject, NSMenuDelegate { func menuNeedsUpdate(_ menu: NSMenu) { menu.removeAllItems() - menu.addItem(currentItem()) + 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 @@ -298,7 +321,7 @@ final class HistogramSubmenuDelegate: NSObject, NSMenuDelegate { case .loading: return Self.mutedItem("Loading…") case .failed(let message): - let item = Self.mutedItem("Usage unavailable") + let item = Self.mutedItem(Self.unavailableTitle) item.toolTip = message return item case .loaded(let bars): diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift index e336a018..3f188f5c 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift @@ -432,6 +432,15 @@ final class GlanceSection { // 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 { @@ -439,6 +448,7 @@ final class GlanceSection { } 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: " · ") } diff --git a/native/macos/MCPProxy/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index 1334bcbc..e2ff121c 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -146,6 +146,20 @@ final class AppState: ObservableObject { /// 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? @@ -475,6 +489,7 @@ final class AppState: ObservableObject { 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 @@ -486,6 +501,14 @@ final class AppState: ObservableObject { 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. @@ -642,5 +665,6 @@ final class AppState: ObservableObject { if usageError != nil { usageError = nil } if !glanceFailureStreak.isEmpty { glanceFailureStreak = [:] } if glanceError != nil { glanceError = nil } + if glanceStale { glanceStale = false } } } diff --git a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift index 0b779ffc..0dbf4e72 100644 --- a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift @@ -341,6 +341,56 @@ final class GlanceHistogramSubmenuTests: XCTestCase { 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.. Date: Wed, 29 Jul 2026 20:22:49 +0300 Subject: [PATCH 61/75] test(tray): pin the legend, and the two fields the glance branches on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legend was the one user-visible thing on the chart that no test asserted the presence of: flipping .chartLegend(.visible) 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. Measured rather than 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. Probed both ways before writing the assertion — visible: 56 red samples, all in the bottom third; hidden: zero, everywhere. The sampler walks the rep's pixel extent so "the bottom 24 points" means the same thing at 1x and 2x, which the existing pixels(of:) helper does not do. The decode test now also asserts `type` and `status`. They are the two fields the entire feature branches on — type drives rules 1-3 and rowLabel's server:tool decision, status drives the symbol, tint, VoiceOver phrasing and error clause — and nothing else sees the wire format for them. --- .../MCPProxyTests/APIClientGlanceTests.swift | 7 +++ .../ActivityHistogramTests.swift | 57 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift b/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift index e8e17d70..761837b2 100644 --- a/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift @@ -101,6 +101,13 @@ final class APIClientGlanceTests: XCTestCase { 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 diff --git a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift index 0dbf4e72..970c2e37 100644 --- a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift @@ -623,6 +623,63 @@ final class ActivityHistogramEncodingTests: XCTestCase { ) } + /// Count red pixels above and below the legend band. + /// + /// Deliberately its own sampler rather than a parameter on `pixels(of:)`: + /// this one walks the rep's PIXEL extent (`pixelsWide`/`pixelsHigh`), which + /// is what makes "the bottom 24 points" mean the same thing at 1x and 2x. + private func redAboveAndBelowTheLegendBand(_ bars: [HistogramBar]) -> (above: Int, below: Int, drawn: Int)? { + let host = NSHostingView(rootView: ActivityHistogramView(bars: bars, accessibilitySummary: "")) + host.frame = NSRect(origin: .zero, size: ActivityHistogram.chartItemSize) + host.layoutSubtreeIfNeeded() + guard let rep = host.bitmapImageRepForCachingDisplay(in: host.bounds) else { return nil } + host.cacheDisplay(in: host.bounds, to: rep) + + // y grows downward in a cached rep, so the legend band is the last rows. + let bandStart = rep.pixelsHigh - Int((24.0 / ActivityHistogram.chartItemSize.height) + * Double(rep.pixelsHigh)) + var above = 0, below = 0, drawn = 0 + for x in stride(from: 0, to: rep.pixelsWide, by: 2) { + for y in stride(from: 0, to: rep.pixelsHigh, by: 2) { + guard let colour = rep.colorAt(x: x, y: y), colour.alphaComponent > 0.5 else { continue } + drawn += 1 + let r = colour.redComponent, g = colour.greenComponent, b = colour.blueComponent + guard r > 0.5, g < 0.45, b < 0.45 else { continue } + if y >= bandStart { below += 1 } else { above += 1 } + } + } + return (above, below, drawn) + } + + /// 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. + func testTheLegendRendersInsideTheFrameThatPaysForIt() throws { + let bars = ActivityHistogram.bars(from: [], now: Fixture.now) + + guard let counts = redAboveAndBelowTheLegendBand(bars) else { + throw XCTSkip("this machine cannot rasterise the chart at all") + } + XCTAssertGreaterThan(counts.drawn, 50, "the chart rendered blank; the counts below mean nothing") + XCTAssertGreaterThan( + counts.below, 0, + "no legend swatch in the bottom band — the 20pt of frame height buys nothing" + ) + XCTAssertEqual( + counts.above, 0, + "an all-zero axis must paint no red above the legend, or this measures chart data" + ) + } + /// The success fill must not follow `Color.accentColor`, or a user whose /// system accent is red sees two near-identical segments. /// From a772b431fc0536b7a4dfb27f09edb8d6702aa8aa Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 20:23:00 +0300 Subject: [PATCH 62/75] docs(tray): correct the stale comments the branch left behind, and zip one loop Five small things the whole-branch review called out: * CoreProcessManager's periodic-refresh comment read "SSE doesn't emit 'activity' events", thirty lines below a switch that now consumes two activity SSE events. Literally still true, but a reader would take it as "SSE carries no activity" and delete the activityVersion bump on its strength. * AppState's prepend comment described updateGlanceSessions as carrying an id-list guard; that became a whole-value Equatable guard for a load-bearing reason (an id-only comparison froze the live call count). * EnvelopeProbe's residual: it answers "does this body carry a success field", so a bare payload with its own success field probes as enveloped. Reachable, harmless, and previously documented as the unreachable shape. * sendSensitiveDataAlert has no production callers and never has had any; the note names sensitive_data.detected as the intended trigger so the next reader does not assume it is live. * updateInPlace indexed `entries` by `activityRows.indices` while the adjacent loop used zip. Now both use zip: one of the two could read out of bounds if the count guard above were weakened, and it was the only one that could. --- native/macos/MCPProxy/MCPProxy/API/APIClient.swift | 6 ++++++ .../MCPProxy/MCPProxy/Core/CoreProcessManager.swift | 7 +++++-- .../MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift | 7 ++++++- .../MCPProxy/Services/NotificationService.swift | 10 ++++++++++ native/macos/MCPProxy/MCPProxy/State/AppState.swift | 6 ++++-- 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift index 7fa04924..87a13ac9 100644 --- a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift +++ b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift @@ -649,6 +649,12 @@ 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 } diff --git a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift index 4a804ccd..6cc962d4 100644 --- a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift +++ b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift @@ -756,8 +756,11 @@ actor CoreProcessManager { 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.upstream_completed` and + // `activity.internal_completed`, 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 } } diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift index 3f188f5c..9da2d973 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift @@ -202,7 +202,12 @@ final class GlanceSection { let summary = summaryTitle(for: state) if summaryItem?.title != summary { summaryItem?.title = summary } - for index in activityRows.indices { apply(entries[index], to: &activityRows[index], now: now) } + // `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 } diff --git a/native/macos/MCPProxy/MCPProxy/Services/NotificationService.swift b/native/macos/MCPProxy/MCPProxy/Services/NotificationService.swift index 65a93d34..2e7f7c4f 100644 --- a/native/macos/MCPProxy/MCPProxy/Services/NotificationService.swift +++ b/native/macos/MCPProxy/MCPProxy/Services/NotificationService.swift @@ -135,6 +135,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 e2ff121c..f15dac9a 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -618,9 +618,11 @@ final class AppState: ObservableObject { /// flight when the core goes away would otherwise write a dead core's row /// back over state `clearGlanceState()` had just emptied. /// - /// Deliberately without the id-list equality guard `updateGlanceSessions(_:)` + /// 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. + /// 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) { guard coreState == .connected else { return } From 42a3d3e84bfc3785279dcd880164df6722b26a05 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 20:36:26 +0300 Subject: [PATCH 63/75] fix(tray): keep live rows on an empty poll, and name the SSE events correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to the fix wave. mergeGlanceActivity discarded every retained row when the polled page was empty — finding 2's own bug shape surviving in the branch that handles "no newest record". 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 an SSE row prepended while that poll was in flight was erased anyway. The doc comment reasoned about unparsable timestamps and never noticed the empty page takes the same branch; both cases are now enumerated, and they mean opposite things — a page WITH records is the server's account of the feed and still wins. The previous commit's corrected comment also invented the event names. `activity.upstream_completed` / `activity.internal_completed` exist nowhere in the repo; the wire names, grepped from internal/runtime/events.go:29,41 and matched by GlanceEvent, are `activity.tool_call.completed` and `activity.internal_tool_call.completed`. --- .../MCPProxy/Core/CoreProcessManager.swift | 8 ++--- .../MCPProxy/MCPProxy/State/AppState.swift | 17 +++++++-- .../MCPProxyTests/AppStateGlanceTests.swift | 35 +++++++++++++++++++ 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift index 6cc962d4..b62c7c86 100644 --- a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift +++ b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift @@ -757,10 +757,10 @@ actor CoreProcessManager { await refreshSecurityStatus() await refreshProfiles() // Bump activityVersion so ActivityView reloads. Still needed after the - // glance's SSE work: the bus emits `activity.upstream_completed` and - // `activity.internal_completed`, which feed the glance rows only — - // there is no bare "activity" event, and nothing else republishes the - // full log ActivityView renders. + // 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 } } diff --git a/native/macos/MCPProxy/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index f15dac9a..99142fae 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -587,10 +587,21 @@ final class AppState: ObservableObject { /// than everything the page carries. The second half is what stops the feed /// becoming append-only: a row the poll omits from within its own window — /// collapsed, retention-pruned, filtered out — is the poll's business and is - /// dropped. A page whose timestamps cannot be parsed retains nothing, since - /// "newer than" is then unanswerable and the server's list is the better - /// answer. + /// dropped. + /// + /// 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 the live rows stand. Replacing here would erase + /// the row the SSE event just put on screen, which is the exact bug this + /// function exists to fix. + /// * 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. static func mergeGlanceActivity(polled: [ActivityEntry], into existing: [ActivityEntry]) -> [ActivityEntry] { + guard !polled.isEmpty else { return Array(existing.prefix(glanceActivityCap)) } + // 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() diff --git a/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift b/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift index 586c37c9..845b451d 100644 --- a/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift @@ -231,6 +231,41 @@ final class AppStateGlanceTests: XCTestCase { 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")) + + state.updateGlanceActivity([]) + + XCTAssertEqual(state.glanceActivity.map(\.requestId), ["r-9"]) + } + + /// …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")) + + 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 { From 037006ffdee0595a48e2b57197e40e9e31899001 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 21:45:06 +0300 Subject: [PATCH 64/75] fix(tray): retain only rows the server has not seen yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty-page guard I added to keep a racing SSE row on screen returned the whole existing list, so it also preserved canonical rows the server had legitimately dropped — and on an idle core nothing ever reached back past them, so they stayed forever while the successful polls kept clearing the staleness marker that would have hinted at trouble. The same hole existed before that guard, for any deleted record whose timestamp was newer than the remaining page's maximum. "Absent from the page" was never the right predicate: retention is for rows the server has not written yet, which is exactly the rows this tray prepended from SSE and no poll has carried back. AppState now tracks those keys, and the merge retains nothing else — a row the page carries becomes the server's record and leaves the set, a row that is dropped leaves it too. My own test passed the broken behaviour because it started from an optimistic row and polled empty once. The three new cases are the ones that could not: a canonical row deleted server-side, a deleted row newer than everything the page still carries, and a live row confirmed by a poll and deleted afterwards. --- .../MCPProxy/MCPProxy/State/AppState.swift | 77 +++++++++++++------ .../MCPProxyTests/AppStateGlanceTests.swift | 57 ++++++++++++++ 2 files changed, 112 insertions(+), 22 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index 99142fae..d4419d57 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -560,15 +560,24 @@ final class AppState: ObservableObject { @MainActor func updateGlanceActivity(_ entries: [ActivityEntry]) { guard coreState == .connected else { return } - let merged = AppState.mergeGlanceActivity(polled: entries, into: glanceActivity) + 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) != fingerprint(glanceActivity) { - glanceActivity = merged + 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 @@ -583,37 +592,57 @@ final class AppState: ObservableObject { /// storage id and the SSE row's provisional `:` differ for /// one and the same call, so id-keying would keep both. /// - /// A row is retained only when it is BOTH absent from the page and newer - /// than everything the page carries. The second half is what stops the feed - /// becoming append-only: a row the poll omits from within its own window — - /// collapsed, retention-pruned, filtered out — is the poll's business and is - /// dropped. + /// 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. /// - /// Two pages carry no "newest record", and they mean opposite things: + /// 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 the live rows stand. Replacing here would erase - /// the row the SSE event just put on screen, which is the exact bug this - /// function exists to fix. + /// 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. - static func mergeGlanceActivity(polled: [ActivityEntry], into existing: [ActivityEntry]) -> [ActivityEntry] { - guard !polled.isEmpty else { return Array(existing.prefix(glanceActivityCap)) } - + /// + /// 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() - guard let newestPolled else { return Array(polled.prefix(glanceActivityCap)) } - let polledKeys = Set(polled.map(GlanceSelection.recordKey)) - let retained = existing.filter { entry in - guard !polledKeys.contains(GlanceSelection.recordKey(for: entry)) else { return false } - guard let stamp = GlanceFormatting.parseTimestamp(entry.timestamp) else { return false } - return stamp > newestPolled + + 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 = [] } - return Array((retained + polled).prefix(glanceActivityCap)) + + 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 @@ -637,6 +666,9 @@ final class AppState: ObservableObject { @MainActor func prependGlanceActivity(_ entry: ActivityEntry) { guard coreState == .connected 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) @@ -676,6 +708,7 @@ final class AppState: ObservableObject { 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/MCPProxyTests/AppStateGlanceTests.swift b/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift index 845b451d..87a39d79 100644 --- a/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift @@ -248,6 +248,63 @@ final class AppStateGlanceTests: XCTestCase { 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")) + 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) + } + /// …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 From 99622fa299695e189252228b1fbcb14816b8539b Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 21:46:33 +0300 Subject: [PATCH 65/75] fix(tray): guard the SSE row publish with the connection generation too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REST refreshes check the generation; the SSE path checked only `.connected`, and the "it is one MainActor hop" argument for leaving it that way was wrong — executors guarantee no ordering between that hop and the reconnect work, so an event read from a dead core's stream can resume after `.connected` has been restored and prepend that core's call to the new core's feed. The existing SSE test covers the disconnected window, where the plain guard already worked. prependGlanceActivity now takes the generation and checks isCurrentConnection, the same shape as the three fetches. The caller captures it where the STREAM is opened rather than at event arrival: a stream belongs to exactly one connection, whereas a generation read at arrival is the post-reconnect one and would pass the guard. Pinned in both directions — an event from the previous connection is dropped, one from the live connection still publishes. --- .../MCPProxy/Core/CoreProcessManager.swift | 20 ++++++++-- .../MCPProxy/MCPProxy/State/AppState.swift | 16 +++++--- .../MCPProxyTests/AppStateGlanceTests.swift | 18 ++++++--- .../MCPProxyTests/GlanceEventTests.swift | 4 +- .../GlanceReconnectGenerationTests.swift | 37 +++++++++++++++++++ 5 files changed, 78 insertions(+), 17 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift index b62c7c86..5b990a34 100644 --- a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift +++ b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift @@ -605,10 +605,16 @@ actor CoreProcessManager { sseTask?.cancel() sseTask = Task { [weak self] in + // Capture the connection generation HERE, where the stream opens: a + // stream belongs to exactly one connection, and every event it + // carries belongs to that one. Reading it at event arrival instead + // would capture whatever generation is current by then — which, + // after a reconnect, is the new one, and the guard would pass. + guard let generation = await self?.currentConnectionGeneration() else { return } let stream = await sseClient.connect() for await event in stream { guard !Task.isCancelled else { break } - await self?.handleSSEEvent(event) + await self?.handleSSEEvent(event, generation: generation) } // Stream ended -- trigger reconnection if still connected guard !Task.isCancelled else { return } @@ -622,7 +628,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 @@ -696,7 +710,7 @@ actor CoreProcessManager { let entry = GlanceEvent.adapt(eventName: event.event, data: data) else { break } - await appState.prependGlanceActivity(entry) + await appState.prependGlanceActivity(entry, generation: generation) case "active_profile.changed": // Profiles v2 T5: the server-level default active profile was switched diff --git a/native/macos/MCPProxy/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index d4419d57..81760d8d 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -653,10 +653,14 @@ final class AppState: ObservableObject { /// Bounded so a busy agent cannot grow the feed without limit; the 30s /// reconciling poll replaces the list wholesale with canonical records. /// - /// Guarded on `coreState == .connected` for the same reason as the three - /// `update*` helpers: SSE teardown is asynchronous, so an event already in - /// flight when the core goes away would otherwise write a dead core's row - /// back over state `clearGlanceState()` had just emptied. + /// 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 @@ -664,8 +668,8 @@ final class AppState: ObservableObject { /// 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) { - guard coreState == .connected else { return } + 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)) diff --git a/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift b/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift index 87a39d79..6e835968 100644 --- a/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift @@ -180,7 +180,8 @@ final class AppStateGlanceTests: XCTestCase { // 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")) + timestamp: "2026-07-29T11:00:30Z"), + generation: state.connectionGeneration) // …and the poll's response, which predates it, resolves now. state.updateGlanceActivity(page) @@ -200,7 +201,8 @@ final class AppStateGlanceTests: XCTestCase { 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")) + 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", @@ -241,7 +243,8 @@ final class AppStateGlanceTests: XCTestCase { 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")) + timestamp: "2026-07-29T11:00:30Z"), + generation: state.connectionGeneration) state.updateGlanceActivity([]) @@ -294,7 +297,8 @@ final class AppStateGlanceTests: XCTestCase { 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")) + 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") @@ -314,7 +318,8 @@ final class AppStateGlanceTests: XCTestCase { 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")) + 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") @@ -331,7 +336,8 @@ final class AppStateGlanceTests: XCTestCase { 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")) + timestamp: "2026-07-29T12:00:00Z"), + generation: state.connectionGeneration) } let page = try (0.. Date: Wed, 29 Jul 2026 21:48:57 +0300 Subject: [PATCH 66/75] perf(api,tray): let the activity list omit payload fields, and have the glance ask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured against the real activity log on this machine (2585 records, newest 100 matching the glance's type filter): 848 KB of JSON whole, 30 KB in summary form. Per record, p50 is 1.3 KB but p90 is 22 KB and p99 is 166 KB — `response` is truncated at 64 KB, `arguments` and `metadata` are not truncated at all. The tray repeats this request every 30 seconds and renders none of those three fields. So the earlier rationale for the deeper page was materially incomplete: it compared bucket scans and struct copies, which is the server's cost, and ignored the wire. 50 -> 100 was not "50 more struct copies", it was another ~420 KB per poll of serialisation, transfer and decoding. GET /api/v1/activity now takes exclude_payloads=true, which drops arguments, response and metadata from the response. It is a projection, not a filter — applied at serialisation, so it changes nothing about which records match — and has_sensitive_data is still derived from metadata before metadata is dropped, since the row icon keys off it. Default is unchanged, pinned by a test, so no other client is affected; recentActivity() deliberately does not set it, because the Dashboard renders exactly those fields. This makes the 100-record page ~28x cheaper than a 100-record page was, and ~14x cheaper than the 50-record page this branch started from. --- internal/httpapi/activity.go | 19 +++- internal/httpapi/activity_handlers_test.go | 100 ++++++++++++++++-- .../MCPProxy/MCPProxy/API/APIClient.swift | 10 +- .../MCPProxyTests/APIClientGlanceTests.swift | 22 +++- oas/docs.go | 2 +- oas/swagger.yaml | 7 ++ 6 files changed, 149 insertions(+), 11 deletions(-) diff --git a/internal/httpapi/activity.go b/internal/httpapi/activity.go index fd6c86bf..09847a94 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 c00c5ad5..08b3d79a 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/native/macos/MCPProxy/MCPProxy/API/APIClient.swift b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift index 87a13ac9..38cda864 100644 --- a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift +++ b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift @@ -405,9 +405,17 @@ actor APIClient { /// 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)" + path: "/api/v1/activity?type=tool_call,internal_tool_call&limit=\(limit)&exclude_payloads=true" ) return response.activities } diff --git a/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift b/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift index 761837b2..cdacd7aa 100644 --- a/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift @@ -81,7 +81,27 @@ final class APIClientGlanceTests: XCTestCase { XCTAssertEqual( GlanceStubURLProtocol.requestedURLs, - ["http://127.0.0.1:8080/api/v1/activity?type=tool_call,internal_tool_call&limit=100"] + ["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"] ) } diff --git a/oas/docs.go b/oas/docs.go index 78209ad6..1335ea4a 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"}},{"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"]}}}, + "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 b3dea712..46f4534d 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: From de344d0edc80a163ec6465733dec7ef885a814c8 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 21:49:46 +0300 Subject: [PATCH 67/75] docs(tray): state the row guarantee in request groups, not records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule 4 collapses every record sharing a request_id into one row, so five records that pass rules 1-3 can be three rows — a failed call contributes a wrapper and an upstream record under one id. The guarantee restated with the deeper page said "five qualify", which is true of records and not of rows. The depth tests all use unique or absent request ids, so nothing would have caught the discrepancy; the new test is a wrapper/upstream page where six qualifying records make three rows. --- .../MCPProxy/MCPProxy/State/AppState.swift | 11 ++++++--- .../GlanceSelectionCollapseTests.swift | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index 81760d8d..0112b327 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -534,9 +534,14 @@ final class AppState: ObservableObject { /// 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: five rows are - /// guaranteed only when five qualify within the newest 100 records matching - /// the type filter. `testRowsGoNoDeeperThanOnePage` pins that boundary. + /// 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. diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift index 79eacc85..b9e85e6c 100644 --- a/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift @@ -99,6 +99,29 @@ final class GlanceSelectionCollapseTests: XCTestCase { 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 From d62862e9b270db89c0fb8c81b2b7333562a22554 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 21:58:29 +0300 Subject: [PATCH 68/75] test(tray): rasterise the chart with ImageRenderer so the pixel tests run in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both encoding tests failed on the GitHub macOS runner. Two causes, and neither was flakiness. NSHostingView.cacheDisplay needs a display context the runner does not have: it painted 13 sample points there against hundreds locally. So the tests were developer-machine-only coverage that happened to look green because they never ran — the `swift-test` job is gated on native/ paths and this is the first branch to touch them. SwiftUI's ImageRenderer draws offscreen through Core Graphics with no window server involved. Measured both ways before switching: identical red and chroma counts, so it is the same raster, not an approximation. The runner's 13 chromatic pixels were real and meaningful — the legend's red "Errors" swatch, which every render carries, success-only included. Asserting no chroma over the whole image was therefore asserting something false; it passed locally only because the old sampler strode `rep.size`, which is the POINT size at 2x and the PIXEL size at 1x, so it covered a quarter of the image on this machine and all of it on the runner. That test is now scoped to the plot area, which is what it always meant, and `scale` is pinned so the pixel grid belongs to the test rather than to the display. The alpha cut-off drops from 0.5 to 0.1: Color.secondary resolves to ~0.5 alpha, so the old threshold excluded the entire success series against a transparent backing and read as a blank chart. No skip was reintroduced. Each assertion is positive or guarded by a drawn-pixel floor, so a blank raster fails loudly instead of passing. --- .../ActivityHistogramTests.swift | 174 ++++++++++-------- 1 file changed, 102 insertions(+), 72 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift index 970c2e37..04550dac 100644 --- a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift @@ -554,12 +554,37 @@ final class UsageRefreshWiringTests: XCTestCase { /// 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`. +/// The hosting-view path needs a display context the GitHub macOS runner does +/// not provide: there it painted 13 sample points where a developer machine +/// paints hundreds, so the tests either failed or — worse, had they skipped — +/// asserted nothing in the one environment nobody watches. `ImageRenderer` draws +/// offscreen through Core Graphics with no window server involved, and its +/// output matches the on-screen raster where it counts (identical red and +/// chroma counts, measured both ways before this was changed). +/// +/// `scale` is pinned so the pixel grid is a property of the test and not of +/// whatever display the machine has. That was the second half of the CI failure: +/// the old sampler strode `rep.size`, which equals the POINT size at 2x and the +/// PIXEL size at 1x, so it silently covered a quarter of the image on a Retina +/// machine and all of it on the runner — which is exactly why the legend's red +/// swatch was invisible locally and fatal in CI. @MainActor final class ActivityHistogramEncodingTests: XCTestCase { - /// What a rasterised chart actually painted. + /// 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 @@ -568,24 +593,43 @@ final class ActivityHistogramEncodingTests: XCTestCase { var chromatic = 0 } - /// Rasterise the chart and count pixels. + /// 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 bitmap to inspect. It + /// 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]) -> Pixels? { - let host = NSHostingView(rootView: ActivityHistogramView(bars: bars, accessibilitySummary: "")) - host.frame = NSRect(origin: .zero, size: ActivityHistogram.chartItemSize) - host.layoutSubtreeIfNeeded() - guard let rep = host.bitmapImageRepForCachingDisplay(in: host.bounds) else { return nil } - host.cacheDisplay(in: host.bounds, to: rep) + 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.5 else { continue } + for x in stride(from: 0, to: rep.pixelsWide, by: 2) { + for y in stride(from: rows.lowerBound, to: rows.upperBound, by: 2) { + guard let colour = rep.colorAt(x: x, y: y), colour.alphaComponent > 0.1 else { continue } counts.drawn += 1 let r = colour.redComponent, g = colour.greenComponent, b = colour.blueComponent @@ -596,12 +640,10 @@ final class ActivityHistogramEncodingTests: XCTestCase { return counts } - private func measure(_ bars: [HistogramBar]) throws -> Pixels { - guard let counts = pixels(of: bars) else { + private func measure(_ bars: [HistogramBar], in region: Region = .whole) throws -> Pixels { + guard let counts = pixels(of: bars, in: region) else { throw XCTSkip("this machine cannot rasterise the chart at all") } - // Asserted, never skipped: a blank render must fail loudly. - XCTAssertGreaterThan(counts.drawn, 100, "the chart rendered blank; the counts below mean nothing") return counts } @@ -609,11 +651,20 @@ final class ActivityHistogramEncodingTests: XCTestCase { /// 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)]) - let withoutErrors = try measure([HistogramBar(hourStart: hour, succeeded: 10, errors: 0)]) + 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) @@ -623,32 +674,29 @@ final class ActivityHistogramEncodingTests: XCTestCase { ) } - /// Count red pixels above and below the legend band. + /// The success fill must not follow `Color.accentColor`, or a user whose + /// system accent is red sees two near-identical segments. /// - /// Deliberately its own sampler rather than a parameter on `pixels(of:)`: - /// this one walks the rep's PIXEL extent (`pixelsWide`/`pixelsHigh`), which - /// is what makes "the bottom 24 points" mean the same thing at 1x and 2x. - private func redAboveAndBelowTheLegendBand(_ bars: [HistogramBar]) -> (above: Int, below: Int, drawn: Int)? { - let host = NSHostingView(rootView: ActivityHistogramView(bars: bars, accessibilitySummary: "")) - host.frame = NSRect(origin: .zero, size: ActivityHistogram.chartItemSize) - host.layoutSubtreeIfNeeded() - guard let rep = host.bitmapImageRepForCachingDisplay(in: host.bounds) else { return nil } - host.cacheDisplay(in: host.bounds, to: rep) - - // y grows downward in a cached rep, so the legend band is the last rows. - let bandStart = rep.pixelsHigh - Int((24.0 / ActivityHistogram.chartItemSize.height) - * Double(rep.pixelsHigh)) - var above = 0, below = 0, drawn = 0 - for x in stride(from: 0, to: rep.pixelsWide, by: 2) { - for y in stride(from: 0, to: rep.pixelsHigh, by: 2) { - guard let colour = rep.colorAt(x: x, y: y), colour.alphaComponent > 0.5 else { continue } - drawn += 1 - let r = colour.redComponent, g = colour.greenComponent, b = colour.blueComponent - guard r > 0.5, g < 0.45, b < 0.45 else { continue } - if y >= bandStart { below += 1 } else { above += 1 } - } - } - return (above, below, drawn) + /// 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 @@ -656,45 +704,27 @@ final class ActivityHistogramEncodingTests: XCTestCase { /// /// `.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. + /// 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. + /// 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) - guard let counts = redAboveAndBelowTheLegendBand(bars) else { - throw XCTSkip("this machine cannot rasterise the chart at all") - } - XCTAssertGreaterThan(counts.drawn, 50, "the chart rendered blank; the counts below mean nothing") + let legend = try measure(bars, in: .legend) + let plot = try measure(bars, in: .plot) + XCTAssertGreaterThan( - counts.below, 0, + legend.red, 0, "no legend swatch in the bottom band — the 20pt of frame height buys nothing" ) XCTAssertEqual( - counts.above, 0, - "an all-zero axis must paint no red above the legend, or this measures chart data" - ) - } - - /// 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 chart 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. - func testSuccessFillCarriesNoChromaAndSoCannotBeTheAccentColour() throws { - let counts = try measure([HistogramBar(hourStart: Fixture.currentHour, - succeeded: 10, errors: 0)]) - - XCTAssertEqual( - counts.chromatic, 0, - "a chart with no errors must paint nothing coloured; \(counts.chromatic) of \(counts.drawn) sampled pixels carried chroma" + plot.red, 0, + "an all-zero axis must paint no red in the plot, or this measures chart data" ) } } From 20591784fee3115d0186ff693d1dbd85b9b03b45 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 22:00:22 +0300 Subject: [PATCH 69/75] docs(tray): correct what the CI runner's raster actually showed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit said the hosting-view path "needs a display context the runner does not provide". The CI log does not support that: the success-only fixture drew 181 samples there, 13 of them chromatic, and 13 is exactly the legend's red swatch at 1x (54 at 2x here). So the runner renders solid shapes fine — it is axis TEXT that did not come through, at 1x backing scale. The two failures were caused by the sampler, not by a missing raster: `rep.size` is points at 2x and pixels at 1x, so the old stride covered a quarter of the image here and all of it on the runner (hence a legend swatch that was invisible locally and fatal there); and the `> 0.5` alpha cut-off dropped the entire Color.secondary success series, which is what made 181 samples look like a blank chart. ImageRenderer is still the right call — explicit scale, offscreen, and nothing below depends on text rendering — but the reason is host variance, not an absent display context. --- .../ActivityHistogramTests.swift | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift index 04550dac..f06bc911 100644 --- a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift @@ -555,21 +555,23 @@ final class UsageRefreshWiringTests: XCTestCase { /// 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`. -/// The hosting-view path needs a display context the GitHub macOS runner does -/// not provide: there it painted 13 sample points where a developer machine -/// paints hundreds, so the tests either failed or — worse, had they skipped — -/// asserted nothing in the one environment nobody watches. `ImageRenderer` draws -/// offscreen through Core Graphics with no window server involved, and its -/// output matches the on-screen raster where it counts (identical red and -/// chroma counts, measured both ways before this was changed). +/// 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. /// -/// `scale` is pinned so the pixel grid is a property of the test and not of -/// whatever display the machine has. That was the second half of the CI failure: -/// the old sampler strode `rep.size`, which equals the POINT size at 2x and the -/// PIXEL size at 1x, so it silently covered a quarter of the image on a Retina -/// machine and all of it on the runner — which is exactly why the legend's red -/// swatch was invisible locally and fatal in CI. +/// 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 { From e3fefd37740eb0314a2b1339ac2af0bf2a6a14d6 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 22:05:57 +0300 Subject: [PATCH 70/75] fix(tray): keep the unconfirmed-key set inside the capped feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prependGlanceActivity added a key for every SSE event and trimmed the feed to the cap without trimming the set, so keys outlived the rows they described. The poll is the only other thing that prunes the set — which means the set grew without bound exactly while polls were failing and SSE stayed healthy, i.e. during the scenario the staleness marker exists to report, for as long as the core stayed up. The cap now trims both together: after the feed is truncated the set is intersected with the keys still in it. --- .../MCPProxy/MCPProxy/State/AppState.swift | 6 +++++ .../MCPProxyTests/AppStateGlanceTests.swift | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/native/macos/MCPProxy/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index 0112b327..d37fd7aa 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -681,6 +681,12 @@ final class AppState: ObservableObject { 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)) } } diff --git a/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift b/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift index 6e835968..c26cbef9 100644 --- a/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift @@ -309,6 +309,29 @@ final class AppStateGlanceTests: XCTestCase { 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 From 7b77b31b46e0e370618fda432ee22f3240579e41 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 22:05:57 +0300 Subject: [PATCH 71/75] test(tray): pin WHERE the connection generation is captured, not just the guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-2 tests hand prependGlanceActivity a generation the test picked, so they verify the final guard and nothing before it — they pass unchanged against the implementation that reads the generation when the event ARRIVES, which after a reconnect is the new generation and sails through. That is the regression the fix was written for, and no test could see it. The stream loop moves into SSEStreamSession, whose whole purpose is to own the rule: capture once, before the stream is opened, and hand that value to every event the stream delivers. startSSEStream is now a single call to it. Three stream-level tests drive it: a reconnect between the stream opening and the event arriving (dropped), an uninterrupted stream (published), and a reconnect during the connect itself (dropped, because the capture precedes open). Verified by reverting to the arrival-time read — the two negative tests fail on their assertions, while every test in GlanceReconnectGenerationTests stays green, which is the point. The handshake is on `open`, not on `captureGeneration`: `open` is called at the same point by both implementations, so the test's sequencing does not depend on the thing under test. Hanging it off the capture made the wrong implementation fail by 5s timeout instead of by assertion. --- .../MCPProxy/Core/CoreProcessManager.swift | 23 +-- .../MCPProxy/Core/SSEStreamSession.swift | 45 ++++++ .../GlanceReconnectGenerationTests.swift | 6 + .../MCPProxyTests/SSEStreamSessionTests.swift | 136 ++++++++++++++++++ 4 files changed, 199 insertions(+), 11 deletions(-) create mode 100644 native/macos/MCPProxy/MCPProxy/Core/SSEStreamSession.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/SSEStreamSessionTests.swift diff --git a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift index 5b990a34..59f857e5 100644 --- a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift +++ b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift @@ -605,17 +605,18 @@ actor CoreProcessManager { sseTask?.cancel() sseTask = Task { [weak self] in - // Capture the connection generation HERE, where the stream opens: a - // stream belongs to exactly one connection, and every event it - // carries belongs to that one. Reading it at event arrival instead - // would capture whatever generation is current by then — which, - // after a reconnect, is the new one, and the guard would pass. - guard let generation = await self?.currentConnectionGeneration() else { return } - let stream = await sseClient.connect() - for await event in stream { - guard !Task.isCancelled else { break } - await self?.handleSSEEvent(event, generation: generation) - } + // 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() diff --git a/native/macos/MCPProxy/MCPProxy/Core/SSEStreamSession.swift b/native/macos/MCPProxy/MCPProxy/Core/SSEStreamSession.swift new file mode 100644 index 00000000..9a304c70 --- /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/MCPProxyTests/GlanceReconnectGenerationTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceReconnectGenerationTests.swift index f0410904..8b15eef4 100644 --- a/native/macos/MCPProxy/MCPProxyTests/GlanceReconnectGenerationTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceReconnectGenerationTests.swift @@ -187,6 +187,12 @@ final class GlanceReconnectGenerationTests: XCTestCase { /// 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() diff --git a/native/macos/MCPProxy/MCPProxyTests/SSEStreamSessionTests.swift b/native/macos/MCPProxy/MCPProxyTests/SSEStreamSessionTests.swift new file mode 100644 index 00000000..c194a09b --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/SSEStreamSessionTests.swift @@ -0,0 +1,136 @@ +import XCTest +@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. +/// +/// What they still cannot see: that `CoreProcessManager.startSSEStream` +/// actually routes through `SSEStreamSession`. That body is deliberately a +/// single call so the omission would be visible on sight; the actor has no +/// injection seam for its SSE client, and adding one is a larger change than +/// this pins. +@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) + } +} From ca8e2ba144b21fcdca1f3141e0011a5712c2f59c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 22:18:45 +0300 Subject: [PATCH 72/75] test(tray): measure the success series, and stop skipping an absent raster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two holes in the same file, both found by mutation rather than by reading. `drawn` counts axes, grid lines and tick labels, so no assertion here proved the success bars were painted at all: setting the success fill to Color.clear left all three encoding tests green. The no-chroma test was the worst of them — with no bars it passes more easily, since what it measures is the absence of colour. The new test compares like for like, the same single-hour axis with ten successes and with none, so the difference is the bar and nothing else. And `measure` still turned "no raster at all" into an XCTSkip. That is the third narrowing of one hole: a failed threshold used to skip, then a blank raster did, and each time the surviving shape was "the environment where these tests assert nothing reports success". It is now XCTUnwrap. There is no environment where a macOS build can legitimately fail to produce a CGImage here 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. --- .../ActivityHistogramTests.swift | 41 +++++++++++++++++-- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift index f06bc911..dc5f4d76 100644 --- a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift @@ -642,11 +642,44 @@ final class ActivityHistogramEncodingTests: XCTestCase { 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 { - guard let counts = pixels(of: bars, in: region) else { - throw XCTSkip("this machine cannot rasterise the chart at all") - } - return counts + 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 From 1d2a3ccbc4c36fb9b26b561df168f14ef9e0c394 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 29 Jul 2026 22:18:56 +0300 Subject: [PATCH 73/75] test(tray): pin that the manager's SSE wiring uses the session helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The disclosed residual turned out to be reachable: with SSEStreamSession left correct, restoring the arrival-time generation read inside startSSEStream passed all 13 tests across both generation test files. Keeping the body to a single call makes the omission visible on sight, and visible on sight is not a test. Three small things make the wiring drivable: * SSEStreaming — the two members (connect/disconnect) the manager actually consumes, so a stub can stand in for SSEClient. * installSSEClient + an internal startSSEStream, the seam the test needs. Production wiring is unchanged; connectToCore still builds the real client. * NotificationService's UNUserNotificationCenter is now resolved lazily. An eager `let` traps with "bundleProxyForCurrentProcess is nil" in an unbundled process, i.e. in any XCTest runner, which is why nothing holding one had ever been constructed in a test — the rate-limit tests mirror the logic in a private struct to get around exactly this. Safe because the type is an actor, so the lazy initialisation is isolated. The negative assertion is deterministic rather than timed: a `status` event follows the glance event, its handling is not generation-guarded, and events are handled in stream order — so once the status event's effect is visible, the glance event has already been processed. Verified with the mutation from the report: only the new wiring test fails, on its assertion. --- .../MCPProxy/MCPProxy/API/SSEClient.swift | 16 ++- .../MCPProxy/Core/CoreProcessManager.swift | 16 ++- .../Services/NotificationService.swift | 10 +- .../MCPProxyTests/SSEStreamSessionTests.swift | 131 +++++++++++++++++- 4 files changed, 164 insertions(+), 9 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/API/SSEClient.swift b/native/macos/MCPProxy/MCPProxy/API/SSEClient.swift index d9cedc8a..2fb52e20 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 59f857e5..3455d0bb 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). @@ -599,8 +599,20 @@ 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() diff --git a/native/macos/MCPProxy/MCPProxy/Services/NotificationService.swift b/native/macos/MCPProxy/MCPProxy/Services/NotificationService.swift index 2e7f7c4f..d42611a2 100644 --- a/native/macos/MCPProxy/MCPProxy/Services/NotificationService.swift +++ b/native/macos/MCPProxy/MCPProxy/Services/NotificationService.swift @@ -95,7 +95,15 @@ actor NotificationService { private var settleGate = ConnectionSettleGate() /// The shared notification center. - private let center = UNUserNotificationCenter.current() + /// + /// Resolved on first use, not at init. `UNUserNotificationCenter.current()` + /// traps with "bundleProxyForCurrentProcess is nil" in an unbundled process, + /// which is what an XCTest runner is — so an eager `let` made this actor, + /// and everything that holds one (notably `CoreProcessManager`), + /// unconstructible from a test. `lazy` is safe here precisely because this + /// is an actor: the initialisation is isolated, so it cannot race. In the + /// bundled app the only difference is which line resolves the center. + private lazy var center = UNUserNotificationCenter.current() // MARK: - Setup diff --git a/native/macos/MCPProxy/MCPProxyTests/SSEStreamSessionTests.swift b/native/macos/MCPProxy/MCPProxyTests/SSEStreamSessionTests.swift index c194a09b..3af8e774 100644 --- a/native/macos/MCPProxy/MCPProxyTests/SSEStreamSessionTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/SSEStreamSessionTests.swift @@ -1,4 +1,5 @@ import XCTest +import Combine @testable import MCPProxy /// WHERE the connection generation is captured, not merely whether it is @@ -13,11 +14,10 @@ import XCTest /// instead, and force the reconnect into the window between the stream opening /// and the event being delivered. /// -/// What they still cannot see: that `CoreProcessManager.startSSEStream` -/// actually routes through `SSEStreamSession`. That body is deliberately a -/// single call so the omission would be visible on sight; the actor has no -/// injection seam for its SSE client, and adding one is a larger change than -/// this pins. +/// `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 { @@ -134,3 +134,124 @@ final class SSEStreamSessionTests: XCTestCase { 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() + } +} From 059462b5b5d786f73634ac7cf02b93ec0696e0dd Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Thu, 30 Jul 2026 10:00:24 +0300 Subject: [PATCH 74/75] fix(tray): deliver menu refreshes while the menu is tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The debounced objectWillChange -> rebuildMenu() sink scheduled on RunLoop.main. Combine's RunLoop scheduler installs its timers in the run loop's .default mode, but while an NSMenu is on screen the main run loop runs in NSEventTrackingRunLoopMode — so the sink was not serviced until the menu closed. That made the whole open-menu update path unreachable in production. GlanceSection.updateInPlace and MenuRebuildGuard's updateInPlace / deferUntilClose branches only ever run if something calls rebuildMenu() while the menu is up, and this sink is the only caller that does. The glance was therefore a snapshot frozen at menu-open time: live QA held the menu open for 45s and watched a 45-second-old call stay captioned "3s", the header counts stay put while the REST API moved on, two captures 2s apart come out pixel-identical, and a call made while the menu was open never appear at all. Schedule on the main dispatch queue instead, which is drained in every run-loop mode. Same reason the timers alongside it publish in .common. The 500ms window and the icon updates are untouched, and the menu is still never restructured under the cursor: with the guard armed, rebuildMenu() returns before touching menu structure on both branches. The scheduler is named rather than written inline so a test can bind to the very value production uses. --- .../macos/MCPProxy/MCPProxy/MCPProxyApp.swift | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift index f4b024fe..b787cb08 100644 --- a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift +++ b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift @@ -43,6 +43,28 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS /// 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) @@ -134,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() @@ -575,8 +597,8 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS /// 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 RunLoop.main — so this costs nothing today and rejects a future - /// off-main caller at compile time (see the note on + /// 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 From 85451c1a81ae3631a84fa39778b53ceff99bde62 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Thu, 30 Jul 2026 10:00:36 +0300 Subject: [PATCH 75/75] test(tray): pin that a refresh lands while the menu is tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every existing glance test calls GlanceSection.updateInPlace directly, so the whole suite stayed green while the one production caller that reaches it during menu tracking was never serviced. These cover the delivery layer underneath: they pump the main run loop in NSEventTrackingRunLoopMode ONLY, the mode it runs in while an NSMenu is up, and never in .default. The end-to-end case arms a MenuRebuildGuard, mutates AppState, and asserts the refresh reaches the section on the updateInPlace branch and rewrites the row already on screen. It binds to AppController.menuRefreshScheduler, so reverting that to RunLoop.main fails it — verified by doing exactly that: the header stays at "12 calls this hour", which is the QA symptom. A control case pins that RunLoop.main delivers nothing under the same pump but is released by one turn of .default, proving the pump is selective rather than draining every mode, and two more pin that the 500ms window still coalesces a burst into one main-thread repaint when nothing is tracking. The tests initialise NSApplication: .eventTracking only reaches the main dispatch queue once AppKit has added it to the run loop's common modes, which holds for the real tray but not for a bare xctest bundle. Without it every case here would pass by measuring a process where no scheduler at all is serviced during tracking. --- .../MenuRefreshSchedulerTests.swift | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 native/macos/MCPProxy/MCPProxyTests/MenuRefreshSchedulerTests.swift diff --git a/native/macos/MCPProxy/MCPProxyTests/MenuRefreshSchedulerTests.swift b/native/macos/MCPProxy/MCPProxyTests/MenuRefreshSchedulerTests.swift new file mode 100644 index 00000000..a94ff992 --- /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(_:))) + } +}