diff --git a/e2e/web-ui-sweep/navigation-consistency.spec.ts b/e2e/web-ui-sweep/navigation-consistency.spec.ts new file mode 100644 index 000000000..8cbda3837 --- /dev/null +++ b/e2e/web-ui-sweep/navigation-consistency.spec.ts @@ -0,0 +1,120 @@ +// Navigation-consistency sweep (Spec 109 "ux-navigation-consistency"). +// +// Created here by PR 109-e (T068) for its own independent test — server +// cards render at a stable height whatever state they are in (FR-013) — and +// extended by later PRs in the same spec (109-i, T137) as their own +// navigation-consistency scenarios land. Registered in the Playwright file +// list in scripts/run-web-smoke.sh so the smoke gate runs it from this PR +// onward, and every later addition to this file is gated too. +// +// Launcher: scripts/run-web-smoke.sh (boots a real mcpproxy instance with its +// embedded frontend, never a dev server). +import { test, expect, Page } from '@playwright/test' + +const BASE = process.env.MCPPROXY_BASE_URL || 'http://127.0.0.1:18080' +const KEY = process.env.MCPPROXY_API_KEY || '' + +function url(route: string): string { + const sep = route.includes('?') ? '&' : '?' + return KEY ? `${BASE}/ui${route}${sep}apikey=${encodeURIComponent(KEY)}` : `${BASE}/ui${route}` +} + +async function goto(page: Page, route: string, anchor: string) { + await page.goto(url(route)) + await page.waitForLoadState('domcontentloaded') + const closeWizard = page.locator('[data-test="close-wizard"]') + if (await closeWizard.isVisible().catch(() => false)) { + await closeWizard.click() + } + await page.locator(anchor).first().waitFor({ state: 'visible' }) +} + +// Spec 109 FR-013 / D15: "Every card in a grid has the same height" — a fixed +// grid, not one that jumps as servers move between states (connected, +// quarantined, erroring, disabled, sign-in-required, ...). Measured at +// 1440px, the desktop breakpoint the grid's `lg:grid-cols-3` targets. +// +// Review round 2 (109-e medium finding): two config-identical, healthy +// fixture servers both land in the SAME grid row at this breakpoint, where +// CSS grid's `align-items:stretch` equalizes every card in a row regardless +// of content — `distinct.size === 1` passed unconditionally whether or not +// the `.server-card { min-height }` rule this invariant depends on even +// existed. scripts/run-web-smoke.sh now seeds 4 fixture servers, one +// quarantined, so the fleet both spans more than one grid row (stretch can +// no longer paper over a row-to-row difference) and includes a card whose +// content genuinely differs from the rest. The row check below turns the +// previously-silent false pass into an explicit, loud skip whenever the +// fixture set is too small to actually exercise the invariant. +test('server cards render at equal heights at 1440px (Spec 109 FR-013)', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + + await goto(page, '/servers', '[data-test="kpi-card-total"], [data-test="servers-first-run-empty"]') + + const cards = page.locator('[data-test="server-card"]') + const count = await cards.count() + + // A fleet with fewer than two servers cannot exercise the invariant, but + // must not silently report a pass either. scripts/run-web-smoke.sh + // registers fixture servers (review rounds 1 and 2, 109-e) specifically so + // this test runs under the release-qa-gate `web-ui-sweep` job from this PR + // onward; a hand run with no MCPPROXY_FIXTURE_PATH, or a leaner fixture + // set, still falls back to skipping rather than reporting a false pass. + test.skip(count < 2, 'fewer than two server cards rendered; nothing to compare') + + const heights: number[] = [] + const tops: number[] = [] + for (let i = 0; i < count; i++) { + const box = await cards.nth(i).boundingBox() + expect(box, `card ${i} has no layout box`).not.toBeNull() + heights.push(Math.round(box!.height)) + tops.push(Math.round(box!.y)) + } + + // Cluster the cards' top offsets into grid rows (a few px of layout jitter + // within one row is expected; a real row boundary is a much bigger jump). + const sortedTops = [...tops].sort((a, b) => a - b) + let rowCount = 1 + for (let i = 1; i < sortedTops.length; i++) { + if (sortedTops[i] - sortedTops[i - 1] > 8) rowCount++ + } + // If every card sits in the same grid row, CSS grid's `align-items:stretch` + // equalizes their heights regardless of content or CSS — the assertion + // below would pass whether or not the invariant it names actually holds. + // Skip loudly instead of reporting a pass that tested nothing. + test.skip(rowCount < 2, + `all ${count} cards share one grid row at 1440px; CSS grid stretch makes height equality trivially true here — a larger MCPPROXY_FIXTURE_PATH fleet (scripts/run-web-smoke.sh) is needed to span a second row`) + + const distinct = new Set(heights) + expect(distinct.size, `expected one height across ${count} cards spanning ${rowCount} grid rows, got ${[...distinct].sort((a, b) => a - b).join(', ')}`).toBe(1) +}) + +// The primary-action row reserves its height even for a `ready` server with +// no button — the row that would otherwise be the only variable-height part +// of an otherwise fixed grid. +// +// Review round 1 (109-e medium finding): the row's "Details" link is +// unconditional — every card renders it whether or not the primary button +// does — so a bounding-box `height > 0` check passes on the Details link +// alone and would still pass with the `min-h-[2.25rem]` reservation this +// test is named after deleted entirely. Reading the CSS `min-height` the +// row's own stylesheet applies, instead of the box Playwright measured +// after layout, tests the reservation itself rather than something else +// that happens to fill the same space. +test('the primary-action row keeps its height with no button (Spec 109 FR-013)', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await goto(page, '/servers', '[data-test="kpi-card-total"], [data-test="servers-first-run-empty"]') + + const rows = page.locator('[data-test="server-card-primary-row"]') + const count = await rows.count() + test.skip(count === 0, 'no server cards rendered') + + for (let i = 0; i < count; i++) { + const row = rows.nth(i) + const box = await row.boundingBox() + expect(box, `primary-action row ${i} has no layout box`).not.toBeNull() + expect(box!.height).toBeGreaterThan(0) + + const minHeightPx = await row.evaluate((el) => parseFloat(getComputedStyle(el).minHeight) || 0) + expect(minHeightPx, `primary-action row ${i} has no min-height CSS reservation — a "ready" card's Details link would still render without it`).toBeGreaterThan(0) + } +}) diff --git a/e2e/web-ui-sweep/visual-a11y-sweep.spec.ts b/e2e/web-ui-sweep/visual-a11y-sweep.spec.ts index 5fc9c3772..be6a561d8 100644 --- a/e2e/web-ui-sweep/visual-a11y-sweep.spec.ts +++ b/e2e/web-ui-sweep/visual-a11y-sweep.spec.ts @@ -580,69 +580,53 @@ test('header search button is enabled with an empty box', async ({ page }) => { }) // --------------------------------------------------------------------------- -// F6 — the Add Server modal must take focus, trap Tab, close on Escape and -// hand focus back to its trigger. -// -// These five `` modals are opened by the `open` ATTRIBUTE rather -// than showModal(), so the browser supplies none of the modal affordances; -// every one of them comes from `useModalA11y`. Its own comment delegates the -// real-browser half of its coverage to "the Playwright sweep" — this is that -// test. Without it, deleting the document keydown listener or the nextTick -// focusInitial() passes the whole sweep. -// -// Escape is dispatched IN-PAGE, never with page.keyboard.press(). Re-checking -// F6 during the audit produced a FALSE NEGATIVE for exactly that reason: a key -// sent through the automation layer never reached the document listener under -// test, so the assertion measured the harness instead of the app. -// -// Assert on the `[open]` ATTRIBUTE, not on DOM presence — the modal box is not -// behind a v-if, so it stays in the DOM when closed. Checking "is it in the -// DOM" is how the original audit mis-measured this. +// Spec 109 FR-062: the header entry starts the catalog-first Add Server flow, +// rather than opening the legacy modal. Keep browser-level modal focus coverage +// on the still-reachable Add Secret dialog. // --------------------------------------------------------------------------- -test('the Add Server modal takes focus, traps Tab and closes on Escape', async ({ page }) => { - // /activity mounts exactly one AddServerModal (TopHeader's). `/` and - // /servers mount a second copy of the same component, which makes the - // data-test locator strict-mode ambiguous there. +test('the header Add Server action opens the catalog-first Add Server page', async ({ page }) => { await goto(page, '/activity') await page.locator('[data-test="header-add-server"]').click() - await expect(page.locator('dialog[data-test="add-server-modal"][open]')).toHaveCount(1) + await expect(page).toHaveURL(/\/ui\/add-server(?:\?|$)/) + await expect(page.locator('[data-test="add-server-page"] h1')).toHaveText('Add Server') + await expect(page.locator('[data-test="add-server-tab-catalog"]')).toHaveClass(/tab-active/) +}) + +test('the Add Secret modal takes focus, traps Tab and closes on Escape', async ({ page }) => { + await goto(page, '/secrets') + const trigger = page.locator('[data-test="secrets-add-button"]') + await trigger.click() + const dialog = page.locator('dialog[data-test="add-secret-modal"]') + await expect(dialog).toHaveAttribute('open', '') + + const box = dialog.locator('[role="dialog"]') const focus = await page.evaluate(() => { - const box = document.querySelector('[data-test="add-server-modal-box"]') + const box = document.querySelector('[data-test="add-secret-modal"] [role="dialog"]') const active = document.activeElement as HTMLElement | null return { inside: !!box && !!active && box.contains(active), onCloseButton: !!active && active.hasAttribute('data-modal-close-button'), } }) - expect(focus.inside, 'focus never entered the Add Server dialog').toBe(true) - expect(focus.onCloseButton, 'focus landed on the header ✕ instead of the form').toBe(false) - - // Tab from the last focusable wraps to the first instead of walking out into - // the page behind the modal. - const wrapped = await page.evaluate(() => { - const box = document.querySelector('[data-test="add-server-modal-box"]') - if (!box) return null + expect(focus.inside, 'focus never entered the Add Secret dialog').toBe(true) + expect(focus.onCloseButton, 'focus landed on the close button instead of the form').toBe(false) + + const wrapped = await box.evaluate((element) => { const focusables = Array.from( - box.querySelectorAll( + element.querySelectorAll( 'a[href],button:not([disabled]),input:not([disabled]):not([type="hidden"]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])', ), ).filter((el) => el.checkVisibility({ checkVisibilityCSS: true })) - if (focusables.length < 2) return null + if (focusables.length < 2) return false focusables[focusables.length - 1].focus() document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })) return document.activeElement === focusables[0] }) expect(wrapped, 'Tab escaped the dialog instead of wrapping to the first control').toBe(true) - await page.evaluate(() => - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })), - ) - await expect(page.locator('dialog[data-test="add-server-modal"][open]')).toHaveCount(0) - - // Focus restoration is deferred a tick, so poll rather than read once. - await expect - .poll(() => page.evaluate(() => document.activeElement?.getAttribute('data-test') ?? null)) - .toBe('header-add-server') + await page.evaluate(() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))) + await expect(dialog).not.toHaveAttribute('open', '') + await expect.poll(() => page.evaluate(() => document.activeElement?.getAttribute('data-test') ?? null)).toBe('secrets-add-button') }) diff --git a/frontend/src/components/ServerCard.vue b/frontend/src/components/ServerCard.vue index 83655e065..e4b1287ac 100644 --- a/frontend/src/components/ServerCard.vue +++ b/frontend/src/components/ServerCard.vue @@ -1,333 +1,181 @@ + + diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index da7796099..bb43a49c5 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -1003,6 +1003,16 @@ export interface ActivityTopTool { count: number } +// Spec 109 FR-013: every server with a call in the period (unlike +// top_servers, which is capped at 5 and carries no error counts). Used by the +// server card's 24h stats line and the macOS Servers rows. +export interface ActivityPerServer { + name: string + calls: number + errors: number + last_call_at: string +} + export interface ActivitySummaryResponse { period: string total_count: number @@ -1026,6 +1036,7 @@ export interface ActivitySummaryResponse { call_error_count: number top_servers?: ActivityTopServer[] top_tools?: ActivityTopTool[] + per_server?: ActivityPerServer[] start_time: string end_time: string } diff --git a/frontend/src/views/Servers.vue b/frontend/src/views/Servers.vue index b13ca62ef..c7b007f10 100644 --- a/frontend/src/views/Servers.vue +++ b/frontend/src/views/Servers.vue @@ -265,6 +265,7 @@ v-for="server in filteredServers" :key="server.name" :server="server" + :activity-stats="perServerActivity[server.name]" v-memo="[ server.connected, server.connecting, @@ -282,7 +283,31 @@ // count is tracked too -- the quarantine note renders it, so a rescan // that stays `warnings` but changes the count must still re-render. server.security_scan?.status, - server.security_scan?.finding_counts?.warning + server.security_scan?.finding_counts?.warning, + // Spec 109 FR-013: the card's status line and its ONE primary + // button are now driven entirely by `health` — mergeServers() + // above replaces `existingServer.health` with a fresh object + // reference on every poll (Object.assign), so without these keys + // a health change carrying none of the OTHER memoized fields + // (e.g. actions gaining a token-expiring login nudge on an + // otherwise unchanged connected/enabled/quarantined server) would + // leave the primary button stale. Picking the scalar fields, not + // `server.health` itself, is what makes the comparison meaningful + // despite the new object reference each poll. + server.health?.status, + server.health?.summary, + server.health?.detail, + server.health?.admin_state, + server.health?.level, + // `action` always equals `actions[0]` (or '' when actions is + // empty) per contracts.ts — the plain scalar equivalent of + // actions[0], without an optional array-index chain. + server.health?.action, + // Spec 109 FR-013: the card's stats line reads this per-server slice + // of the one activity summary fetch. + perServerActivity[server.name]?.calls, + perServerActivity[server.name]?.errors, + perServerActivity[server.name]?.last_call_at ]" /> @@ -309,6 +334,7 @@ import { serverDetailPath } from '@/utils/serverRoute' import CollapsibleHintsPanel from '@/components/CollapsibleHintsPanel.vue' import type { Hint } from '@/components/CollapsibleHintsPanel.vue' import { useSecurityScannerStatus } from '@/composables/useSecurityScannerStatus' +import type { ActivityPerServer } from '@/types' type ServerFilter = 'all' | 'connected' | 'enabled' | 'quarantined' | 'needs_review' const KNOWN_FILTERS: ServerFilter[] = ['all', 'connected', 'enabled', 'quarantined', 'needs_review'] @@ -325,6 +351,29 @@ const scanAllRunning = ref(false) const showAddServer = ref(false) const { hasEnabledScanners } = useSecurityScannerStatus() +// Spec 109 FR-013: the server card's stats line (last call, 24h errors) +// reads this ONE per-page-load summary rather than issuing a request per +// card. Keyed by server name; a server absent from the map had no call in +// the period. +const perServerActivity = ref>({}) + +async function loadActivitySummary() { + try { + const res = await api.getActivitySummary('24h') + if (res.success && res.data) { + const byName: Record = {} + for (const entry of res.data.per_server ?? []) { + byName[entry.name] = entry + } + perServerActivity.value = byName + } + } catch { + // Best-effort: the card falls back to "never" / 0 errors, which is a + // safe (if stale) default — a failed summary fetch must not block the + // rest of the Servers page from rendering. + } +} + // Spec 109-k (activity-scope-filters), T119: Servers wired to the URL filter // contract (url-filter-contract.md — `status` and `q` are client-side only // here, `GET /servers` takes no query string). Read on mount and on every @@ -349,7 +398,10 @@ function applyScopeQueryParams() { } } -onMounted(applyScopeQueryParams) +onMounted(() => { + void loadActivitySummary() + applyScopeQueryParams() +}) watch(() => [route.query.status, route.query.q], applyScopeQueryParams) // Live QA fix (Spec 109-k, FR-080 "router.replace on change"): the read side @@ -443,6 +495,7 @@ const filteredServers = computed(() => { async function refreshServers() { await serversStore.fetchServers() + void loadActivitySummary() } async function scanAllServers() { diff --git a/frontend/tests/unit/scanner-gate-wording.spec.ts b/frontend/tests/unit/scanner-gate-wording.spec.ts index a51fb6a3f..60aa6d305 100644 --- a/frontend/tests/unit/scanner-gate-wording.spec.ts +++ b/frontend/tests/unit/scanner-gate-wording.spec.ts @@ -3,8 +3,6 @@ import { mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import FlaggedToolsPanel from '@/components/FlaggedToolsPanel.vue' import FindingChip from '@/components/FindingChip.vue' -import ServerCard from '@/components/ServerCard.vue' -import type { Server } from '@/types' // UX audit F09. One Server Detail render said BOTH of these, about the same two // findings: @@ -45,11 +43,6 @@ vi.mock('@/composables/useSecurityScannerStatus', () => ({ useSecurityScannerStatus: () => ({ hasEnabledScanners: () => true }), })) -const RouterLinkStub = { - props: ['to'], - template: '', -} - const GROUPS = [ { tool: 'resolve-library-id', @@ -71,35 +64,6 @@ function mountPanel() { }) } -function mountQuarantinedCard(scanned = true) { - const server = { - name: 'context7-docs', - protocol: 'http', - url: 'https://mcp.context7.com/mcp', - enabled: true, - quarantined: true, - connected: false, - connecting: false, - tool_count: 0, - health: { action: 'approve' }, - security_scan: scanned - ? { - last_scan_at: '2026-09-05T10:00:00Z', - status: 'dangerous', - finding_counts: { dangerous: 2, warning: 0, info: 0, total: 2 }, - } - : undefined, - } as unknown as Server - - return mount(ServerCard, { - props: { server }, - global: { - plugins: [createPinia()], - stubs: { RouterLink: RouterLinkStub, 'router-link': RouterLinkStub }, - }, - }) -} - beforeEach(() => { setActivePinia(createPinia()) vi.clearAllMocks() @@ -133,17 +97,10 @@ describe('scanner gate wording — the panel and the dialog agree (F09)', () => expect(chip.attributes('title')).toContain('review-only (soft-tier)') }) - // FR-005 (specs/109-ux-navigation-consistency/contracts/health-vocabulary.md): - // the card's Approve action now only navigates to the review screen (see - // server-card-approve-review-navigation.spec.ts) — it never renders the - // force-approve dialog itself, so mountQuarantinedCard's dialog is gone from - // here. That dialog (and its F09 wording, "names the gate instead of 'the - // scanner gate'" / "says nothing about findings in no-scan mode") now lives - // only on the review screen and is covered by - // server-detail-approve-dialog.spec.ts. - it('the card no longer renders the force-approve dialog itself (moved to the review screen)', () => { - const card = mountQuarantinedCard() - expect(card.find('.modal-open').exists()).toBe(false) - expect(card.findAll('button').find((b) => b.text().trim() === 'Approve')).toBeUndefined() - }) + // The force-approve dialog itself (both modes) is covered by + // server-detail-approve-dialog.spec.ts. Spec 109 (PR 109-e, FR-005/FR-014) + // removed ServerCard's own copy of that dialog: the card's primary action + // for `approve` is now a plain "Review" link to `/review/` — it never + // approves — so ServerDetail.vue's Security tab is the one place left that + // performs the approval and owns this wording. }) diff --git a/frontend/tests/unit/server-card-approve-review-navigation.spec.ts b/frontend/tests/unit/server-card-approve-review-navigation.spec.ts index 39d308a0b..ca6909f1a 100644 --- a/frontend/tests/unit/server-card-approve-review-navigation.spec.ts +++ b/frontend/tests/unit/server-card-approve-review-navigation.spec.ts @@ -55,19 +55,26 @@ beforeEach(() => { // FR-005)"): the card's primary action for a quarantined server must open the // review screen, not call the approve API directly — matching the macOS // implementation (DashboardView.performAction .approve case). +// +// Spec 109-e rewrote ServerCard to a single primary action bound to +// `health.actions[0]`/`health.action` (data-test="server-card-primary-action"), +// replacing the per-action buttons (`server-card-approve` etc.) this test +// originally targeted. The Review link now opens the Tools tab directly +// (round 1, 109-e high finding: a `/review/` redirect this used to rely +// on never existed on this branch), not the Security tab. describe('ServerCard — approve action opens Review, never approves directly (FR-005)', () => { it('renders the primary action as "Review", not "Approve"', () => { const card = mountCard(makeQuarantinedServer()) - const action = card.find('[data-test="server-card-approve"]') + const action = card.find('[data-test="server-card-primary-action"]') expect(action.exists()).toBe(true) expect(action.text()).toBe('Review') expect(action.text()).not.toContain('Approve') }) - it('links to the server Security tab instead of triggering approval', () => { + it('links to the server Tools tab instead of triggering approval', () => { const card = mountCard(makeQuarantinedServer()) - const action = card.find('[data-test="server-card-approve"]') - expect(action.attributes('href')).toContain('tab=security') + const action = card.find('[data-test="server-card-primary-action"]') + expect(action.attributes('href')).toContain('tab=tools') }) it('never calls securityApproveServer on click, even with a clean completed scan', async () => { @@ -84,7 +91,7 @@ describe('ServerCard — approve action opens Review, never approves directly (F const store = useServersStore() const spy = vi.spyOn(store, 'securityApproveServer') - await card.find('[data-test="server-card-approve"]').trigger('click') + await card.find('[data-test="server-card-primary-action"]').trigger('click') expect(spy).not.toHaveBeenCalled() }) diff --git a/frontend/tests/unit/server-card-next-action.spec.ts b/frontend/tests/unit/server-card-next-action.spec.ts new file mode 100644 index 000000000..f224009b9 --- /dev/null +++ b/frontend/tests/unit/server-card-next-action.spec.ts @@ -0,0 +1,393 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import ServerCard from '@/components/ServerCard.vue' +import type { Server, HealthStatus } from '@/types' + +// Spec 109 (PR 109-e) T067, FR-013/FR-014: the server card shows exactly one +// primary button, driven by `health.actions[0]`, and folds every secondary +// action into a ⋯ menu — Delete is reachable ONLY from that menu, with a +// confirmation naming the server. Trust mode is a shield icon with a +// tooltip. The stats line links to Activity, scoped to this server and the +// last 24h (plus `status=error` when there were any). + +vi.mock('@/services/api', () => ({ + default: { + getServers: vi.fn().mockResolvedValue({ success: true, data: { servers: [] } }), + }, +})) + +vi.mock('@/composables/useSecurityScannerStatus', () => ({ + useSecurityScannerStatus: () => ({ hasEnabledScanners: () => true }), +})) + +const RouterLinkStub = { + props: ['to'], + template: '', +} + +function makeServer(overrides: Partial = {}): Server { + return { + name: 'srv', + protocol: 'http', + url: 'https://example.invalid/mcp', + enabled: true, + quarantined: false, + connected: true, + connecting: false, + tool_count: 3, + ...overrides, + } as Server +} + +function mountCard(server: Server) { + return mount(ServerCard, { + props: { server }, + global: { + plugins: [createPinia()], + stubs: { RouterLink: RouterLinkStub, 'router-link': RouterLinkStub }, + }, + }) +} + +beforeEach(() => { + setActivePinia(createPinia()) +}) + +// One fixture per internal/health.ActionLabels entry (mirrored by the Go +// T041 table this build's backend actually emits), plus the empty/"ready" +// case. `kind` records whether the primary button executes in place or +// navigates, so the href/click assertions below can branch per case. +const FIXTURES: Array<{ + name: string + actions: string[] + label: string | null + kind: 'execute' | 'navigate' | 'none' + hrefContains?: string +}> = [ + { name: 'ready (no action)', actions: [], label: null, kind: 'none' }, + { name: 'login', actions: ['login'], label: 'Sign in', kind: 'execute' }, + { name: 'restart', actions: ['restart'], label: 'Restart', kind: 'execute' }, + { name: 'enable', actions: ['enable'], label: 'Enable', kind: 'execute' }, + // Review round 1 (109-e high finding): `/review/` is not a + // registered route (109-a's interim `/review` → `?tab=tools` redirect it + // depends on has not landed on this branch), so it 404s today. Points + // straight at the Tools tab it was meant to redirect to. + { name: 'approve', actions: ['approve'], label: 'Review', kind: 'navigate', hrefContains: 'tab=tools' }, + { name: 'set_secret', actions: ['set_secret'], label: 'Add secret', kind: 'navigate', hrefContains: '/secrets' }, + { name: 'configure', actions: ['configure'], label: 'Fix config', kind: 'navigate', hrefContains: 'tab=config' }, + { name: 'edit_url', actions: ['edit_url'], label: 'Edit URL', kind: 'navigate', hrefContains: 'focus=endpoint' }, + { name: 'view_logs', actions: ['view_logs'], label: 'View logs', kind: 'navigate', hrefContains: 'tab=logs' }, +] + +describe('ServerCard — one primary action, from actions[0] (Spec 109 FR-013/FR-014)', () => { + for (const fixture of FIXTURES) { + it(`${fixture.name}: exactly ${fixture.kind === 'none' ? 'no' : 'one'} primary button`, () => { + const wrapper = mountCard( + makeServer({ + health: { + level: 'healthy', + admin_state: 'enabled', + summary: '', + status: 'ready', + usable: fixture.actions.length === 0, + actions: fixture.actions, + action: fixture.actions[0] ?? '', + } as unknown as HealthStatus, + }) + ) + + const buttons = wrapper.findAll('[data-test="server-card-primary-action"]') + if (fixture.kind === 'none') { + expect(buttons).toHaveLength(0) + return + } + expect(buttons).toHaveLength(1) + expect(buttons[0].text()).toBe(fixture.label) + if (fixture.kind === 'navigate') { + expect(buttons[0].element.tagName).toBe('A') + if (fixture.hrefContains) { + expect(buttons[0].attributes('href')).toContain(fixture.hrefContains) + } + } else { + expect(buttons[0].element.tagName).toBe('BUTTON') + } + }) + } + + it('the token-expiring ready fixture shows Sign in, not "no button"', () => { + // `actions` can carry a proactive nudge on an otherwise-ready, usable + // server (a token expiring soon) — the primary button gates on + // actions[0], never on `status`. + const wrapper = mountCard( + makeServer({ + health: { + level: 'healthy', + admin_state: 'enabled', + summary: 'Connected', + status: 'ready', + usable: true, + actions: ['login'], + action: 'login', + } as unknown as HealthStatus, + }) + ) + const button = wrapper.find('[data-test="server-card-primary-action"]') + expect(button.exists()).toBe(true) + expect(button.text()).toBe('Sign in') + }) + + it('never executes approve directly — it always navigates to the review location', () => { + const wrapper = mountCard( + makeServer({ + quarantined: true, + health: { + level: 'healthy', + admin_state: 'quarantined', + summary: 'Quarantined for review', + status: 'needs_review', + usable: false, + actions: ['approve'], + action: 'approve', + } as unknown as HealthStatus, + }) + ) + const button = wrapper.find('[data-test="server-card-primary-action"]') + expect(button.element.tagName).toBe('A') + // Not `/review/srv` — that route does not exist (see the FIXTURES + // comment above); it goes straight to the Tools tab. + expect(button.attributes('href')).toBe('/servers/srv?tab=tools') + }) + + // Review round 1 (109-e medium finding, coverage gap b): only the empty + // `action` + empty `actions` combination was ever tested for "no button". + // The real old-core fallback branch in `primaryAction` — `actions` present + // but empty, `action` truthy — was never exercised. + it('falls back to the legacy scalar `action` when `actions` is empty', () => { + const wrapper = mountCard( + makeServer({ + health: { + level: 'healthy', + admin_state: 'enabled', + summary: '', + status: 'ready', + usable: false, + actions: [], + action: 'restart', + } as unknown as HealthStatus, + }) + ) + const button = wrapper.find('[data-test="server-card-primary-action"]') + expect(button.exists()).toBe(true) + expect(button.text()).toBe('Restart') + expect(button.element.tagName).toBe('BUTTON') + }) + + // Review round 1 (109-e medium finding, coverage gap a): no fixture ever + // had more than one action, so a regression that re-rendered a second + // button from `actions[1]` — re-growing the multi-button row FR-013 + // removed — would pass every FIXTURES case above unnoticed. + it('renders only actions[0] as the primary button when actions has more than one entry', () => { + const wrapper = mountCard( + makeServer({ + quarantined: true, + health: { + level: 'degraded', + admin_state: 'quarantined', + summary: 'Sign-in required', + status: 'signin_required', + usable: false, + actions: ['login', 'approve'], + action: 'login', + } as unknown as HealthStatus, + }) + ) + const buttons = wrapper.findAll('[data-test="server-card-primary-action"]') + expect(buttons).toHaveLength(1) + expect(buttons[0].text()).toBe('Sign in') + }) +}) + +describe('ServerCard — ⋯ menu (Spec 109 FR-013)', () => { + it('lists Enable/Disable, Scan, Restart, Logs, Edit, Trust mode and Delete, correctly labelled', () => { + const wrapper = mountCard(makeServer({ enabled: true, trust_mode: 'auto' })) + const menu = wrapper.find('[data-test="server-card-menu"]') + expect(menu.exists()).toBe(true) + + // Review round 1 (109-e medium finding, coverage gap d): the original + // assertions only checked `.exists()` on each hook, so an inverted + // Enable/Disable label or a blank Trust-mode entry would still pass. + // Checking rendered text catches that class of regression. + expect(wrapper.find('[data-test="server-card-menu-toggle"]').text()).toBe('Disable') + expect(wrapper.find('[data-test="server-card-menu-scan"]').exists()).toBe(true) + expect(wrapper.find('[data-test="server-card-menu-restart"]').text()).toBe('Restart') + expect(wrapper.find('[data-test="server-card-menu-logs"]').text()).toBe('Logs') + expect(wrapper.find('[data-test="server-card-menu-edit"]').text()).toBe('Edit') + expect(wrapper.find('[data-test="server-card-menu-trust"]').text()).toContain('Auto') + expect(wrapper.find('[data-test="server-card-menu-delete"]').text()).toBe('Delete') + }) + + it('shows Disable/Enable inverted for a disabled server', () => { + const wrapper = mountCard(makeServer({ enabled: false })) + expect(wrapper.find('[data-test="server-card-menu-toggle"]').text()).toBe('Enable') + }) + + it('Delete is reachable only from the ⋯ menu, and its confirmation names the server', async () => { + const wrapper = mountCard(makeServer({ name: 'my-server' })) + + // Review round 1 (109-e medium finding, coverage gap c): the old check + // used a directional CSS adjacent-sibling selector plus an exact-string + // match on "Delete Server", both of which a bare "Delete" placed + // anywhere else on the card FACE (outside the ⋯ menu) would evade. + // Scanning the primary-action row's own text directly does not. + const primaryRow = wrapper.find('[data-test="server-card-primary-row"]') + expect(primaryRow.exists()).toBe(true) + expect(primaryRow.text()).not.toContain('Delete') + // Nor anywhere else on the card face outside the ⋯ dropdown-content menu. + const menuContent = wrapper.find('[data-test="server-card-menu"]') + const outsideMenuButtons = wrapper + .findAll('button') + .filter((b) => !menuContent.element.contains(b.element)) + expect(outsideMenuButtons.some((b) => b.text().includes('Delete'))).toBe(false) + + await wrapper.find('[data-test="server-card-menu-delete"]').trigger('click') + + expect(wrapper.text()).toContain('Are you sure you want to delete the server') + expect(wrapper.text()).toContain('my-server') + expect(wrapper.find('[data-test="server-card-delete-confirm"]').exists()).toBe(true) + }) + + // Review round 1 (109-e high finding): a server that is BOTH quarantined + // AND needs OAuth sign-in reports `actions = ["login", "approve"]` + // (FR-010) — the primary button surfaces only `actions[0]` ("Sign in"), + // so "approve" would otherwise vanish with no path to review left on the + // card at all. The ⋯ menu must offer Review independently of whichever + // action is primary, the same way the ⋯-menu already gates Scan/Logout on + // server state rather than on the primary action. + it('offers Review from the ⋯ menu whenever the server is quarantined, even when it is not the primary action', () => { + const wrapper = mountCard( + makeServer({ + name: 'srv', + quarantined: true, + health: { + level: 'degraded', + admin_state: 'quarantined', + summary: 'Sign-in required', + status: 'signin_required', + usable: false, + actions: ['login', 'approve'], + action: 'login', + } as unknown as HealthStatus, + }) + ) + + // The primary button is still "Sign in" — Review does not fight it for + // the one primary slot. + const primaryButton = wrapper.find('[data-test="server-card-primary-action"]') + expect(primaryButton.text()).toBe('Sign in') + + const review = wrapper.find('[data-test="server-card-menu-review"]') + expect(review.exists()).toBe(true) + expect(review.text()).toBe('Review') + expect(review.attributes('href')).toBe('/servers/srv?tab=tools') + }) + + it('does not offer Review from the ⋯ menu for a non-quarantined server', () => { + const wrapper = mountCard(makeServer({ quarantined: false })) + expect(wrapper.find('[data-test="server-card-menu-review"]').exists()).toBe(false) + }) +}) + +describe('ServerCard — trust mode shield icon + tooltip (Spec 109 FR-013)', () => { + it('renders an icon with a tooltip, not a text label', () => { + const wrapper = mountCard(makeServer({ trust_mode: 'auto' })) + const shield = wrapper.find('[data-test="server-trust-mode"]') + expect(shield.exists()).toBe(true) + expect(shield.find('svg').exists()).toBe(true) + expect(shield.attributes('data-tip')).toContain('Auto') + }) + + // Review round 1 (109-e medium finding): the FR-013 icon-only rewrite left + // the shield with no accessible name at all outside the invalid-value edge + // case (the CSS `data-tip` tooltip is not read by a screen reader). Every + // card's trust mode, not just the invalid one, must have an accessible + // name. + it('has an accessible name naming the trust mode, for every mode — not only the invalid case', () => { + const wrapper = mountCard(makeServer({ trust_mode: 'auto' })) + const shield = wrapper.find('[data-test="server-trust-mode"]') + expect(shield.attributes('role')).toBe('img') + expect(shield.attributes('aria-label')).toContain('Auto') + }) + + it('still names the effective mode and the invalid marker for an unrecognized value', () => { + const wrapper = mountCard(makeServer({ trust_mode: 'bogus' })) + const shield = wrapper.find('[data-test="server-trust-mode"]') + expect(shield.attributes('aria-label')).toContain('not recognized') + expect(shield.attributes('aria-label')).toContain('bogus') + }) +}) + +describe('ServerCard — stats line links to Activity (Spec 109 FR-013)', () => { + it('links to /activity scoped to this server and the last 24h', () => { + const wrapper = mountCard(makeServer({ name: 'alpha' })) + const stats = wrapper.find('[data-test="server-card-stats-line"]') + expect(stats.attributes('href')).toBe('/activity?server=alpha&from=-24h') + }) + + it('adds status=error when the server has errors in the period', () => { + const wrapper = mountCard(makeServer({ name: 'beta' }), ) + wrapper.setProps({ + activityStats: { name: 'beta', calls: 5, errors: 2, last_call_at: new Date().toISOString() }, + }) + return wrapper.vm.$nextTick().then(() => { + const stats = wrapper.find('[data-test="server-card-stats-line"]') + expect(stats.attributes('href')).toBe('/activity?server=beta&from=-24h&status=error') + }) + }) +}) + +// Review round 2 (109-e medium finding): `lastCallText`'s only reactive +// dependency is `activityStats.last_call_at` — it read `Date.now()` but had +// nothing that re-evaluates the computed on the passage of time alone, so an +// idle server's "Xm ago" label froze at whatever it was on first render and +// stayed wrong indefinitely while the tab stayed open. +describe('ServerCard — last-call label keeps ticking (Spec 109 FR-013)', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('updates "Xm ago" as time passes, without a prop or health change', () => { + const fixedNow = new Date('2026-01-01T12:00:00.000Z') + vi.setSystemTime(fixedNow) + const lastCallAt = new Date(fixedNow.getTime() - 5 * 60_000).toISOString() // 5m ago + + const wrapper = mountCard(makeServer({ name: 'gamma' })) + wrapper.setProps({ + activityStats: { name: 'gamma', calls: 1, errors: 0, last_call_at: lastCallAt }, + }) + + return wrapper.vm.$nextTick().then(async () => { + expect(wrapper.find('[data-test="server-card-stats-line"]').text()).toContain('last call 5m ago') + + // Advance the clock by 3 hours with no prop change at all — only the + // ticking clock inside the component should move the label. + vi.setSystemTime(new Date(fixedNow.getTime() + 3 * 60 * 60_000)) + vi.advanceTimersByTime(60_000) // let the component's own interval fire + await wrapper.vm.$nextTick() + + expect(wrapper.find('[data-test="server-card-stats-line"]').text()).toContain('last call 3h ago') + }) + }) + + it('clears its interval on unmount (no leaked timer)', () => { + const wrapper = mountCard(makeServer({ name: 'delta' })) + const clearSpy = vi.spyOn(global, 'clearInterval') + wrapper.unmount() + expect(clearSpy).toHaveBeenCalled() + clearSpy.mockRestore() + }) +}) diff --git a/frontend/tests/unit/server-card-quarantine-scan-headline.spec.ts b/frontend/tests/unit/server-card-quarantine-scan-headline.spec.ts index 50dc2691c..9621130fe 100644 --- a/frontend/tests/unit/server-card-quarantine-scan-headline.spec.ts +++ b/frontend/tests/unit/server-card-quarantine-scan-headline.spec.ts @@ -51,38 +51,31 @@ beforeEach(() => { // statements are individually true, but stacked as peers they tell the operator // opposite things about whether the server is safe. The scan verdict is about // CONTENT; quarantine is about REVIEW STATE. +// +// Spec 109 FR-013 folded the old full-width banner into the card's single +// stats line (D15), so this now pins the same "never say settled while +// quarantined" invariant against `[data-test="server-card-security-line"]` +// instead of a standalone banner element. describe('ServerCard — one security headline per card (#1065)', () => { const cleanScan = { status: 'clean' as const } it('shows the standalone scan verdict when the server is NOT quarantined', () => { const wrapper = mountCard(makeServer({ security_scan: cleanScan } as Partial)) - expect(wrapper.find('[data-test="security-scan-badge"]').exists()).toBe(true) - expect(wrapper.find('[data-test="server-card-quarantine"]').exists()).toBe(false) + const line = wrapper.find('[data-test="server-card-security-line"]') + expect(line.exists()).toBe(true) + expect(line.text()).toContain('Clean') + expect(line.text()).not.toContain('review') }) - it('suppresses the standalone verdict while quarantined', () => { + it('subordinates a clean verdict to the review ask while quarantined', () => { const wrapper = mountCard( makeServer({ quarantined: true, security_scan: cleanScan } as Partial) ) - expect(wrapper.find('[data-test="security-scan-badge"]').exists()).toBe(false) - expect(wrapper.find('[data-test="server-card-quarantine"]').exists()).toBe(true) - }) - - it('restates a clean verdict inside the banner, subordinate to the review ask', () => { - const wrapper = mountCard( - makeServer({ quarantined: true, security_scan: cleanScan } as Partial) - ) - - const banner = wrapper.find('[data-test="server-card-quarantine"]') - expect(banner.text()).toContain('Quarantined — needs security review') - - const note = wrapper.find('[data-test="server-card-quarantine-scan-note"]') - expect(note.exists()).toBe(true) - expect(note.text()).toBe('Last scan: clean — still needs review') - // The note lives inside the banner, not beside it. - expect(banner.element.contains(note.element)).toBe(true) + const line = wrapper.find('[data-test="server-card-security-line"]') + expect(line.exists()).toBe(true) + expect(line.text()).toBe('· Last scan: clean — still needs review') }) it('never says a quarantined server is settled — every verdict still asks for review', () => { @@ -98,29 +91,35 @@ describe('ServerCard — one security headline per card (#1065)', () => { const wrapper = mountCard( makeServer({ quarantined: true, security_scan: scan } as unknown as Partial) ) - const note = wrapper.find('[data-test="server-card-quarantine-scan-note"]') - expect(note.exists(), `status ${String(scan.status)} produced no note`).toBe(true) - expect(note.text()).toBe(expected) - expect(note.text(), `status ${String(scan.status)} reads as settled`).toMatch(/review/) + const line = wrapper.find('[data-test="server-card-security-line"]') + expect(line.exists(), `status ${String(scan.status)} produced no line`).toBe(true) + expect(line.text()).toBe(`· ${expected}`) + expect(line.text(), `status ${String(scan.status)} reads as settled`).toMatch(/review/) } }) - it('keeps the banner a one-liner when there is nothing to report', () => { + it('has nothing to report when there is no scan yet', () => { for (const scan of [undefined, { status: 'not_scanned' as const }]) { const wrapper = mountCard( makeServer({ quarantined: true, security_scan: scan } as unknown as Partial) ) - expect(wrapper.find('[data-test="server-card-quarantine"]').exists()).toBe(true) - expect(wrapper.find('[data-test="server-card-quarantine-scan-note"]').exists()).toBe(false) + expect(wrapper.find('[data-test="server-card-security-line"]').exists()).toBe(false) } }) - it('still offers Review from the banner', () => { + it('still offers Review as the primary action while quarantined', () => { const wrapper = mountCard( - makeServer({ quarantined: true, security_scan: cleanScan } as Partial) + makeServer({ + quarantined: true, + security_scan: cleanScan, + health: { level: 'healthy', admin_state: 'quarantined', summary: 'Quarantined for review', status: 'needs_review', usable: false, actions: ['approve'], action: 'approve' }, + } as unknown as Partial) ) - const review = wrapper.find('[data-test="server-card-quarantine-review"]') + const review = wrapper.find('[data-test="server-card-primary-action"]') expect(review.exists()).toBe(true) - expect(review.attributes('href')).toContain('tab=security') + expect(review.text()).toContain('Review') + // `/review/` is not a registered route (109-e review round 1); + // it links straight to the Tools tab instead. + expect(review.attributes('href')).toContain('tab=tools') }) }) diff --git a/frontend/tests/unit/server-card-quarantined-oauth-login.spec.ts b/frontend/tests/unit/server-card-quarantined-oauth-login.spec.ts index a3aa3ad93..5e0ba6867 100644 --- a/frontend/tests/unit/server-card-quarantined-oauth-login.spec.ts +++ b/frontend/tests/unit/server-card-quarantined-oauth-login.spec.ts @@ -20,10 +20,15 @@ const RouterLinkStub = { const LOGIN_ERROR = "OAuth authentication required for server 'github' - login available via Web UI or 'mcpproxy auth login --server=github'" -// The shape GET /api/v1/servers returns for a remote OAuth server imported with -// quarantine on (e.g. GitHub MCP at api.githubcopilot.com): health.action is -// 'approve' because the quarantine branch wins, yet the server cannot connect -// until the user signs in. +// The shape GET /api/v1/servers returns for a remote OAuth server imported +// with quarantine on (e.g. GitHub MCP at api.githubcopilot.com): quarantined +// AND needing OAuth sign-in. internal/health.quarantinedOAuthLoginState +// (calculator.go, PR #1366 + this branch's own round of fixes) resolves this +// to actions=[login, approve] — login is the ONE primary action +// (Spec 109 FR-013/FR-014, actions[0]), with Review still reachable +// independently from the ⋯ menu (gated on `server.quarantined`, mirroring +// macOS ServersView.swift's contextMenuActions) since the primary button +// alone can't surface both. function quarantinedOAuthServer(overrides: Partial = {}): Server { return { name: 'github', @@ -44,8 +49,10 @@ function quarantinedOAuthServer(overrides: Partial = {}): Server { level: 'degraded', admin_state: 'quarantined', summary: 'Quarantined — Sign-in required', + status: 'sign_in_required', detail: LOGIN_ERROR, - action: 'approve', + action: 'login', + actions: ['login', 'approve'], }, ...overrides, } as Server @@ -65,62 +72,80 @@ beforeEach(() => { }) describe('ServerCard — quarantined server that needs OAuth sign-in', () => { - it('offers Login alongside Approve even though health.action is "approve"', () => { + it('shows Sign in as the ONE primary action, with Review still reachable from the ⋯ menu', () => { const card = mountCard(quarantinedOAuthServer()) - const login = card.find('[data-test="server-card-login"]') - expect(login.exists()).toBe(true) - expect(login.text()).toContain('Login') - expect(card.find('[data-test="server-card-approve"]').exists()).toBe(true) + const primary = card.find('[data-test="server-card-primary-action"]') + expect(primary.exists()).toBe(true) + expect(primary.text()).toBe('Sign in') + // actions=[login, approve]: login wins the ONE primary slot (FR-013/ + // FR-014), but quarantined still needs a path to Review — gated + // independently on `server.quarantined` in the ⋯ menu, not on whichever + // action is primary (109-e round 1 high finding). + expect(card.find('[data-test="server-card-menu-review"]').exists()).toBe(true) expect(card.find('[data-test="server-status-chip"]').text()).toBe('Sign-in required') }) - it('Login triggers the OAuth flow for the server', async () => { + it('the primary action triggers the OAuth flow for the server', async () => { const card = mountCard(quarantinedOAuthServer()) const store = useServersStore() const spy = vi.spyOn(store, 'triggerOAuthLogin').mockResolvedValue(undefined as never) - await card.find('[data-test="server-card-login"]').trigger('click') + await card.find('[data-test="server-card-primary-action"]').trigger('click') await flushPromises() expect(spy).toHaveBeenCalledWith('github') }) - it('drops the red error alert — the Login button already conveys the sign-in', () => { - const card = mountCard(quarantinedOAuthServer()) - expect(card.find('[data-test="server-card-error"]').exists()).toBe(false) - }) - - it('offers no Login for a quarantined server that does not need sign-in', () => { + it('offers Review, not Sign in, for a quarantined server that does not need sign-in', () => { const card = mountCard( quarantinedOAuthServer({ last_error: undefined, diagnostic: undefined, - health: { level: 'healthy', admin_state: 'quarantined', summary: 'Quarantined for review', action: 'approve' }, + health: { + level: 'healthy', + admin_state: 'quarantined', + summary: 'Quarantined for review', + status: 'needs_review', + action: 'approve', + actions: ['approve'], + }, } as Partial) ) - expect(card.find('[data-test="server-card-login"]').exists()).toBe(false) - expect(card.find('[data-test="server-card-approve"]').exists()).toBe(true) + const primary = card.find('[data-test="server-card-primary-action"]') + expect(primary.text()).toBe('Review') + expect(primary.text()).not.toBe('Sign in') + expect(card.find('[data-test="server-card-menu-review"]').exists()).toBe(true) }) - it('offers Enable, not Login, for a disabled server with a stale sign-in diagnostic', () => { + it('offers Enable, not Sign in, for a disabled server with a stale sign-in diagnostic', () => { const card = mountCard( quarantinedOAuthServer({ quarantined: false, enabled: false, - health: { level: 'healthy', admin_state: 'disabled', summary: 'Disabled', action: 'enable' }, + health: { + level: 'healthy', + admin_state: 'disabled', + summary: 'Disabled', + status: 'disabled', + action: 'enable', + actions: ['enable'], + }, } as Partial) ) - expect(card.find('[data-test="server-card-login"]').exists()).toBe(false) - expect(card.find('[data-test="server-card-enable"]').exists()).toBe(true) + const primary = card.find('[data-test="server-card-primary-action"]') + expect(primary.text()).toBe('Enable') }) - it('drops Login for a just-disabled server whose stale health.action is still "login" (disableServer optimistic-update race)', () => { + it('shows Enable, not Sign in, for a just-disabled server whose stale health.action is still "login" (disableServer optimistic-update race)', () => { // disableServer() (stores/servers.ts) optimistically flips top-level // `enabled` to false immediately, but the `health` object (admin_state // still 'enabled', action still 'login') is only replaced once the - // SSE-triggered refresh lands. During that window showLogin must not - // render the stale Login CTA for a server the user just disabled. + // SSE-triggered refresh lands. During that window the primary action + // must not render the stale Sign-in CTA for a server the user just + // disabled — `primaryAction`'s own race guard forces 'enable' whenever + // `enabled` (which updates immediately) says false but the stale action + // still says 'login'. const card = mountCard( quarantinedOAuthServer({ quarantined: false, @@ -129,11 +154,14 @@ describe('ServerCard — quarantined server that needs OAuth sign-in', () => { level: 'degraded', admin_state: 'enabled', summary: 'Sign-in required', + status: 'sign_in_required', detail: LOGIN_ERROR, action: 'login', + actions: ['login'], }, } as Partial) ) - expect(card.find('[data-test="server-card-login"]').exists()).toBe(false) + const primary = card.find('[data-test="server-card-primary-action"]') + expect(primary.text()).toBe('Enable') }) }) diff --git a/frontend/tests/unit/server-card-review-and-error.spec.ts b/frontend/tests/unit/server-card-review-and-error.spec.ts index f2ca53f2a..48bc98f49 100644 --- a/frontend/tests/unit/server-card-review-and-error.spec.ts +++ b/frontend/tests/unit/server-card-review-and-error.spec.ts @@ -51,112 +51,144 @@ beforeEach(() => { // Audit F7: the Servers page states "Quarantined N — Need security review" on // the stat tile above, then offered the card no way to review anything. +// +// Spec 109 FR-013/FR-014 (109-e) folded the card's whole action row down to +// ONE primary button = `health.actions[0]`, so "Review" is now that primary +// button, deep-linked straight to the server's Tools tab (never the old +// Security-tab link, and never a direct approve — FR-005). Review round 1 +// (109-e high finding): it used to link to `/review/` on the theory +// that a redirect to `?tab=tools` existed elsewhere — that route was never +// registered, so it 404'd; the link now goes straight to the Tools tab. describe('ServerCard — quarantined card affords review (audit F7)', () => { - it('offers Review, deep-linked to the server Security tab', () => { + it('offers Review as the one primary action, deep-linked to the Tools tab', () => { const wrapper = mountCard( makeServer({ quarantined: true, enabled: false, - health: { level: 'healthy', admin_state: 'disabled', summary: 'Disabled', action: 'enable' }, - } as Partial) + health: { + level: 'healthy', admin_state: 'quarantined', summary: 'Quarantined for review', + status: 'needs_review', usable: false, actions: ['approve'], action: 'approve', + }, + } as unknown as Partial) ) - const review = wrapper.find('[data-test="server-card-quarantine-review"]') + const review = wrapper.find('[data-test="server-card-primary-action"]') expect(review.exists()).toBe(true) expect(review.text()).toContain('Review') - expect(review.attributes('href')).toContain('tab=security') + expect(review.attributes('href')).toBe(`/servers/${wrapper.props('server').name}?tab=tools`) }) - it('demotes Enable while the server is still held back', () => { - const quarantined = mountCard( + it('shows exactly one primary button, whatever the server state', () => { + const wrapper = mountCard( makeServer({ quarantined: true, enabled: false, - health: { level: 'healthy', admin_state: 'disabled', summary: 'Disabled', action: 'enable' }, - } as Partial) + health: { + level: 'healthy', admin_state: 'disabled', summary: 'Disabled', + status: 'disabled', usable: false, actions: ['enable'], action: 'enable', + }, + } as unknown as Partial) ) - expect(quarantined.find('[data-test="server-card-enable"]').classes()).toContain('btn-outline') + const primaryButtons = wrapper.findAll('[data-test="server-card-primary-action"]') + expect(primaryButtons).toHaveLength(1) + expect(primaryButtons[0].text()).toContain('Enable') + }) - const plainDisabled = mountCard( + it('shows no primary button for a healthy, actionless server', () => { + const wrapper = mountCard( makeServer({ - enabled: false, - health: { level: 'healthy', admin_state: 'disabled', summary: 'Disabled', action: 'enable' }, - } as Partial) + health: { + level: 'healthy', admin_state: 'enabled', summary: 'Connected', + status: 'ready', usable: true, actions: [], action: '', + }, + } as unknown as Partial) ) - expect(plainDisabled.find('[data-test="server-card-enable"]').classes()).toContain('btn-primary') + expect(wrapper.find('[data-test="server-card-primary-action"]').exists()).toBe(false) }) - it('explains why the Scan button is disabled', () => { + it('explains why the Scan menu item is disabled', () => { const wrapper = mountCard(makeServer({ enabled: false })) const scan = wrapper.find('[data-test="server-card-scan-disabled"]') expect(scan.exists()).toBe(true) expect(scan.attributes('title')).toBeTruthy() expect(scan.attributes('title')).toContain('Enable the server') }) + + it('offers Delete only from the ⋯ menu, with a confirmation naming the server', async () => { + const wrapper = mountCard(makeServer()) + + // Never a bare Delete control on the card face. + expect(wrapper.find('[data-test="server-card-delete-confirm"]').exists()).toBe(false) + + const menuDelete = wrapper.find('[data-test="server-card-menu-delete"]') + expect(menuDelete.exists()).toBe(true) + await menuDelete.trigger('click') + + expect(wrapper.text()).toContain('Are you sure you want to delete the server') + expect(wrapper.text()).toContain('test-server') + expect(wrapper.find('[data-test="server-card-delete-confirm"]').exists()).toBe(true) + }) }) // Audit F12: the card rendered a good badge ("Host not found") AND a full-width // red block containing the entire wrapped Go error chain. +// +// Spec 109 FR-013 folds that block into the ONE status line: the plain-language +// summary is the line's detail segment, and the raw chain moves to the +// tooltip — never printed on the card face. describe('ServerCard — error dump is collapsed (audit F12)', () => { const wrappedError = 'failed to connect: MCP initialize failed during no-auth strategy: transport error: ' + 'failed to send request: Post "https://example.invalid/mcp": ' + 'dial tcp: lookup example.invalid: no such host' - it('shows the plain-language summary, with the raw chain behind a disclosure', () => { + it('shows the plain-language summary on the card, with the raw chain only in the tooltip', () => { const wrapper = mountCard( makeServer({ last_error: wrappedError, health: { - level: 'unhealthy', - admin_state: 'enabled', - summary: 'Host not found', - detail: wrappedError, - action: 'edit_url', + level: 'unhealthy', admin_state: 'enabled', summary: 'Host not found', + detail: wrappedError, status: 'error', usable: false, actions: ['edit_url'], action: 'edit_url', }, - } as Partial) + } as unknown as Partial) ) - expect(wrapper.find('[data-test="server-card-error-summary"]').text()).toBe('Host not found') - - const details = wrapper.find('[data-test="server-card-error"] details') - expect(details.exists()).toBe(true) - // Collapsed by default — no `open` attribute. - expect(details.attributes('open')).toBeUndefined() - expect(wrapper.find('[data-test="server-card-error-detail"]').text()).toContain('dial tcp') + const statusLine = wrapper.find('[data-test="server-card-status-line"]') + expect(statusLine.text()).toContain('Host not found') + // The raw wrapped chain never appears as visible text on the card. + expect(statusLine.text()).not.toContain('dial tcp') + expect(statusLine.attributes('data-tip')).toContain('dial tcp') }) it('falls back to the root cause when no health summary is present', () => { const wrapper = mountCard(makeServer({ last_error: 'outer: inner: the real cause' })) - expect(wrapper.find('[data-test="server-card-error-summary"]').text()).toBe('the real cause') + expect(wrapper.find('[data-test="server-card-status-line"]').text()).toContain('the real cause') }) // For a quarantined or disabled server the health calculator short-circuits - // and summary describes the ADMIN state, not the failure. Using it here would - // print "Quarantined for review" in a red error alert sitting directly above - // the quarantine banner that already says exactly that. - it('does not restate the admin state as the error on a quarantined server', () => { + // and summary describes the ADMIN state, not the failure. The status TEXT + // segment already says that ("Needs review"), so the detail segment must + // fall through to the structured diagnostic instead of restating it. + it('does not restate the admin state as the error detail on a quarantined server', () => { const wrapper = mountCard( makeServer({ quarantined: true, last_error: 'failed to connect: stdio transport: transport error: transport closed', health: { - level: 'healthy', - admin_state: 'quarantined', - summary: 'Quarantined for review', - action: 'approve', + level: 'healthy', admin_state: 'quarantined', summary: 'Quarantined for review', + status: 'needs_review', usable: false, actions: ['approve'], action: 'approve', }, diagnostic: { code: 'MCPX_STDIO_EXIT_BEFORE_INITIALIZE', severity: 'error', user_message: 'The stdio server process exited before completing the MCP initialize handshake.', }, - } as Partial) + } as unknown as Partial) ) - const summary = wrapper.find('[data-test="server-card-error-summary"]').text() - expect(summary).not.toContain('Quarantined') - expect(summary).toContain('exited before completing') + const line = wrapper.find('[data-test="server-card-status-line"]').text() + expect(line).not.toContain('Quarantined for review — Quarantined') + expect(line).toContain('exited before completing') }) it('falls back to the root cause on a quarantined server with no diagnostic', () => { @@ -165,34 +197,31 @@ describe('ServerCard — error dump is collapsed (audit F12)', () => { quarantined: true, last_error: 'failed to connect: transport error: transport closed', health: { - level: 'healthy', - admin_state: 'quarantined', - summary: 'Quarantined for review', - action: 'approve', + level: 'healthy', admin_state: 'quarantined', summary: 'Quarantined for review', + status: 'needs_review', usable: false, actions: ['approve'], action: 'approve', }, - } as Partial) + } as unknown as Partial) ) - expect(wrapper.find('[data-test="server-card-error-summary"]').text()).toBe('transport closed') + expect(wrapper.find('[data-test="server-card-status-line"]').text()).toContain('transport closed') }) }) -// Audit F11: a name that does not resolve is an address problem. Restart -// redials the same broken address forever. +// Audit F11: a name that does not resolve is not a restartable outage. Send +// the user to the field that is actually wrong — now via the ONE primary +// action (Spec 109 FR-013/FR-014), not a dedicated button. describe('ServerCard — edit_url action (audit F11)', () => { - it('offers Edit URL pointing at the config tab with the endpoint focused', () => { + it('offers Edit URL as the primary action, pointing at the config tab with the endpoint focused', () => { const wrapper = mountCard( makeServer({ last_error: 'no such host', health: { - level: 'unhealthy', - admin_state: 'enabled', - summary: 'Host not found', - action: 'edit_url', + level: 'unhealthy', admin_state: 'enabled', summary: 'Host not found', + status: 'error', usable: false, actions: ['edit_url'], action: 'edit_url', }, - } as Partial) + } as unknown as Partial) ) - const link = wrapper.find('[data-test="server-card-edit-url"]') + const link = wrapper.find('[data-test="server-card-primary-action"]') expect(link.exists()).toBe(true) expect(link.text()).toContain('Edit URL') expect(link.attributes('href')).toContain('tab=config') diff --git a/frontend/tests/unit/server-detail-approve-dialog.spec.ts b/frontend/tests/unit/server-detail-approve-dialog.spec.ts index cee26ed39..064780abb 100644 --- a/frontend/tests/unit/server-detail-approve-dialog.spec.ts +++ b/frontend/tests/unit/server-detail-approve-dialog.spec.ts @@ -17,6 +17,12 @@ import { createRouter, createWebHistory } from 'vue-router' const SERVER = 'context7-docs' let serverQuarantined = true +// Review round 2 (109-e medium finding): scanner-gate-wording.spec.ts's own +// no-scan-mode test was deleted when ServerCard's copy of this dialog was +// removed (Spec 109, 109-e), on the claim that this file covers "both modes" +// — it did not; this file had only the findings-mode fixture below. Toggling +// `withScan` lets the same mount function drive either dialog mode. +let withScan = true vi.mock('@/services/api', () => { const ok = (data: unknown = {}) => Promise.resolve({ success: true, data }) @@ -32,14 +38,18 @@ vi.mock('@/services/api', () => { enabled: true, connected: false, quarantined: serverQuarantined, - trust_mode: 'scan', + trust_mode: withScan ? 'scan' : 'manual', tool_count: 0, - security_scan: { - status: 'dangerous', - risk_score: 60, - last_scan_at: '2026-09-05T10:00:00Z', - finding_counts: { dangerous: 2, warning: 0, info: 0, total: 2 }, - }, + ...(withScan + ? { + security_scan: { + status: 'dangerous', + risk_score: 60, + last_scan_at: '2026-09-05T10:00:00Z', + finding_counts: { dangerous: 2, warning: 0, info: 0, total: 2 }, + }, + } + : {}), }, ], }) @@ -84,6 +94,7 @@ beforeEach(() => { setActivePinia(createPinia()) vi.clearAllMocks() serverQuarantined = true + withScan = true }) describe('ServerDetail — the approve dialog names the gate it skips (F09)', () => { @@ -97,4 +108,24 @@ describe('ServerDetail — the approve dialog names the gate it skips (F09)', () expect(modal.text()).toContain('skips the scan-based approval gate') expect(modal.text()).toContain('unquarantines this server') }) + + // Review round 2 (109-e medium finding): the gate sentence + // ("skips the scan-based approval gate…") is shared by BOTH dialog modes — + // it must say nothing about findings, because the no_scan mode has none. + // scanner-gate-wording.spec.ts pinned this for ServerCard's now-deleted + // copy of the dialog; deleting that test left this exact regression class + // (the shared sentence mentioning findings even with no scan) uncoverable + // anywhere — server-detail-quarantine-banner.spec.ts's own no-scan-mode + // test only checks the "No Security Scan Run" title, never this body text. + it('says nothing about findings in the no-scan mode of the same dialog', async () => { + withScan = false + const wrapper = await mountDetail() + await wrapper.get('[data-test="quarantine-action-approve"]').trigger('click') + + const modal = wrapper.get('.modal-open') + expect(modal.text()).toContain('No Security Scan Run') + expect(modal.text()).toContain('skips the scan-based approval gate') + expect(modal.text()).not.toContain('dangerous finding') + expect(modal.text()).not.toContain('these findings') + }) }) diff --git a/frontend/tests/unit/servers-view-trust-badge.spec.ts b/frontend/tests/unit/servers-view-trust-badge.spec.ts index bf8ecc958..081dc2395 100644 --- a/frontend/tests/unit/servers-view-trust-badge.spec.ts +++ b/frontend/tests/unit/servers-view-trust-badge.spec.ts @@ -5,12 +5,17 @@ import { createRouter, createWebHistory } from 'vue-router' import { TRUST_MODES } from '@/utils/trustMode' // Spec 088 US1 / FR-007 (T012): the servers list must show each server's trust -// mode at a glance. The list renders ServerCard tiles, so the compact badge -// lives next to the existing server-level status chip. The badge label comes -// from utils/trustMode.ts TRUST_MODES and always reflects the EFFECTIVE mode +// mode at a glance. The list renders ServerCard tiles, so the badge lives next +// to the existing server-level status chip. The mode comes from +// utils/trustMode.ts TRUST_MODES and always reflects the EFFECTIVE mode // (fail-closed to manual); an unrecognized raw value is shown as effective + // a subtle marker rather than being hidden or silently rewritten // (US1 scenario 4 / FR-001). +// +// Spec 109 FR-013 (109-e) redefined the rendering: "Trust mode is a shield +// icon with a tooltip" — no visible text label on the card face any more, so +// the assertions below read the TOOLTIP (`title`/`data-tip`) instead of the +// element's text, which is now icon-only. vi.mock('@/services/api', () => { const ok = (data: unknown = {}) => Promise.resolve({ success: true, data }) @@ -79,45 +84,44 @@ describe('Servers list — trust-mode badge (spec 088 FR-007)', () => { }) it.each(TRUST_MODES.map((m) => [m.mode, m.label] as const))( - 'shows the compact TRUST_MODES label for trust_mode=%s', + 'names the mode in the tooltip for trust_mode=%s', async (mode, label) => { const wrapper = await mountServers([makeServer('srv', mode)]) const badge = wrapper.find('[data-test="server-trust-mode"]') expect(badge.exists()).toBe(true) - expect(badge.text()).toContain(label) + expect(badge.attributes('data-tip')).toContain(label) } ) it('shows the effective default (Manual) when trust_mode is unset, with no invalid marker', async () => { const wrapper = await mountServers([makeServer('unset')]) const badge = wrapper.find('[data-test="server-trust-mode"]') - expect(badge.text()).toContain('Manual') + expect(badge.attributes('data-tip')).toContain('Manual') expect(badge.attributes('data-trust-invalid')).toBeUndefined() }) it('shows the effective mode plus a marker (raw value in the tooltip) for an unrecognized value', async () => { const wrapper = await mountServers([makeServer('hand-edited', 'bogus')]) const badge = wrapper.find('[data-test="server-trust-mode"]') - // Effective mode is shown, never the raw value as if it were a mode. - expect(badge.text()).toContain('Manual') - expect(badge.text()).not.toContain('bogus') + // Effective mode is named, and the raw value appears too — but only in + // the tooltip explaining WHY it fell back, never as if it were a mode. + expect(badge.attributes('data-tip')).toContain('Manual') // Subtle marker distinguishes it from an explicitly-configured manual. expect(badge.attributes('data-trust-invalid')).toBe('true') - expect(badge.attributes('title')).toContain('bogus') + expect(badge.attributes('data-tip')).toContain('bogus') }) it('mis-cased values fail closed like any other unrecognized value', async () => { const wrapper = await mountServers([makeServer('miscased', 'Scan')]) const badge = wrapper.find('[data-test="server-trust-mode"]') - expect(badge.text()).toContain('Manual') + expect(badge.attributes('data-tip')).toContain('Manual') expect(badge.attributes('data-trust-invalid')).toBe('true') }) - it('styles the badge like the neighbouring server-level chips', async () => { + it('renders as a shield icon, sitting alongside the status line', async () => { const wrapper = await mountServers([makeServer('styled', 'scan')]) const badge = wrapper.find('[data-test="server-trust-mode"]') - expect(badge.classes()).toContain('badge') - expect(badge.classes()).toContain('badge-sm') + expect(badge.find('svg').exists()).toBe(true) // Sits alongside the existing status chip, not replacing it. expect(wrapper.find('[data-test="server-status-chip"]').exists()).toBe(true) }) @@ -125,7 +129,7 @@ describe('Servers list — trust-mode badge (spec 088 FR-007)', () => { it('explains the mode in the tooltip for a valid mode', async () => { const wrapper = await mountServers([makeServer('tip', 'auto')]) const badge = wrapper.find('[data-test="server-trust-mode"]') - expect(badge.attributes('title')).toContain('Trust mode') - expect(badge.attributes('title')).toContain('Auto') + expect(badge.attributes('data-tip')).toContain('Trust mode') + expect(badge.attributes('data-tip')).toContain('Auto') }) }) diff --git a/internal/contracts/activity.go b/internal/contracts/activity.go index bb9620668..d54948d5f 100644 --- a/internal/contracts/activity.go +++ b/internal/contracts/activity.go @@ -150,8 +150,16 @@ type ActivitySummaryResponse struct { CallErrorCount int `json:"call_error_count"` TopServers []ActivityTopServer `json:"top_servers,omitempty"` // Top servers by activity count TopTools []ActivityTopTool `json:"top_tools,omitempty"` // Top tools by activity count - StartTime string `json:"start_time"` // Start of the period (RFC3339) - EndTime string `json:"end_time"` // End of the period (RFC3339) + // PerServer covers EVERY server with at least one call in the period + // (unlike TopServers, which is capped at 5 and carries no error counts). + // Spec 109 FR-013: the server-card stats line and the macOS Servers rows + // read this — one `GET /activity/summary` response per page load — rather + // than issuing a per-server activity query each. Computed in the same + // counting pass as the totals above, from the same CountsAsCall/ + // IsManagementBuiltin definitions TopServers already uses. + PerServer []ActivityPerServer `json:"per_server,omitempty"` + StartTime string `json:"start_time"` // Start of the period (RFC3339) + EndTime string `json:"end_time"` // End of the period (RFC3339) } // ActivityTopServer represents a server's activity count in the summary @@ -160,6 +168,17 @@ type ActivityTopServer struct { Count int `json:"count"` // Activity count } +// ActivityPerServer is one server's call/error/recency counters within the +// summary period (Spec 109 FR-013, additive to ActivitySummaryResponse). +type ActivityPerServer struct { + Name string `json:"name"` // Server name + Calls int `json:"calls"` // Calls counted per storage.CountsAsCall + Errors int `json:"errors"` // Of those calls, how many failed + // LastCallAt is RFC3339, or "" if the server had no call in the period + // (PerServer only lists servers that did, so this is always set). + LastCallAt string `json:"last_call_at"` +} + // ActivityTopTool represents a tool's activity count in the summary type ActivityTopTool struct { Server string `json:"server"` // Server name diff --git a/internal/httpapi/activity.go b/internal/httpapi/activity.go index 515008b79..ebb2197a5 100644 --- a/internal/httpapi/activity.go +++ b/internal/httpapi/activity.go @@ -829,6 +829,7 @@ func (s *Server) handleActivitySummary(w http.ResponseWriter, r *http.Request) { var callCount, callErrorCount int serverCounts := make(map[string]int) toolCounts := make(map[string]int) + perServer := make(map[string]*perServerAccumulator) // The stream holds a read transaction open until the channel is drained or // closed, so this loop must always run to completion. @@ -839,8 +840,12 @@ func (s *Server) handleActivitySummary(w http.ResponseWriter, r *http.Request) { // different questions, and the Activity Log used to print the first // under the second's label while the Usage tab printed the second — // same instance, same window, different numbers (F1, #1046). One shared - // definition, in storage, settles it for both surfaces. - if counted, isError := storage.CountsAsCall(a); counted { + // definition, in storage, settles it for both surfaces. Hoisted out of + // the `if` (rather than shadowed inside it) so the per-server tally + // below can reuse the exact same counted/isError verdict (Spec 109 + // FR-013) instead of recomputing it. + counted, isError := storage.CountsAsCall(a) + if counted { callCount++ if isError { callErrorCount++ @@ -885,6 +890,24 @@ func (s *Server) handleActivitySummary(w http.ResponseWriter, r *http.Request) { if a.ServerName != "" { serverCounts[a.ServerName]++ + + // Spec 109 FR-013: per-server calls/errors/last-call-time, for the + // server card stats line — the SAME call/error definition as the + // CallCount/CallErrorCount totals above, just split by server. + if counted { + agg := perServer[a.ServerName] + if agg == nil { + agg = &perServerAccumulator{} + perServer[a.ServerName] = agg + } + agg.calls++ + if isError { + agg.errors++ + } + if agg.lastCallAt.IsZero() || a.Timestamp.After(agg.lastCallAt) { + agg.lastCallAt = a.Timestamp + } + } } if a.ServerName != "" && a.ToolName != "" { @@ -895,6 +918,7 @@ func (s *Server) handleActivitySummary(w http.ResponseWriter, r *http.Request) { // Build top servers list (top 5) topServers := buildTopServers(serverCounts, 5) + perServerList := buildPerServerSummary(perServer) // Build top tools list (top 5) topTools := buildTopTools(toolCounts, 5) @@ -911,6 +935,7 @@ func (s *Server) handleActivitySummary(w http.ResponseWriter, r *http.Request) { CallErrorCount: callErrorCount, TopServers: topServers, TopTools: topTools, + PerServer: perServerList, StartTime: startTime.Format(time.RFC3339), EndTime: endTime.Format(time.RFC3339), } @@ -918,6 +943,43 @@ func (s *Server) handleActivitySummary(w http.ResponseWriter, r *http.Request) { s.writeSuccess(w, response) } +// perServerAccumulator is the running per-server tally for +// contracts.ActivityPerServer, built in the same StreamActivities pass as the +// summary totals (Spec 109 FR-013). +type perServerAccumulator struct { + calls int + errors int + lastCallAt time.Time +} + +// buildPerServerSummary converts the per-server accumulator map into the +// sorted (by name, for a stable response) contracts.ActivityPerServer list. +// Unlike buildTopServers this is NOT capped — every server with at least one +// call in the period is included, since the server card needs its own stats +// regardless of how busy other servers were. +func buildPerServerSummary(agg map[string]*perServerAccumulator) []contracts.ActivityPerServer { + if len(agg) == 0 { + return nil + } + names := make([]string, 0, len(agg)) + for name := range agg { + names = append(names, name) + } + sort.Strings(names) + + result := make([]contracts.ActivityPerServer, 0, len(names)) + for _, name := range names { + a := agg[name] + result = append(result, contracts.ActivityPerServer{ + Name: name, + Calls: a.calls, + Errors: a.errors, + LastCallAt: a.lastCallAt.Format(time.RFC3339), + }) + } + return result +} + // buildTopServers returns top N servers by activity count. func buildTopServers(counts map[string]int, limit int) []contracts.ActivityTopServer { // Convert map to slice for sorting diff --git a/internal/httpapi/activity_call_parity_test.go b/internal/httpapi/activity_call_parity_test.go index fb79d29bb..3fd38eacf 100644 --- a/internal/httpapi/activity_call_parity_test.go +++ b/internal/httpapi/activity_call_parity_test.go @@ -101,6 +101,15 @@ func parityRecords(ts time.Time) []*storage.ActivityRecord { {Type: storage.ActivityTypeToolQuarantineChange, ServerName: "everything", ToolName: "echo", Status: "tool_auto_approved", Timestamp: ts}, {Type: storage.ActivityTypeToolQuarantineChange, ServerName: "memory", ToolName: "read_graph", Status: "tool_auto_approved", Timestamp: ts}, {Type: storage.ActivityTypeSecurityScan, ServerName: "everything", ToolName: "echo", Status: storage.ActivityStatusSuccess, Timestamp: ts}, + // Review round 1 (109-e medium finding): a server whose ONLY row in + // the window is a non-call event — never a ToolCall/InternalToolCall/ + // PolicyDecision, so storage.CountsAsCall is always false for it + // (activity_summary_per_server_test.go's + // TestActivitySummaryPerServerExcludesNonCallOnlyServers needs a real + // example of this to be a genuine regression test rather than a + // restatement of the general "no zero-call PerServer entry" + // invariant against servers that all have real calls anyway). + {Type: storage.ActivityTypeSecurityScan, ServerName: "scan-only", ToolName: "echo", Status: storage.ActivityStatusSuccess, Timestamp: ts}, } } diff --git a/internal/httpapi/activity_summary_per_server_test.go b/internal/httpapi/activity_summary_per_server_test.go new file mode 100644 index 000000000..7cb6b5eca --- /dev/null +++ b/internal/httpapi/activity_summary_per_server_test.go @@ -0,0 +1,106 @@ +package httpapi + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Spec 109 FR-013 (PR 109-e, T070): the server-card stats line and the macOS +// Servers rows need per-server calls/errors/last-call-time from ONE +// `GET /activity/summary` response per page load, rather than a per-server +// query each. `PerServer` is additive and computed in the SAME counting pass +// as the existing totals, from the same storage.CountsAsCall/ +// IsManagementBuiltin definitions TopServers already uses — reusing the +// activity_call_parity_test.go fixture keeps the two aggregates pinned +// against one record set instead of a second, possibly-drifting one. +func TestActivitySummaryPerServer(t *testing.T) { + ts := time.Now().UTC().Add(-time.Minute) + srv := newCallParityServer(t, ts) + + summary := getSummary(t, srv) + + require.NotEmpty(t, summary.PerServer, "every server with a call in the period must appear") + + byName := make(map[string]struct { + calls int + errors int + }, len(summary.PerServer)) + for _, ps := range summary.PerServer { + byName[ps.Name] = struct { + calls int + errors int + }{ps.Calls, ps.Errors} + // Every listed server had at least one call in the period, so it must + // carry a resolved last-call time, never the empty string. + assert.NotEmpty(t, ps.LastCallAt, "%s: PerServer only lists servers with a call", ps.Name) + parsed, err := time.Parse(time.RFC3339, ps.LastCallAt) + require.NoError(t, err, "%s: last_call_at must be RFC3339", ps.Name) + assert.WithinDuration(t, ts, parsed, time.Second, "%s: last_call_at must be the fixture's call time", ps.Name) + } + + // Hand-counted from parityRecords (see activity_call_parity_test.go): + // everything: 3 successful echo + 2 failed doesnotexist = 5 calls, 2 errors + // memory: create_entities success + the code_execution sub-call + // (read_graph) = 2 calls, 0 errors + // broken-remote: 1 failed call = 1 call, 1 error + // evil: 1 blocked policy decision, counted as a failed call + assert.Equal(t, 5, byName["everything"].calls, "everything: calls") + assert.Equal(t, 2, byName["everything"].errors, "everything: errors") + assert.Equal(t, 2, byName["memory"].calls, "memory: calls") + assert.Equal(t, 0, byName["memory"].errors, "memory: errors") + assert.Equal(t, 1, byName["broken-remote"].calls, "broken-remote: calls") + assert.Equal(t, 1, byName["broken-remote"].errors, "broken-remote: errors") + assert.Equal(t, 1, byName["evil"].calls, "evil: calls (a policy block is a call the user made and did not get)") + assert.Equal(t, 1, byName["evil"].errors, "evil: errors") + + // The three discovery/management built-ins (retrieve_tools x2, + // describe_tool) carry no ServerName and must never synthesize a + // pseudo-server entry. + for _, ps := range summary.PerServer { + assert.NotEmpty(t, ps.Name, "PerServer must never carry an empty server name") + } + + // Deterministic ordering (by name) so two callers of the same response + // never see a different row order. + names := make([]string, len(summary.PerServer)) + for i, ps := range summary.PerServer { + names[i] = ps.Name + } + assert.IsIncreasing(t, names, "PerServer is sorted by name for a stable response") +} + +// A server whose only activity in the period is a non-call event (a security +// scan, a quarantine auto-approval) must not appear in PerServer at all — +// PerServer answers "which servers had a CALL", not "which servers have any +// row in the log" (that broader question is TopServers/serverCounts). +func TestActivitySummaryPerServerExcludesNonCallOnlyServers(t *testing.T) { + ts := time.Now().UTC().Add(-time.Minute) + srv := newCallParityServer(t, ts) + + summary := getSummary(t, srv) + + // "everything" also has a security-scan-only-looking row in the fixture, + // but it has real calls too, so this also checks the general invariant: + // no PerServer entry may report zero calls. + for _, ps := range summary.PerServer { + assert.Greater(t, ps.Calls, 0, "%s: PerServer must not list a server with zero calls", ps.Name) + } + + // "scan-only" has no call in the fixture at all — only a security scan — + // so it must not appear in PerServer. A regression that hoisted + // per-server accumulator creation out of the `if counted` gate + // (internal/httpapi/activity.go) would add it here with Calls == 0, + // which the loop above would also have already caught, but asserting its + // absence by name is what actually exercises this test's own stated + // invariant instead of restating the general one against servers that + // all happen to have real calls too. + names := make([]string, len(summary.PerServer)) + for i, ps := range summary.PerServer { + names[i] = ps.Name + } + assert.NotContains(t, names, "scan-only", + "a server whose only activity is a security scan must never appear in PerServer") +} diff --git a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift index 5048613a7..cb994143b 100644 --- a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift +++ b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift @@ -1530,31 +1530,67 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS sub.addItem(.separator()) - let leading = TrayServerAction.leadingMenuActions(for: server) - - // OAuth sign-in — calm, actionable affordance shown first when - // login is required (MCP-1822), not error framing. Offered beside - // Review quarantine, never instead of it. - if leading.contains(.login) { - let login = NSMenuItem(title: TrayServerAction.login.menuTitle, - action: #selector(loginServer(_:)), keyEquivalent: "") - login.target = self - login.representedObject = server.name - login.image = NSImage(systemSymbolName: "person.badge.key", accessibilityDescription: "sign in") - sub.addItem(login) + // Spec 109 FR-014: the ONE primary action, from the same pure mapping + // and label table (`TrayPrimaryPresentation.primaryItem`, + // `HealthStatus.actionLabels`) the Servers row uses for the exact + // same `actions[0]` value — replaces the old ad hoc "needsAuth" / + // "quarantined" special cases, which showed Sign-in and Review but + // nothing at all for a missing secret, a bad config or a bad URL + // (FR-014's "never a missing item"). `login`/`restart`/`enable` run + // in place; every other value opens the screen that performs it — + // never a one-click approve (FR-005). + let primary = TrayPrimaryPresentation.primaryItem(for: server) + var primaryOpensReview = false + if let primary { + let item = NSMenuItem(title: primary.label, action: nil, keyEquivalent: "") + item.target = self + switch primary.kind { + case .execute(let action): + item.representedObject = server.name + switch action { + case .login: item.action = #selector(loginServer(_:)) + case .restart: item.action = #selector(restartServer(_:)) + case .enable: item.action = #selector(enableServer(_:)) + case .disable, .approve: item.action = nil // never produced for this kind + } + item.image = NSImage(systemSymbolName: primaryExecuteSymbol(action), accessibilityDescription: primary.label) + case .open(let destination): + item.action = #selector(showServerDetailFromMenu(_:)) + switch destination { + case .review: + item.representedObject = server.name + primaryOpensReview = true + case .config: + // FR-014 (review round 3, F-FR014-focus): `primary.focusField` + // is non-nil only for `edit_url` — every other `.config` + // destination (`set_secret`, `configure`) opens with no + // field focused, same as before. + item.representedObject = ServerDetailTarget(serverName: server.name, tab: .config, + focusField: primary.focusField) + case .logs: + item.representedObject = ServerDetailTarget(serverName: server.name, tab: .logs) + } + item.image = NSImage(systemSymbolName: primaryOpenSymbol(destination), accessibilityDescription: primary.label) + } + sub.addItem(item) sub.addItem(.separator()) } - // F8(a): a quarantined server offered only Disable · Restart · View - // Logs — the one thing it needs is a review, and the menu had no path - // to it at all. Deep-links to Server Detail, which opens on Tools with - // the quarantine banner. - if leading.contains(.approve) { - let review = NSMenuItem(title: TrayServerAction.approve.menuTitle, + // Review round 1 (109-e high finding): `actions[0]` alone drives the + // primary item above, so a server that is BOTH quarantined AND needs + // OAuth sign-in (FR-010: `actions = ["login", "approve"]`) shows only + // "Sign in" there — "approve" never surfaces. Gating this row + // independently on `server.quarantined`, the same way + // ServersView.swift's `contextMenuActions` does for the Servers-row + // context menu, restores the one thing a quarantined server needs + // (the old unconditional `if server.quarantined { show Review }` + // this replaced) without reintroducing a second primary button. + if server.quarantined && !primaryOpensReview { + let review = NSMenuItem(title: HealthStatus.actionLabels["approve"] ?? "Review", action: #selector(showServerDetailFromMenu(_:)), keyEquivalent: "") review.target = self review.representedObject = server.name - review.image = NSImage(systemSymbolName: "checkmark.shield", accessibilityDescription: "review quarantine") + review.image = NSImage(systemSymbolName: "checkmark.shield", accessibilityDescription: "Review") sub.addItem(review) sub.addItem(.separator()) } @@ -1563,37 +1599,65 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS // `Disable`/`Enable` for everything else put two mental models — // transient process control vs. persistent admin state — on the same // `enabled` flag, and left submenus reading "Disabled … Start". - if server.enabled { - let disable = NSMenuItem(title: TrayServerAction.disable.menuTitle, - action: #selector(disableServer(_:)), keyEquivalent: "") - disable.target = self - disable.representedObject = server.name - sub.addItem(disable) - } else { - let enable = NSMenuItem(title: TrayServerAction.enable.menuTitle, - action: #selector(enableServer(_:)), keyEquivalent: "") - enable.target = self - enable.representedObject = server.name - sub.addItem(enable) + // + // Review round 2 (109-e medium finding): these three rows used to be + // unconditional, so whichever one the primary item above already + // performs (Enable/Restart/View logs) rendered TWICE in the same + // submenu. `TraySecondaryPresentation.items` drops the one the + // primary already covers. + let secondaryActions = TraySecondaryPresentation.items(for: server) + for secondary in secondaryActions { + switch secondary { + case .toggleEnabled(let enable): + let action: TrayServerAction = enable ? .enable : .disable + let item = NSMenuItem(title: action.menuTitle, + action: enable ? #selector(enableServer(_:)) : #selector(disableServer(_:)), + keyEquivalent: "") + item.target = self + item.representedObject = server.name + sub.addItem(item) + case .restart: + let item = NSMenuItem(title: TrayServerAction.restart.menuTitle, + action: #selector(restartServer(_:)), keyEquivalent: "") + item.target = self + item.representedObject = server.name + sub.addItem(item) + case .viewLogs: + continue // added below, after the separator + } } - - let restart = NSMenuItem(title: TrayServerAction.restart.menuTitle, - action: #selector(restartServer(_:)), keyEquivalent: "") - restart.target = self - restart.representedObject = server.name - sub.addItem(restart) - sub.addItem(.separator()) - - let logs = NSMenuItem(title: "View Logs", action: #selector(viewServerLogs(_:)), keyEquivalent: "") - logs.target = self - logs.representedObject = server.name - sub.addItem(logs) + if secondaryActions.contains(.viewLogs) { + let logs = NSMenuItem(title: "View Logs", action: #selector(viewServerLogs(_:)), keyEquivalent: "") + logs.target = self + logs.representedObject = server.name + sub.addItem(logs) + } item.submenu = sub return item } + /// SF Symbol for a primary item that RUNS in place (Spec 109 FR-014). + private func primaryExecuteSymbol(_ action: TrayServerAction) -> String { + switch action { + case .login: return "person.badge.key" + case .restart: return "arrow.clockwise" + case .enable: return "play.fill" + case .disable: return "stop.fill" + case .approve: return "checkmark.shield" + } + } + + /// SF Symbol for a primary item that OPENS a screen (Spec 109 FR-014). + private func primaryOpenSymbol(_ destination: TrayPrimaryDestination) -> String { + switch destination { + case .review: return "checkmark.shield" + case .config: return "gearshape" + case .logs: return "doc.text" + } + } + // MARK: - Menu Actions @objc private func retryCore() { @@ -1776,7 +1840,8 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS } /// Navigate to a server's detail page. The represented object is the - /// server name or a typed target carrying the tab from `fix.target`. + /// server name or a typed target carrying the tab from `fix.target` and + /// the endpoint focus required by `edit_url` (Spec 109 FR-014). private func attentionDetailTarget(for item: AttentionItem) -> ServerDetailTarget { let tabName = URLComponents(string: item.fix.target)?.queryItems? .first(where: { $0.name == "tab" })?.value?.lowercased() @@ -1786,7 +1851,8 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS case "logs": tab = .logs default: tab = .tools } - return ServerDetailTarget(serverName: item.subject.name, tab: tab) + let focusField: TrayConfigFocusField? = item.fix.verb == "edit_url" ? .endpoint : nil + return ServerDetailTarget(serverName: item.subject.name, tab: tab, focusField: focusField) } @objc private func showServerDetailFromMenu(_ sender: NSMenuItem) { diff --git a/native/macos/MCPProxy/MCPProxy/Menu/TrayPresentation.swift b/native/macos/MCPProxy/MCPProxy/Menu/TrayPresentation.swift index c9aa364a7..ee9a738b9 100644 --- a/native/macos/MCPProxy/MCPProxy/Menu/TrayPresentation.swift +++ b/native/macos/MCPProxy/MCPProxy/Menu/TrayPresentation.swift @@ -288,6 +288,149 @@ enum TrayServerAction: String, Equatable { } } +// MARK: - F14 · One primary action, wherever it's shown (Spec 109 FR-014) + +/// Where a primary action that does not run in place takes the user. Both +/// the macOS Servers row and the tray server submenu resolve to the SAME +/// destination for the same `actions[0]` value — only how they get there +/// (a sheet vs. `.showServerDetail`) is AppKit/SwiftUI wiring, not a decision +/// this pure type makes. +enum TrayPrimaryDestination: Equatable { + /// The server's review location — the Tools tab, where the per-tool + /// approval banner and the quarantine review UI already live, until + /// 109-g's dedicated review sheet ships (matches the Web UI's interim + /// `/review/` → `?tab=tools` redirect, 109-a T026a). + case review + /// A field to fill in: the secret value (`set_secret`) or the endpoint + /// (`edit_url`) — both resolved to the server's Config tab, the one + /// place either can be edited today (mirrors DashboardView.AttentionRow). + case config + /// The server's log viewer. + case logs +} + +/// What clicking the primary item does: run the action in place, or open the +/// screen that performs it. Never a one-click approve (FR-005) — `approve` +/// only ever resolves to `.open(.review)`. +enum TrayPrimaryKind: Equatable { + case execute(TrayServerAction) + case open(TrayPrimaryDestination) +} + +/// A field to draw focus to once a `.open(.config)` destination has opened — +/// FR-014's "with the field focused" (review round 3, F-FR014-focus). Only +/// `edit_url` names one: the URL field is the one concrete remedy for a bad +/// endpoint, the same field the Web UI's `ServerCard.primaryHref` sends +/// `&focus=endpoint` for (`ServerDetail.vue`'s `applyEndpointFocus`). +/// `configure`'s underlying causes are heterogeneous (a hash mismatch, a +/// denied tool, a missing annotation, …) with no single field to name — the +/// Web UI itself does not focus anything for `configure` either, so this +/// stays `nil` for it rather than guessing a field that would often be wrong. +enum TrayConfigFocusField: Equatable { + case endpoint +} + +/// The ONE primary item for a server's `health.actions[0]` (or the legacy +/// singular `action`), shared verbatim by the Servers row and the tray +/// submenu (FR-014). `label` always comes from `HealthStatus.actionLabels` — +/// the same table the Web UI and CLI render — never a private per-surface +/// wording (the old tray submenu said "Review quarantine…"; the shared table +/// says "Review"). +struct TrayPrimaryItem: Equatable { + let label: String + let kind: TrayPrimaryKind + /// Non-nil only for `edit_url` (see `TrayConfigFocusField`). Both + /// `ServersView.primaryActionClicked` and `MCPProxyApp`'s tray submenu + /// forward this into `ServerDetailTarget.focusField` so `ServerDetailView` + /// can focus the matching control once Config opens. + let focusField: TrayConfigFocusField? +} + +enum TrayPrimaryPresentation { + /// Resolves a raw `actions[0]` (or legacy `action`) STRING value. `nil` + /// for an empty or unrecognized action — no button, no menu item (the + /// normal `ready` case, FR-013). Every value `HealthStatus.actionLabels` + /// carries a label for is handled explicitly below, so a label with no + /// destination can only mean this table and this function have drifted — + /// caught by TrayPrimaryItemTests, not silently swallowed into `nil`. + static func primaryItem(for action: String?) -> TrayPrimaryItem? { + guard let action, !action.isEmpty else { return nil } + guard let label = HealthStatus.actionLabels[action] else { return nil } + if let executable = TrayServerAction.fromHealthAction(action) { + return TrayPrimaryItem(label: label, kind: .execute(executable), focusField: nil) + } + switch action { + case "approve": return TrayPrimaryItem(label: label, kind: .open(.review), focusField: nil) + case "set_secret": return TrayPrimaryItem(label: label, kind: .open(.config), focusField: nil) + case "configure": return TrayPrimaryItem(label: label, kind: .open(.config), focusField: nil) + case "edit_url": return TrayPrimaryItem(label: label, kind: .open(.config), focusField: .endpoint) + case "view_logs": return TrayPrimaryItem(label: label, kind: .open(.logs), focusField: nil) + default: return nil + } + } + + /// `server.health.action`, falling back to a synthesized `"approve"` for + /// a quarantined server whose core predates the health vocabulary (no + /// `health` object at all) — the same old-core tolerance + /// `HealthStatus.isUsable`/`actionsOrLegacyFallback` already give every + /// other consumer of this field, so a quarantined server never loses its + /// one path to review just because the payload is from an older core. + static func effectiveAction(for server: ServerStatus) -> String? { + if let action = server.health?.action, !action.isEmpty { return action } + if server.quarantined { return "approve" } + return nil + } + + /// Convenience over a `ServerStatus` — the Servers row and the tray + /// submenu both call this one function (FR-014's "one pure function"). + static func primaryItem(for server: ServerStatus) -> TrayPrimaryItem? { + primaryItem(for: effectiveAction(for: server)) + } +} + +// MARK: - F14 (round 2) · The always-present tail, minus the primary's echo + +/// One of the tray submenu's/Servers-row's always-present secondary rows — +/// Enable/Disable, Restart, View Logs — that stay regardless of whichever +/// `actions[0]` value is primary. +enum TraySecondaryAction: Equatable { + case toggleEnabled(enable: Bool) + case restart + case viewLogs +} + +/// Review round 2 (109-e medium finding): the primary item added for +/// FR-014 was laid ABOVE these three always-present rows without suppressing +/// whichever one it duplicates, so a disabled server (primary = Enable) or a +/// restart-needing one (primary = Restart) or a token-refresh-pending one +/// (primary = View logs) showed the identical command twice in the same +/// menu/row — once as the accent-tinted primary, once as the plain tail +/// item. `items(for:)` is the ONE place both the tray submenu +/// (`MCPProxyApp.buildServerMenuItem`) and the Servers row +/// (`ServersView.makeActionsCell`) decide which of the three tail rows to +/// actually render, so neither surface can reintroduce the duplicate by +/// drifting from the other. +enum TraySecondaryPresentation { + static func items(for server: ServerStatus) -> [TraySecondaryAction] { + let primaryKind = TrayPrimaryPresentation.primaryItem(for: server)?.kind + var items: [TraySecondaryAction] = [] + if server.enabled { + // `disable` is never a primary action (TrayServerAction.fromHealthAction + // never produces it), so Disable can never be the primary's echo. + items.append(.toggleEnabled(enable: false)) + } else if primaryKind != .execute(.enable) { + items.append(.toggleEnabled(enable: true)) + } + if primaryKind != .execute(.restart) { + items.append(.restart) + } + if primaryKind != .open(.logs) { + items.append(.viewLogs) + } + return items + } +} + enum TrayServerActionFailure { static func title(action: TrayServerAction, server: String) -> String { "Couldn’t \(action.verb) \(server)" diff --git a/native/macos/MCPProxy/MCPProxy/State/HomeAttentionAction.swift b/native/macos/MCPProxy/MCPProxy/State/HomeAttentionAction.swift index 008c80cae..28151e0b0 100644 --- a/native/macos/MCPProxy/MCPProxy/State/HomeAttentionAction.swift +++ b/native/macos/MCPProxy/MCPProxy/State/HomeAttentionAction.swift @@ -35,11 +35,15 @@ enum HomeAttentionAction { } catch { // Action errors are visible via the attention list's own refresh. } - case "set_secret", "configure", "edit_url": - // None of these complete via a single API call — they need a - // form (the secret value, the new URL, isolation fields). Take - // the user to the server's Config tab instead of no-op'ing. + case "set_secret", "configure": + // These require a form (the secret value or configuration + // fields), so take the user to the Config tab instead of + // no-op'ing. navigateToServerDetail(item.subject.name, tab: .config) + case "edit_url": + // The endpoint itself is invalid. Open its edit form and put the + // cursor in the concrete field that needs correction. + navigateToServerDetail(item.subject.name, tab: .config, focusField: .endpoint) case "view_logs": navigateToServerDetail(item.subject.name, tab: .logs) case "review": @@ -62,12 +66,16 @@ enum HomeAttentionAction { /// other links already use, so a `.showServerDetail` observer set up /// once in ServersView handles every doorway into server detail. @MainActor - private static func navigateToServerDetail(_ serverName: String, tab: ServerDetailTab) { + private static func navigateToServerDetail( + _ serverName: String, + tab: ServerDetailTab, + focusField: TrayConfigFocusField? = nil + ) { NotificationCenter.default.post(name: .switchToServers, object: nil) DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { NotificationCenter.default.post( name: .showServerDetail, - object: ServerDetailTarget(serverName: serverName, tab: tab) + object: ServerDetailTarget(serverName: serverName, tab: tab, focusField: focusField) ) } } diff --git a/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift b/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift index 92ed6761e..c19034fd9 100644 --- a/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift +++ b/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift @@ -31,6 +31,15 @@ enum ServerDetailTab: String, CaseIterable { struct ServerDetailTarget { let serverName: String let tab: ServerDetailTab + /// FR-014's "with the field focused" for `edit_url` — nil for every other + /// action (review round 3, F-FR014-focus). See `TrayConfigFocusField`. + let focusField: TrayConfigFocusField? + + init(serverName: String, tab: ServerDetailTab, focusField: TrayConfigFocusField? = nil) { + self.serverName = serverName + self.tab = tab + self.focusField = focusField + } } // MARK: - Isolation Override (GH #1142) @@ -90,10 +99,17 @@ struct ServerDetailView: View { @State private var isApproving = false @State private var actionMessage: String? + /// FR-014's "with the field focused" for `edit_url` (review round 3, + /// F-FR014-focus) — consumed once by `applyPendingFocusIfNeeded()` so a + /// later tab switch or re-render doesn't keep re-stealing focus. + @State private var pendingFocusField: TrayConfigFocusField? + @FocusState private var focusedConfigField: TrayConfigFocusField? + init( server: ServerStatus, appState: AppState, initialTab: ServerDetailTab = .tools, + initialFocusField: TrayConfigFocusField? = nil, onDismiss: @escaping () -> Void ) { self.initialServer = server @@ -101,6 +117,7 @@ struct ServerDetailView: View { self.onDismiss = onDismiss self._server = State(initialValue: server) self._selectedTab = State(initialValue: initialTab) + self._pendingFocusField = State(initialValue: initialFocusField) } // Edit mode state for Config tab @@ -164,6 +181,7 @@ struct ServerDetailView: View { case .config: configTab } } + .onAppear { applyPendingFocusIfNeeded() } .sheet(item: $convertSheet) { ctx in convertToSecretSheet(ctx) } @@ -632,7 +650,8 @@ struct ServerDetailView: View { if server.protocol == "http" || server.protocol == "sse" || server.protocol == "streamable-http" { configSection(title: "Connection") { if isEditing { - configEditRow(label: "URL", text: $editURL, placeholder: "https://api.example.com/mcp") + configEditRow(label: "URL", text: $editURL, placeholder: "https://api.example.com/mcp", + focusValue: .endpoint) } else { configRow(label: "URL", value: server.url ?? "N/A") } @@ -844,7 +863,13 @@ struct ServerDetailView: View { } @ViewBuilder - private func configEditRow(label: String, text: Binding, placeholder: String, multiline: Bool = false) -> some View { + private func configEditRow( + label: String, + text: Binding, + placeholder: String, + multiline: Bool = false, + focusValue: TrayConfigFocusField? = nil + ) -> some View { HStack(alignment: .top) { Text(label) .font(.scaled(.subheadline, scale: fontScale)) @@ -855,6 +880,14 @@ struct ServerDetailView: View { .font(.scaledMonospaced(.subheadline, scale: fontScale)) .frame(height: 60) .border(Color(nsColor: .separatorColor), width: 1) + } else if let focusValue { + // FR-014 (review round 3, F-FR014-focus): only the URL row + // passes a non-nil `focusValue` today, so this branch is the + // one place `focusedConfigField` actually binds to a control. + TextField(placeholder, text: text) + .font(.scaledMonospaced(.subheadline, scale: fontScale)) + .textFieldStyle(.roundedBorder) + .focused($focusedConfigField, equals: focusValue) } else { TextField(placeholder, text: text) .font(.scaledMonospaced(.subheadline, scale: fontScale)) @@ -1184,6 +1217,26 @@ struct ServerDetailView: View { isEditing = true } + /// FR-014's "with the field focused" (review round 3, F-FR014-focus): + /// `edit_url`'s primary action lands here on `.config` carrying + /// `focusField == .endpoint` — enter edit mode (mirrors the Web UI's + /// `startEditUrl()`) and focus the URL field once it exists. Consumes + /// `pendingFocusField` so a later re-render (e.g. `refreshServer()`) + /// cannot re-steal focus away from whatever the user is doing next. + private func applyPendingFocusIfNeeded() { + guard let field = pendingFocusField else { return } + guard selectedTab == .config else { return } + pendingFocusField = nil + if !isEditing { startEditing() } + // The URL TextField materialises only once `isEditing` re-renders the + // Config tab body — asyncAfter this run-loop turn (same pattern the + // Web UI's `nextTick(() => urlInputRef.value?.focus())` uses) lets + // that happen before `focusedConfigField` is set. + DispatchQueue.main.async { + focusedConfigField = field + } + } + private func saveEdits() async { guard let client = apiClient else { return } isSavingEdit = true diff --git a/native/macos/MCPProxy/MCPProxy/Views/ServersView.swift b/native/macos/MCPProxy/MCPProxy/Views/ServersView.swift index d1d0751d3..ea9cc8482 100644 --- a/native/macos/MCPProxy/MCPProxy/Views/ServersView.swift +++ b/native/macos/MCPProxy/MCPProxy/Views/ServersView.swift @@ -19,6 +19,11 @@ struct ServersView: View { @State private var loadTask: Task? @State private var selectedServer: ServerStatus? @State private var selectedServerInitialTab: ServerDetailTab = .tools + /// FR-014's "with the field focused" for `edit_url` (review round 3, + /// F-FR014-focus) — threaded alongside `selectedServerInitialTab` from + /// whichever surface (row button, `.showServerDetail` notification) + /// opened this server. + @State private var selectedServerInitialFocusField: TrayConfigFocusField? @State private var showAddServer = false @State private var addServerInitialTab: AddServerTab = .manual @@ -29,6 +34,7 @@ struct ServersView: View { server: server, appState: appState, initialTab: selectedServerInitialTab, + initialFocusField: selectedServerInitialFocusField, onDismiss: { selectedServer = nil } ) } else { @@ -64,12 +70,15 @@ struct ServersView: View { .onReceive(NotificationCenter.default.publisher(for: .showServerDetail)) { notification in let serverName: String let tab: ServerDetailTab + let focusField: TrayConfigFocusField? if let target = notification.object as? ServerDetailTarget { serverName = target.serverName tab = target.tab + focusField = target.focusField } else if let name = notification.object as? String { serverName = name tab = .tools + focusField = nil } else { return } @@ -77,6 +86,7 @@ struct ServersView: View { if let server = servers.first(where: { $0.name == serverName }) ?? appState.servers.first(where: { $0.name == serverName }) { selectedServerInitialTab = tab + selectedServerInitialFocusField = focusField selectedServer = server } } @@ -205,6 +215,12 @@ struct ServersView: View { // later manual double-click on an unrelated server would // silently reopen on that same stale tab instead of Tools. selectedServerInitialTab = .tools + selectedServerInitialFocusField = nil + selectedServer = server + }, + onOpenDetail: { server, tab, focusField in + selectedServerInitialTab = tab + selectedServerInitialFocusField = focusField selectedServer = server }, onServersChanged: { @@ -319,6 +335,51 @@ enum ServerColumn: String, CaseIterable { } } +// MARK: - Row presentation (Spec 109 FR-013/FR-014) + +/// One row's context-menu action, as DATA — built once per +/// `menuNeedsUpdate` and rendered into NSMenuItems by the coordinator below. +/// Kept separate from AppKit so `ServerRowActionTests` can assert on the +/// DECISION (which items, in what order) without a live NSMenu, and so a +/// reintroduced one-click approve/unquarantine call is a compile error, not +/// a runtime regression: there is no case here that means "approve directly" +/// — `.openReview` only ever navigates (Spec 109 FR-005/FR-014). +enum ServerRowMenuAction: Equatable { + case toggleEnabled(enable: Bool) + case restart + case signIn + case openReview + case viewDetails + case viewLogs + case delete +} + +enum ServerRowPresentation { + /// The row's ONE primary action (Spec 109 FR-013/FR-014) — the SAME + /// decision `TrayPrimaryPresentation` makes for the tray submenu, for + /// the identical `actions[0]` value. `nil` for the normal `ready` case. + static func primaryAction(for server: ServerStatus) -> TrayPrimaryItem? { + TrayPrimaryPresentation.primaryItem(for: server) + } + + /// The ordered context-menu action list for one server. A quarantined + /// (or tool-quarantined) server's review action OPENS the review + /// location — it never calls approveTools/unquarantine directly. + static func contextMenuActions(for server: ServerStatus) -> [ServerRowMenuAction] { + var items: [ServerRowMenuAction] = [.toggleEnabled(enable: !server.enabled), .restart] + if server.isOAuthLoginRequired { + items.append(.signIn) + } + if server.quarantined || server.pendingApprovalCount > 0 { + items.append(.openReview) + } + items.append(.viewDetails) + items.append(.viewLogs) + items.append(.delete) + return items + } +} + // MARK: - AppKit NSTableView wrapper struct ServerTableView: NSViewRepresentable { @@ -326,6 +387,12 @@ struct ServerTableView: NSViewRepresentable { let apiClient: APIClient? var fontScale: CGFloat = 1.0 var onDoubleClick: ((ServerStatus) -> Void)? + /// Opens Server Detail on a SPECIFIC tab (Spec 109 FR-014's `approve` → + /// Tools/review, `configure`/`edit_url`/`set_secret` → Config, `view_logs` + /// → Logs) — `onDoubleClick` always opens Tools, this can open any tab. + /// The third parameter is FR-014's "with the field focused" for + /// `edit_url` (review round 3, F-FR014-focus) — nil for every other tab. + var onOpenDetail: ((ServerStatus, ServerDetailTab, TrayConfigFocusField?) -> Void)? var onServersChanged: (() -> Void)? func makeNSView(context: Context) -> NSScrollView { @@ -387,6 +454,7 @@ struct ServerTableView: NSViewRepresentable { context.coordinator.apiClient = apiClient context.coordinator.fontScale = fontScale context.coordinator.onDoubleClick = onDoubleClick + context.coordinator.onOpenDetail = onOpenDetail context.coordinator.onServersChanged = onServersChanged context.coordinator.tableView?.reloadData() } @@ -402,6 +470,7 @@ struct ServerTableView: NSViewRepresentable { var apiClient: APIClient? var fontScale: CGFloat = 1.0 var onDoubleClick: ((ServerStatus) -> Void)? + var onOpenDetail: ((ServerStatus, ServerDetailTab, TrayConfigFocusField?) -> Void)? var onServersChanged: (() -> Void)? weak var tableView: NSTableView? @@ -473,6 +542,59 @@ struct ServerTableView: NSViewRepresentable { onDoubleClick?(sorted[row]) } + // Spec 109 FR-013/FR-014: dispatches the row's ONE primary action — + // `login`/`restart`/`enable` run in place; every other value opens + // the screen that performs it (never a one-click approve, FR-005). + @objc func primaryActionClicked(_ sender: NSButton) { + let row = sender.tag + let sorted = sortedServers + guard row >= 0, row < sorted.count else { return } + let server = sorted[row] + guard let primary = ServerRowPresentation.primaryAction(for: server) else { return } + switch primary.kind { + case .execute(let action): + Task { + switch action { + case .login: try? await apiClient?.loginServer(server.id) + case .restart: try? await apiClient?.restartServer(server.id) + case .enable: try? await apiClient?.enableServer(server.id) + case .disable, .approve: break // never produced for this kind + } + await MainActor.run { onServersChanged?() } + } + case .open(let destination): + let tab: ServerDetailTab + switch destination { + case .review: tab = .tools + case .config: tab = .config + case .logs: tab = .logs + } + // FR-014 (review round 3, F-FR014-focus): `primary.focusField` + // is non-nil only for `edit_url`, so this is a no-op for + // every other destination. + onOpenDetail?(server, tab, primary.focusField) + } + } + + private func primaryActionSymbol(_ kind: TrayPrimaryKind) -> String { + switch kind { + case .execute(let action): + switch action { + case .login: return "person.badge.key" + case .restart: return "arrow.clockwise" + case .enable: return "play.fill" + case .disable: return "stop.fill" + case .approve: return "checkmark.shield" + } + case .open(let destination): + switch destination { + case .review: return "checkmark.shield" + case .config: return "gearshape" + case .logs: return "doc.text" + } + } + } + @objc func toggleEnabledClicked(_ sender: NSButton) { let row = sender.tag let sorted = sortedServers @@ -527,15 +649,19 @@ struct ServerTableView: NSViewRepresentable { // MARK: - Right-Click Context Menu - // This menu is deliberately NOT bound to HealthStatus.actionLabels - // (Spec 109 FR-014's one-table mandate for the single primary - // suggested-action CTA — the Dashboard button, the Web UI action - // label, the CLI ACTION hint). It lists every applicable command as - // its own imperative verb phrase ("Approve All Tools", "View Logs"), - // several of which (Restart, View Details, Delete Server) have no - // HealthAction counterpart at all, so there is no single table this - // menu could read from. "Approve All Tools" is also gated on - // `pendingApprovalCount`, a quarantine signal, not `health.action`. + // Built from `ServerRowPresentation.contextMenuActions` (Spec 109 + // FR-013/FR-014): a quarantined server's review action OPENS the + // review location (`.openReview`) rather than calling + // `apiClient.approveTools` — there is no menu-action case that means + // "approve directly", so that one-click path cannot be reintroduced + // here without adding a new case and a new call site (T069/T078's + // pin). This menu still lists every applicable command as its own + // imperative verb phrase rather than reading `HealthStatus. + // actionLabels` — several commands (Restart, View Details, Delete) + // have no HealthAction counterpart, so there is no single table this + // whole menu could read from; the ONE-table mandate applies to the + // single PRIMARY action (`makePrimaryActionButton` below and the tray + // submenu), not to this exhaustive secondary menu. func menuNeedsUpdate(_ menu: NSMenu) { menu.removeAllItems() guard let tableView else { return } @@ -544,69 +670,58 @@ struct ServerTableView: NSViewRepresentable { guard row >= 0, row < sorted.count else { return } let server = sorted[row] - // Enable/Disable (stdio servers use Stop/Start terminology) - if server.enabled { - let disableLabel = server.protocol == "stdio" ? "Stop" : "Disable" - let disable = NSMenuItem(title: disableLabel, action: #selector(ctxDisableServer(_:)), keyEquivalent: "") - disable.target = self - disable.representedObject = server - menu.addItem(disable) - } else { - let enableLabel = server.protocol == "stdio" ? "Start" : "Enable" - let enable = NSMenuItem(title: enableLabel, action: #selector(ctxEnableServer(_:)), keyEquivalent: "") - enable.target = self - enable.representedObject = server - menu.addItem(enable) - } - - // Restart - let restart = NSMenuItem(title: "Restart", action: #selector(ctxRestartServer(_:)), keyEquivalent: "") - restart.target = self - restart.representedObject = server - menu.addItem(restart) - - // Sign in (if auth needed) — calm, actionable affordance (MCP-1819/T3) - if server.isOAuthLoginRequired { - menu.addItem(.separator()) - let login = NSMenuItem(title: "Sign in", action: #selector(ctxLoginServer(_:)), keyEquivalent: "") - login.target = self - login.representedObject = server - login.image = NSImage(systemSymbolName: "person.badge.key", accessibilityDescription: "sign in") - menu.addItem(login) - } - - // Approve Tools (if quarantined) - if server.pendingApprovalCount > 0 { - menu.addItem(.separator()) - let approve = NSMenuItem(title: "Approve All Tools", action: #selector(ctxApproveTools(_:)), keyEquivalent: "") - approve.target = self - approve.representedObject = server - approve.image = NSImage(systemSymbolName: "checkmark.shield", accessibilityDescription: "approve") - menu.addItem(approve) - } - - menu.addItem(.separator()) - - // View Details - let details = NSMenuItem(title: "View Details", action: #selector(ctxViewDetails(_:)), keyEquivalent: "") - details.target = self - details.representedObject = server - menu.addItem(details) - - // View Logs - let logs = NSMenuItem(title: "View Logs", action: #selector(ctxViewLogs(_:)), keyEquivalent: "") - logs.target = self - logs.representedObject = server - menu.addItem(logs) - - menu.addItem(.separator()) - - // Delete - let delete = NSMenuItem(title: "Delete Server", action: #selector(ctxDeleteServer(_:)), keyEquivalent: "") - delete.target = self - delete.representedObject = server - delete.image = NSImage(systemSymbolName: "trash", accessibilityDescription: "delete") - menu.addItem(delete) + for action in ServerRowPresentation.contextMenuActions(for: server) { + switch action { + case .toggleEnabled(let enable): + let label = enable + ? (server.protocol == "stdio" ? "Start" : "Enable") + : (server.protocol == "stdio" ? "Stop" : "Disable") + let item = NSMenuItem( + title: label, + action: enable ? #selector(ctxEnableServer(_:)) : #selector(ctxDisableServer(_:)), + keyEquivalent: "") + item.target = self + item.representedObject = server + menu.addItem(item) + case .restart: + let item = NSMenuItem(title: "Restart", action: #selector(ctxRestartServer(_:)), keyEquivalent: "") + item.target = self + item.representedObject = server + menu.addItem(item) + case .signIn: + menu.addItem(.separator()) + let item = NSMenuItem(title: "Sign in", action: #selector(ctxLoginServer(_:)), keyEquivalent: "") + item.target = self + item.representedObject = server + item.image = NSImage(systemSymbolName: "person.badge.key", accessibilityDescription: "sign in") + menu.addItem(item) + case .openReview: + menu.addItem(.separator()) + let item = NSMenuItem(title: "Review", action: #selector(ctxOpenReview(_:)), keyEquivalent: "") + item.target = self + item.representedObject = server + item.image = NSImage(systemSymbolName: "checkmark.shield", accessibilityDescription: "review") + menu.addItem(item) + case .viewDetails: + menu.addItem(.separator()) + let item = NSMenuItem(title: "View Details", action: #selector(ctxViewDetails(_:)), keyEquivalent: "") + item.target = self + item.representedObject = server + menu.addItem(item) + case .viewLogs: + let item = NSMenuItem(title: "View Logs", action: #selector(ctxViewLogs(_:)), keyEquivalent: "") + item.target = self + item.representedObject = server + menu.addItem(item) + case .delete: + menu.addItem(.separator()) + let item = NSMenuItem(title: "Delete Server", action: #selector(ctxDeleteServer(_:)), keyEquivalent: "") + item.target = self + item.representedObject = server + item.image = NSImage(systemSymbolName: "trash", accessibilityDescription: "delete") + menu.addItem(item) + } + } } @objc private func ctxEnableServer(_ sender: NSMenuItem) { @@ -638,12 +753,13 @@ struct ServerTableView: NSViewRepresentable { Task { try? await apiClient?.loginServer(server.id) } } - @objc private func ctxApproveTools(_ sender: NSMenuItem) { + // Spec 109 FR-005/FR-014: opens the review location — the Tools tab, + // where the per-tool approval banner already lives — rather than + // calling `apiClient.approveTools` directly. This is the only + // handler `.openReview` can dispatch to. + @objc private func ctxOpenReview(_ sender: NSMenuItem) { guard let server = sender.representedObject as? ServerStatus else { return } - Task { - try? await apiClient?.approveTools(server.id) - await MainActor.run { onServersChanged?() } - } + onOpenDetail?(server, .tools, nil) } @objc private func ctxViewDetails(_ sender: NSMenuItem) { @@ -894,27 +1010,58 @@ struct ServerTableView: NSViewRepresentable { stack.alignment = .centerY stack.translatesAutoresizingMaskIntoConstraints = false + // Spec 109 FR-013/FR-014: the row's ONE primary action, from the + // same pure mapping and label table the tray submenu uses for + // the identical `actions[0]` value. Leads the stack, tinted to + // stand out from the always-present icons that follow. + if let primary = ServerRowPresentation.primaryAction(for: server) { + let primaryButton = makeIconButton( + symbolName: primaryActionSymbol(primary.kind), + accessibilityLabel: primary.label, + action: #selector(primaryActionClicked(_:)), + tag: row + ) + primaryButton.contentTintColor = .controlAccentColor + primaryButton.toolTip = primary.label + stack.addArrangedSubview(primaryButton) + } + + // Review round 2 (109-e medium finding): these two icons used to + // be unconditional, so a disabled server (primary = Enable) or a + // restart-needing one (primary = Restart) showed the identical + // command twice in the same row — once as the accent-tinted + // primary button above, once as the plain icon below. Reusing + // `TraySecondaryPresentation` (the tray submenu's own dedup rule + // for the same `actions[0]` value) keeps both surfaces from + // drifting apart on which duplicate they suppress. + let secondaryActions = TraySecondaryPresentation.items(for: server) + // Play/Stop toggle button - let toggleLabel = server.protocol == "stdio" - ? (server.enabled ? "Stop" : "Start") - : (server.enabled ? "Disable" : "Enable") - let toggleButton = makeIconButton( - symbolName: server.enabled ? "stop.fill" : "play.fill", - accessibilityLabel: toggleLabel, - action: #selector(toggleEnabledClicked(_:)), - tag: row - ) - toggleButton.contentTintColor = server.enabled ? .systemGray : .systemGreen - stack.addArrangedSubview(toggleButton) + if let toggle = secondaryActions.first(where: { if case .toggleEnabled = $0 { return true }; return false }), + case .toggleEnabled(let enable) = toggle { + let toggleLabel = server.protocol == "stdio" + ? (enable ? "Start" : "Stop") + : (enable ? "Enable" : "Disable") + let toggleButton = makeIconButton( + symbolName: enable ? "play.fill" : "stop.fill", + accessibilityLabel: toggleLabel, + action: #selector(toggleEnabledClicked(_:)), + tag: row + ) + toggleButton.contentTintColor = enable ? .systemGreen : .systemGray + stack.addArrangedSubview(toggleButton) + } // Restart button - let restartButton = makeIconButton( - symbolName: "arrow.clockwise", - accessibilityLabel: "Restart", - action: #selector(restartButtonClicked(_:)), - tag: row - ) - stack.addArrangedSubview(restartButton) + if secondaryActions.contains(.restart) { + let restartButton = makeIconButton( + symbolName: "arrow.clockwise", + accessibilityLabel: "Restart", + action: #selector(restartButtonClicked(_:)), + tag: row + ) + stack.addArrangedSubview(restartButton) + } // Info button (opens detail) let infoButton = makeIconButton( diff --git a/native/macos/MCPProxy/MCPProxyTests/CrossSurfaceRenderedLabelParityTests.swift b/native/macos/MCPProxy/MCPProxyTests/CrossSurfaceRenderedLabelParityTests.swift new file mode 100644 index 000000000..70d75bbe1 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/CrossSurfaceRenderedLabelParityTests.swift @@ -0,0 +1,104 @@ +// CrossSurfaceRenderedLabelParityTests.swift +// MCPProxy +// +// Review round 2 (109-e medium finding): `TrayPrimaryItemTests +// .testRowAndSubmenuPrimaryLabelsAreIdentical` compares +// `TrayPrimaryPresentation.primaryItem(for:).label` against +// `ServerRowPresentation.primaryAction(for:).label` — but the row's +// implementation IS that exact same call (`ServerRowPresentation +// .primaryAction` is a one-line passthrough to `TrayPrimaryPresentation +// .primaryItem`), so the two sides of that assertion are always the same +// string read twice. It cannot catch a rendered-string divergence like the +// tail row's "View logs" vs "View Logs" (see TraySecondaryActionTests / +// the round 2 macOS finding) if one crept into the PRIMARY item instead — +// and only 2 of the 8 `HealthStatus.actionLabels` values had their actual +// rendered menu-item title asserted anywhere (TrayAuditMenuTests' Review and +// Sign-in fixtures). +// +// This file builds the REAL tray submenu (`AppController.rebuildMenu`) and +// the REAL Servers-row actions cell (`ServerTableView.Coordinator +// .tableView(_:viewFor:row:)`) independently, for every labeled action, and +// compares the actual rendered strings each surface produced — not the pure +// function both happen to call. +import XCTest +import AppKit +@testable import MCPProxy + +@MainActor +final class CrossSurfaceRenderedLabelParityTests: XCTestCase { + + private final class TestMenuHost: TrayMenuHost { + var menu: NSMenu? + } + + private func renderedTraySubmenuTitles(for server: ServerStatus) throws -> [String] { + let host = TestMenuHost() + let controller = AppController(glanceDataSource: CountingGlanceDataSource(), menuHost: host) + controller.appState.coreState = .connected + controller.appState.servers = [server] + controller.rebuildMenu() + + let serversParent = try XCTUnwrap( + (host.menu?.items ?? []).first { $0.title.hasPrefix("Servers (") }) + let submenu = try XCTUnwrap(serversParent.submenu) + let row = try XCTUnwrap(submenu.items.first { $0.title == server.name }, + "no row for \(server.name): \(submenu.items.map(\.title))") + return try XCTUnwrap(row.submenu).items.map(\.title) + } + + /// The row's REAL primary icon button, built by the same cell factory + /// `tableView(_:viewFor:row:)` uses in production — its accessible + /// tooltip is `primary.label`, set at the exact call site this test + /// exercises (`ServersView.swift`'s `makeActionsCell`). + private func renderedRowPrimaryButtonToolTip(for server: ServerStatus) -> String? { + let coordinator = ServerTableView.Coordinator() + coordinator.servers = [server] + let tableView = NSTableView() + let column = NSTableColumn(identifier: ServerColumn.actions.identifier) + guard let cell = coordinator.tableView(tableView, viewFor: column, row: 0) else { return nil } + let stack = cell.subviews.first { $0 is NSStackView } as? NSStackView + let primaryButton = stack?.arrangedSubviews + .compactMap { $0 as? NSButton } + .first { $0.contentTintColor == .controlAccentColor } + return primaryButton?.toolTip + } + + /// Every action `HealthStatus.actionLabels` carries a label for — not + /// just the 2 (Review, Sign in) the existing tray suite happened to + /// exercise with a real rendered menu. + func testEveryLabeledActionRendersTheIdenticalPrimaryStringOnBothRealSurfaces() throws { + for (action, expectedLabel) in HealthStatus.actionLabels { + let server = Self.server(health: ("healthy", "", action)) + + let trayTitles = try renderedTraySubmenuTitles(for: server) + XCTAssertTrue(trayTitles.contains(expectedLabel), + "\(action): tray submenu did not render '\(expectedLabel)': \(trayTitles)") + + let rowToolTip = renderedRowPrimaryButtonToolTip(for: server) + XCTAssertEqual(rowToolTip, expectedLabel, + "\(action): the row's real primary button tooltip diverged from the tray's rendered label") + } + } + + // MARK: - Helpers + + private static func server(quarantined: Bool = false, + health: (level: String, summary: String, action: String)?) -> ServerStatus { + var healthJSON = "" + if let health { + healthJSON = """ + , "health": {"level": "\(health.level)", "admin_state": "enabled", + "summary": "\(health.summary)", "action": "\(health.action)"} + """ + } + let json = """ + { + "id": "srv", "name": "srv", "protocol": "http", "enabled": true, + "connected": \(health == nil), "quarantined": \(quarantined), "tool_count": 1 + \(healthJSON) + } + """.data(using: .utf8)! + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(ServerStatus.self, from: json) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/HomeRoutingTests.swift b/native/macos/MCPProxy/MCPProxyTests/HomeRoutingTests.swift index 6a0322707..e26d0420c 100644 --- a/native/macos/MCPProxy/MCPProxyTests/HomeRoutingTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/HomeRoutingTests.swift @@ -99,9 +99,13 @@ final class HomeRoutingTests: XCTestCase { let source = try homeAttentionActionSource() let body = try performFixBody(in: source) - let configCase = try caseBody(labelContaining: "\"edit_url\"", in: body) + let configCase = try caseBody(labelContaining: "\"set_secret\"", in: body) XCTAssertTrue(configCase.contains("navigateToServerDetail(item.subject.name, tab: .config)"), - "set_secret/configure/edit_url must open the Config tab") + "set_secret/configure must open the Config tab") + + let editURLCase = try caseBody(labelContaining: "\"edit_url\"", in: body) + XCTAssertTrue(editURLCase.contains("navigateToServerDetail(item.subject.name, tab: .config, focusField: .endpoint)"), + "edit_url must open Config with the endpoint field focused") let logsCase = try caseBody(labelContaining: "\"view_logs\"", in: body) XCTAssertTrue(logsCase.contains("navigateToServerDetail(item.subject.name, tab: .logs)"), @@ -114,12 +118,38 @@ final class HomeRoutingTests: XCTestCase { "review must not call approveTools directly") let navigateBody = try functionBody(named: "navigateToServerDetail", in: source) - XCTAssertTrue(navigateBody.contains("ServerDetailTarget(serverName: serverName, tab: tab)"), - "navigateToServerDetail must post a typed ServerDetailTarget carrying the requested tab") + XCTAssertTrue(navigateBody.contains("ServerDetailTarget(serverName: serverName, tab: tab, focusField: focusField)"), + "navigateToServerDetail must post a typed ServerDetailTarget carrying the requested tab and focus field") XCTAssertFalse(navigateBody.contains("object: serverName)"), "navigateToServerDetail must not post a bare server-name String — ServersView would then default the tab to .tools regardless of which verb fired") } + /// `edit_url` names the one broken field, unlike the broad `configure` + /// and `set_secret` actions. The target must preserve that intent all the + /// way through the notification that ServersView consumes. + func testEditURLFixPostsTypedEndpointFocusedServerDetailTarget() async { + let item = AttentionItem( + id: "config_error:server:remote", + kind: "config_error", + rank: 30, + subject: AttentionSubject(type: "server", id: "remote", name: "remote"), + summary: "remote: endpoint is invalid", + fix: AttentionFix(verb: "edit_url", label: "Edit URL", target: "/servers/remote?tab=config&focus=endpoint"), + since: Date() + ) + let appState = AppState() + + let opened = expectation(forNotification: .showServerDetail, object: nil) { note in + guard let target = note.object as? ServerDetailTarget else { return false } + return target.serverName == "remote" + && target.tab == .config + && target.focusField == .endpoint + } + + await HomeAttentionAction.performFix(item, appState: appState) + await fulfillment(of: [opened], timeout: 2) + } + /// Spec 109 FR-005 / T064 pin: Home's review action never calls /// `approveTools`/`unquarantineServer` — that one-click approve /// (`DashboardView.swift:1066` in the old file) is removed by this PR. diff --git a/native/macos/MCPProxy/MCPProxyTests/ReviewNeverApprovesDirectlySourceGuardTests.swift b/native/macos/MCPProxy/MCPProxyTests/ReviewNeverApprovesDirectlySourceGuardTests.swift new file mode 100644 index 000000000..fa4375958 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/ReviewNeverApprovesDirectlySourceGuardTests.swift @@ -0,0 +1,102 @@ +// ReviewNeverApprovesDirectlySourceGuardTests.swift +// MCPProxy +// +// Review round 2 (109-e medium finding): the "a quarantined server's review +// path never calls approveTools/unquarantine directly" guarantee (FR-005) was +// pinned only against the pure `ServerRowPresentation`/`TrayPrimaryPresentation` +// data functions — never against `MCPProxyApp.swift`'s real +// `showServerDetailFromMenu` handler or the independent quarantine-review +// row's selector wiring, both of which are the tray's equivalent of +// `ServersView.swift`'s `ctxOpenReview` (pinned behaviorally in +// ServerRowDispatchTests). `showServerDetailFromMenu` opens a real window +// (`showMainWindow()`), which the existing test target avoids invoking +// directly (see MainWindowRoutingTests) — so this is a source-level +// regression guard, the same pattern DashboardRoutingTests and +// ServersViewRoutingTests already use for exactly this "can't safely drive +// AppKit window creation from XCTest" constraint. +// +// `APIClient.approveTools` already exists and is already called directly +// elsewhere (e.g. DashboardView.swift) — nothing stops a future edit from +// wiring the tray's review selector to it the same way, and every other test +// in this package would stay green if it did. + +import XCTest +@testable import MCPProxy + +final class ReviewNeverApprovesDirectlySourceGuardTests: XCTestCase { + + // Review round 3 (F4.1): the real APIClient methods are + // `approveSpecificTools(_:tools:)` and `unquarantineServer(_:)` + // (API/APIClient.swift) — neither contains the OLD, narrower substrings + // ("approveTools(", "unquarantine(") that used to be the only ones + // checked here, so a rewiring to either real method would have passed + // silently. Both the old and the real spellings are checked so a future + // rename can't quietly narrow this guard back down. + private static let forbidden = [ + "approveTools(", "unquarantine(", + "approveSpecificTools(", "unquarantineServer(", + ] + + func testShowServerDetailFromMenuNeverCallsApproveDirectly() throws { + let body = try functionBody(named: "showServerDetailFromMenu", in: try mcpProxyAppSource()) + for call in Self.forbidden { + XCTAssertFalse(body.contains(call), + "showServerDetailFromMenu must only navigate (FR-005) — found `\(call)`") + } + XCTAssertTrue(body.contains(".showServerDetail"), + "showServerDetailFromMenu must still post the navigation notification") + } + + /// The independent quarantine-review row (round 1's fix, restoring a + /// review path when `actions[0]` is something other than "approve") must + /// keep dispatching to the same navigation-only handler. + func testIndependentQuarantineReviewRowTargetsTheNavigationHandler() throws { + let source = try mcpProxyAppSource() + guard let range = source.range(of: "if server.quarantined && !primaryOpensReview {") else { + XCTFail("could not find the independent quarantine-review row in MCPProxyApp.swift") + return + } + guard let end = source.range(of: "\n }", range: range.upperBound.. String { + guard let start = source.range(of: "func \(name)(") else { + XCTFail("could not find `func \(name)` in MCPProxyApp.swift") + return "" + } + guard let openBrace = source.range(of: "{", range: start.upperBound.. String { + let packageRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // MCPProxyTests + .deletingLastPathComponent() // package root + let url = packageRoot.appendingPathComponent("MCPProxy/MCPProxyApp.swift") + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path), + "missing source file at \(url.path)") + return try String(contentsOf: url, encoding: .utf8) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/ServerRowActionTests.swift b/native/macos/MCPProxy/MCPProxyTests/ServerRowActionTests.swift new file mode 100644 index 000000000..08bc73c32 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/ServerRowActionTests.swift @@ -0,0 +1,138 @@ +// ServerRowActionTests.swift +// MCPProxyTests +// +// Spec 109 FR-013/FR-014 (PR 109-e, T069): the Servers-table row's primary +// action and context-menu items, per fixture. Pins that no row or +// context-menu action calls `approveTools`/`unquarantine` directly — a +// quarantined row's primary action (and its context-menu review item) OPEN +// the review location instead, so this PR cannot reintroduce the pre-X2 +// one-click approve path whatever its merge order with 109-f (which deletes +// the Web/tray copies of the same call). + +import XCTest +@testable import MCPProxy + +final class ServerRowActionTests: XCTestCase { + + // MARK: - Primary action + + func testPrimaryActionMirrorsTrayPresentation() { + for action in ["login", "restart", "enable", "approve", "set_secret", "configure", "edit_url", "view_logs"] { + let server = Self.server(health: ("healthy", "", action)) + XCTAssertEqual( + ServerRowPresentation.primaryAction(for: server), + TrayPrimaryPresentation.primaryItem(for: server), + "\(action): the row must make the identical decision as the tray") + } + } + + func testReadyServerHasNoPrimaryAction() { + let server = Self.server(health: nil) + XCTAssertNil(ServerRowPresentation.primaryAction(for: server)) + } + + func testQuarantinedRowPrimaryActionOpensReviewNeverApproves() { + let server = Self.server(quarantined: true, health: ("healthy", "Quarantined for review", "approve")) + let primary = ServerRowPresentation.primaryAction(for: server) + XCTAssertEqual(primary?.kind, .open(.review)) + XCTAssertNotEqual(primary?.kind, .execute(.approve)) + } + + // MARK: - Context menu + + func testContextMenuAlwaysOffersToggleAndRestart() { + let server = Self.server(health: nil) + let actions = ServerRowPresentation.contextMenuActions(for: server) + XCTAssertEqual(actions.first, .toggleEnabled(enable: false), "enabled server offers Disable, not Enable") + XCTAssertTrue(actions.contains(.restart)) + XCTAssertTrue(actions.contains(.viewDetails)) + XCTAssertTrue(actions.contains(.viewLogs)) + XCTAssertTrue(actions.contains(.delete)) + } + + func testDisabledServerOffersEnableNotDisable() { + let server = Self.server(enabled: false, health: nil) + let actions = ServerRowPresentation.contextMenuActions(for: server) + XCTAssertEqual(actions.first, .toggleEnabled(enable: true)) + } + + func testSignInOnlyWhenOAuthLoginRequired() { + let plain = Self.server(health: nil) + XCTAssertFalse(ServerRowPresentation.contextMenuActions(for: plain).contains(.signIn)) + + let needsAuth = Self.server(health: ("degraded", "", "login")) + XCTAssertTrue(ServerRowPresentation.contextMenuActions(for: needsAuth).contains(.signIn)) + } + + // The pin: a quarantined server's context menu carries `.openReview` — + // there is no case in `ServerRowMenuAction` that means "call approveTools + // directly", so this is a structural guarantee, not just a behavioral one. + func testQuarantinedServerContextMenuOffersOpenReviewOnly() { + let server = Self.server(quarantined: true, health: ("healthy", "Quarantined for review", "approve")) + let actions = ServerRowPresentation.contextMenuActions(for: server) + XCTAssertTrue(actions.contains(.openReview)) + // Every case in the enum, enumerated here so a future case addition + // must update this list — the point is there is no "approve" case. + for action in actions { + switch action { + case .toggleEnabled, .restart, .signIn, .openReview, .viewDetails, .viewLogs, .delete: + continue + } + } + } + + func testToolLevelQuarantineAlsoOffersOpenReview() { + // Server-level trusted, but tools pending approval (Spec 032). + let server = Self.server(quarantined: false, pendingApprovalCount: 2, health: nil) + XCTAssertTrue(ServerRowPresentation.contextMenuActions(for: server).contains(.openReview)) + } + + func testHealthyTrustedServerHasNoReviewItem() { + let server = Self.server(health: nil) + XCTAssertFalse(ServerRowPresentation.contextMenuActions(for: server).contains(.openReview)) + } + + /// Review round 2 (109-e medium finding): TrayAuditMenuTests pins this + /// exact fixture (FR-010's `actions = ["login", "approve"]`) on the tray + /// submenu only. The Servers-row context menu makes the identical + /// independent-gating decision (`.signIn` on `isOAuthLoginRequired`, + /// `.openReview` on `quarantined`, neither conditioned on the other) but + /// had no fixture of its own — a regression that coupled the two here + /// would have shipped with the whole suite green. + func testQuarantinedRowThatAlsoNeedsLoginOffersBothSignInAndReview() { + let server = Self.server(quarantined: true, health: ("degraded", "Sign-in required", "login")) + let actions = ServerRowPresentation.contextMenuActions(for: server) + XCTAssertTrue(actions.contains(.signIn), "the primary is Sign in, but Review must not be dropped") + XCTAssertTrue(actions.contains(.openReview), "a quarantined server always needs a path to review") + } + + // MARK: - Helpers + + private static func server(enabled: Bool = true, + quarantined: Bool = false, + pendingApprovalCount: Int = 0, + health: (level: String, summary: String, action: String)?) -> ServerStatus { + var healthJSON = "" + if let health { + healthJSON = """ + , "health": {"level": "\(health.level)", "admin_state": "enabled", + "summary": "\(health.summary)", "action": "\(health.action)"} + """ + } + var quarantineJSON = "" + if pendingApprovalCount > 0 { + quarantineJSON = """ + , "quarantine": {"pending_count": \(pendingApprovalCount), "changed_count": 0} + """ + } + let json = """ + { + "id": "srv", "name": "srv", "protocol": "http", "enabled": \(enabled), + "connected": \(health == nil && enabled), "quarantined": \(quarantined), "tool_count": 1 + \(healthJSON)\(quarantineJSON) + } + """.data(using: .utf8)! + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(ServerStatus.self, from: json) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/ServerRowDispatchTests.swift b/native/macos/MCPProxy/MCPProxyTests/ServerRowDispatchTests.swift new file mode 100644 index 000000000..5904e53e9 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/ServerRowDispatchTests.swift @@ -0,0 +1,170 @@ +// ServerRowDispatchTests.swift +// MCPProxy +// +// Review round 2 (109-e medium finding): every existing pin for "a +// quarantined row never approves directly" (ServerRowActionTests, +// TrayPrimaryItemTests) asserts only the PURE DATA the row and tray submenu +// are built from (`ServerRowPresentation.contextMenuActions`, +// `.primaryAction`) — never the real `@objc` handlers `ServersView.swift` +// actually wires a click to. A future rewiring of `ctxOpenReview` or +// `primaryActionClicked`'s `.open(.review)` case to call +// `apiClient.approveTools(id)` directly (that method already exists and is +// already called elsewhere, e.g. DashboardView.swift) would pass every test +// in this package unchanged. +// +// These tests build the REAL `ServerTableView.Coordinator`, run its REAL +// menu-construction (`menuNeedsUpdate`) and button handler +// (`primaryActionClicked`), and dispatch through `NSApplication.sendAction` +// exactly as a real click would — the same technique `GlanceRowRoutingTests` +// uses for the tray's glance rows — so a handler rewired to approve directly +// fails here even though the pure-function tests elsewhere stay green. + +import XCTest +import AppKit +@testable import MCPProxy + +@MainActor +final class ServerRowDispatchTests: XCTestCase { + + /// `NSTableView.clickedRow` is a read-only AppKit property normally set + /// by a real mouse event; overriding it in a tiny subclass is the + /// standard way to drive `menuNeedsUpdate(_:)` — which reads it — without + /// a live click. + private final class FakeClickTableView: NSTableView { + var fakeClickedRow: Int = -1 + override var clickedRow: Int { fakeClickedRow } + } + + /// Review round 3 (F4.2): `apiClient` used to be left `nil`, so a handler + /// rewired to call `apiClient?.approveTools(id)` / `apiClient?. + /// unquarantineServer(id)` directly would have that call silently no-op + /// on the nil optional — every assertion here would still pass. Wiring a + /// REAL `APIClient` backed by `GlanceStubURLProtocol` means such a call + /// fires a real (intercepted) HTTP request, which `requestedURLs` below + /// can actually observe and fail on. + private func makeCoordinator(servers: [ServerStatus]) -> (ServerTableView.Coordinator, FakeClickTableView) { + GlanceStubURLProtocol.reset() + let coordinator = ServerTableView.Coordinator() + coordinator.servers = servers + coordinator.apiClient = GlanceStubURLProtocol.makeClient() + let tableView = FakeClickTableView() + coordinator.tableView = tableView + return (coordinator, tableView) + } + + /// A future regression that fires `apiClient?.approveTools`/ + /// `unquarantineServer` from inside a `Task {}` needs the run loop to turn + /// once before `GlanceStubURLProtocol.requestedURLs` observes it — + /// asserting immediately after a synchronous dispatch would let that + /// Task's request land AFTER the assertion and pass vacuously. + private func assertNoApproveOrUnquarantineRequestFired() async { + try? await Task.sleep(nanoseconds: 200_000_000) + XCTAssertTrue( + GlanceStubURLProtocol.requestedURLs.allSatisfy { url in + !url.contains("/tools/approve") && !url.contains("/unquarantine") + }, + "unexpected approve/unquarantine request(s): \(GlanceStubURLProtocol.requestedURLs)" + ) + } + + // MARK: - Context menu (`ctxOpenReview`) + + /// The row's REAL right-click menu for a quarantined server, dispatched + /// through the REAL `ctxOpenReview` handler `menuNeedsUpdate` wires it to. + func testQuarantinedRowContextMenuReviewClickOpensToolsTabNeverApproves() async throws { + let server = Self.server(quarantined: true, health: ("healthy", "Quarantined for review", "approve")) + let (coordinator, tableView) = makeCoordinator(servers: [server]) + tableView.fakeClickedRow = 0 + + var opened: (ServerStatus, ServerDetailTab)? + coordinator.onOpenDetail = { server, tab, _ in opened = (server, tab) } + + let menu = NSMenu() + coordinator.menuNeedsUpdate(menu) + let review = try XCTUnwrap(menu.items.first { $0.title == "Review" }, + "expected a Review row: \(menu.items.map(\.title))") + XCTAssertNotNil(review.action) + XCTAssertTrue(review.target === coordinator) + + let sent = NSApplication.shared.sendAction(review.action!, to: review.target, from: review) + XCTAssertTrue(sent, "the Review row did not dispatch") + XCTAssertEqual(opened?.0.name, server.name) + XCTAssertEqual(opened?.1, .tools, "review must open the Tools tab, never approve directly") + await assertNoApproveOrUnquarantineRequestFired() + } + + /// FR-010: a server that is BOTH quarantined AND needs sign-in offers + /// both rows in the SAME real menu — mirrors + /// `TrayAuditMenuTests.testAQuarantinedServerThatAlsoNeedsLoginOffersBothSignInAndReview`, + /// which pins only the tray side of this fixture. + func testQuarantinedAndLoginRowContextMenuOffersBothRealRows() { + let server = Self.server(quarantined: true, health: ("degraded", "Sign-in required", "login")) + let (coordinator, tableView) = makeCoordinator(servers: [server]) + tableView.fakeClickedRow = 0 + + let menu = NSMenu() + coordinator.menuNeedsUpdate(menu) + let titles = menu.items.map(\.title) + XCTAssertTrue(titles.contains("Sign in"), "\(titles)") + XCTAssertTrue(titles.contains("Review"), "\(titles)") + } + + // MARK: - Primary icon button (`primaryActionClicked`) + + /// The row's REAL primary-button handler for a quarantined server (primary + /// = Review) must open the Tools tab, never call approve/unquarantine. + func testQuarantinedRowPrimaryButtonClickOpensToolsTabNeverApproves() async { + let server = Self.server(quarantined: true, health: ("healthy", "Quarantined for review", "approve")) + let (coordinator, _) = makeCoordinator(servers: [server]) + + var opened: (ServerStatus, ServerDetailTab)? + coordinator.onOpenDetail = { server, tab, _ in opened = (server, tab) } + + let button = NSButton() + button.tag = 0 + coordinator.primaryActionClicked(button) + + XCTAssertEqual(opened?.0.name, server.name) + XCTAssertEqual(opened?.1, .tools, "the primary button must open Tools, never approve directly") + await assertNoApproveOrUnquarantineRequestFired() + } + + /// The row's REAL primary-button handler for an in-place action (login) + /// never opens a screen — it must not silently do nothing either. + func testLoginPrimaryButtonClickNeverOpensAScreen() { + let server = Self.server(quarantined: false, health: ("degraded", "Sign-in required", "login")) + let (coordinator, _) = makeCoordinator(servers: [server]) + + var openedCount = 0 + coordinator.onOpenDetail = { _, _, _ in openedCount += 1 } + + let button = NSButton() + button.tag = 0 + coordinator.primaryActionClicked(button) + + XCTAssertEqual(openedCount, 0, "login executes in place; it must not navigate") + } + + // MARK: - Helpers + + /// Same JSON-decode fixture pattern as TrayPrimaryItemTests/ServerRowActionTests. + private static func server(quarantined: Bool = false, + health: (level: String, summary: String, action: String)?) -> ServerStatus { + var healthJSON = "" + if let health { + healthJSON = """ + , "health": {"level": "\(health.level)", "admin_state": "enabled", + "summary": "\(health.summary)", "action": "\(health.action)"} + """ + } + let json = """ + { + "id": "srv", "name": "srv", "protocol": "http", "enabled": true, + "connected": \(health == nil), "quarantined": \(quarantined), "tool_count": 1 + \(healthJSON) + } + """.data(using: .utf8)! + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(ServerStatus.self, from: json) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/TrayAuditMenuTests.swift b/native/macos/MCPProxy/MCPProxyTests/TrayAuditMenuTests.swift index 99689bd39..fbc133869 100644 --- a/native/macos/MCPProxy/MCPProxyTests/TrayAuditMenuTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/TrayAuditMenuTests.swift @@ -157,8 +157,12 @@ final class TrayAuditMenuTests: XCTestCase { XCTAssertFalse(titles.contains("Protocol: streamable-http")) } - // MARK: - F8a · A path to the quarantine review + // MARK: - F8a / Spec 109 FR-014 · A path to the quarantine review + // Spec 109 FR-014 moved this row's wording onto the ONE cross-surface + // label table (`HealthStatus.actionLabels`), so it now reads "Review" — + // the same word the Servers row and the Web UI use for `actions[0] == + // "approve"` — rather than the tray's own private "Review quarantine…". func testAQuarantinedServerOffersAReview() throws { let (controller, host) = makeController(servers: [ Self.server(name: "everything", proto: "http", enabled: true, quarantined: true) @@ -166,7 +170,7 @@ final class TrayAuditMenuTests: XCTestCase { controller.rebuildMenu() let items = try serverSubmenu(host, named: "everything").items - let review = try XCTUnwrap(items.first { $0.title.hasPrefix("Review quarantine") }, + let review = try XCTUnwrap(items.first { $0.title == "Review" }, "quarantined server offered only \(items.map(\.title))") XCTAssertNotNil(review.action, "a review row with no action is the F14 dead link again") XCTAssertTrue(review.target === controller) @@ -179,7 +183,79 @@ final class TrayAuditMenuTests: XCTestCase { ]) controller.rebuildMenu() let titles = try serverSubmenu(host, named: "github").items.map(\.title) - XCTAssertFalse(titles.contains { $0.hasPrefix("Review quarantine") }) + XCTAssertFalse(titles.contains("Review")) + } + + /// Review round 1 (109-e high finding): a server that is BOTH quarantined + /// AND needs OAuth sign-in reports `actions = ["login", "approve"]` + /// (FR-010, internal/health/calculator.go quarantinedOAuthLoginState) — + /// the primary item is "Sign in", from `actions[0]` alone, exactly like + /// `TrayPrimaryItemTests` verifies. But dropping "approve" that way must + /// not drop the row's ONLY path to review: the old unconditional + /// `if server.quarantined { show Review }` block this replaced would have + /// still shown a review row here, and this submenu must too. + func testAQuarantinedServerThatAlsoNeedsLoginOffersBothSignInAndReview() throws { + let (controller, host) = makeController(servers: [ + Self.server(name: "everything", proto: "http", enabled: true, quarantined: true, + health: ("degraded", "Sign-in required", "login")) + ]) + controller.rebuildMenu() + + let items = try serverSubmenu(host, named: "everything").items + let titles = items.map(\.title) + + let signIn = try XCTUnwrap(items.first { $0.title == "Sign in" }, + "expected the primary Sign-in row: \(titles)") + XCTAssertNotNil(signIn.action) + + let review = try XCTUnwrap(items.first { $0.title == "Review" }, + "a quarantined+login server must still offer a review path (FR-010/FR-014 parity — the ⋯/context-menu surfaces still gate independently on `quarantined`): \(titles)") + XCTAssertNotNil(review.action, "a review row with no action is the F14 dead link again") + XCTAssertTrue(review.target === controller) + XCTAssertEqual(review.representedObject as? String, "everything") + } + + /// Review round 2 (109-e medium finding): `actions[0]` in + /// {enable, restart, view_logs} rendered the same command TWICE in this + /// submenu — once as the accent-tinted primary item, once as the + /// always-present tail row it was never suppressing. Each case here + /// asserts there is exactly ONE menu item for the command, not that the + /// primary exists (that's `TrayPrimaryItemTests`'s job). + func testDisabledServerWithEnablePrimaryShowsEnableOnlyOnce() throws { + let (controller, host) = makeController(servers: [ + Self.server(name: "demo", proto: "http", enabled: false, + health: ("degraded", "Disabled", "enable")) + ]) + controller.rebuildMenu() + + let titles = try Self.disabledServerSubmenu(host, named: "demo").items.map(\.title) + XCTAssertEqual(titles.filter { $0 == "Enable" }.count, 1, "Enable shown twice: \(titles)") + } + + func testRestartNeedingServerShowsRestartOnlyOnce() throws { + let (controller, host) = makeController(servers: [ + Self.server(name: "broken", proto: "http", enabled: true, + health: ("unhealthy", "failed to connect", "restart")) + ]) + controller.rebuildMenu() + + let titles = try serverSubmenu(host, named: "broken").items.map(\.title) + XCTAssertEqual(titles.filter { $0 == "Restart" }.count, 1, "Restart shown twice: \(titles)") + } + + func testTokenRefreshPendingServerShowsViewLogsOnlyOnce() throws { + let (controller, host) = makeController(servers: [ + Self.server(name: "stale-token", proto: "http", enabled: true, + health: ("degraded", "Token refresh pending", "view_logs")) + ]) + controller.rebuildMenu() + + let items = try serverSubmenu(host, named: "stale-token").items + // The primary reads "View logs" (shared actionLabels wording); the + // static tail row reads "View Logs" — same command, different + // casing, so match case-insensitively to catch either spelling. + let logRows = items.filter { $0.title.caseInsensitiveCompare("View Logs") == .orderedSame } + XCTAssertEqual(logRows.count, 1, "View Logs shown twice: \(items.map(\.title))") } // MARK: - F4 · Attention rows do not mutate on a navigation click diff --git a/native/macos/MCPProxy/MCPProxyTests/TrayPrimaryItemTests.swift b/native/macos/MCPProxy/MCPProxyTests/TrayPrimaryItemTests.swift new file mode 100644 index 000000000..392c78e26 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/TrayPrimaryItemTests.swift @@ -0,0 +1,149 @@ +// TrayPrimaryItemTests.swift +// MCPProxyTests +// +// Spec 109 FR-014 (PR 109-e, T069a): `TrayPrimaryPresentation.primaryItem(for:)` +// is the ONE pure function both the macOS Servers row and the tray server +// submenu read for `actions[0]` — table-tested over every value here so the +// two surfaces cannot drift, and so the mapping can never regress into a +// missing item or a direct one-click approve. + +import XCTest +@testable import MCPProxy + +final class TrayPrimaryItemTests: XCTestCase { + + // MARK: - Executed in place + + func testLoginRestartEnableExecuteInPlace() { + let login = try! XCTUnwrap(TrayPrimaryPresentation.primaryItem(for: "login")) + XCTAssertEqual(login.label, "Sign in") + XCTAssertEqual(login.kind, .execute(.login)) + + let restart = try! XCTUnwrap(TrayPrimaryPresentation.primaryItem(for: "restart")) + XCTAssertEqual(restart.label, "Restart") + XCTAssertEqual(restart.kind, .execute(.restart)) + + let enable = try! XCTUnwrap(TrayPrimaryPresentation.primaryItem(for: "enable")) + XCTAssertEqual(enable.label, "Enable") + XCTAssertEqual(enable.kind, .execute(.enable)) + } + + // MARK: - Opens the screen that performs it + + func testApproveOpensReviewNeverExecutesDirectly() { + let item = try! XCTUnwrap(TrayPrimaryPresentation.primaryItem(for: "approve")) + XCTAssertEqual(item.label, "Review") + XCTAssertEqual(item.kind, .open(.review)) + // FR-005: never a one-click approve. + XCTAssertNotEqual(item.kind, .execute(.approve)) + XCTAssertNil(item.focusField) + } + + func testSetSecretOpensConfig() { + let item = try! XCTUnwrap(TrayPrimaryPresentation.primaryItem(for: "set_secret")) + XCTAssertEqual(item.label, "Add secret") + XCTAssertEqual(item.kind, .open(.config)) + // set_secret has no single field to name — the secret form isn't a + // Config-tab control (FR-014, review round 3 F-FR014-focus). + XCTAssertNil(item.focusField) + } + + func testConfigureOpensConfig() { + let item = try! XCTUnwrap(TrayPrimaryPresentation.primaryItem(for: "configure")) + XCTAssertEqual(item.label, "Fix config") + XCTAssertEqual(item.kind, .open(.config)) + // `configure`'s underlying causes are heterogeneous, with no single + // field to focus — matches the Web UI, which also focuses nothing + // for `configure` (only `edit_url` gets `&focus=endpoint`). + XCTAssertNil(item.focusField) + } + + func testEditURLOpensConfigWithTheURLFieldFocused() { + let item = try! XCTUnwrap(TrayPrimaryPresentation.primaryItem(for: "edit_url")) + XCTAssertEqual(item.label, "Edit URL") + XCTAssertEqual(item.kind, .open(.config)) + // FR-014 (review round 3, F-FR014-focus): "the Configuration tab with + // the field focused" — `edit_url` is the one action with a concrete + // field to name. + XCTAssertEqual(item.focusField, .endpoint) + } + + func testViewLogsOpensLogs() { + let item = try! XCTUnwrap(TrayPrimaryPresentation.primaryItem(for: "view_logs")) + XCTAssertEqual(item.label, "View logs") + XCTAssertEqual(item.kind, .open(.logs)) + } + + // MARK: - No value yields a missing item or a direct approve + + func testEveryLabeledActionResolvesToAnItem() { + for (action, label) in HealthStatus.actionLabels { + let item = TrayPrimaryPresentation.primaryItem(for: action) + XCTAssertNotNil(item, "\(action) has a label but no primary item — FR-014 forbids a missing item") + XCTAssertEqual(item?.label, label, "\(action): label must come from the shared table") + XCTAssertNotEqual(item?.kind, .execute(.approve), "\(action): approve must never execute in place") + } + } + + func testEmptyOrUnknownActionYieldsNoItem() { + XCTAssertNil(TrayPrimaryPresentation.primaryItem(for: "")) + XCTAssertNil(TrayPrimaryPresentation.primaryItem(for: nil)) + XCTAssertNil(TrayPrimaryPresentation.primaryItem(for: "some_future_action")) + } + + // MARK: - The Servers row and the tray submenu say the same thing + + func testRowAndSubmenuPrimaryLabelsAreIdentical() { + for action in HealthStatus.actionLabels.keys { + let server = Self.server(health: ("healthy", "", action)) + let trayLabel = TrayPrimaryPresentation.primaryItem(for: server)?.label + let rowLabel = ServerRowPresentation.primaryAction(for: server)?.label + XCTAssertEqual(trayLabel, rowLabel, "\(action): tray and row must show the identical primary label") + } + } + + // MARK: - Old-core / quarantine fallback + + func testQuarantinedServerWithNoHealthFallsBackToApprove() { + let server = Self.server(quarantined: true, health: nil) + let item = TrayPrimaryPresentation.primaryItem(for: server) + XCTAssertEqual(item?.kind, .open(.review)) + } + + func testHealthActionTakesPrecedenceOverQuarantineFallback() { + // A quarantined server that ALSO needs sign-in shows Sign in first + // (actions priority order), not Review — matches health.ActionPriority. + let server = Self.server(quarantined: true, health: ("degraded", "", "login")) + let item = TrayPrimaryPresentation.primaryItem(for: server) + XCTAssertEqual(item?.kind, .execute(.login)) + } + + func testNonQuarantinedServerWithNoHealthHasNoPrimaryItem() { + let server = Self.server(quarantined: false, health: nil) + XCTAssertNil(TrayPrimaryPresentation.primaryItem(for: server)) + } + + // MARK: - Helpers + + /// Same JSON-decode fixture pattern as TrayAuditMenuTests, so a future + /// `ServerStatus` field addition cannot silently break either file. + private static func server(quarantined: Bool = false, + health: (level: String, summary: String, action: String)?) -> ServerStatus { + var healthJSON = "" + if let health { + healthJSON = """ + , "health": {"level": "\(health.level)", "admin_state": "enabled", + "summary": "\(health.summary)", "action": "\(health.action)"} + """ + } + let json = """ + { + "id": "srv", "name": "srv", "protocol": "http", "enabled": true, + "connected": \(health == nil), "quarantined": \(quarantined), "tool_count": 1 + \(healthJSON) + } + """.data(using: .utf8)! + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(ServerStatus.self, from: json) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/TraySecondaryActionTests.swift b/native/macos/MCPProxy/MCPProxyTests/TraySecondaryActionTests.swift new file mode 100644 index 000000000..84af4f692 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/TraySecondaryActionTests.swift @@ -0,0 +1,113 @@ +// TraySecondaryActionTests.swift +// MCPProxyTests +// +// Review round 2 (109-e medium finding): the tray submenu and the Servers +// row each carry three always-present tail rows — Enable/Disable, Restart, +// View Logs — laid out UNDER the Spec 109 FR-014 primary item without ever +// checking whether the primary already performs one of them. A disabled +// server (primary = Enable), a restart-needing server (primary = Restart) +// and a token-refresh-pending server (primary = View logs) each ended up +// showing the identical command twice in the same menu/row. +// +// `TraySecondaryPresentation.items(for:)` is the one pure function both +// surfaces now read to decide which of the three tail rows to render — these +// tests pin its decisions directly, without a live NSMenu or NSTableView. + +import XCTest +@testable import MCPProxy + +final class TraySecondaryActionTests: XCTestCase { + + // MARK: - No primary action: everything applicable shows + + func testHealthyEnabledServerOffersDisableAndRestartAndViewLogs() { + let server = Self.server(enabled: true, health: nil) + let items = TraySecondaryPresentation.items(for: server) + XCTAssertEqual(items, [.toggleEnabled(enable: false), .restart, .viewLogs]) + } + + func testDisabledServerWithNoPrimaryOffersEnableAndRestartAndViewLogs() { + // A disabled server the health calculator has not flagged with an + // "enable" action (e.g. old core, or a future health vocabulary + // change) must still be enable-able from the tail. + let server = Self.server(enabled: false, health: nil) + let items = TraySecondaryPresentation.items(for: server) + XCTAssertEqual(items, [.toggleEnabled(enable: true), .restart, .viewLogs]) + } + + // MARK: - The duplicate this round fixes + + func testEnablePrimaryDropsTheEchoedEnableTailRow() { + let server = Self.server(enabled: false, health: ("degraded", "Disabled", "enable")) + let items = TraySecondaryPresentation.items(for: server) + XCTAssertFalse(items.contains(.toggleEnabled(enable: true)), + "Enable must not appear twice: once as the primary, once in the tail") + XCTAssertEqual(items, [.restart, .viewLogs], "Restart and View Logs are untouched by an Enable primary") + } + + func testRestartPrimaryDropsTheEchoedRestartTailRow() { + let server = Self.server(enabled: true, health: ("unhealthy", "failed to connect", "restart")) + let items = TraySecondaryPresentation.items(for: server) + XCTAssertFalse(items.contains(.restart), + "Restart must not appear twice: once as the primary, once in the tail") + XCTAssertEqual(items, [.toggleEnabled(enable: false), .viewLogs]) + } + + func testViewLogsPrimaryDropsTheEchoedViewLogsTailRow() { + let server = Self.server(enabled: true, health: ("degraded", "Token refresh pending", "view_logs")) + let items = TraySecondaryPresentation.items(for: server) + XCTAssertFalse(items.contains(.viewLogs), + "View Logs must not appear twice: once as the primary, once in the tail") + XCTAssertEqual(items, [.toggleEnabled(enable: false), .restart]) + } + + // MARK: - A primary that is neither Enable, Restart nor View Logs changes nothing + + func testLoginPrimaryLeavesAllThreeTailRowsInPlace() { + let server = Self.server(enabled: true, health: ("degraded", "Sign-in required", "login")) + let items = TraySecondaryPresentation.items(for: server) + XCTAssertEqual(items, [.toggleEnabled(enable: false), .restart, .viewLogs]) + } + + func testApprovePrimaryLeavesAllThreeTailRowsInPlace() { + let server = Self.server(enabled: true, quarantined: true, + health: ("healthy", "Quarantined for review", "approve")) + let items = TraySecondaryPresentation.items(for: server) + XCTAssertEqual(items, [.toggleEnabled(enable: false), .restart, .viewLogs]) + } + + // MARK: - Disable is never the primary's echo + + func testEnabledServerAlwaysOffersDisableRegardlessOfPrimary() { + for action in ["login", "restart", "approve", "set_secret", "configure", "edit_url", "view_logs"] { + let server = Self.server(enabled: true, health: ("degraded", "", action)) + XCTAssertTrue(TraySecondaryPresentation.items(for: server).contains(.toggleEnabled(enable: false)), + "\(action): Disable is never produced as a primary action, so it must never be suppressed") + } + } + + // MARK: - Helpers + + /// Same JSON-decode fixture pattern as TrayPrimaryItemTests, so a future + /// `ServerStatus` field addition cannot silently break either file. + private static func server(enabled: Bool, + quarantined: Bool = false, + health: (level: String, summary: String, action: String)?) -> ServerStatus { + var healthJSON = "" + if let health { + healthJSON = """ + , "health": {"level": "\(health.level)", "admin_state": "enabled", + "summary": "\(health.summary)", "action": "\(health.action)"} + """ + } + let json = """ + { + "id": "srv", "name": "srv", "protocol": "http", "enabled": \(enabled), + "connected": \(health == nil && enabled), "quarantined": \(quarantined), "tool_count": 1 + \(healthJSON) + } + """.data(using: .utf8)! + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(ServerStatus.self, from: json) + } +} diff --git a/oas/docs.go b/oas/docs.go index 3a4c70a75..b22077584 100644 --- a/oas/docs.go +++ b/oas/docs.go @@ -6,7 +6,7 @@ import "github.com/swaggo/swag/v2" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, - "components": {"schemas":{"config.AuditLogConfig":{"description":"AuditLog configures the Spec 107 edition-neutral audit sink\n(internal/audit). nil means \"use the per-edition/per-transport\ndefault\" (EffectiveAuditLog); restart-pinned (bound at sink\nconstruction). See audit_log.go.","properties":{"compress":{"type":"boolean"},"enabled":{"type":"boolean"},"max_age_days":{"type":"integer"},"max_backups":{"type":"integer"},"max_size_mb":{"type":"integer"},"path":{"type":"string"},"stdout":{"type":"boolean"}},"type":"object"},"config.ConcurrencyDefaults":{"description":"ServerConcurrencyDefaults is scope (b) of FR-020: the blanket per-server\ndefault set inherited by every server that does not override a setting.\nAbsent (the default) = no per-server limiting unless a server configures\nit explicitly. File/API-configured only — no env scheme (FR-022).","properties":{"max_concurrent_requests":{"type":"integer"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"}},"type":"object"},"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":"ActivityMaxSizeMB caps the total activity-log size in MB before the\noldest records are pruned. Omit the key for the 256MB default; set it to\n0 to disable the size cap.","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"aggregate_upstream_prompts":{"description":"AggregateUpstreamPrompts, when true, aggregates every connected upstream\nserver's advertised MCP prompts into mcpproxy's own prompts/list\n(exposed as \"\u003cserver\u003e__\u003cprompt\u003e\"). OFF by default: users are safe by\ndefault and opt in deliberately. EnablePrompts still governs the built-in\nprompts + the prompts capability; this flag gates ONLY the upstream\naggregation performed by RefreshPrompts. Hot-reloadable.","type":"boolean"},"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"},"anonymous_profile":{"description":"AnonymousProfile confines every caller whose request authenticates as\ncredential kind \"anonymous\" (no credential, or an unrecognised\nnon-agent token accepted by the require_mcp_auth:false back-compat\nbranch) to the named profile (Spec 108 FR-008). Empty (default) means\nunconfined, legacy anonymous behaviour. A name that does not match any\nconfigured profile resolves anonymous callers to deny-all and is\nreported as a validation warning (data-model.md §1).","type":"string"},"api_key":{"description":"Security settings","type":"string"},"audit_log":{"$ref":"#/components/schemas/config.AuditLogConfig"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_parallel":{"description":"Default concurrency for call_tools() batches (1-32, default: 8)","type":"integer"},"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"},"direct_tool_response_mode":{"description":"DirectToolResponseMode selects the serialization of the DIRECT\nenumeration surface (Spec 102). Valid values: \"\" (= full), \"full\"\n(default: today's schema-bearing entries), \"deferred\" (description +\ncompact signature, with a minimal permissive input schema; upstream\ninputSchema and outputSchema are stripped and recovered on demand via\ndescribe_tool).\n\nDeliberately NOT an extension of tool_response_mode: reusing that axis\nwould silently change /mcp/all output for every deployment already\nrunning compact, which FR-015 forbids. Serialization-only — it never\nchanges WHICH tools are listed, only how (FR-008). Hot-reloadable.","type":"string"},"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"},"http_idle_timeout":{"description":"HTTPIdleTimeout caps how long an idle keep-alive connection is kept open.\nUnset = 180s. \"0s\" removes the dedicated idle deadline, but net/http then\nfalls back to ReadTimeout — idle is fully unbounded only when\nhttp_read_timeout is also \"0s\". Requires a restart.","type":"string"},"http_read_timeout":{"description":"HTTPReadTimeout caps how long reading a whole request (headers + body)\nmay take. Unset = 120s; \"0s\" disables it. Requires a restart.","type":"string"},"http_write_timeout":{"description":"HTTPWriteTimeout caps how long producing a whole response may take on\nnon-streaming endpoints (REST, Web UI, health). Unset = 120s; \"0s\"\ndisables it globally. MCP and SSE /events routes are exempt by design.","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_concurrent_requests":{"description":"Concurrency limits (spec 093, GH #955). Scope (a) of FR-020: the GLOBAL\nAGGREGATE limiter — one proxy-wide cap on concurrently running upstream\ntool calls, with its own bounded wait queue. Tri-state pointers: absent =\nthe limiter does not exist (default, zero behavior change); an explicit 0\nmax also disables it; positive = that cap. This scope is NEVER a\nper-server inheritance source — per-server values come from\nServerConcurrencyDefaults / the per-server overrides — but a server's\neffective concurrency is bounded by BOTH its own limiter and this one.\nResolved by ResolveGlobalConcurrency; hot-reloadable; overridable via\nMCPPROXY_MAX_CONCURRENT_REQUESTS / _QUEUE_SIZE / _QUEUE_TIMEOUT (FR-022).","type":"integer"},"max_result_size_chars":{"description":"MaxResultSizeChars is advertised on every tool as\n` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; it raises Claude Code's\ninline-response ceiling from 50k to up to 500k chars. Omit the key for\nthe 500000 default; set it to 0 to disable the annotation.","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"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"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"},"server_concurrency_defaults":{"$ref":"#/components/schemas/config.ConcurrencyDefaults"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_call_max_records_per_server":{"description":"Calls retained per server (default: 1000)","type":"integer"},"tool_call_max_response_size":{"description":"Bounds for the per-server tool-call history behind GET /api/v1/tool-calls\n(#1176). It is a recent-debugging window, not an audit log — the activity\nlog is the durable record — and it kept every upstream response whole,\nper server, forever. A non-positive value means \"use the default\", not\n\"disable\": this store must never be unbounded again, so there is\ndeliberately no off switch.","type":"integer"},"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},"trusted_proxies":{"description":"TrustedProxies lists the CIDRs or IP addresses whose X-Forwarded-For /\nX-Real-IP / X-Forwarded-Proto / X-Forwarded-Host headers are believed\n(Spec 107 FR-027). Empty (default) trusts nobody. Edition-neutral, live\n(hot-reloadable). Env override: MCPPROXY_TRUSTED_PROXIES (comma-separated).\nThe one reader is ForwardedHeaders; validation is validateTrustedProxies.","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.\nThe endpoint is admin-authenticated (SEC-07): scrapers must present the\nglobal API key, via X-API-Key or an Authorization: Bearer header.","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":{"code_execution":{"description":"CodeExecution: nil = inherit the global enable_code_execution gate;\nnon-nil narrows it (a profile can only narrow, never widen, FR-006).","type":"boolean"},"description":{"description":"\u003c= 500 chars (FR-001)","type":"string"},"management_tools":{"description":"ManagementTools: nil = legacy/inherit (FR-016); non-nil sets the\nprofile-level visibility of upstream_servers/quarantine_security.","type":"boolean"},"max_tier":{"description":"MaxTier caps the tier a tool may run at under this profile: \"\" (no\ncap) | \"read\" | \"write\" | \"destructive\" (FR-001).","type":"string"},"name":{"description":"slug (Spec 057 rules unchanged)","type":"string"},"servers":{"description":"references to mcpServers[].name","items":{"type":"string"},"type":"array","uniqueItems":false},"switchable_to":{"description":"SwitchableTo is the list of profile names a session under this\nprofile may ` + "`" + `set_profile` + "`" + ` into (FR-022). nil = legacy/none (research\nD6); a non-nil EMPTY list is an explicit \"none\" and must round-trip as\n` + "`" + `[]` + "`" + `, never be dropped by omitempty — hence the pointer (see the\nMarshalJSON note below, and profiles_v3_test.go's switchable_to round\ntrip case).","items":{"type":"string"},"type":"array","uniqueItems":false},"title":{"description":"display only, \u003c= 80 chars (FR-001)","type":"string"},"tools":{"$ref":"#/components/schemas/config.ProfileToolRules"},"unannotated":{"description":"Unannotated is this profile's handling of a tool whose effective\nannotations carry no tier hint: \"\" (unset, see EffectiveUnannotated) |\n\"deny\" | \"as_write\" | \"as_read\" (FR-001, FR-003).","type":"string"}},"type":"object"},"config.ProfileToolRules":{"description":"Tools holds the allow/deny/classify rule set (FR-001, FR-004, FR-005).","properties":{"allow":{"items":{"type":"string"},"type":"array","uniqueItems":false},"classify":{"additionalProperties":{"type":"string"},"type":"object"},"deny":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag for this registry (MCP-866):\nRegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom\nfor user-added registries. It is authoritatively (re)computed by the\nregistries merge from whether the ID is a shipped default — a user cannot\nclaim \"official\" by writing it into their config.","type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"auto_baseline_scan":{"description":"AutoBaselineScan is the kill-switch for the AUTOMATIC, informational\nPass-1 baseline scan: the free in-process TPA scan mcpproxy runs for every\nnewly admitted server (any trust mode) and, once per installation, over\npre-existing servers that have never been scanned.\n\nInformational ONLY: the resulting verdict populates the security badge and\nthe scan summary, and NEVER gates quarantine or approval. The\ntrust_mode:\"scan\" admission gate is a separate path and is unaffected by\nthis flag.\n\nDefault (nil) is ENABLED. Set to false to suppress every automatic scan\n(manual scans keep working). Env override: MCPPROXY_AUTO_BASELINE_SCAN,\nwhich wins over this field on every path.","type":"boolean"},"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"},"tpa_bundle_path":{"description":"TPABundlePath is the filesystem path to the tpa-db scanner-bundle.json\nthe offline TPA scanner runs (spec 086 FR-019: the signature-DB location\nMUST be configuration-driven, not hardcoded). Empty (the default) runs the\ncorpus embedded in this build.\n\nEnv override: MCPPROXY_TPA_BUNDLE_PATH. Hot-reloadable — the path is\nre-read on every config.reloaded event via\nscanner.Service.ApplySecurityConfig, so a corpus refresh needs no restart.\nA configured bundle that fails to read/parse/version-check/compile is\nREFUSED and the previously active corpus stays live (fail-closed, never\nfail-empty); the reason is logged and surfaced in the security overview's\nsignature_bundle.load_error.","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"},"expose_prompts":{"description":"ExposePrompts overrides whether this server's advertised MCP prompts are\naggregated into mcpproxy's prompts/list. nil (default) inherits the\ndefault-aggregate behavior (included if the server advertises\nCapabilities.Prompts); false excludes it regardless of capability.","type":"boolean"},"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"},"max_concurrent_requests":{"description":"Per-server concurrency overrides — scope (c) of FR-020 (spec 093, #955).\nTri-state per setting, exactly like HealthCheckInterval: absent = inherit\nthe per-server default set (server_concurrency_defaults), explicit 0 =\ndisable that setting for this server (0 max = no per-server limiter at\nall; 0 queue_size = no pending capacity, shed immediately at the cap),\npositive = override. The global aggregate limiter is never inherited from\nhere — it applies on top, so effective concurrency is min(per-server,\nglobal). Resolved by Config.ResolveServerConcurrency.","type":"integer"},"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"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"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.\nOmit the key for the 0.1 default; set it to 0 to sample nothing.","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.\n\nNOTE: for a RELEASED build the running binary's own version is\nauthoritative and overrides this field — a stable build is never\noffered an RC (even with channel=rc), and an RC build always tracks the\nrc channel. This field only takes effect on dev/unstamped builds. See\ninternal/updatecheck.Checker.IncludePrereleases.","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\", \"self_reference\"","type":"string"}},"type":"object"},"connect.ConnectResult":{"description":"The full result; its action mirrors the top-level one","properties":{"action":{"description":"\"created\", \"updated\", \"already_exists\", \"removed\", \"not_found\"","type":"string"},"backup_path":{"type":"string"},"client":{"type":"string"},"config_path":{"type":"string"},"display_path":{"description":"DisplayPath is ConfigPath with the home directory shortened to \"~\"\n(FR-037). Populated for every result whose ConfigPath is known.","type":"string"},"message":{"type":"string"},"reload_hint":{"description":"ReloadHint is this client's instruction for making the write take\neffect (FR-037/FR-042), e.g. \"Restart Cursor to load MCPProxy\". Empty\nfor an unknown client.","type":"string"},"server_name":{"type":"string"},"success":{"type":"boolean"}},"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":{"agent_name":{"description":"Agent token name when auth_type is \"agent\"","type":"string"},"arguments":{"description":"Tool call arguments","type":"object"},"auth_type":{"description":"\"admin\", \"agent\", \"user\" or \"admin_user\"; empty without an auth context or on another caller's row for a scoped caller","type":"string"},"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"},"parent_id":{"description":"Correlation id of the parent call (the code_execution whose sandbox issued this sub-call)","type":"string"},"request_bytes":{"description":"Byte sizes measured pre-truncation, mirroring storage.ActivityRecord\n(Spec 069 A1). They are the only cost signal a bodies-off export carries:\nwith payloads suppressed there is no text left to measure, so a consumer\naccounting for a record it cannot read has nothing else to go on. They are\nbyte LENGTHS, not token counts — the basis for an explicit estimate, never\na measured figure (spec 103, contracts/replay-input.md).\n\nZero means UNKNOWN, not free: legacy records predate the measurement and\ncode-execution sub-calls record both as zero. Hence omitempty — an absent\nkey tells a consumer to fall to exclusion accounting, whereas a present\nzero would read as a costless call and silently understate the workload.","type":"integer"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_bytes":{"description":"Raw upstream response size in bytes before truncation","type":"integer"},"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\", \"rejected\"","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"},"call_count":{"description":"CallCount is how many of those records are CALLS THE USER MADE, as\ndefined once in storage.CountsAsCall and shared with the usage aggregate\nbehind the Usage tab (audit finding F1, #1046). TotalCount answers \"how\nmany rows does the Activity Log have\"; CallCount answers \"how many calls\nwere there\". They are different questions — quarantine auto-approvals,\nsystem start, security scans and management chatter are events, not calls\n— and printing either one under the other's label is how the same instance\ncame to report 51 calls on one screen and 19 on another.","type":"integer"},"call_error_count":{"description":"CallErrorCount is the failures within CallCount, so an error RATE computed\nfrom this response has one denominator. It is not ErrorCount: a policy\nblock is a failed call but carries status \"blocked\", and a shed call is an\nerror in neither sense because it never ran.","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"other_count":{"description":"OtherCount is every record whose status is outside the four-value\nvocabulary above, so that\n\n\tsuccess + error + blocked + rejected + other == total\n\nholds by construction. The status field is a CLOSED vocabulary for tool\ncalls, but the activity log is wider than tool calls: a quarantine change\nstores its ACTION there (\"approved\", \"auto_approved\"), a policy decision\nstores its DECISION (\"allow\"). Those rows were counted in the total and in\nnone of the four buckets, so the Activity Log's own status tiles summed to\nless than the denominator printed beside them — 15+4+0+0 under a \"42\"\n(audit finding F2, #1046). The residual now has a name and a tile.","type":"integer"},"period":{"description":"Time period (1h, 24h, 7d, 30d)","type":"string"},"rejected_count":{"description":"RejectedCount is the number of calls shed by a concurrency limiter before\nthey reached an upstream (spec 093). Counted separately from errors: it is\nproxy backpressure, not an upstream fault, and it is the signal an\noperator right-sizes max_concurrent_requests against.","type":"integer"},"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.AttentionFix":{"properties":{"label":{"type":"string"},"target":{"type":"string"},"verb":{"type":"string"}},"type":"object"},"contracts.AttentionItem":{"properties":{"detail":{"type":"string"},"fix":{"$ref":"#/components/schemas/contracts.AttentionFix"},"id":{"description":"kind:type:subject[:state]","type":"string"},"kind":{"type":"string"},"rank":{"type":"integer"},"since":{"type":"string"},"subject":{"$ref":"#/components/schemas/contracts.AttentionSubject"},"summary":{"type":"string"}},"type":"object"},"contracts.AttentionSubject":{"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"description":"server|tool|client","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\", \"edit_url\", or \"\" (none)\nInvariant: Action always equals Actions[0], or \"\" when Actions is empty.","type":"string"},"actions":{"description":"Actions lists every applicable next step in priority order (FR-012):\nlogin \u003e set_secret \u003e configure \u003e edit_url \u003e approve \u003e restart \u003e\nview_logs \u003e enable. Always non-nil (empty slice, never null).","items":{"type":"string"},"type":"array","uniqueItems":false},"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"},"status":{"description":"Status is the ONE status vocabulary rendered as text on every surface\n(Web UI, macOS, tray, CLI) — Spec 109 FR-010/FR-011. Values: \"ready\",\n\"connecting\", \"sign_in_required\", \"needs_review\", \"needs_secret\",\n\"needs_config\", \"error\", \"disabled\". Unlike Level (a severity signal for\nbadge/tray coloring only), no renderer may print Level as text.","type":"string"},"summary":{"description":"Summary is a human-readable status message (e.g., \"Connected (5 tools)\")","type":"string"},"usable":{"description":"Usable reports whether the server can currently serve tool calls. True\nonly when Status == \"ready\".","type":"boolean"}},"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"},"launched_by":{"description":"LaunchedBy is the durable launch provenance of the running core (Spec\n092 FR-001a): \"tray\" when a tray spawned it, \"installer\" when the macOS\nPKG postinstall did, \"\" when user-launched or unknown. Always present\n(possibly empty) so a tray can distinguish \"old core, not mine\" from\n\"old core I may supersede\".","type":"string"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"pid":{"description":"PID is the operating-system process id of the running core (Spec 092\nFR-002). A tray that merely ATTACHED to a core holds no Process handle\nfor it, so without this there is no mechanism at all to stop a stale\ncore — the consent action would have nothing to act on and could only\nprint instructions. Paired with LaunchedBy it is what lets a newer tray\nsupersede a core an older tray started.","type":"integer"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"update_policy":{"$ref":"#/components/schemas/contracts.UpdatePolicy"},"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":{"description":"Enabled is the EFFECTIVE isolation state for this server: whether its\nprocess is actually CONFINED, after the global setting, the per-server\noverride, the structural gates and the host's capabilities. It is NOT the\nraw per-server override — read EnabledOverride for that (GH #1142).\n\nREAD-ONLY. The write surfaces reject an ` + "`" + `enabled` + "`" + ` key precisely because\nit is derived: echoing it back would convert \"inherits the global\nsetting\" into a permanent explicit override. Write EnabledOverride.\n\nIt stays a non-pointer bool that is always present on the wire: the macOS\ntray decodes it as a non-optional Swift Bool, so omitting or nulling the\nkey would fail Codable for the whole server payload. Older clients that\nread this field now simply get a true answer.","type":"boolean"},"enabled_override":{"description":"EnabledOverride is the RAW per-server ` + "`" + `isolation.enabled` + "`" + ` override, as\npersisted. Absent means \"inherit the global setting\" — which is a\ndistinct state from an explicit false, and the distinction the reporting\nbug used to destroy.","type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"memory_limit":{"type":"string"},"mode_override":{"description":"ModeOverride is the RAW per-server ` + "`" + `isolation.mode` + "`" + ` override\n(\"docker\" | \"sandbox\" | \"none\"). Absent means \"inherit\".","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.IsolationEffective":{"description":"IsolationEffective exposes the resolved isolation state (and the rule\nthat decided it) so clients can distinguish \"inherits global\" from an\nexplicit per-server choice. Read-only; never consumed on PATCH.","properties":{"global_mode":{"description":"GlobalMode is what \"inherit\" resolves to right now.","type":"string"},"inherited":{"description":"Inherited is true when the server sets neither ` + "`" + `isolation.enabled` + "`" + ` nor\n` + "`" + `isolation.mode` + "`" + `, so its state tracks the global setting.","type":"boolean"},"isolated":{"description":"Isolated reports whether the process is actually CONFINED. It is NOT\nsimply Mode != \"none\": \"sandbox\" on a host that cannot enforce Landlock\n(any non-Linux OS, or a kernel without the LSM) runs the server\nunconfined, and Source then says \"sandbox-unavailable\" (GH #1142).","type":"boolean"},"mode":{"description":"Mode is the effective isolation mode: \"docker\" | \"sandbox\" | \"none\" —\nexactly what the spawn path branches on.","type":"string"},"source":{"description":"Source names the deciding rule: \"global\", \"server-mode\",\n\"server-opt-out\", \"server-opt-in-ignored\", \"not-stdio\",\n\"already-docker\", \"sandbox-unavailable\" or \"unsupported-mode\".\nTreat an unrecognized value as \"global\".","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.PreflightPolicy":{"properties":{"exclude_destructive":{"type":"boolean"},"exclude_open_world":{"type":"boolean"},"read_only_only":{"type":"boolean"}},"type":"object"},"contracts.PreflightReason":{"type":"string","x-enum-varnames":["PreflightReasonServerInitializing","PreflightReasonServerUnhealthy","PreflightReasonServerDisabled","PreflightReasonServerQuarantined","PreflightReasonToolPendingApproval","PreflightReasonToolChanged","PreflightReasonToolBlockedByUser","PreflightReasonOAuthRequired","PreflightReasonHashMismatch","PreflightReasonServerNotInScope","PreflightReasonToolDeniedByConfig","PreflightReasonMissingAnnotation","PreflightReasonPolicyFiltered","PreflightReasonNotFound","PreflightReasonServerNotConfigured"]},"contracts.PreflightRequest":{"properties":{"policy":{"$ref":"#/components/schemas/contracts.PreflightPolicy"},"profile":{"description":"Profile evaluates under a named profile's server scope. Unknown: 400.","type":"string"},"tools":{"description":"Tools is 1..100 entries BEFORE dedup; duplicates are collapsed, and\nduplicate ids carrying different pins are a validation error.","items":{"$ref":"#/components/schemas/contracts.PreflightToolRef"},"type":"array","uniqueItems":false},"wait_ms":{"description":"WaitMS polls local state for up to this many milliseconds (cap 10000)\nwhile every failure is retryable-class.","type":"integer"}},"type":"object"},"contracts.PreflightResponse":{"properties":{"checked_at":{"type":"string"},"tools":{"description":"Tools are ordered by first occurrence of each unique id in the request.","items":{"$ref":"#/components/schemas/contracts.PreflightToolResult"},"type":"array","uniqueItems":false},"verdict":{"$ref":"#/components/schemas/contracts.PreflightVerdict"},"waited_ms":{"description":"WaitedMS is present when wait_ms was requested (0 when the wait\nsemaphore was exhausted and the request resolved immediately).","type":"integer"}},"type":"object"},"contracts.PreflightStatus":{"type":"string","x-enum-varnames":["PreflightStatusReady","PreflightStatusUnavailable"]},"contracts.PreflightToolRef":{"properties":{"id":{"description":"ID is a canonical \"\u003cserver\u003e:\u003ctool\u003e\" id. A malformed id is answered with a\nper-ID not_found carrying a format hint, never a request-level error.","type":"string"},"pin_hash":{"description":"PinHash is \"sha256/v{N}:{hex}\" — the schema version is embedded so a\nproxy-side hash-algorithm bump is distinguishable from upstream drift.","type":"string"}},"type":"object"},"contracts.PreflightToolResult":{"properties":{"action":{"type":"string"},"detail":{"type":"string"},"did_you_mean":{"description":"DidYouMean carries up to 3 nearest caller-visible ids on not_found. It\nnever crosses a scope boundary and never names a quarantined server's\ntools.","items":{"type":"string"},"type":"array","uniqueItems":false},"hash":{"description":"Hash is the tool's current pin (\"sha256/v{N}:{hex}\") — operator tier,\nready results only. Never disclosed to an agent token.","type":"string"},"id":{"type":"string"},"reason":{"$ref":"#/components/schemas/contracts.PreflightReason"},"remediation":{"type":"string"},"retryable":{"type":"boolean"},"status":{"$ref":"#/components/schemas/contracts.PreflightStatus"}},"type":"object"},"contracts.PreflightVerdict":{"type":"string","x-enum-varnames":["PreflightVerdictReady","PreflightVerdictDegradedRetryable","PreflightVerdictBlocked","PreflightVerdictUnknownIDs"]},"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"},"expose_prompts":{"description":"ExposePrompts mirrors config.ServerConfig.ExposePrompts (F9): the per-server\nprompt-aggregation override. Tri-state *bool — nil/omitted means \"inherit\ndefault aggregation\". Surfaced on GET so a caller that PATCHed the override\ncan read it back; PATCH/POST accept it via AddServerRequest.","type":"boolean"},"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"},"isolation_effective":{"$ref":"#/components/schemas/contracts.IsolationEffective"},"last_error":{"type":"string"},"last_reconnect_at":{"type":"string"},"last_retry_time":{"type":"string"},"max_concurrent_requests":{"description":"Spec 093 (GH #955) — per-server concurrency overrides, scope (c) of\nFR-020. Each setting is tri-state: nil (omitted) means \"inherit\nserver_concurrency_defaults\", 0 disables that setting for this server,\npositive overrides it. Surfaced on the GET path so a caller can read back\nwhat it set; PATCH/POST accept them via AddServerRequest. The effective\nconcurrency for a server is additionally bounded by the global aggregate\nlimiter, which is NOT an inheritance source for these fields.","type":"integer"},"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"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_count":{"type":"integer"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets this disconnected server","type":"boolean"},"retry_count":{"type":"integer"},"retry_stopped":{"description":"RetryStopped reports that automatic reconnection has been given up for\ngood because the failure is deterministic and unrecoverable — a missing\nbinary, an image without the interpreter, an unparseable config (GH\n#1145). It is NOT ordinary exponential backoff, which keeps retrying;\nnothing will happen until the user fixes the config or restarts the\nserver. RetryStoppedCode is the stable MCPX_* code that proved it and\nRetryStoppedReason the catalog message explaining how to fix it. All three\nare omitted for servers that are healthy or still retrying.","type":"boolean"},"retry_stopped_code":{"type":"string"},"retry_stopped_reason":{"type":"string"},"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"},"estimated":{"description":"Estimated (Spec 109-k FR-070-ish, url-filter-contract.md / audit F-Token):\ntrue while AverageQueryResultSize is a synthetic simulation (a sample of\nthe first ` + "`" + `tools_limit` + "`" + ` tools' schemas — no real retrieve_tools call has\ncompleted yet in this runtime's usage aggregate); false once at least one\nreal retrieve_tools call has, at which point AverageQueryResultSize is\nderived from the real observed average response size instead. The Web\nUI and macOS render an \"estimate\" label while this is true.","type":"boolean"},"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.Tier":{"description":"Tier is computed by AnnotationTier (Spec 109 FR-028/X11) from\nAnnotations — read|write|destructive|unannotated. Set by every producer\nof a Tool (enrichServerTools, the global tools handler); never left for\na consuming surface to compute.","type":"string","x-enum-varnames":["TierRead","TierWrite","TierDestructive","TierUnannotated","TierUnknown"]},"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"},"hash":{"description":"Hash is the tool's current stored hash rendered in the preflight pin\nformat \"sha256/v{N}:{hex}\" (Spec 098 FR-011), where N is the approval\nrecord's HashSchemaVersion. It is the authoring surface for\n` + "`" + `POST /api/v1/preflight` + "`" + ` pins and ` + "`" + `mcpproxy tools preflight --pin` + "`" + `:\ncopy the value straight into a pin.\n\nDisclosure is OPERATOR TIER ONLY — same rule as the preflight per-tool\nresult. The field is omitted for agent-token callers and for tools with\nno stored hash (no approval record yet, or a record written before\nhashes existed).","type":"string"},"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"},"tier":{"$ref":"#/components/schemas/contracts.Tier"},"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"},"arguments_truncated":{"description":"ArgumentsTruncated marks Arguments as a placeholder rather than the\narguments the tool was called with. Replaying such a record without\nsupplying arguments explicitly is refused.","type":"boolean"},"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"},"response_bytes":{"description":"Marshalled response size before truncation","type":"integer"},"response_truncated":{"description":"ResponseTruncated and ResponseBytes describe a STORAGE-side cut (#1176):\nthe caller received the response whole, and only the persisted copy was\nshortened to tool_call_max_response_size. When ResponseTruncated is true\nthe Response object carries {truncated, original_bytes, preview, note}\ninstead of the upstream result, and ResponseBytes is its size before the\ncut.","type":"boolean"},"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"},"behind_summary":{"description":"Spec 079 FR-002 — how far behind the running build is. All four are\nadditive (FR-021) and absent when the delta could not be resolved, in\nwhich case every surface renders its pre-delta wording.","type":"string"},"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"},"releases_behind":{"description":"Releases on the offered channel between the running and offered versions","type":"integer"},"releases_behind_saturated":{"description":"ReleasesBehind is a lower bound: the running build predates the scanned release window","type":"boolean"},"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"},"weeks_behind":{"description":"Whole weeks between the two releases' publish dates; 0 is a real value, absent means unknown","type":"integer"}},"type":"object"},"contracts.UpdatePolicy":{"description":"UpdatePolicy is the effective, hot-reloadable update policy (Spec 092\nFR-015). Always present: the ` + "`" + `update` + "`" + ` object above is omitted both when\nupdate checking is disabled AND when no check has produced a result\nyet, so its absence cannot tell a client whether it is allowed to run\nits own (e.g. Sparkle feed) check. This field states the answer.","properties":{"channel":{"description":"Channel is the tracked release channel: \"stable\" or \"rc\".","type":"string"},"enabled":{"description":"Enabled is the effective automatic-check kill switch: update_check.enabled\nwith MCPPROXY_DISABLE_AUTO_UPDATE=true winning over it. A user-initiated\n\"Check for Updates\" stays available regardless.","type":"boolean"},"nudges_suppressed":{"description":"NudgesSuppressed asks UI surfaces to stay quiet (CI / non-interactive)\nwhile machine-readable fields keep reporting the facts.","type":"boolean"}},"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_estimated":{"description":"TokensSavedEstimated echoes ServerTokenMetrics.Estimated (Spec 109-k):\ntrue while TokensSaved is a synthetic simulation rather than derived\nfrom a real retrieve_tools call. Dropped (false, the zero value) for a\nscoped caller along with TokensSaved itself, above.","type":"boolean"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"total_calls":{"description":"TotalCalls and TotalErrors are the headline counts for the window: the sum\nof the timeline this same response carries, so the tiles and the histogram\nunder them cannot disagree. They are NOT the sum of Tools — that list is\nlifetime-cumulative, upstream-only and truncated to top-N, and summing it\nclient-side is what made the Usage tab print a third number for the same\n24 hours (audit finding F1, #1046). The population is\nstorage.CountsAsCall, shared with ActivitySummaryResponse.CallCount.\n\nTwo bounds on how exactly this matches the Activity Log's own count.\nBoth are bounded and disclosed, unlike the population mismatch they\nreplace, which was unbounded and silent:\n\n - Window granularity is the timeline's: whole hour buckets, so the span\n is the requested window rounded up to a bucket edge.\n - This response is served from a snapshot behind a short read cache\n (observability.usage_cache_ttl, 5s by default) so the endpoint never\n scans the activity log per request, while the summary endpoint counts\n live. Calls that land inside that window appear on the Activity Log\n first. FreshnessMs and GeneratedAt say how old the figures are, and\n the Usage tab prints it (\"Updated 3s ago\").","type":"integer"},"total_errors":{"type":"integer"},"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_exceeds":{"type":"boolean"},"p50_ms":{"description":"P50Ms and P95Ms are read off a fixed latency histogram, so they are BUCKET\nBOUNDS, not measured durations: the true percentile is at or below the\nvalue, and a client must render it as a bound (\"≤ 5 ms\"). P50Exceeds /\nP95Exceeds flip that reading for the unbounded overflow bucket, where the\nvalue is the last bound and the truth is above it (\"\u003e 10 s\").","type":"integer"},"p95_exceeds":{"type":"boolean"},"p95_ms":{"type":"integer"},"rejected":{"description":"spec 093: shed by a concurrency limit; never executed, so excluded from calls/latency","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"},"expose_prompts":{"description":"ExposePrompts is the per-server override for prompt aggregation (F9):\nwhether this server's advertised MCP prompts are merged into mcpproxy's\nprompts/list. Tri-state *bool mirroring config.ServerConfig.ExposePrompts —\na nil pointer means \"leave unchanged\" on PATCH (and \"inherit the default\naggregate behavior\" on create); a present value (including false) is applied.","type":"boolean"},"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"},"max_concurrent_requests":{"description":"MaxConcurrentRequests / QueueSize / QueueTimeout are the per-server\nconcurrency overrides (spec 093 / GH #955, FR-020 scope (c)). Each is\ntri-state: a nil pointer means \"leave unchanged\" on PATCH and \"inherit\nserver_concurrency_defaults\" on create; an explicit 0 disables that\nsetting for this server; a positive value overrides it. Do NOT collapse\nthem to plain values — an omitted field would then silently reset a\nconfigured limit.","type":"integer"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"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.ConnectConflictResponse":{"properties":{"action":{"description":"already_exists | precondition_failed","type":"string"},"data":{"$ref":"#/components/schemas/connect.ConnectResult"},"error":{"description":"Human-readable message","type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.ConnectRequest":{"properties":{"force":{"description":"Overwrite existing entry","type":"boolean"},"precondition_token":{"description":"PreconditionToken is the opaque token from the preview this write was\nconfirmed against (Spec 091 FR-005). When present, the core rechecks it\nat write time and responds 409 with action \"precondition_failed\" —\nwriting nothing — if the config or the entry MCPProxy would write has\ndrifted since; the caller then re-previews instead of retrying. Absent\nmeans exactly the pre-091 behavior. A replace-classified flow sends this\nTOGETHER with force=true: the token, not the absence of force, is the\noverwrite safety.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.EnvFieldPreview":{"properties":{"empty_or_placeholder":{"type":"boolean"},"name":{"type":"string"},"secret_like":{"type":"boolean"},"value_present":{"type":"boolean"}},"type":"object"},"httpapi.HeaderFieldPreview":{"properties":{"empty_or_placeholder":{"type":"boolean"},"name":{"type":"string"},"secret_like":{"type":"boolean"},"value_present":{"type":"boolean"}},"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":{"allow_paste_fallback":{"description":"AllowPasteFallback opts into detecting a bare URL or a single command\nline (FR-064) when Format/format detection would otherwise fail — see\nconfigimport.ImportOptions.AllowPasteFallback for why this must stay\nopt-in (review round 4 F-E). Only the interactive Paste tab sets this;\nevery other caller of this endpoint (the general \"Import config\"\npanel, or a direct API call) leaves it false and gets a clear\n\"unable to detect configuration format\" error for a plain one-liner\ninstead of it being silently guessed at and, on apply, added as a\nreal server with no confirmation step.","type":"boolean"},"content":{"description":"Raw JSON or TOML content","type":"string"},"env_override":{"additionalProperties":{"type":"string"},"description":"EnvOverride/HeaderOverride (Spec 109 FR-064/065, PR review round 4\nF-A/F-D fix): the Paste tab's per-field Value/Secret edits — a plain\nvalue the user typed, or a keyring ref path if they chose Secret —\nkeyed by field name. Applied to the matching imported server's\nEnv/Headers ONLY when preview=false, directly on the server this\nrequest's own raw Content parses to server-side. This is what lets\nthe apply call carry the user's edited env/header values without\never round-tripping the redacted preview: url/command/args on apply\nalways come from re-parsing Content here, never from a client-held\npreview response, so a credential embedded in a URL query param or\nargv flag (which the preview necessarily redacted for display) is\nnever overwritten with the masked placeholder.","type":"object"},"format":{"description":"Optional format hint","type":"string"},"header_override":{"additionalProperties":{"type":"string"},"type":"object"},"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"},"env":{"items":{"$ref":"#/components/schemas/httpapi.EnvFieldPreview"},"type":"array","uniqueItems":false},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"headers":{"items":{"$ref":"#/components/schemas/httpapi.HeaderFieldPreview"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"summary":{"description":"Summary, Tags, Env and Headers are the Spec 109 FR-064 preview\nenrichment (contracts/rest-api.md \"Import preview\"). Summary and Tags\nare built from the already-redacted URL/Command/Args above, so a\nsecret embedded in argv or a URL query never reaches Summary either.\nEnv/Headers never carry the raw value — only its presence and two\nbooleans a surface uses to default the Value/Secret toggle (FR-065).","type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (enabled,\nmode_override, image, network_mode, extra_args, working_dir). A nil\npointer means \"do not touch isolation config\". A present object is\napplied field-by-field ON TOP of the persisted overrides, so omitting a\nfield leaves it alone; clear an individual override by sending it\nexplicitly (` + "`" + `\"enabled\": null` + "`" + `, ` + "`" + `\"image\": \"\"` + "`" + `).","properties":{"enabled":{"description":"Enabled exists ONLY to detect and reject an echoed-back read. It is the\neffective state on the read surface and is never writable; see validate().","type":"boolean"},"enabled_override":{"description":"EnabledOverride is the tri-state per-server override — the RAW value, the\nsame one reads return as ` + "`" + `enabled_override` + "`" + `. It has THREE meaningful wire\nstates, and collapsing them is what silently un-isolated servers\n(GH #1142):\n - absent → leave the persisted override untouched\n - null → clear the override, back to inheriting the global\n - true / false → set an explicit opt-in / opt-out","type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"mode_override":{"description":"ModeOverride sets ` + "`" + `isolation.mode` + "`" + ` (\"docker\" | \"sandbox\" | \"none\").\nnil leaves the persisted value alone; an empty string clears it. An\nunrecognized value is rejected with a 400 rather than persisted.","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"},"connected_client_id":{"description":"ConnectedClientID records a successful connect write for this client id\n(Spec 109-b FR-042, review round 6). The REST connect endpoint\n(POST /api/v1/connect/{client}) already records this itself on success;\nthis field exists so ` + "`" + `mcpproxy connect` + "`" + ` — which writes the client's\nconfig file directly, without going through that endpoint, so the\ncommand still works when no daemon is running — can relay the same\nevent to a daemon that IS running, keeping ClientConnectedAt in sync\nacross both surfaces. Must be a known id from the fixed connect client\nregistry (internal/connect.GetAllClients); any other value is rejected,\nmatching the field's \"bounded by the registry\" invariant\n(data-model.md §7).","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.OnboardingStateResponse":{"properties":{"configured_server_count":{"description":"ConfiguredServerCount is the number of upstream MCP servers configured\nin mcpproxy (counts both enabled and disabled).","type":"integer"},"connected_client_count":{"description":"ConnectedClientCount is the number of supported clients currently\npointing at mcpproxy.","type":"integer"},"connected_client_ids":{"description":"ConnectedClientIDs are the identifiers of supported clients currently\npointing at mcpproxy. Drawn exclusively from the fixed adapter table —\nuser-entered values never appear here.","items":{"type":"string"},"type":"array","uniqueItems":false},"first_mcp_client_ever":{"description":"FirstMCPClientEver is true once any MCP client has successfully completed\nan ` + "`" + `initialize` + "`" + ` round-trip with this mcpproxy. Sourced from the Spec 044\nactivation bucket. Drives the Verify tab's \"green check\" state.","type":"boolean"},"has_configured_server":{"description":"HasConfiguredServer is true if at least one upstream MCP server is\nconfigured (regardless of current connection health).","type":"boolean"},"has_connected_client":{"description":"HasConnectedClient is true if at least one supported AI client currently\nhas mcpproxy registered in its config.","type":"boolean"},"has_usable_server":{"description":"HasUsableServer is true once at least one enabled, non-quarantined\nserver has usable health and at least one approved (non-disabled) tool.\nThis is the real \"the wizard has something to try\" signal:\nHasConfiguredServer only means a server entry exists, even while every\none of them sits quarantined, requires sign-in, or has zero approved\ntools. The Servers step and Setup badge use this instead of\nHasConfiguredServer, which is kept above for compatibility.\nHealthStatus.Usable is the shared\nreadiness contract used by every UI surface (Spec 109-c, tasks.md T051).","type":"boolean"},"incomplete_tab_count":{"description":"IncompleteTabCount is the number of wizard tabs whose state is incomplete.\nDrives the sidebar Setup entry's badge. Formula:\n +1 if HasConnectedClient == false\n +1 if HasUsableServer == false\n +1 if FirstMCPClientEver == false","type":"integer"},"mcp_clients_seen_ever":{"description":"MCPClientsSeenEver is the capped list of recognized client names that\nhave ever called this mcpproxy. Names come from the MCP ` + "`" + `initialize` + "`" + `\npayload's ` + "`" + `clientInfo.name` + "`" + ` field, sanitized. Surfaces on the Verify tab\nso the user can see whether their real IDE — not a test client — has\nconnected.","items":{"type":"string"},"type":"array","uniqueItems":false},"should_show_wizard":{"description":"ShouldShowWizard is the derived flag the frontend uses to decide\nwhether to auto-show. True when not engaged and IncompleteTabCount \u003e 0\n(Spec 046 v2 — semantics widened to also count the Verify tab).","type":"boolean"},"state":{"$ref":"#/components/schemas/storage.OnboardingState"},"usable_servers":{"description":"UsableServers lists the names behind HasUsableServer, for the Verify\nstep's suggested-prompt generator (FR-042): prompts are only built from\ntools of servers in this list, never from a quarantined or toolless one.","items":{"type":"string"},"type":"array","uniqueItems":false}},"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"},"httpapi.UpdateFailureRequest":{"properties":{"stage":{"description":"Stage is the failure stage of the update session.","enum":["appcast","download","install","other"],"type":"string"}},"type":"object"},"httpapi.attentionResponse":{"properties":{"count":{"type":"integer"},"generated_at":{"type":"string"},"items":{"items":{"$ref":"#/components/schemas/contracts.AttentionItem"},"type":"array","uniqueItems":false}},"type":"object"},"management.BulkOperationResult":{"properties":{"errors":{"additionalProperties":{"type":"string"},"description":"Map of server name to error message","type":"object"},"failed":{"description":"Number of failed operations","type":"integer"},"successful":{"description":"Number of successful operations","type":"integer"},"total":{"description":"Total servers processed","type":"integer"}},"type":"object"},"observability.HealthResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"observability.HealthStatus":{"properties":{"error":{"type":"string"},"latency":{"type":"string"},"name":{"type":"string"},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"}},"type":"object"},"observability.ReadinessResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"ready\" or \"not_ready\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"secureenv.EnvConfig":{"description":"Environment configuration for secure variable filtering","properties":{"allowed_system_vars":{"items":{"type":"string"},"type":"array","uniqueItems":false},"custom_vars":{"additionalProperties":{"type":"string"},"type":"object"},"enhance_path":{"description":"Enable PATH enhancement for Launchd scenarios","type":"boolean"},"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"},"storage.OnboardingState":{"description":"State is the persisted wizard engagement record. Engaged is true once\nthe wizard was shown and the user completed or skipped it.","properties":{"client_connected_at":{"additionalProperties":{"type":"string"},"description":"ClientConnectedAt records, per client id from the fixed connect client\nregistry (internal/connect.GetAllClients — never user input), the last\ntime a connect write succeeded for that client (Spec 109-b FR-042).\nWritten by the connect success path through UpdateOnboardingState so a\nconcurrent onboarding/mark write can never drop it. Consumed by the\nVerify step / presence layer to tell \"connected, never seen\" apart from\n\"connected seconds ago, hasn't reconnected yet\".","type":"object"},"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"completed_external\",\n\"skipped\" (Spec 080 FR-001). \"completed_external\" records a dismissal\nwhere the connect step was untouched but the install was already\nconnected outside the wizard (CLI, ConnectModal, manual config).","type":"string"},"engaged":{"description":"Engaged is true once the wizard was shown and the user completed or\nskipped it. Once true, the wizard does not auto-show again, even if\nstate regresses (e.g. user disconnects all clients).","type":"boolean"},"engaged_at":{"description":"EngagedAt is the timestamp of completion or explicit skip.","type":"string"},"first_shown_at":{"description":"FirstShownAt is the timestamp of first wizard render.","type":"string"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\".","type":"string"}},"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"}}}, + "components": {"schemas":{"config.AuditLogConfig":{"description":"AuditLog configures the Spec 107 edition-neutral audit sink\n(internal/audit). nil means \"use the per-edition/per-transport\ndefault\" (EffectiveAuditLog); restart-pinned (bound at sink\nconstruction). See audit_log.go.","properties":{"compress":{"type":"boolean"},"enabled":{"type":"boolean"},"max_age_days":{"type":"integer"},"max_backups":{"type":"integer"},"max_size_mb":{"type":"integer"},"path":{"type":"string"},"stdout":{"type":"boolean"}},"type":"object"},"config.ConcurrencyDefaults":{"description":"ServerConcurrencyDefaults is scope (b) of FR-020: the blanket per-server\ndefault set inherited by every server that does not override a setting.\nAbsent (the default) = no per-server limiting unless a server configures\nit explicitly. File/API-configured only — no env scheme (FR-022).","properties":{"max_concurrent_requests":{"type":"integer"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"}},"type":"object"},"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":"ActivityMaxSizeMB caps the total activity-log size in MB before the\noldest records are pruned. Omit the key for the 256MB default; set it to\n0 to disable the size cap.","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"aggregate_upstream_prompts":{"description":"AggregateUpstreamPrompts, when true, aggregates every connected upstream\nserver's advertised MCP prompts into mcpproxy's own prompts/list\n(exposed as \"\u003cserver\u003e__\u003cprompt\u003e\"). OFF by default: users are safe by\ndefault and opt in deliberately. EnablePrompts still governs the built-in\nprompts + the prompts capability; this flag gates ONLY the upstream\naggregation performed by RefreshPrompts. Hot-reloadable.","type":"boolean"},"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"},"anonymous_profile":{"description":"AnonymousProfile confines every caller whose request authenticates as\ncredential kind \"anonymous\" (no credential, or an unrecognised\nnon-agent token accepted by the require_mcp_auth:false back-compat\nbranch) to the named profile (Spec 108 FR-008). Empty (default) means\nunconfined, legacy anonymous behaviour. A name that does not match any\nconfigured profile resolves anonymous callers to deny-all and is\nreported as a validation warning (data-model.md §1).","type":"string"},"api_key":{"description":"Security settings","type":"string"},"audit_log":{"$ref":"#/components/schemas/config.AuditLogConfig"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_parallel":{"description":"Default concurrency for call_tools() batches (1-32, default: 8)","type":"integer"},"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"},"direct_tool_response_mode":{"description":"DirectToolResponseMode selects the serialization of the DIRECT\nenumeration surface (Spec 102). Valid values: \"\" (= full), \"full\"\n(default: today's schema-bearing entries), \"deferred\" (description +\ncompact signature, with a minimal permissive input schema; upstream\ninputSchema and outputSchema are stripped and recovered on demand via\ndescribe_tool).\n\nDeliberately NOT an extension of tool_response_mode: reusing that axis\nwould silently change /mcp/all output for every deployment already\nrunning compact, which FR-015 forbids. Serialization-only — it never\nchanges WHICH tools are listed, only how (FR-008). Hot-reloadable.","type":"string"},"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"},"http_idle_timeout":{"description":"HTTPIdleTimeout caps how long an idle keep-alive connection is kept open.\nUnset = 180s. \"0s\" removes the dedicated idle deadline, but net/http then\nfalls back to ReadTimeout — idle is fully unbounded only when\nhttp_read_timeout is also \"0s\". Requires a restart.","type":"string"},"http_read_timeout":{"description":"HTTPReadTimeout caps how long reading a whole request (headers + body)\nmay take. Unset = 120s; \"0s\" disables it. Requires a restart.","type":"string"},"http_write_timeout":{"description":"HTTPWriteTimeout caps how long producing a whole response may take on\nnon-streaming endpoints (REST, Web UI, health). Unset = 120s; \"0s\"\ndisables it globally. MCP and SSE /events routes are exempt by design.","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_concurrent_requests":{"description":"Concurrency limits (spec 093, GH #955). Scope (a) of FR-020: the GLOBAL\nAGGREGATE limiter — one proxy-wide cap on concurrently running upstream\ntool calls, with its own bounded wait queue. Tri-state pointers: absent =\nthe limiter does not exist (default, zero behavior change); an explicit 0\nmax also disables it; positive = that cap. This scope is NEVER a\nper-server inheritance source — per-server values come from\nServerConcurrencyDefaults / the per-server overrides — but a server's\neffective concurrency is bounded by BOTH its own limiter and this one.\nResolved by ResolveGlobalConcurrency; hot-reloadable; overridable via\nMCPPROXY_MAX_CONCURRENT_REQUESTS / _QUEUE_SIZE / _QUEUE_TIMEOUT (FR-022).","type":"integer"},"max_result_size_chars":{"description":"MaxResultSizeChars is advertised on every tool as\n` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; it raises Claude Code's\ninline-response ceiling from 50k to up to 500k chars. Omit the key for\nthe 500000 default; set it to 0 to disable the annotation.","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"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"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"},"server_concurrency_defaults":{"$ref":"#/components/schemas/config.ConcurrencyDefaults"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_call_max_records_per_server":{"description":"Calls retained per server (default: 1000)","type":"integer"},"tool_call_max_response_size":{"description":"Bounds for the per-server tool-call history behind GET /api/v1/tool-calls\n(#1176). It is a recent-debugging window, not an audit log — the activity\nlog is the durable record — and it kept every upstream response whole,\nper server, forever. A non-positive value means \"use the default\", not\n\"disable\": this store must never be unbounded again, so there is\ndeliberately no off switch.","type":"integer"},"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},"trusted_proxies":{"description":"TrustedProxies lists the CIDRs or IP addresses whose X-Forwarded-For /\nX-Real-IP / X-Forwarded-Proto / X-Forwarded-Host headers are believed\n(Spec 107 FR-027). Empty (default) trusts nobody. Edition-neutral, live\n(hot-reloadable). Env override: MCPPROXY_TRUSTED_PROXIES (comma-separated).\nThe one reader is ForwardedHeaders; validation is validateTrustedProxies.","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.\nThe endpoint is admin-authenticated (SEC-07): scrapers must present the\nglobal API key, via X-API-Key or an Authorization: Bearer header.","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":{"code_execution":{"description":"CodeExecution: nil = inherit the global enable_code_execution gate;\nnon-nil narrows it (a profile can only narrow, never widen, FR-006).","type":"boolean"},"description":{"description":"\u003c= 500 chars (FR-001)","type":"string"},"management_tools":{"description":"ManagementTools: nil = legacy/inherit (FR-016); non-nil sets the\nprofile-level visibility of upstream_servers/quarantine_security.","type":"boolean"},"max_tier":{"description":"MaxTier caps the tier a tool may run at under this profile: \"\" (no\ncap) | \"read\" | \"write\" | \"destructive\" (FR-001).","type":"string"},"name":{"description":"slug (Spec 057 rules unchanged)","type":"string"},"servers":{"description":"references to mcpServers[].name","items":{"type":"string"},"type":"array","uniqueItems":false},"switchable_to":{"description":"SwitchableTo is the list of profile names a session under this\nprofile may ` + "`" + `set_profile` + "`" + ` into (FR-022). nil = legacy/none (research\nD6); a non-nil EMPTY list is an explicit \"none\" and must round-trip as\n` + "`" + `[]` + "`" + `, never be dropped by omitempty — hence the pointer (see the\nMarshalJSON note below, and profiles_v3_test.go's switchable_to round\ntrip case).","items":{"type":"string"},"type":"array","uniqueItems":false},"title":{"description":"display only, \u003c= 80 chars (FR-001)","type":"string"},"tools":{"$ref":"#/components/schemas/config.ProfileToolRules"},"unannotated":{"description":"Unannotated is this profile's handling of a tool whose effective\nannotations carry no tier hint: \"\" (unset, see EffectiveUnannotated) |\n\"deny\" | \"as_write\" | \"as_read\" (FR-001, FR-003).","type":"string"}},"type":"object"},"config.ProfileToolRules":{"description":"Tools holds the allow/deny/classify rule set (FR-001, FR-004, FR-005).","properties":{"allow":{"items":{"type":"string"},"type":"array","uniqueItems":false},"classify":{"additionalProperties":{"type":"string"},"type":"object"},"deny":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag for this registry (MCP-866):\nRegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom\nfor user-added registries. It is authoritatively (re)computed by the\nregistries merge from whether the ID is a shipped default — a user cannot\nclaim \"official\" by writing it into their config.","type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"auto_baseline_scan":{"description":"AutoBaselineScan is the kill-switch for the AUTOMATIC, informational\nPass-1 baseline scan: the free in-process TPA scan mcpproxy runs for every\nnewly admitted server (any trust mode) and, once per installation, over\npre-existing servers that have never been scanned.\n\nInformational ONLY: the resulting verdict populates the security badge and\nthe scan summary, and NEVER gates quarantine or approval. The\ntrust_mode:\"scan\" admission gate is a separate path and is unaffected by\nthis flag.\n\nDefault (nil) is ENABLED. Set to false to suppress every automatic scan\n(manual scans keep working). Env override: MCPPROXY_AUTO_BASELINE_SCAN,\nwhich wins over this field on every path.","type":"boolean"},"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"},"tpa_bundle_path":{"description":"TPABundlePath is the filesystem path to the tpa-db scanner-bundle.json\nthe offline TPA scanner runs (spec 086 FR-019: the signature-DB location\nMUST be configuration-driven, not hardcoded). Empty (the default) runs the\ncorpus embedded in this build.\n\nEnv override: MCPPROXY_TPA_BUNDLE_PATH. Hot-reloadable — the path is\nre-read on every config.reloaded event via\nscanner.Service.ApplySecurityConfig, so a corpus refresh needs no restart.\nA configured bundle that fails to read/parse/version-check/compile is\nREFUSED and the previously active corpus stays live (fail-closed, never\nfail-empty); the reason is logged and surfaced in the security overview's\nsignature_bundle.load_error.","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"},"expose_prompts":{"description":"ExposePrompts overrides whether this server's advertised MCP prompts are\naggregated into mcpproxy's prompts/list. nil (default) inherits the\ndefault-aggregate behavior (included if the server advertises\nCapabilities.Prompts); false excludes it regardless of capability.","type":"boolean"},"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"},"max_concurrent_requests":{"description":"Per-server concurrency overrides — scope (c) of FR-020 (spec 093, #955).\nTri-state per setting, exactly like HealthCheckInterval: absent = inherit\nthe per-server default set (server_concurrency_defaults), explicit 0 =\ndisable that setting for this server (0 max = no per-server limiter at\nall; 0 queue_size = no pending capacity, shed immediately at the cap),\npositive = override. The global aggregate limiter is never inherited from\nhere — it applies on top, so effective concurrency is min(per-server,\nglobal). Resolved by Config.ResolveServerConcurrency.","type":"integer"},"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"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"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.\nOmit the key for the 0.1 default; set it to 0 to sample nothing.","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.\n\nNOTE: for a RELEASED build the running binary's own version is\nauthoritative and overrides this field — a stable build is never\noffered an RC (even with channel=rc), and an RC build always tracks the\nrc channel. This field only takes effect on dev/unstamped builds. See\ninternal/updatecheck.Checker.IncludePrereleases.","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\", \"self_reference\"","type":"string"}},"type":"object"},"connect.ConnectResult":{"description":"The full result; its action mirrors the top-level one","properties":{"action":{"description":"\"created\", \"updated\", \"already_exists\", \"removed\", \"not_found\"","type":"string"},"backup_path":{"type":"string"},"client":{"type":"string"},"config_path":{"type":"string"},"display_path":{"description":"DisplayPath is ConfigPath with the home directory shortened to \"~\"\n(FR-037). Populated for every result whose ConfigPath is known.","type":"string"},"message":{"type":"string"},"reload_hint":{"description":"ReloadHint is this client's instruction for making the write take\neffect (FR-037/FR-042), e.g. \"Restart Cursor to load MCPProxy\". Empty\nfor an unknown client.","type":"string"},"server_name":{"type":"string"},"success":{"type":"boolean"}},"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.ActivityPerServer":{"properties":{"calls":{"description":"Calls counted per storage.CountsAsCall","type":"integer"},"errors":{"description":"Of those calls, how many failed","type":"integer"},"last_call_at":{"description":"LastCallAt is RFC3339, or \"\" if the server had no call in the period\n(PerServer only lists servers that did, so this is always set).","type":"string"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityRecord":{"properties":{"agent_name":{"description":"Agent token name when auth_type is \"agent\"","type":"string"},"arguments":{"description":"Tool call arguments","type":"object"},"auth_type":{"description":"\"admin\", \"agent\", \"user\" or \"admin_user\"; empty without an auth context or on another caller's row for a scoped caller","type":"string"},"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"},"parent_id":{"description":"Correlation id of the parent call (the code_execution whose sandbox issued this sub-call)","type":"string"},"request_bytes":{"description":"Byte sizes measured pre-truncation, mirroring storage.ActivityRecord\n(Spec 069 A1). They are the only cost signal a bodies-off export carries:\nwith payloads suppressed there is no text left to measure, so a consumer\naccounting for a record it cannot read has nothing else to go on. They are\nbyte LENGTHS, not token counts — the basis for an explicit estimate, never\na measured figure (spec 103, contracts/replay-input.md).\n\nZero means UNKNOWN, not free: legacy records predate the measurement and\ncode-execution sub-calls record both as zero. Hence omitempty — an absent\nkey tells a consumer to fall to exclusion accounting, whereas a present\nzero would read as a costless call and silently understate the workload.","type":"integer"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_bytes":{"description":"Raw upstream response size in bytes before truncation","type":"integer"},"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\", \"rejected\"","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"},"call_count":{"description":"CallCount is how many of those records are CALLS THE USER MADE, as\ndefined once in storage.CountsAsCall and shared with the usage aggregate\nbehind the Usage tab (audit finding F1, #1046). TotalCount answers \"how\nmany rows does the Activity Log have\"; CallCount answers \"how many calls\nwere there\". They are different questions — quarantine auto-approvals,\nsystem start, security scans and management chatter are events, not calls\n— and printing either one under the other's label is how the same instance\ncame to report 51 calls on one screen and 19 on another.","type":"integer"},"call_error_count":{"description":"CallErrorCount is the failures within CallCount, so an error RATE computed\nfrom this response has one denominator. It is not ErrorCount: a policy\nblock is a failed call but carries status \"blocked\", and a shed call is an\nerror in neither sense because it never ran.","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"other_count":{"description":"OtherCount is every record whose status is outside the four-value\nvocabulary above, so that\n\n\tsuccess + error + blocked + rejected + other == total\n\nholds by construction. The status field is a CLOSED vocabulary for tool\ncalls, but the activity log is wider than tool calls: a quarantine change\nstores its ACTION there (\"approved\", \"auto_approved\"), a policy decision\nstores its DECISION (\"allow\"). Those rows were counted in the total and in\nnone of the four buckets, so the Activity Log's own status tiles summed to\nless than the denominator printed beside them — 15+4+0+0 under a \"42\"\n(audit finding F2, #1046). The residual now has a name and a tile.","type":"integer"},"per_server":{"description":"PerServer covers EVERY server with at least one call in the period\n(unlike TopServers, which is capped at 5 and carries no error counts).\nSpec 109 FR-013: the server-card stats line and the macOS Servers rows\nread this — one ` + "`" + `GET /activity/summary` + "`" + ` response per page load — rather\nthan issuing a per-server activity query each. Computed in the same\ncounting pass as the totals above, from the same CountsAsCall/\nIsManagementBuiltin definitions TopServers already uses.","items":{"$ref":"#/components/schemas/contracts.ActivityPerServer"},"type":"array","uniqueItems":false},"period":{"description":"Time period (1h, 24h, 7d, 30d)","type":"string"},"rejected_count":{"description":"RejectedCount is the number of calls shed by a concurrency limiter before\nthey reached an upstream (spec 093). Counted separately from errors: it is\nproxy backpressure, not an upstream fault, and it is the signal an\noperator right-sizes max_concurrent_requests against.","type":"integer"},"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.AttentionFix":{"properties":{"label":{"type":"string"},"target":{"type":"string"},"verb":{"type":"string"}},"type":"object"},"contracts.AttentionItem":{"properties":{"detail":{"type":"string"},"fix":{"$ref":"#/components/schemas/contracts.AttentionFix"},"id":{"description":"kind:type:subject[:state]","type":"string"},"kind":{"type":"string"},"rank":{"type":"integer"},"since":{"type":"string"},"subject":{"$ref":"#/components/schemas/contracts.AttentionSubject"},"summary":{"type":"string"}},"type":"object"},"contracts.AttentionSubject":{"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"description":"server|tool|client","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\", \"edit_url\", or \"\" (none)\nInvariant: Action always equals Actions[0], or \"\" when Actions is empty.","type":"string"},"actions":{"description":"Actions lists every applicable next step in priority order (FR-012):\nlogin \u003e set_secret \u003e configure \u003e edit_url \u003e approve \u003e restart \u003e\nview_logs \u003e enable. Always non-nil (empty slice, never null).","items":{"type":"string"},"type":"array","uniqueItems":false},"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"},"status":{"description":"Status is the ONE status vocabulary rendered as text on every surface\n(Web UI, macOS, tray, CLI) — Spec 109 FR-010/FR-011. Values: \"ready\",\n\"connecting\", \"sign_in_required\", \"needs_review\", \"needs_secret\",\n\"needs_config\", \"error\", \"disabled\". Unlike Level (a severity signal for\nbadge/tray coloring only), no renderer may print Level as text.","type":"string"},"summary":{"description":"Summary is a human-readable status message (e.g., \"Connected (5 tools)\")","type":"string"},"usable":{"description":"Usable reports whether the server can currently serve tool calls. True\nonly when Status == \"ready\".","type":"boolean"}},"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"},"launched_by":{"description":"LaunchedBy is the durable launch provenance of the running core (Spec\n092 FR-001a): \"tray\" when a tray spawned it, \"installer\" when the macOS\nPKG postinstall did, \"\" when user-launched or unknown. Always present\n(possibly empty) so a tray can distinguish \"old core, not mine\" from\n\"old core I may supersede\".","type":"string"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"pid":{"description":"PID is the operating-system process id of the running core (Spec 092\nFR-002). A tray that merely ATTACHED to a core holds no Process handle\nfor it, so without this there is no mechanism at all to stop a stale\ncore — the consent action would have nothing to act on and could only\nprint instructions. Paired with LaunchedBy it is what lets a newer tray\nsupersede a core an older tray started.","type":"integer"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"update_policy":{"$ref":"#/components/schemas/contracts.UpdatePolicy"},"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":{"description":"Enabled is the EFFECTIVE isolation state for this server: whether its\nprocess is actually CONFINED, after the global setting, the per-server\noverride, the structural gates and the host's capabilities. It is NOT the\nraw per-server override — read EnabledOverride for that (GH #1142).\n\nREAD-ONLY. The write surfaces reject an ` + "`" + `enabled` + "`" + ` key precisely because\nit is derived: echoing it back would convert \"inherits the global\nsetting\" into a permanent explicit override. Write EnabledOverride.\n\nIt stays a non-pointer bool that is always present on the wire: the macOS\ntray decodes it as a non-optional Swift Bool, so omitting or nulling the\nkey would fail Codable for the whole server payload. Older clients that\nread this field now simply get a true answer.","type":"boolean"},"enabled_override":{"description":"EnabledOverride is the RAW per-server ` + "`" + `isolation.enabled` + "`" + ` override, as\npersisted. Absent means \"inherit the global setting\" — which is a\ndistinct state from an explicit false, and the distinction the reporting\nbug used to destroy.","type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"memory_limit":{"type":"string"},"mode_override":{"description":"ModeOverride is the RAW per-server ` + "`" + `isolation.mode` + "`" + ` override\n(\"docker\" | \"sandbox\" | \"none\"). Absent means \"inherit\".","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.IsolationEffective":{"description":"IsolationEffective exposes the resolved isolation state (and the rule\nthat decided it) so clients can distinguish \"inherits global\" from an\nexplicit per-server choice. Read-only; never consumed on PATCH.","properties":{"global_mode":{"description":"GlobalMode is what \"inherit\" resolves to right now.","type":"string"},"inherited":{"description":"Inherited is true when the server sets neither ` + "`" + `isolation.enabled` + "`" + ` nor\n` + "`" + `isolation.mode` + "`" + `, so its state tracks the global setting.","type":"boolean"},"isolated":{"description":"Isolated reports whether the process is actually CONFINED. It is NOT\nsimply Mode != \"none\": \"sandbox\" on a host that cannot enforce Landlock\n(any non-Linux OS, or a kernel without the LSM) runs the server\nunconfined, and Source then says \"sandbox-unavailable\" (GH #1142).","type":"boolean"},"mode":{"description":"Mode is the effective isolation mode: \"docker\" | \"sandbox\" | \"none\" —\nexactly what the spawn path branches on.","type":"string"},"source":{"description":"Source names the deciding rule: \"global\", \"server-mode\",\n\"server-opt-out\", \"server-opt-in-ignored\", \"not-stdio\",\n\"already-docker\", \"sandbox-unavailable\" or \"unsupported-mode\".\nTreat an unrecognized value as \"global\".","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.PreflightPolicy":{"properties":{"exclude_destructive":{"type":"boolean"},"exclude_open_world":{"type":"boolean"},"read_only_only":{"type":"boolean"}},"type":"object"},"contracts.PreflightReason":{"type":"string","x-enum-varnames":["PreflightReasonServerInitializing","PreflightReasonServerUnhealthy","PreflightReasonServerDisabled","PreflightReasonServerQuarantined","PreflightReasonToolPendingApproval","PreflightReasonToolChanged","PreflightReasonToolBlockedByUser","PreflightReasonOAuthRequired","PreflightReasonHashMismatch","PreflightReasonServerNotInScope","PreflightReasonToolDeniedByConfig","PreflightReasonMissingAnnotation","PreflightReasonPolicyFiltered","PreflightReasonNotFound","PreflightReasonServerNotConfigured"]},"contracts.PreflightRequest":{"properties":{"policy":{"$ref":"#/components/schemas/contracts.PreflightPolicy"},"profile":{"description":"Profile evaluates under a named profile's server scope. Unknown: 400.","type":"string"},"tools":{"description":"Tools is 1..100 entries BEFORE dedup; duplicates are collapsed, and\nduplicate ids carrying different pins are a validation error.","items":{"$ref":"#/components/schemas/contracts.PreflightToolRef"},"type":"array","uniqueItems":false},"wait_ms":{"description":"WaitMS polls local state for up to this many milliseconds (cap 10000)\nwhile every failure is retryable-class.","type":"integer"}},"type":"object"},"contracts.PreflightResponse":{"properties":{"checked_at":{"type":"string"},"tools":{"description":"Tools are ordered by first occurrence of each unique id in the request.","items":{"$ref":"#/components/schemas/contracts.PreflightToolResult"},"type":"array","uniqueItems":false},"verdict":{"$ref":"#/components/schemas/contracts.PreflightVerdict"},"waited_ms":{"description":"WaitedMS is present when wait_ms was requested (0 when the wait\nsemaphore was exhausted and the request resolved immediately).","type":"integer"}},"type":"object"},"contracts.PreflightStatus":{"type":"string","x-enum-varnames":["PreflightStatusReady","PreflightStatusUnavailable"]},"contracts.PreflightToolRef":{"properties":{"id":{"description":"ID is a canonical \"\u003cserver\u003e:\u003ctool\u003e\" id. A malformed id is answered with a\nper-ID not_found carrying a format hint, never a request-level error.","type":"string"},"pin_hash":{"description":"PinHash is \"sha256/v{N}:{hex}\" — the schema version is embedded so a\nproxy-side hash-algorithm bump is distinguishable from upstream drift.","type":"string"}},"type":"object"},"contracts.PreflightToolResult":{"properties":{"action":{"type":"string"},"detail":{"type":"string"},"did_you_mean":{"description":"DidYouMean carries up to 3 nearest caller-visible ids on not_found. It\nnever crosses a scope boundary and never names a quarantined server's\ntools.","items":{"type":"string"},"type":"array","uniqueItems":false},"hash":{"description":"Hash is the tool's current pin (\"sha256/v{N}:{hex}\") — operator tier,\nready results only. Never disclosed to an agent token.","type":"string"},"id":{"type":"string"},"reason":{"$ref":"#/components/schemas/contracts.PreflightReason"},"remediation":{"type":"string"},"retryable":{"type":"boolean"},"status":{"$ref":"#/components/schemas/contracts.PreflightStatus"}},"type":"object"},"contracts.PreflightVerdict":{"type":"string","x-enum-varnames":["PreflightVerdictReady","PreflightVerdictDegradedRetryable","PreflightVerdictBlocked","PreflightVerdictUnknownIDs"]},"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"},"expose_prompts":{"description":"ExposePrompts mirrors config.ServerConfig.ExposePrompts (F9): the per-server\nprompt-aggregation override. Tri-state *bool — nil/omitted means \"inherit\ndefault aggregation\". Surfaced on GET so a caller that PATCHed the override\ncan read it back; PATCH/POST accept it via AddServerRequest.","type":"boolean"},"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"},"isolation_effective":{"$ref":"#/components/schemas/contracts.IsolationEffective"},"last_error":{"type":"string"},"last_reconnect_at":{"type":"string"},"last_retry_time":{"type":"string"},"max_concurrent_requests":{"description":"Spec 093 (GH #955) — per-server concurrency overrides, scope (c) of\nFR-020. Each setting is tri-state: nil (omitted) means \"inherit\nserver_concurrency_defaults\", 0 disables that setting for this server,\npositive overrides it. Surfaced on the GET path so a caller can read back\nwhat it set; PATCH/POST accept them via AddServerRequest. The effective\nconcurrency for a server is additionally bounded by the global aggregate\nlimiter, which is NOT an inheritance source for these fields.","type":"integer"},"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"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_count":{"type":"integer"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets this disconnected server","type":"boolean"},"retry_count":{"type":"integer"},"retry_stopped":{"description":"RetryStopped reports that automatic reconnection has been given up for\ngood because the failure is deterministic and unrecoverable — a missing\nbinary, an image without the interpreter, an unparseable config (GH\n#1145). It is NOT ordinary exponential backoff, which keeps retrying;\nnothing will happen until the user fixes the config or restarts the\nserver. RetryStoppedCode is the stable MCPX_* code that proved it and\nRetryStoppedReason the catalog message explaining how to fix it. All three\nare omitted for servers that are healthy or still retrying.","type":"boolean"},"retry_stopped_code":{"type":"string"},"retry_stopped_reason":{"type":"string"},"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"},"estimated":{"description":"Estimated (Spec 109-k FR-070-ish, url-filter-contract.md / audit F-Token):\ntrue while AverageQueryResultSize is a synthetic simulation (a sample of\nthe first ` + "`" + `tools_limit` + "`" + ` tools' schemas — no real retrieve_tools call has\ncompleted yet in this runtime's usage aggregate); false once at least one\nreal retrieve_tools call has, at which point AverageQueryResultSize is\nderived from the real observed average response size instead. The Web\nUI and macOS render an \"estimate\" label while this is true.","type":"boolean"},"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.Tier":{"description":"Tier is computed by AnnotationTier (Spec 109 FR-028/X11) from\nAnnotations — read|write|destructive|unannotated. Set by every producer\nof a Tool (enrichServerTools, the global tools handler); never left for\na consuming surface to compute.","type":"string","x-enum-varnames":["TierRead","TierWrite","TierDestructive","TierUnannotated","TierUnknown"]},"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"},"hash":{"description":"Hash is the tool's current stored hash rendered in the preflight pin\nformat \"sha256/v{N}:{hex}\" (Spec 098 FR-011), where N is the approval\nrecord's HashSchemaVersion. It is the authoring surface for\n` + "`" + `POST /api/v1/preflight` + "`" + ` pins and ` + "`" + `mcpproxy tools preflight --pin` + "`" + `:\ncopy the value straight into a pin.\n\nDisclosure is OPERATOR TIER ONLY — same rule as the preflight per-tool\nresult. The field is omitted for agent-token callers and for tools with\nno stored hash (no approval record yet, or a record written before\nhashes existed).","type":"string"},"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"},"tier":{"$ref":"#/components/schemas/contracts.Tier"},"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"},"arguments_truncated":{"description":"ArgumentsTruncated marks Arguments as a placeholder rather than the\narguments the tool was called with. Replaying such a record without\nsupplying arguments explicitly is refused.","type":"boolean"},"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"},"response_bytes":{"description":"Marshalled response size before truncation","type":"integer"},"response_truncated":{"description":"ResponseTruncated and ResponseBytes describe a STORAGE-side cut (#1176):\nthe caller received the response whole, and only the persisted copy was\nshortened to tool_call_max_response_size. When ResponseTruncated is true\nthe Response object carries {truncated, original_bytes, preview, note}\ninstead of the upstream result, and ResponseBytes is its size before the\ncut.","type":"boolean"},"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"},"behind_summary":{"description":"Spec 079 FR-002 — how far behind the running build is. All four are\nadditive (FR-021) and absent when the delta could not be resolved, in\nwhich case every surface renders its pre-delta wording.","type":"string"},"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"},"releases_behind":{"description":"Releases on the offered channel between the running and offered versions","type":"integer"},"releases_behind_saturated":{"description":"ReleasesBehind is a lower bound: the running build predates the scanned release window","type":"boolean"},"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"},"weeks_behind":{"description":"Whole weeks between the two releases' publish dates; 0 is a real value, absent means unknown","type":"integer"}},"type":"object"},"contracts.UpdatePolicy":{"description":"UpdatePolicy is the effective, hot-reloadable update policy (Spec 092\nFR-015). Always present: the ` + "`" + `update` + "`" + ` object above is omitted both when\nupdate checking is disabled AND when no check has produced a result\nyet, so its absence cannot tell a client whether it is allowed to run\nits own (e.g. Sparkle feed) check. This field states the answer.","properties":{"channel":{"description":"Channel is the tracked release channel: \"stable\" or \"rc\".","type":"string"},"enabled":{"description":"Enabled is the effective automatic-check kill switch: update_check.enabled\nwith MCPPROXY_DISABLE_AUTO_UPDATE=true winning over it. A user-initiated\n\"Check for Updates\" stays available regardless.","type":"boolean"},"nudges_suppressed":{"description":"NudgesSuppressed asks UI surfaces to stay quiet (CI / non-interactive)\nwhile machine-readable fields keep reporting the facts.","type":"boolean"}},"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_estimated":{"description":"TokensSavedEstimated echoes ServerTokenMetrics.Estimated (Spec 109-k):\ntrue while TokensSaved is a synthetic simulation rather than derived\nfrom a real retrieve_tools call. Dropped (false, the zero value) for a\nscoped caller along with TokensSaved itself, above.","type":"boolean"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"total_calls":{"description":"TotalCalls and TotalErrors are the headline counts for the window: the sum\nof the timeline this same response carries, so the tiles and the histogram\nunder them cannot disagree. They are NOT the sum of Tools — that list is\nlifetime-cumulative, upstream-only and truncated to top-N, and summing it\nclient-side is what made the Usage tab print a third number for the same\n24 hours (audit finding F1, #1046). The population is\nstorage.CountsAsCall, shared with ActivitySummaryResponse.CallCount.\n\nTwo bounds on how exactly this matches the Activity Log's own count.\nBoth are bounded and disclosed, unlike the population mismatch they\nreplace, which was unbounded and silent:\n\n - Window granularity is the timeline's: whole hour buckets, so the span\n is the requested window rounded up to a bucket edge.\n - This response is served from a snapshot behind a short read cache\n (observability.usage_cache_ttl, 5s by default) so the endpoint never\n scans the activity log per request, while the summary endpoint counts\n live. Calls that land inside that window appear on the Activity Log\n first. FreshnessMs and GeneratedAt say how old the figures are, and\n the Usage tab prints it (\"Updated 3s ago\").","type":"integer"},"total_errors":{"type":"integer"},"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_exceeds":{"type":"boolean"},"p50_ms":{"description":"P50Ms and P95Ms are read off a fixed latency histogram, so they are BUCKET\nBOUNDS, not measured durations: the true percentile is at or below the\nvalue, and a client must render it as a bound (\"≤ 5 ms\"). P50Exceeds /\nP95Exceeds flip that reading for the unbounded overflow bucket, where the\nvalue is the last bound and the truth is above it (\"\u003e 10 s\").","type":"integer"},"p95_exceeds":{"type":"boolean"},"p95_ms":{"type":"integer"},"rejected":{"description":"spec 093: shed by a concurrency limit; never executed, so excluded from calls/latency","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"},"expose_prompts":{"description":"ExposePrompts is the per-server override for prompt aggregation (F9):\nwhether this server's advertised MCP prompts are merged into mcpproxy's\nprompts/list. Tri-state *bool mirroring config.ServerConfig.ExposePrompts —\na nil pointer means \"leave unchanged\" on PATCH (and \"inherit the default\naggregate behavior\" on create); a present value (including false) is applied.","type":"boolean"},"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"},"max_concurrent_requests":{"description":"MaxConcurrentRequests / QueueSize / QueueTimeout are the per-server\nconcurrency overrides (spec 093 / GH #955, FR-020 scope (c)). Each is\ntri-state: a nil pointer means \"leave unchanged\" on PATCH and \"inherit\nserver_concurrency_defaults\" on create; an explicit 0 disables that\nsetting for this server; a positive value overrides it. Do NOT collapse\nthem to plain values — an omitted field would then silently reset a\nconfigured limit.","type":"integer"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"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.ConnectConflictResponse":{"properties":{"action":{"description":"already_exists | precondition_failed","type":"string"},"data":{"$ref":"#/components/schemas/connect.ConnectResult"},"error":{"description":"Human-readable message","type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.ConnectRequest":{"properties":{"force":{"description":"Overwrite existing entry","type":"boolean"},"precondition_token":{"description":"PreconditionToken is the opaque token from the preview this write was\nconfirmed against (Spec 091 FR-005). When present, the core rechecks it\nat write time and responds 409 with action \"precondition_failed\" —\nwriting nothing — if the config or the entry MCPProxy would write has\ndrifted since; the caller then re-previews instead of retrying. Absent\nmeans exactly the pre-091 behavior. A replace-classified flow sends this\nTOGETHER with force=true: the token, not the absence of force, is the\noverwrite safety.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.EnvFieldPreview":{"properties":{"empty_or_placeholder":{"type":"boolean"},"name":{"type":"string"},"secret_like":{"type":"boolean"},"value_present":{"type":"boolean"}},"type":"object"},"httpapi.HeaderFieldPreview":{"properties":{"empty_or_placeholder":{"type":"boolean"},"name":{"type":"string"},"secret_like":{"type":"boolean"},"value_present":{"type":"boolean"}},"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":{"allow_paste_fallback":{"description":"AllowPasteFallback opts into detecting a bare URL or a single command\nline (FR-064) when Format/format detection would otherwise fail — see\nconfigimport.ImportOptions.AllowPasteFallback for why this must stay\nopt-in (review round 4 F-E). Only the interactive Paste tab sets this;\nevery other caller of this endpoint (the general \"Import config\"\npanel, or a direct API call) leaves it false and gets a clear\n\"unable to detect configuration format\" error for a plain one-liner\ninstead of it being silently guessed at and, on apply, added as a\nreal server with no confirmation step.","type":"boolean"},"content":{"description":"Raw JSON or TOML content","type":"string"},"env_override":{"additionalProperties":{"type":"string"},"description":"EnvOverride/HeaderOverride (Spec 109 FR-064/065, PR review round 4\nF-A/F-D fix): the Paste tab's per-field Value/Secret edits — a plain\nvalue the user typed, or a keyring ref path if they chose Secret —\nkeyed by field name. Applied to the matching imported server's\nEnv/Headers ONLY when preview=false, directly on the server this\nrequest's own raw Content parses to server-side. This is what lets\nthe apply call carry the user's edited env/header values without\never round-tripping the redacted preview: url/command/args on apply\nalways come from re-parsing Content here, never from a client-held\npreview response, so a credential embedded in a URL query param or\nargv flag (which the preview necessarily redacted for display) is\nnever overwritten with the masked placeholder.","type":"object"},"format":{"description":"Optional format hint","type":"string"},"header_override":{"additionalProperties":{"type":"string"},"type":"object"},"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"},"env":{"items":{"$ref":"#/components/schemas/httpapi.EnvFieldPreview"},"type":"array","uniqueItems":false},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"headers":{"items":{"$ref":"#/components/schemas/httpapi.HeaderFieldPreview"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"summary":{"description":"Summary, Tags, Env and Headers are the Spec 109 FR-064 preview\nenrichment (contracts/rest-api.md \"Import preview\"). Summary and Tags\nare built from the already-redacted URL/Command/Args above, so a\nsecret embedded in argv or a URL query never reaches Summary either.\nEnv/Headers never carry the raw value — only its presence and two\nbooleans a surface uses to default the Value/Secret toggle (FR-065).","type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (enabled,\nmode_override, image, network_mode, extra_args, working_dir). A nil\npointer means \"do not touch isolation config\". A present object is\napplied field-by-field ON TOP of the persisted overrides, so omitting a\nfield leaves it alone; clear an individual override by sending it\nexplicitly (` + "`" + `\"enabled\": null` + "`" + `, ` + "`" + `\"image\": \"\"` + "`" + `).","properties":{"enabled":{"description":"Enabled exists ONLY to detect and reject an echoed-back read. It is the\neffective state on the read surface and is never writable; see validate().","type":"boolean"},"enabled_override":{"description":"EnabledOverride is the tri-state per-server override — the RAW value, the\nsame one reads return as ` + "`" + `enabled_override` + "`" + `. It has THREE meaningful wire\nstates, and collapsing them is what silently un-isolated servers\n(GH #1142):\n - absent → leave the persisted override untouched\n - null → clear the override, back to inheriting the global\n - true / false → set an explicit opt-in / opt-out","type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"mode_override":{"description":"ModeOverride sets ` + "`" + `isolation.mode` + "`" + ` (\"docker\" | \"sandbox\" | \"none\").\nnil leaves the persisted value alone; an empty string clears it. An\nunrecognized value is rejected with a 400 rather than persisted.","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"},"connected_client_id":{"description":"ConnectedClientID records a successful connect write for this client id\n(Spec 109-b FR-042, review round 6). The REST connect endpoint\n(POST /api/v1/connect/{client}) already records this itself on success;\nthis field exists so ` + "`" + `mcpproxy connect` + "`" + ` — which writes the client's\nconfig file directly, without going through that endpoint, so the\ncommand still works when no daemon is running — can relay the same\nevent to a daemon that IS running, keeping ClientConnectedAt in sync\nacross both surfaces. Must be a known id from the fixed connect client\nregistry (internal/connect.GetAllClients); any other value is rejected,\nmatching the field's \"bounded by the registry\" invariant\n(data-model.md §7).","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.OnboardingStateResponse":{"properties":{"configured_server_count":{"description":"ConfiguredServerCount is the number of upstream MCP servers configured\nin mcpproxy (counts both enabled and disabled).","type":"integer"},"connected_client_count":{"description":"ConnectedClientCount is the number of supported clients currently\npointing at mcpproxy.","type":"integer"},"connected_client_ids":{"description":"ConnectedClientIDs are the identifiers of supported clients currently\npointing at mcpproxy. Drawn exclusively from the fixed adapter table —\nuser-entered values never appear here.","items":{"type":"string"},"type":"array","uniqueItems":false},"first_mcp_client_ever":{"description":"FirstMCPClientEver is true once any MCP client has successfully completed\nan ` + "`" + `initialize` + "`" + ` round-trip with this mcpproxy. Sourced from the Spec 044\nactivation bucket. Drives the Verify tab's \"green check\" state.","type":"boolean"},"has_configured_server":{"description":"HasConfiguredServer is true if at least one upstream MCP server is\nconfigured (regardless of current connection health).","type":"boolean"},"has_connected_client":{"description":"HasConnectedClient is true if at least one supported AI client currently\nhas mcpproxy registered in its config.","type":"boolean"},"has_usable_server":{"description":"HasUsableServer is true once at least one enabled, non-quarantined\nserver has usable health and at least one approved (non-disabled) tool.\nThis is the real \"the wizard has something to try\" signal:\nHasConfiguredServer only means a server entry exists, even while every\none of them sits quarantined, requires sign-in, or has zero approved\ntools. The Servers step and Setup badge use this instead of\nHasConfiguredServer, which is kept above for compatibility.\nHealthStatus.Usable is the shared\nreadiness contract used by every UI surface (Spec 109-c, tasks.md T051).","type":"boolean"},"incomplete_tab_count":{"description":"IncompleteTabCount is the number of wizard tabs whose state is incomplete.\nDrives the sidebar Setup entry's badge. Formula:\n +1 if HasConnectedClient == false\n +1 if HasUsableServer == false\n +1 if FirstMCPClientEver == false","type":"integer"},"mcp_clients_seen_ever":{"description":"MCPClientsSeenEver is the capped list of recognized client names that\nhave ever called this mcpproxy. Names come from the MCP ` + "`" + `initialize` + "`" + `\npayload's ` + "`" + `clientInfo.name` + "`" + ` field, sanitized. Surfaces on the Verify tab\nso the user can see whether their real IDE — not a test client — has\nconnected.","items":{"type":"string"},"type":"array","uniqueItems":false},"should_show_wizard":{"description":"ShouldShowWizard is the derived flag the frontend uses to decide\nwhether to auto-show. True when not engaged and IncompleteTabCount \u003e 0\n(Spec 046 v2 — semantics widened to also count the Verify tab).","type":"boolean"},"state":{"$ref":"#/components/schemas/storage.OnboardingState"},"usable_servers":{"description":"UsableServers lists the names behind HasUsableServer, for the Verify\nstep's suggested-prompt generator (FR-042): prompts are only built from\ntools of servers in this list, never from a quarantined or toolless one.","items":{"type":"string"},"type":"array","uniqueItems":false}},"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"},"httpapi.UpdateFailureRequest":{"properties":{"stage":{"description":"Stage is the failure stage of the update session.","enum":["appcast","download","install","other"],"type":"string"}},"type":"object"},"httpapi.attentionResponse":{"properties":{"count":{"type":"integer"},"generated_at":{"type":"string"},"items":{"items":{"$ref":"#/components/schemas/contracts.AttentionItem"},"type":"array","uniqueItems":false}},"type":"object"},"management.BulkOperationResult":{"properties":{"errors":{"additionalProperties":{"type":"string"},"description":"Map of server name to error message","type":"object"},"failed":{"description":"Number of failed operations","type":"integer"},"successful":{"description":"Number of successful operations","type":"integer"},"total":{"description":"Total servers processed","type":"integer"}},"type":"object"},"observability.HealthResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"observability.HealthStatus":{"properties":{"error":{"type":"string"},"latency":{"type":"string"},"name":{"type":"string"},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"}},"type":"object"},"observability.ReadinessResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"ready\" or \"not_ready\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"secureenv.EnvConfig":{"description":"Environment configuration for secure variable filtering","properties":{"allowed_system_vars":{"items":{"type":"string"},"type":"array","uniqueItems":false},"custom_vars":{"additionalProperties":{"type":"string"},"type":"object"},"enhance_path":{"description":"Enable PATH enhancement for Launchd scenarios","type":"boolean"},"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"},"storage.OnboardingState":{"description":"State is the persisted wizard engagement record. Engaged is true once\nthe wizard was shown and the user completed or skipped it.","properties":{"client_connected_at":{"additionalProperties":{"type":"string"},"description":"ClientConnectedAt records, per client id from the fixed connect client\nregistry (internal/connect.GetAllClients — never user input), the last\ntime a connect write succeeded for that client (Spec 109-b FR-042).\nWritten by the connect success path through UpdateOnboardingState so a\nconcurrent onboarding/mark write can never drop it. Consumed by the\nVerify step / presence layer to tell \"connected, never seen\" apart from\n\"connected seconds ago, hasn't reconnected yet\".","type":"object"},"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"completed_external\",\n\"skipped\" (Spec 080 FR-001). \"completed_external\" records a dismissal\nwhere the connect step was untouched but the install was already\nconnected outside the wizard (CLI, ConnectModal, manual config).","type":"string"},"engaged":{"description":"Engaged is true once the wizard was shown and the user completed or\nskipped it. Once true, the wizard does not auto-show again, even if\nstate regresses (e.g. user disconnects all clients).","type":"boolean"},"engaged_at":{"description":"EngagedAt is the timestamp of completion or explicit skip.","type":"string"},"first_shown_at":{"description":"FirstShownAt is the timestamp of first wizard render.","type":"string"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\".","type":"string"}},"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","preflight","prompt_get"],"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","rejected"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Filter by parent call id — returns the sub-calls one code_execution issued","in":"query","name":"parent_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 except a contextual whitelist (intent.reason, intent.operation_type, decision, reason, client_name, client_version) (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 by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Filter by parent call id — exports the sub-calls one code_execution issued","in":"query","name":"parent_id","schema":{"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to 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"},"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":"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","rejected"],"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/attention":{"get":{"description":"One list, one count, every surface (Web UI, macOS tray/Home, CLI) reads from it. Filtered per caller: an administrator sees every item; a scoped caller (agent token, or a non-admin server-edition OAuth user session) sees only items whose server it may enumerate, and never a client item.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.attentionResponse"}}},"description":"The needs-attention list"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the needs-attention list","tags":["attention"]}},"/api/v1/catalog/search":{"get":{"description":"Fans out to every enabled catalog source (registry) in parallel, merges, de-duplicates and ranks the results (FR-060/061). An empty q returns official + popular sections instead of a flat list. Open to any authenticated caller — added is the only field filtered per caller scope (FR-007).","parameters":[{"description":"Free-text search","in":"query","name":"q","schema":{"type":"string"}},{"description":"Narrow to one catalog source id","in":"query","name":"source","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Max results (default 20, max 50)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"OK"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search the server catalog across every enabled source","tags":["catalog"]}},"/api/v1/code/scripts":{"get":{"description":"List the stored scripts available to the code_execution tool. Scripts are ` + "`" + `\u003cname\u003e.js` + "`" + ` / ` + "`" + `\u003cname\u003e.ts` + "`" + ` files in the ` + "`" + `scripts/` + "`" + ` directory next to the active configuration file. Entries are advisory: ` + "`" + `ok` + "`" + ` scripts are invocable, ` + "`" + `ambiguous` + "`" + ` names have both extensions, and ` + "`" + `invalid` + "`" + ` ones report why (empty, oversized, unreadable, non-regular). Read-only — there is no write surface for stored scripts. Administrator-only (Spec 105 FR-012): an agent token, whatever its server scope, is refused with 403 — the listing is the enumeration the missing-script error withholds from a scoped caller.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Stored scripts and the directory they were read from"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot list stored scripts"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List stored code-execution scripts","tags":["code"]}},"/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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read the configuration document"},"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, zcode)","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, zcode)","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.\nOptionally accepts precondition_token from a preview (Spec 091): when supplied,\nthe core rechecks the raw pre-write state and the entry it would write, and\nrefuses a drifted write with 409 before taking any backup. The 409 body's\naction discriminates the two conflict kinds: \"precondition_failed\" (stale\npreview — re-preview, do not retry) vs \"already_exists\" (entry present — pass\nforce=true). force=true never rescues a stale token.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode, zcode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional connection parameters (server_name, force, precondition_token)"},"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/httpapi.ConnectConflictResponse"}}},"description":"Conflict: action=already_exists (use force=true) or action=precondition_failed (preview is stale; re-preview)"},"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.\nSpec 091 adds three fields: existing_entry_summary (present only when\nentry_exists — a sanitized, non-secret projection of the entry being replaced:\nits name, type, endpoint with query/userinfo stripped, command, and header and\nenv NAMES, never values); precondition_token (always present — an opaque keyed\ndigest of the raw pre-write state and the pending entry, echoed back on POST\nconnect to detect drift); and connect_refusal (present when the write would\nrefuse regardless of intent, e.g. a non-create-capable client with no config —\ntreat its presence as \"Connect unavailable\").","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode, zcode)","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, zcode)","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\nweb_ui_url carries the ?apikey= credential ONLY for an authenticated admin; a scoped agent token receives the bare URL\nThis endpoint is designed for tray-core communication and version checking\nUse refresh=true query parameter to force an immediate update check against GitHub\nThe launched_by field reports durable launch provenance (\"tray\", \"installer\", or \"\" for user-launched/unknown)","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":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Updated OnboardingStateResponse"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read onboarding state"},"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":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OnboardingStateResponse"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read onboarding state"},"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/preflight":{"post":{"description":"Deterministic, side-effect-free availability check for a caller-supplied list of tool IDs (Spec 098). Performs zero upstream calls and mutates no runtime state. HTTP status reports whether the CHECK executed: a fully blocked set is still 200, with the availability verdict in the body. Every executed preflight writes an activity record before the response is returned.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.PreflightRequest"}}},"description":"Tool IDs (1-100 before dedup), optional profile, annotation policy filters and wait budget","required":true},"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":"Preflight verdict and per-tool results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Validation error (malformed, oversized, doubled or unknown-field body; empty or oversized tool list; conflicting duplicate pins; unknown profile; wait_ms out of range)"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Missing or invalid credentials"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Runtime unavailable, evaluator infrastructure read failure, or the activity record could not be persisted"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Preflight required tools","tags":["tools"]}},"/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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot change the active profile)"},"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.\nrouting_mode is what /mcp is actually serving; pending_routing_mode carries a\nrestart-pending value persisted on disk (empty when there is none).\ntool_response_mode and direct_tool_response_mode report the two serialization axes, resolved.","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"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unsupported scope filter (profile/client/token, Spec 109-k FR-080a)"},"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. Isolation: ` + "`" + `isolation.enabled` + "`" + ` is READ-ONLY (it reports the effective state on reads) and is rejected with 400; set the per-server override via ` + "`" + `isolation.enabled_override` + "`" + ` (true | false | null to clear, omit to leave unchanged). An unrecognized ` + "`" + `isolation.mode_override` + "`" + ` is rejected with 400.","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. Isolation: ` + "`" + `isolation.enabled` + "`" + ` is READ-ONLY (it reports the effective state on reads) and is rejected with 400; set the per-server override via ` + "`" + `isolation.enabled_override` + "`" + ` (true | false | null to clear, omit to leave unchanged). An unrecognized ` + "`" + `isolation.mode_override` + "`" + ` is rejected with 400.","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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read MCP session history"},"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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read MCP session history"},"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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read deployment-wide 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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read the deployment telemetry 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/telemetry/update-failure":{"post":{"description":"Records one terminal update-session failure, identified only by its\nstage (appcast, download, install, other). The body carries no error\ntext, URL, or version — the stage is the only value transmitted.\nReturns 204 both when the occurrence was durably persisted and when\ntelemetry is inactive at event time (config opt-out, environment\nopt-out, CI, or dev build), in which case nothing is recorded.\nCallers cannot and need not distinguish the two.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UpdateFailureRequest"}}},"description":"Update failure stage","required":true},"responses":{"204":{"description":"Accepted (recorded, or a deliberate no-op while telemetry is inactive)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Malformed body, unknown field, trailing value, or stage outside the closed set"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Persistence failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Record a desktop auto-update failure occurrence (Spec 095)","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"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"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"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unsupported scope filter (profile/client/token, Spec 109-k FR-080a)"},"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)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"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"]}}}, diff --git a/oas/swagger.yaml b/oas/swagger.yaml index 48af94d71..6dc4c8a48 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -1390,6 +1390,23 @@ components: total: type: integer type: object + contracts.ActivityPerServer: + properties: + calls: + description: Calls counted per storage.CountsAsCall + type: integer + errors: + description: Of those calls, how many failed + type: integer + last_call_at: + description: |- + LastCallAt is RFC3339, or "" if the server had no call in the period + (PerServer only lists servers that did, so this is always set). + type: string + name: + description: Server name + type: string + type: object contracts.ActivityRecord: properties: agent_name: @@ -1526,6 +1543,19 @@ components: than the denominator printed beside them — 15+4+0+0 under a \"42\"\n(audit finding F2, #1046). The residual now has a name and a tile." type: integer + per_server: + description: |- + PerServer covers EVERY server with at least one call in the period + (unlike TopServers, which is capped at 5 and carries no error counts). + Spec 109 FR-013: the server-card stats line and the macOS Servers rows + read this — one `GET /activity/summary` response per page load — rather + than issuing a per-server activity query each. Computed in the same + counting pass as the totals above, from the same CountsAsCall/ + IsManagementBuiltin definitions TopServers already uses. + items: + $ref: '#/components/schemas/contracts.ActivityPerServer' + type: array + uniqueItems: false period: description: Time period (1h, 24h, 7d, 30d) type: string diff --git a/scripts/run-web-smoke.sh b/scripts/run-web-smoke.sh index 6448fa23d..6a6d1f656 100755 --- a/scripts/run-web-smoke.sh +++ b/scripts/run-web-smoke.sh @@ -144,6 +144,23 @@ if [[ -n "$FIXTURE_PATH" ]]; then ;; esac SWEEP_SERVER_NAME="sweep-stdio" + # Review round 1 (109-e medium finding): navigation-consistency.spec.ts's + # "equal card heights" test — 109-e's own headline FR-013/D15 test — skips + # itself below 2 server cards, and a single fixture server left it skipping + # in every run of this gate until 109-i's later, larger fixture set lands. + # A second entry running the SAME fixture binary under a different name + # costs nothing new to build and is enough to let that test actually run + # from this PR onward, rather than only from 109-i. + # + # Review round 2 (109-e medium finding): two config-identical, healthy + # servers both land in the SAME `lg:grid-cols-3` row at 1440px, where CSS + # grid's `align-items:stretch` equalizes every card in a row regardless of + # content — so the test could never actually fail (a broken `min-height` + # rule or a state-dependent height regression would stay invisible). A 3rd + # and 4th entry push the fleet past one row (3 + 1) so row heights are no + # longer stretched together, and the 4th is quarantined so its card renders + # genuinely different content (a review action + quarantine note) instead + # of an identical copy of the first two. SERVERS_JSON=$(cat <