From 830ef3ff0b6fe93244d64c73e376cbcb2d09a51c Mon Sep 17 00:00:00 2001 From: Anton Date: Tue, 7 Jul 2026 20:36:36 +0200 Subject: [PATCH] feat(cate.mcp): install endpoint into agents + default Dashboard Install into agents (Endpoint screen): one click writes the aggregated /mcp endpoint into each agent's workspace-local config, each in that agent's own shape and preserving other keys. Cate Agent (.pi/mcp.json), Claude Code (.mcp.json), Cursor (.cursor/mcp.json), OpenCode (opencode.json), Codex (.codex/config.toml). Antigravity and PI are listed but disabled with the reason. Stale installs (the port reminted each session) surface an Update action. Default Dashboard: the panel opens on server health plus a live feed of tool calls through the endpoint. A bounded ActivityLog is recorded at the aggregator and served on its own /api/activity route, kept off /api/state so recording never churns the state serial. Bumps to v1.3.0. --- extensions/cate.mcp/README.md | 6 +- extensions/cate.mcp/manifest.json | 2 +- extensions/cate.mcp/package.json | 2 +- extensions/cate.mcp/src/public/api.ts | 18 + .../src/public/components/DashboardView.tsx | 140 +++++++ .../src/public/components/EndpointView.tsx | 98 ++++- .../src/public/components/Sidebar.tsx | 14 +- .../cate.mcp/src/public/components/util.tsx | 10 + extensions/cate.mcp/src/public/main.tsx | 27 +- extensions/cate.mcp/src/public/styles.css | 216 +++++++++++ extensions/cate.mcp/src/public/view.test.ts | 28 ++ extensions/cate.mcp/src/public/view.ts | 21 ++ .../cate.mcp/src/server/activity.test.ts | 35 ++ extensions/cate.mcp/src/server/activity.ts | 31 ++ .../cate.mcp/src/server/agent-install.test.ts | 200 ++++++++++ .../cate.mcp/src/server/agent-install.ts | 342 ++++++++++++++++++ .../cate.mcp/src/server/aggregate.test.ts | 5 +- extensions/cate.mcp/src/server/aggregate.ts | 21 +- extensions/cate.mcp/src/server/api.test.ts | 52 ++- extensions/cate.mcp/src/server/http-app.ts | 44 ++- extensions/cate.mcp/src/server/main.ts | 7 +- extensions/cate.mcp/src/shared/types.ts | 45 +++ 22 files changed, 1324 insertions(+), 40 deletions(-) create mode 100644 extensions/cate.mcp/src/public/components/DashboardView.tsx create mode 100644 extensions/cate.mcp/src/public/view.test.ts create mode 100644 extensions/cate.mcp/src/public/view.ts create mode 100644 extensions/cate.mcp/src/server/activity.test.ts create mode 100644 extensions/cate.mcp/src/server/activity.ts create mode 100644 extensions/cate.mcp/src/server/agent-install.test.ts create mode 100644 extensions/cate.mcp/src/server/agent-install.ts diff --git a/extensions/cate.mcp/README.md b/extensions/cate.mcp/README.md index 2d16ba9..a52afaa 100644 --- a/extensions/cate.mcp/README.md +++ b/extensions/cate.mcp/README.md @@ -6,6 +6,8 @@ Built directly on the official [`@modelcontextprotocol/sdk`](https://www.npmjs.c ## What it does +- **Dashboard (default view)**: server health at a glance plus a live Activity feed. An endpoint strip (live dot, URL, server/tool counts), a stat row (running / degraded / needs-auth, total tools, and recent calls/errors — labelled "recent" since the feed is a bounded ~200-entry ring), a servers health grid (status, tool count, uptime), and a newest-first feed of every tool call through the aggregated endpoint (`time · server__tool · caller · duration · ok/err`), self-refreshed every 2s from `GET /api/activity`. The panel opens here. + - **Workspace config in `.cate/mcp.json`**, Claude-Desktop-style and hand-editable: ```json @@ -28,6 +30,8 @@ Built directly on the official [`@modelcontextprotocol/sdk`](https://www.npmjs.c - **Unified MCP endpoint at `/mcp`**: the extension server itself is an MCP server (streamable HTTP) aggregating every tool/resource/prompt of every enabled and running managed server, deterministically namespaced `__`. Upstream failures come back as tool errors, never protocol crashes; `listChanged` fires when upstreams change. Auth is the same bearer token Cate injects. The panel's Endpoint card shows the URL and header with copy buttons; point any MCP client (including a coding agent) at it. +- **One-click install into coding agents**: the Endpoint screen writes this endpoint into each agent's workspace-local config — Cate Agent (`.pi/mcp.json`, read by the `pi-mcp-adapter` package), Claude Code (`.mcp.json`), Cursor (`.cursor/mcp.json`), OpenCode (`opencode.json`), and Codex (`.codex/config.toml`), each in that agent's own shape, preserving every other key. Antigravity (global-only config) and PI (no MCP) are listed but disabled with the reason. The endpoint's port/token are reminted each Cate session, so an install is a snapshot: a stale entry is flagged with an Update button, and the endpoint only serves while the panel is open. + - **Discover tab**: searches the official registry at `registry.modelcontextprotocol.io` (`GET /v0/servers?search=…` with cursor pagination) and one-click prefills the add-server form from a registry entry's npm/pypi/oci package or remote URL. Registry failures stay inside that tab. - **OAuth for remote servers**: on a 401 the server shows `needs-auth` with a Connect button; the PKCE flow runs through `GET /oauth/callback` on the extension's loopback port. Tokens live in `.cate/mcp-auth.json` (mode 0600, auto-gitignored via `.cate/.gitignore`), never in `mcp.json`; refresh is handled through the SDK provider hooks. @@ -37,7 +41,7 @@ Built directly on the official [`@modelcontextprotocol/sdk`](https://www.npmjs.c ```bash npm install npm run build # vite (panel) -> dist/public, esbuild (server bundle) -> dist/server.js -npm test # vitest: 88 tests +npm test # vitest: 113 tests npm run typecheck # browser + server + test tsconfigs ``` diff --git a/extensions/cate.mcp/manifest.json b/extensions/cate.mcp/manifest.json index 6ce3a66..7d30108 100644 --- a/extensions/cate.mcp/manifest.json +++ b/extensions/cate.mcp/manifest.json @@ -1,7 +1,7 @@ { "id": "cate.mcp", "name": "MCP Servers", - "version": "1.1.0", + "version": "1.3.0", "description": "Native MCP server manager. Configure stdio and remote MCP servers in .cate/mcp.json, supervise them with health checks and auto-restart, browse their tools/resources/prompts, invoke tools from a playground, discover servers in the official MCP registry, and expose everything through one aggregated MCP endpoint any client can connect to.", "panels": [ { diff --git a/extensions/cate.mcp/package.json b/extensions/cate.mcp/package.json index 614302f..c447e16 100644 --- a/extensions/cate.mcp/package.json +++ b/extensions/cate.mcp/package.json @@ -1,7 +1,7 @@ { "name": "cate-mcp-extension", "private": true, - "version": "1.1.0", + "version": "1.3.0", "description": "Native MCP server manager, a server-backed Cate extension.", "scripts": { "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"", diff --git a/extensions/cate.mcp/src/public/api.ts b/extensions/cate.mcp/src/public/api.ts index f344174..c776d00 100644 --- a/extensions/cate.mcp/src/public/api.ts +++ b/extensions/cate.mcp/src/public/api.ts @@ -6,6 +6,8 @@ // ============================================================================= import type { + ActivityResponse, + AgentsResponse, PromptGetResponse, ResourceReadResponse, ServerConfigInput, @@ -91,6 +93,22 @@ export function registrySearch(q: string, cursor: string | null): Promise(`api/registry/search?${params.toString()}`) as Promise } +export function fetchAgents(): Promise { + return request('api/agents') as Promise +} + +export function fetchActivity(limit?: number): Promise { + return request(limit ? `api/activity?limit=${limit}` : 'api/activity') as Promise +} + +export function installAgent(id: string): Promise { + return postJson('api/agents/install', { id }) as Promise +} + +export function uninstallAgent(id: string): Promise { + return postJson('api/agents/uninstall', { id }) as Promise +} + export interface OAuthStartResult { ok: boolean error?: string diff --git a/extensions/cate.mcp/src/public/components/DashboardView.tsx b/extensions/cate.mcp/src/public/components/DashboardView.tsx new file mode 100644 index 0000000..58a2534 --- /dev/null +++ b/extensions/cate.mcp/src/public/components/DashboardView.tsx @@ -0,0 +1,140 @@ +// Default view: server health at a glance (Overview) plus a live feed of tool +// calls flowing through the aggregated endpoint (Activity). The overview is +// derived from the /api/state snapshot the panel already polls; the feed +// self-fetches /api/activity on its own 2s poll (visibility-gated, mirroring +// main.tsx) so recording a call never has to churn the state serial. + +import { useCallback, useEffect, useState } from 'react' +import type { ActivityEntry, ServerSnapshot, StateSnapshot } from '../../shared/types' +import { fetchActivity } from '../api' +import { StatusDot, formatUptime } from './util' + +const POLL_MS = 2000 +const FEED_LIMIT = 100 + +/** Per-server meta line: tool count + uptime, or a problem word in warn tone. */ +function serverMeta(server: ServerSnapshot): { text: string; warn: boolean } { + if (server.status === 'needs-auth') return { text: 'needs auth', warn: true } + if (server.status === 'error') return { text: 'error', warn: true } + if (server.status === 'disabled') return { text: 'disabled', warn: false } + const parts = [`${server.tools.length} tools`] + const up = formatUptime(server.startedAt) + if (up) parts.push(`up ${up}`) + return { text: parts.join(' · '), warn: server.status === 'degraded' } +} + +function Stat({ label, value, warn = false }: { label: string; value: number; warn?: boolean }) { + return ( +
+ 0 ? ' mcp-dash__statval--warn' : ''}`}>{value} + {label} +
+ ) +} + +export function DashboardView({ state }: { state: StateSnapshot }) { + const [entries, setEntries] = useState([]) + const [summary, setSummary] = useState<{ total: number; errors: number }>({ total: 0, errors: 0 }) + + const load = useCallback(async (): Promise => { + const res = await fetchActivity(FEED_LIMIT) + if (res.ok && res.entries) { + setEntries(res.entries) + if (res.summary) setSummary(res.summary) + } + }, []) + + useEffect(() => { + void load() + const timer = setInterval(() => { + if (document.visibilityState === 'visible') void load() + }, POLL_MS) + const onVisibility = (): void => { + if (document.visibilityState === 'visible') void load() + } + document.addEventListener('visibilitychange', onVisibility) + return () => { + clearInterval(timer) + document.removeEventListener('visibilitychange', onVisibility) + } + }, [load]) + + const servers = state.servers + const totalTools = servers.reduce((n, s) => n + s.tools.length, 0) + const running = servers.filter((s) => s.status === 'running').length + const degraded = servers.filter((s) => s.status === 'degraded').length + const needsAuth = servers.filter((s) => s.status === 'needs-auth').length + + return ( +
+
+ + Endpoint live + + {state.endpoint.url} + + + {servers.length} servers · {totalTools} tools + +
+ +
+ + + + + + +
+ + {servers.length === 0 ? ( +
+ No servers yet. Add a server, or open Discover to browse the registry. +
+ ) : ( + <> +
Servers
+
+ {servers.map((server) => { + const meta = serverMeta(server) + return ( +
+ + + {server.name} + + + {meta.text} + +
+ ) + })} +
+ + )} + +
+ Activity recent +
+ {entries.length === 0 ? ( +
No calls yet — connect an agent to the endpoint.
+ ) : ( +
+ {entries.map((e, i) => ( +
+ {new Date(e.at).toLocaleTimeString()} + + {e.server}__{e.tool} + + {e.client && {e.client}} + {e.durationMs}ms + + {e.isError ? 'err' : 'ok'} + +
+ ))} +
+ )} +
+ ) +} diff --git a/extensions/cate.mcp/src/public/components/EndpointView.tsx b/extensions/cate.mcp/src/public/components/EndpointView.tsx index 3365854..54697ce 100644 --- a/extensions/cate.mcp/src/public/components/EndpointView.tsx +++ b/extensions/cate.mcp/src/public/components/EndpointView.tsx @@ -1,13 +1,89 @@ // Endpoint view: the aggregated MCP endpoint as two definition rows (URL and -// Authorization header, each copyable) and exactly one muted hint line. +// Authorization header, each copyable), then a one-click install of that +// endpoint into each supported coding agent's workspace-local config. +// +// The endpoint only serves while this panel is open, and its port/token are +// reminted each Cate session — so the install is a snapshot. We say the first +// out loud, and flag a stale entry (port changed since install) with an Update +// action rather than pretending it still points somewhere live. -import { useState } from 'react' -import type { EndpointInfo } from '../../shared/types' +import { useCallback, useEffect, useState } from 'react' +import type { AgentTargetStatus, EndpointInfo } from '../../shared/types' +import { fetchAgents, installAgent, uninstallAgent } from '../api' import { CopyIconButton, EyeIcon, EyeOffIcon } from './util' +function AgentRow({ + agent, + busy, + onInstall, + onRemove, +}: { + agent: AgentTargetStatus + busy: boolean + onInstall: () => void + onRemove: () => void +}) { + return ( +
+ {agent.label} + + {agent.supported ? agent.path : agent.reason} + + + {!agent.supported ? ( + + Unavailable + + ) : agent.installed ? ( + <> + {agent.stale && ( + + )} + {!agent.stale && Installed} + + + ) : ( + + )} + +
+ ) +} + export function EndpointView({ endpoint }: { endpoint: EndpointInfo }) { const [revealed, setRevealed] = useState(false) + const [agents, setAgents] = useState(null) + const [busyId, setBusyId] = useState(null) + const [error, setError] = useState(null) const masked = endpoint.authHeader.replace(/Bearer .*/, 'Bearer ••••••••') + + const load = useCallback(async (): Promise => { + const res = await fetchAgents() + if (res.ok && res.agents) setAgents(res.agents) + else setError(res.error ?? 'could not load agents') + }, []) + + // Reload whenever the endpoint URL changes too: a new session mints a new + // port, which turns a previously matching install stale. + useEffect(() => { + void load() + }, [load, endpoint.url]) + + async function act(id: string, fn: () => Promise<{ ok: boolean; error?: string }>): Promise { + setBusyId(id) + setError(null) + const res = await fn() + if (!res.ok) setError(res.error ?? 'action failed') + await load() + setBusyId(null) + } + return (
@@ -31,6 +107,22 @@ export function EndpointView({ endpoint }: { endpoint: EndpointInfo }) {
Any MCP client can connect here.
+ +
Install into agents
+ {error &&
{error}
} + {agents && ( +
+ {agents.map((agent) => ( + void act(agent.id, () => installAgent(agent.id))} + onRemove={() => void act(agent.id, () => uninstallAgent(agent.id))} + /> + ))} +
+ )}
) } diff --git a/extensions/cate.mcp/src/public/components/Sidebar.tsx b/extensions/cate.mcp/src/public/components/Sidebar.tsx index eafc7fb..a343351 100644 --- a/extensions/cate.mcp/src/public/components/Sidebar.tsx +++ b/extensions/cate.mcp/src/public/components/Sidebar.tsx @@ -4,8 +4,8 @@ import { useState } from 'react' import type { StateSnapshot } from '../../shared/types' -import type { View } from '../main' -import { BracesIcon, BroadcastIcon, CompassIcon, PlusIcon, SidebarIcon, StatusDot, openConfigFile } from './util' +import type { View } from '../view' +import { BracesIcon, BroadcastIcon, CompassIcon, DashboardIcon, PlusIcon, SidebarIcon, StatusDot, openConfigFile } from './util' const FILTER_THRESHOLD = 8 @@ -66,6 +66,16 @@ export function Sidebar({ ))}
+
)} + {effective.kind === 'dashboard' && } {effective.kind === 'discover' && ( setDrawer({ kind: 'add', prefill })} diff --git a/extensions/cate.mcp/src/public/styles.css b/extensions/cate.mcp/src/public/styles.css index 995929f..dbd0452 100644 --- a/extensions/cate.mcp/src/public/styles.css +++ b/extensions/cate.mcp/src/public/styles.css @@ -598,6 +598,50 @@ white-space: nowrap; } +/* Install-into-agents list on the Endpoint screen: label, muted path, actions. */ +.mcp-agents { + display: flex; + flex-direction: column; + gap: 1px; +} +.mcp-agentrow { + display: flex; + align-items: center; + gap: 10px; + min-height: 32px; + padding: 2px 8px; + border-radius: var(--cate-radius-sm); + min-width: 0; +} +.mcp-agentrow:hover { + background: var(--cate-hover); +} +.mcp-agentrow__label { + flex: 0 0 auto; + width: 110px; + font-size: 13px; + color: var(--cate-fg); +} +.mcp-agentrow__path { + flex: 1 1 auto; + min-width: 0; + color: var(--cate-muted); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mcp-agentrow__actions { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 6px; +} +.mcp-agentrow__ok { + color: var(--cate-success); + font-size: 12px; +} + /* --- modal (agent popover spec: surface-3, strong hairline, 12px radius, deep shadow) — centered over a scrim, capped to the panel viewport ---------------------- */ @@ -688,6 +732,178 @@ background: var(--cate-hover-strong); } +/* --- dashboard (default view) ----------------------------------------------------------- */ + +.mcp-dash { + gap: 12px; +} + +/* Endpoint strip: live dot + label + url, with a server/tool count pinned right. */ +.mcp-dash__strip { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 1px solid var(--cate-border); + border-radius: var(--cate-radius); + background: var(--cate-bg-elev); +} +.mcp-dash__striplabel { + flex: 0 0 auto; + font-size: 12px; + font-weight: 500; + color: var(--cate-fg); +} +.mcp-dash__stripurl { + flex: 1 1 auto; + min-width: 0; + color: var(--cate-muted); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mcp-dash__stripcount { + flex: 0 0 auto; + color: var(--cate-fg-secondary); + font-size: 11px; +} + +/* Stat row: compact figure tiles that reflow to the panel width. */ +.mcp-dash__stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(88px, 1fr)); + gap: 6px; +} +.mcp-dash__stat { + display: flex; + flex-direction: column; + gap: 3px; + padding: 8px 10px; + border: 1px solid var(--cate-border); + border-radius: var(--cate-radius-sm); + background: var(--cate-bg-elev); +} +.mcp-dash__statval { + font-size: 18px; + font-weight: 500; + line-height: 1; + color: var(--cate-fg); + font-variant-numeric: tabular-nums; +} +.mcp-dash__statval--warn { + color: var(--cate-warning); +} +.mcp-dash__statlabel { + font-size: 10.5px; + color: var(--cate-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* Servers health grid. */ +.mcp-dash__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 4px; +} +.mcp-dash__server { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 6px 10px; + border: 1px solid var(--cate-border); + border-radius: var(--cate-radius-sm); + background: var(--cate-bg-elev); +} +.mcp-dash__servername { + flex: 1 1 auto; + min-width: 0; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mcp-dash__servermeta { + flex: 0 0 auto; + font-size: 11px; + color: var(--cate-muted); +} +.mcp-dash__servermeta--warn { + color: var(--cate-warning); +} + +/* Activity feed rows: time · server__tool · caller chip · duration · ok/err. */ +.mcp-dash__note { + color: var(--cate-muted); + font-size: 10px; + font-weight: 400; + text-transform: none; + letter-spacing: 0; +} +.mcp-dash__feed { + display: flex; + flex-direction: column; + gap: 1px; +} +.mcp-dash__row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 3px 8px; + border-radius: var(--cate-radius-sm); +} +.mcp-dash__row:hover { + background: var(--cate-hover); +} +.mcp-dash__time { + flex: 0 0 auto; + color: var(--cate-muted); + font-size: 11px; + font-variant-numeric: tabular-nums; +} +.mcp-dash__tool { + flex: 1 1 auto; + min-width: 0; + font-size: 12px; + color: var(--cate-fg); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mcp-dash__chip { + flex: 0 0 auto; + padding: 1px 6px; + border-radius: var(--cate-radius-xs); + background: var(--cate-hover); + color: var(--cate-fg-secondary); + font-size: 10px; +} +.mcp-dash__dur { + flex: 0 0 auto; + color: var(--cate-muted); + font-size: 11px; + font-variant-numeric: tabular-nums; +} +.mcp-dash__result { + flex: 0 0 auto; + width: 26px; + text-align: right; + font-size: 11px; + color: var(--cate-success); +} +.mcp-dash__result--err { + color: var(--cate-danger); +} +.mcp-dash__empty, +.mcp-dash__hint { + color: var(--cate-muted); + font-size: 12px; + padding: 6px 2px; +} + /* --- misc ------------------------------------------------------------------------------- */ .mcp-muted { diff --git a/extensions/cate.mcp/src/public/view.test.ts b/extensions/cate.mcp/src/public/view.test.ts new file mode 100644 index 0000000..82ece13 --- /dev/null +++ b/extensions/cate.mcp/src/public/view.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { parseView, serializeView, type View } from './view' + +describe('view (de)serialization', () => { + it('defaults to Dashboard for an unset, legacy, or unparseable saved value', () => { + expect(parseView(undefined)).toBeNull() // -> caller falls back to Dashboard + expect(parseView('servers')).toBeNull() // legacy value + expect(parseView('garbage')).toBeNull() + expect(parseView(42)).toBeNull() + }) + + it('restores an explicitly-saved selection', () => { + expect(parseView('dashboard')).toEqual({ kind: 'dashboard' }) + expect(parseView('discover')).toEqual({ kind: 'discover' }) + expect(parseView('endpoint')).toEqual({ kind: 'endpoint' }) + expect(parseView('server:linear')).toEqual({ kind: 'server', name: 'linear' }) + }) + + it('round-trips through serializeView', () => { + const views: View[] = [ + { kind: 'dashboard' }, + { kind: 'discover' }, + { kind: 'endpoint' }, + { kind: 'server', name: 'fs' }, + ] + for (const v of views) expect(parseView(serializeView(v))).toEqual(v) + }) +}) diff --git a/extensions/cate.mcp/src/public/view.ts b/extensions/cate.mcp/src/public/view.ts new file mode 100644 index 0000000..33b7daa --- /dev/null +++ b/extensions/cate.mcp/src/public/view.ts @@ -0,0 +1,21 @@ +// Which pane the panel shows, plus its storage-persisted (de)serialization. +// Kept out of main.tsx so the pure logic is unit-testable without booting the +// React root. Dashboard is the default: an unset or unparseable saved value +// resolves to it (never auto-select-first-server). + +export type View = + | { kind: 'dashboard' } + | { kind: 'server'; name: string } + | { kind: 'discover' } + | { kind: 'endpoint' } + +export function serializeView(view: View): string { + return view.kind === 'server' ? `server:${view.name}` : view.kind +} + +export function parseView(raw: unknown): View | null { + if (typeof raw !== 'string') return null + if (raw === 'dashboard' || raw === 'discover' || raw === 'endpoint') return { kind: raw } + if (raw.startsWith('server:')) return { kind: 'server', name: raw.slice('server:'.length) } + return null // legacy 'servers'/unknown value falls through to the Dashboard default +} diff --git a/extensions/cate.mcp/src/server/activity.test.ts b/extensions/cate.mcp/src/server/activity.test.ts new file mode 100644 index 0000000..8963a9b --- /dev/null +++ b/extensions/cate.mcp/src/server/activity.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { ActivityLog } from './activity' + +function entry(tool: string, isError = false) { + return { at: Date.now(), server: 's', tool, durationMs: 1, isError } +} + +describe('ActivityLog', () => { + it('bounds the ring to 200 entries, dropping the oldest', () => { + const log = new ActivityLog() + for (let i = 0; i < 250; i++) log.record(entry(`t${i}`)) + const recent = log.recent() + expect(recent.length).toBe(200) + // newest-first: t249 first, oldest surviving is t50 (t0..t49 evicted) + expect(recent[0].tool).toBe('t249') + expect(recent[recent.length - 1].tool).toBe('t50') + }) + + it('recent() is newest-first and honors the limit', () => { + const log = new ActivityLog() + log.record(entry('a')) + log.record(entry('b')) + log.record(entry('c')) + expect(log.recent().map((e) => e.tool)).toEqual(['c', 'b', 'a']) + expect(log.recent(2).map((e) => e.tool)).toEqual(['c', 'b']) + }) + + it('summary() counts totals and errors over the ring', () => { + const log = new ActivityLog() + log.record(entry('a')) + log.record(entry('b', true)) + log.record(entry('c', true)) + expect(log.summary()).toEqual({ total: 3, errors: 2 }) + }) +}) diff --git a/extensions/cate.mcp/src/server/activity.ts b/extensions/cate.mcp/src/server/activity.ts new file mode 100644 index 0000000..9cfb8da --- /dev/null +++ b/extensions/cate.mcp/src/server/activity.ts @@ -0,0 +1,31 @@ +// A bounded ring buffer of tool calls that flowed through the aggregated /mcp +// endpoint, for the panel's Activity feed. Deliberately in-memory and lossy: +// the last N calls only, never persisted, and recording MUST NOT bump the +// manager serial (it is read on its own /api/activity poll, not /api/state). + +import type { ActivityEntry } from '../shared/types' + +const MAX_ENTRIES = 200 + +export class ActivityLog { + private readonly entries: ActivityEntry[] = [] + + constructor(private readonly max: number = MAX_ENTRIES) {} + + record(entry: ActivityEntry): void { + this.entries.push(entry) + if (this.entries.length > this.max) this.entries.shift() + } + + /** Newest first; capped to `limit` when given. */ + recent(limit?: number): ActivityEntry[] { + const newestFirst = this.entries.slice().reverse() + return limit === undefined ? newestFirst : newestFirst.slice(0, limit) + } + + summary(): { total: number; errors: number } { + let errors = 0 + for (const e of this.entries) if (e.isError) errors++ + return { total: this.entries.length, errors } + } +} diff --git a/extensions/cate.mcp/src/server/agent-install.test.ts b/extensions/cate.mcp/src/server/agent-install.test.ts new file mode 100644 index 0000000..4453df2 --- /dev/null +++ b/extensions/cate.mcp/src/server/agent-install.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import fs from 'fs' +import os from 'os' +import path from 'path' +import { AgentInstaller, ENTRY_NAME } from './agent-install' + +let tmp: string +const EP = { url: 'http://127.0.0.1:40000/mcp', token: 'tok-abc' } +const EP2 = { url: 'http://127.0.0.1:59999/mcp', token: 'tok-xyz' } + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cate-mcp-agent-')) +}) +afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }) +}) + +function read(rel: string): string { + return fs.readFileSync(path.join(tmp, rel), 'utf8') +} +function write(rel: string, text: string): void { + const file = path.join(tmp, rel) + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, text) +} +function statusOf(inst: AgentInstaller, id: string) { + return inst.list(EP).find((a) => a.id === id)! +} + +describe('AgentInstaller.list', () => { + it('lists all agents; two are unsupported with a reason', () => { + const agents = new AgentInstaller(tmp).list(EP) + expect(agents.map((a) => a.id).sort()).toEqual( + ['antigravity', 'cate-agent', 'claude-code', 'codex', 'cursor', 'opencode', 'pi'].sort(), + ) + const anti = agents.find((a) => a.id === 'antigravity')! + const pi = agents.find((a) => a.id === 'pi')! + expect(anti.supported).toBe(false) + expect(anti.reason).toBeTruthy() + expect(pi.supported).toBe(false) + expect(agents.filter((a) => a.supported).map((a) => a.id).sort()).toEqual( + ['cate-agent', 'claude-code', 'codex', 'cursor', 'opencode'], + ) + }) + + it('nothing installed on a clean workspace', () => { + for (const a of new AgentInstaller(tmp).list(EP)) expect(a.installed).toBe(false) + }) +}) + +describe('Claude Code (.mcp.json)', () => { + it('installs an http server, preserving unknown keys', () => { + write('.mcp.json', JSON.stringify({ mcpServers: { other: { command: 'x' } }, custom: 1 }, null, 2)) + const inst = new AgentInstaller(tmp) + expect(inst.install('claude-code', EP)).toEqual({ ok: true }) + const doc = JSON.parse(read('.mcp.json')) + expect(doc.custom).toBe(1) + expect(doc.mcpServers.other).toEqual({ command: 'x' }) + expect(doc.mcpServers[ENTRY_NAME]).toEqual({ + type: 'http', + url: EP.url, + headers: { Authorization: `Bearer ${EP.token}` }, + }) + expect(statusOf(inst, 'claude-code').installed).toBe(true) + }) + + it('creates the file when absent', () => { + const inst = new AgentInstaller(tmp) + expect(inst.install('claude-code', EP)).toEqual({ ok: true }) + expect(JSON.parse(read('.mcp.json')).mcpServers[ENTRY_NAME].url).toBe(EP.url) + }) + + it('flags a stale install when the endpoint url changed', () => { + const inst = new AgentInstaller(tmp) + inst.install('claude-code', EP) + const stale = inst.list(EP2).find((a) => a.id === 'claude-code')! + expect(stale.installed).toBe(true) + expect(stale.stale).toBe(true) + // Re-install against the new endpoint clears staleness. + inst.install('claude-code', EP2) + expect(inst.list(EP2).find((a) => a.id === 'claude-code')!.stale).toBe(false) + }) + + it('uninstall removes only our entry', () => { + write('.mcp.json', JSON.stringify({ mcpServers: { other: { command: 'x' } } })) + const inst = new AgentInstaller(tmp) + inst.install('claude-code', EP) + expect(inst.uninstall('claude-code')).toEqual({ ok: true }) + const doc = JSON.parse(read('.mcp.json')) + expect(doc.mcpServers[ENTRY_NAME]).toBeUndefined() + expect(doc.mcpServers.other).toEqual({ command: 'x' }) + }) + + it('refuses to clobber invalid JSON', () => { + write('.mcp.json', '{ not json') + const res = new AgentInstaller(tmp).install('claude-code', EP) + expect(res.ok).toBe(false) + expect(read('.mcp.json')).toBe('{ not json') + }) +}) + +describe('Cate Agent (.pi/mcp.json, read by pi-mcp-adapter)', () => { + it('installs a url entry the adapter can read', () => { + const inst = new AgentInstaller(tmp) + expect(inst.install('cate-agent', EP)).toEqual({ ok: true }) + const entry = JSON.parse(read('.pi/mcp.json')).mcpServers[ENTRY_NAME] + expect(entry).toEqual({ url: EP.url, headers: { Authorization: `Bearer ${EP.token}` } }) + expect(statusOf(inst, 'cate-agent').installed).toBe(true) + }) +}) + +describe('Cursor (.cursor/mcp.json)', () => { + it('installs a url entry without a type key', () => { + const inst = new AgentInstaller(tmp) + expect(inst.install('cursor', EP)).toEqual({ ok: true }) + const entry = JSON.parse(read('.cursor/mcp.json')).mcpServers[ENTRY_NAME] + expect(entry).toEqual({ url: EP.url, headers: { Authorization: `Bearer ${EP.token}` } }) + expect(entry.type).toBeUndefined() + }) +}) + +describe('OpenCode (opencode.json)', () => { + it('installs under the mcp block with a fresh $schema', () => { + const inst = new AgentInstaller(tmp) + expect(inst.install('opencode', EP)).toEqual({ ok: true }) + const doc = JSON.parse(read('opencode.json')) + expect(doc.$schema).toBe('https://opencode.ai/config.json') + expect(doc.mcp[ENTRY_NAME]).toEqual({ + type: 'remote', + url: EP.url, + enabled: true, + headers: { Authorization: `Bearer ${EP.token}` }, + }) + }) + + it('does not overwrite an existing $schema', () => { + write('opencode.json', JSON.stringify({ $schema: 'custom', model: 'x' })) + const inst = new AgentInstaller(tmp) + inst.install('opencode', EP) + const doc = JSON.parse(read('opencode.json')) + expect(doc.$schema).toBe('custom') + expect(doc.model).toBe('x') + }) +}) + +describe('Codex (.codex/config.toml)', () => { + it('appends a block, preserving existing content', () => { + write('.codex/config.toml', 'model = "gpt-5"\n\n[mcp_servers.other]\nurl = "http://x"\n') + const inst = new AgentInstaller(tmp) + expect(inst.install('codex', EP)).toEqual({ ok: true }) + const text = read('.codex/config.toml') + expect(text).toContain('model = "gpt-5"') + expect(text).toContain('[mcp_servers.other]') + expect(text).toContain(`[mcp_servers.${ENTRY_NAME}]`) + expect(text).toContain(`url = "${EP.url}"`) + expect(text).toContain(`http_headers = { "Authorization" = "Bearer ${EP.token}" }`) + expect(statusOf(inst, 'codex').installed).toBe(true) + }) + + it('replaces our block in place on re-install (no duplicate)', () => { + const inst = new AgentInstaller(tmp) + inst.install('codex', EP) + inst.install('codex', EP2) + const text = read('.codex/config.toml') + expect(text.match(/\[mcp_servers\.cate\]/g)!.length).toBe(1) + expect(text).toContain(EP2.url) + expect(text).not.toContain(EP.url) + }) + + it('detects staleness from the url line', () => { + const inst = new AgentInstaller(tmp) + inst.install('codex', EP) + expect(inst.list(EP2).find((a) => a.id === 'codex')!.stale).toBe(true) + }) + + it('uninstall drops our block but keeps the rest', () => { + write('.codex/config.toml', 'model = "gpt-5"\n') + const inst = new AgentInstaller(tmp) + inst.install('codex', EP) + expect(inst.uninstall('codex')).toEqual({ ok: true }) + const text = read('.codex/config.toml') + expect(text).toContain('model = "gpt-5"') + expect(text).not.toContain('[mcp_servers.cate]') + }) +}) + +describe('unsupported agents', () => { + it('install/uninstall reject with the reason and write nothing', () => { + const inst = new AgentInstaller(tmp) + const res = inst.install('antigravity', EP) + expect(res.ok).toBe(false) + const pi = inst.install('pi', EP) + expect(pi.ok).toBe(false) + expect(fs.readdirSync(tmp)).toHaveLength(0) + }) + + it('unknown agent id is an error', () => { + expect(new AgentInstaller(tmp).install('nope', EP).ok).toBe(false) + }) +}) diff --git a/extensions/cate.mcp/src/server/agent-install.ts b/extensions/cate.mcp/src/server/agent-install.ts new file mode 100644 index 0000000..0e0f8da --- /dev/null +++ b/extensions/cate.mcp/src/server/agent-install.ts @@ -0,0 +1,342 @@ +// ============================================================================= +// Install the aggregated /mcp endpoint into a coding agent's WORKSPACE-LOCAL +// config file, one click from the panel's Endpoint screen. +// +// Each supported agent stores MCP servers in a different file and shape, so an +// adapter owns three pure operations over the file's text: detect (is our entry +// there, and at what URL), install (upsert our entry, preserving every other +// key), and remove (delete just our entry). The AgentInstaller does the FS: read +// the file, run the adapter, write atomically. +// +// We only write INSIDE the workspace. Two agents Cate supports have no working +// workspace-local HTTP MCP config today (Antigravity reads only a global +// ~/.gemini file; PI has no built-in MCP), so they appear in the list as +// `supported: false` with a reason rather than being silently skipped or, worse, +// handed a config their tool ignores. +// +// The endpoint we write is a SNAPSHOT: the extension server's loopback port and +// bearer token are minted fresh each Cate session, so an install goes stale on +// the next restart. `detect` returns the stored URL so the panel can flag a +// stale entry and offer "Update"; the endpoint only serves while this panel is +// open, which the panel says out loud. +// ============================================================================= + +import fs from 'fs' +import path from 'path' +import type { AgentTargetStatus } from '../shared/types' + +/** The server name we write into every agent config (recognisable, stable). */ +export const ENTRY_NAME = 'cate' + +export interface Endpoint { + url: string + token: string +} + +interface DetectResult { + installed: boolean + /** URL of our entry when installed, for stale detection. */ + url?: string +} + +type OpResult = { ok: true; text: string } | { ok: false; error: string } + +interface Adapter { + id: string + label: string + /** Workspace-relative config path (POSIX separators, for display + FS join). */ + relPath: string + /** False when this agent can't take a workspace-local HTTP MCP config. */ + supported: boolean + /** Shown as a tooltip when supported is false. */ + reason?: string + detect: (text: string | null) => DetectResult + install: (text: string | null, ep: Endpoint) => OpResult + remove: (text: string | null) => OpResult +} + +// --- JSON helpers ----------------------------------------------------------- + +type JsonObject = Record + +function isObject(v: unknown): v is JsonObject { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + +/** Parse existing file text into a mutable object, or an error. Empty/missing + * starts a fresh document. */ +function parseJsonDoc(text: string | null): { ok: true; doc: JsonObject } | { ok: false; error: string } { + if (text === null || text.trim() === '') return { ok: true, doc: {} } + let root: unknown + try { + root = JSON.parse(text) + } catch (err) { + return { ok: false, error: `not valid JSON: ${err instanceof Error ? err.message : String(err)}` } + } + if (!isObject(root)) return { ok: false, error: 'config root must be a JSON object' } + return { ok: true, doc: root } +} + +function serializeJson(doc: JsonObject): string { + return JSON.stringify(doc, null, 2) + '\n' +} + +function bearer(token: string): Record { + return { Authorization: `Bearer ${token}` } +} + +/** Adapter over a JSON file that keys MCP servers under `sectionKey` and, for + * our entry, builds the per-agent shape via `buildEntry`. Covers Claude Code + * (mcpServers) and OpenCode (mcp) — they differ only in the section key, the + * entry shape, and whether a fresh file gets a `$schema`. */ +function jsonAdapter(opts: { + id: string + label: string + relPath: string + sectionKey: string + buildEntry: (ep: Endpoint) => JsonObject + /** Applied only when creating the file from scratch (e.g. opencode $schema). */ + freshDefaults?: JsonObject +}): Adapter { + const section = (doc: JsonObject): JsonObject => { + if (!isObject(doc[opts.sectionKey])) doc[opts.sectionKey] = {} + return doc[opts.sectionKey] as JsonObject + } + return { + id: opts.id, + label: opts.label, + relPath: opts.relPath, + supported: true, + detect: (text) => { + const parsed = parseJsonDoc(text) + if (!parsed.ok) return { installed: false } + const sec = parsed.doc[opts.sectionKey] + const entry = isObject(sec) ? sec[ENTRY_NAME] : undefined + if (!isObject(entry)) return { installed: false } + return { installed: true, url: typeof entry.url === 'string' ? entry.url : undefined } + }, + install: (text, ep) => { + const fresh = text === null || text.trim() === '' + const parsed = parseJsonDoc(text) + if (!parsed.ok) return { ok: false, error: parsed.error } + const doc = parsed.doc + if (fresh && opts.freshDefaults) { + for (const [k, v] of Object.entries(opts.freshDefaults)) if (!(k in doc)) doc[k] = v + } + section(doc)[ENTRY_NAME] = opts.buildEntry(ep) + return { ok: true, text: serializeJson(doc) } + }, + remove: (text) => { + const parsed = parseJsonDoc(text) + if (!parsed.ok) return { ok: false, error: parsed.error } + const sec = parsed.doc[opts.sectionKey] + if (isObject(sec) && ENTRY_NAME in sec) delete sec[ENTRY_NAME] + return { ok: true, text: serializeJson(parsed.doc) } + }, + } +} + +// --- Codex TOML adapter ----------------------------------------------------- +// Codex config is TOML. Rather than pull in a TOML library to round-trip an +// entire user file, we edit only our own `[mcp_servers.cate]` block as text: +// replace it in place if present, else append it. Detection and URL extraction +// are scoped to that block. Every other line is left byte-for-byte untouched. + +const CODEX_HEADER = `[mcp_servers.${ENTRY_NAME}]` + +/** [start,end) line indices of our TOML block (header line through the line + * before the next `[` table header or EOF), or null if absent. */ +function findCodexBlock(lines: string[]): { start: number; end: number } | null { + const start = lines.findIndex((l) => l.trim() === CODEX_HEADER) + if (start === -1) return null + let end = lines.length + for (let i = start + 1; i < lines.length; i++) { + if (lines[i].trimStart().startsWith('[')) { + end = i + break + } + } + return { start, end } +} + +function codexBlock(ep: Endpoint): string { + // TOML basic strings share JSON's escaping for the safe chars in a URL/token. + return [ + CODEX_HEADER, + `url = ${JSON.stringify(ep.url)}`, + `http_headers = { "Authorization" = ${JSON.stringify(`Bearer ${ep.token}`)} }`, + ].join('\n') +} + +const codexAdapter: Adapter = { + id: 'codex', + label: 'Codex', + relPath: '.codex/config.toml', + supported: true, + detect: (text) => { + if (text === null) return { installed: false } + const lines = text.split('\n') + const block = findCodexBlock(lines) + if (!block) return { installed: false } + for (let i = block.start + 1; i < block.end; i++) { + const m = lines[i].match(/^\s*url\s*=\s*"([^"]*)"/) + if (m) return { installed: true, url: m[1] } + } + return { installed: true } + }, + install: (text, ep) => { + const block = codexBlock(ep) + if (text === null || text.trim() === '') return { ok: true, text: block + '\n' } + const lines = text.split('\n') + const found = findCodexBlock(lines) + if (found) { + lines.splice(found.start, found.end - found.start, ...block.split('\n')) + return { ok: true, text: lines.join('\n') } + } + const sep = text.endsWith('\n') ? '\n' : '\n\n' + return { ok: true, text: text.replace(/\n*$/, '') + sep + '\n' + block + '\n' } + }, + remove: (text) => { + if (text === null) return { ok: true, text: '' } + const lines = text.split('\n') + const found = findCodexBlock(lines) + if (!found) return { ok: true, text } + // Drop the block plus one trailing blank separator line, if any. + let end = found.end + if (lines[end] !== undefined && lines[end].trim() === '') end++ + lines.splice(found.start, end - found.start) + return { ok: true, text: lines.join('\n').replace(/\n{3,}/g, '\n\n') } + }, +} + +// --- unsupported placeholders ---------------------------------------------- + +function unsupported(id: string, label: string, relPath: string, reason: string): Adapter { + const no = (): OpResult => ({ ok: false, error: reason }) + return { + id, + label, + relPath, + supported: false, + reason, + detect: () => ({ installed: false }), + install: no, + remove: no, + } +} + +// --- registry --------------------------------------------------------------- + +const ADAPTERS: Adapter[] = [ + // Cate's own in-app agent (pi). pi has no native MCP, so this targets the + // `pi-mcp-adapter` package's project-local pi config (`.pi/mcp.json`): a + // top-level mcpServers map where a remote server is `url` + `headers`. The + // adapter reads it on session start and proxies each server's tools. + jsonAdapter({ + id: 'cate-agent', + label: 'Cate Agent', + relPath: '.pi/mcp.json', + sectionKey: 'mcpServers', + buildEntry: (ep) => ({ url: ep.url, headers: bearer(ep.token) }), + }), + jsonAdapter({ + id: 'claude-code', + label: 'Claude Code', + relPath: '.mcp.json', + sectionKey: 'mcpServers', + buildEntry: (ep) => ({ type: 'http', url: ep.url, headers: bearer(ep.token) }), + }), + jsonAdapter({ + id: 'cursor', + label: 'Cursor', + relPath: '.cursor/mcp.json', + sectionKey: 'mcpServers', + // Cursor infers HTTP from the presence of `url`; no `type` key needed. + buildEntry: (ep) => ({ url: ep.url, headers: bearer(ep.token) }), + }), + jsonAdapter({ + id: 'opencode', + label: 'OpenCode', + relPath: 'opencode.json', + sectionKey: 'mcp', + buildEntry: (ep) => ({ type: 'remote', url: ep.url, enabled: true, headers: bearer(ep.token) }), + freshDefaults: { $schema: 'https://opencode.ai/config.json' }, + }), + codexAdapter, + unsupported( + 'antigravity', + 'Antigravity', + '~/.gemini/config/mcp_config.json', + 'Antigravity only reads a global (~/.gemini) MCP config; it ignores a workspace-local file, so there is nothing to install here.', + ), + unsupported( + 'pi', + 'PI Agent', + '—', + 'PI has no built-in MCP support yet.', + ), +] + +export class AgentInstaller { + constructor(private readonly workspaceRoot: string) {} + + private file(a: Adapter): string { + return path.join(this.workspaceRoot, ...a.relPath.split('/')) + } + + private readText(a: Adapter): string | null { + try { + return fs.readFileSync(this.file(a), 'utf8') + } catch { + return null + } + } + + /** Current install status for every agent, against `ep` (to flag staleness). */ + list(ep: Endpoint): AgentTargetStatus[] { + return ADAPTERS.map((a) => { + const det = a.supported ? a.detect(this.readText(a)) : { installed: false } + return { + id: a.id, + label: a.label, + path: a.relPath, + supported: a.supported, + reason: a.reason, + installed: det.installed, + stale: det.installed && det.url !== undefined && det.url !== ep.url, + } + }) + } + + install(id: string, ep: Endpoint): { ok: true } | { ok: false; error: string } { + const a = ADAPTERS.find((x) => x.id === id) + if (!a) return { ok: false, error: `unknown agent "${id}"` } + if (!a.supported) return { ok: false, error: a.reason ?? 'not supported' } + const res = a.install(this.readText(a), ep) + if (!res.ok) return { ok: false, error: `could not edit ${a.relPath}: ${res.error}` } + return this.write(a, res.text) + } + + uninstall(id: string): { ok: true } | { ok: false; error: string } { + const a = ADAPTERS.find((x) => x.id === id) + if (!a) return { ok: false, error: `unknown agent "${id}"` } + if (!a.supported) return { ok: false, error: a.reason ?? 'not supported' } + const res = a.remove(this.readText(a)) + if (!res.ok) return { ok: false, error: `could not edit ${a.relPath}: ${res.error}` } + return this.write(a, res.text) + } + + /** Atomic temp+rename write, creating parent dirs. */ + private write(a: Adapter, text: string): { ok: true } | { ok: false; error: string } { + const file = this.file(a) + try { + fs.mkdirSync(path.dirname(file), { recursive: true }) + const tmp = `${file}.cate-${process.pid}.tmp` + fs.writeFileSync(tmp, text, 'utf8') + fs.renameSync(tmp, file) + return { ok: true } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } + } +} diff --git a/extensions/cate.mcp/src/server/aggregate.test.ts b/extensions/cate.mcp/src/server/aggregate.test.ts index 52da205..8d57255 100644 --- a/extensions/cate.mcp/src/server/aggregate.test.ts +++ b/extensions/cate.mcp/src/server/aggregate.test.ts @@ -12,6 +12,7 @@ import { ToolListChangedNotificationSchema, } from '@modelcontextprotocol/sdk/types.js' import { createAggregateServer, type AggregateSource, type UpstreamConnection } from './aggregate' +import { ActivityLog } from './activity' interface TestUpstream extends UpstreamConnection { close(): Promise @@ -71,7 +72,7 @@ async function makeUpstream(name: string): Promise { } async function makeAggregateClient(source: AggregateSource): Promise { - const aggServer = createAggregateServer(source) + const aggServer = createAggregateServer(source, new ActivityLog()) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() await aggServer.connect(serverTransport) const client = new Client({ name: 'agg-client', version: '0' }) @@ -161,7 +162,7 @@ describe('unified endpoint aggregation', () => { it('declares listChanged and can push the notification to a session', async () => { const a = await makeUpstream('alpha') - const aggServer = createAggregateServer({ activeConnections: () => [a] }) + const aggServer = createAggregateServer({ activeConnections: () => [a] }, new ActivityLog()) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() await aggServer.connect(serverTransport) const client = new Client({ name: 'agg-client', version: '0' }) diff --git a/extensions/cate.mcp/src/server/aggregate.ts b/extensions/cate.mcp/src/server/aggregate.ts index 59dc897..bef1946 100644 --- a/extensions/cate.mcp/src/server/aggregate.ts +++ b/extensions/cate.mcp/src/server/aggregate.ts @@ -34,6 +34,7 @@ import { import type { Client } from '@modelcontextprotocol/sdk/client/index.js' import { namespaceName, splitNamespacedName } from '../shared/naming' import type { PromptInfo, ResourceInfo, ToolInfo } from '../shared/types' +import type { ActivityLog } from './activity' /** What the aggregator needs from a managed server (subset of ManagedConnection). */ export interface UpstreamConnection { @@ -60,7 +61,7 @@ function upstreamByName(source: AggregateSource, namespaced: string): { conn: Up } /** Build one aggregating MCP Server instance (one per client session). */ -export function createAggregateServer(source: AggregateSource): Server { +export function createAggregateServer(source: AggregateSource, activityLog: ActivityLog): Server { const server = new Server( { name: 'cate-mcp-aggregate', version: '1.0.0' }, { @@ -92,16 +93,21 @@ export function createAggregateServer(source: AggregateSource): Server { const { conn, item } = upstreamByName(source, request.params.name) const known = conn.tools.some((t) => t.name === item) if (!known) throw new McpError(ErrorCode.InvalidParams, `server "${conn.name}" has no tool "${item}"`) + const started = Date.now() + // Best-effort caller attribution from the MCP clientInfo captured at init. + const client = server.getClientVersion()?.name try { - const result = await conn.activeClient!.callTool( + const result = (await conn.activeClient!.callTool( { name: item, arguments: (request.params.arguments as Record) ?? {} }, undefined, { timeout: 120_000 }, - ) - return result as CallToolResult + )) as CallToolResult + activityLog.record({ at: started, server: conn.name, tool: item, durationMs: Date.now() - started, isError: result.isError === true, client }) + return result } catch (err) { // Upstream failure -> tool error, not a protocol error: the aggregate // session must survive one broken upstream. + activityLog.record({ at: started, server: conn.name, tool: item, durationMs: Date.now() - started, isError: true, client }) return { content: [ { @@ -190,7 +196,10 @@ interface Session { export class Aggregator { private readonly sessions = new Map() - constructor(private readonly source: AggregateSource) {} + constructor( + private readonly source: AggregateSource, + readonly activityLog: ActivityLog, + ) {} /** Fan a list_changed to every connected session (upstreams moved). */ notifyListsChanged(): void { @@ -209,7 +218,7 @@ export class Aggregator { return } if (req.method === 'POST' && isInitializeRequest(parsedBody)) { - const server = createAggregateServer(this.source) + const server = createAggregateServer(this.source, this.activityLog) const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (id) => { diff --git a/extensions/cate.mcp/src/server/api.test.ts b/extensions/cate.mcp/src/server/api.test.ts index ee7b49e..1334e0c 100644 --- a/extensions/cate.mcp/src/server/api.test.ts +++ b/extensions/cate.mcp/src/server/api.test.ts @@ -14,6 +14,8 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { Manager } from './manager' import { Aggregator } from './aggregate' +import { ActivityLog } from './activity' +import { AgentInstaller } from './agent-install' import { createRequestHandler } from './http-app' import type { RegistryResult } from './registry-client' import type { StateSnapshot } from '../shared/types' @@ -40,12 +42,13 @@ async function boot(autostart = false): Promise { autostart, connection: { pingIntervalMs: 60_000, backoffBaseMs: 50, backoffCapMs: 100, maxRestartAttempts: 2 }, }) - const aggregator = new Aggregator(manager) + const aggregator = new Aggregator(manager, new ActivityLog()) manager.onInventoryChange(() => aggregator.notifyListsChanged()) const handler = createRequestHandler({ manager, aggregator, registry: { search: async () => registryStub() }, + installer: new AgentInstaller(tmp), token: TOKEN, publicDir: path.join(tmp, 'no-public'), }) @@ -270,6 +273,37 @@ describe('registry endpoint', () => { }) }) +describe('agent install endpoints', () => { + it('lists agents, installs the endpoint, then uninstalls it', async () => { + const listed = (await (await api('/api/agents')).json()) as { + ok: boolean + agents: { id: string; installed: boolean }[] + } + expect(listed.ok).toBe(true) + expect(listed.agents.find((a) => a.id === 'claude-code')?.installed).toBe(false) + + const ins = await api('/api/agents/install', { method: 'POST', body: JSON.stringify({ id: 'claude-code' }) }) + expect(ins.status).toBe(200) + const doc = JSON.parse(fs.readFileSync(path.join(app.tmp, '.mcp.json'), 'utf8')) + expect(doc.mcpServers.cate.url).toBe(app.manager.getState().endpoint.url) + expect(doc.mcpServers.cate.headers.Authorization).toBe(`Bearer ${TOKEN}`) + + const after = (await (await api('/api/agents')).json()) as { agents: { id: string; installed: boolean }[] } + expect(after.agents.find((a) => a.id === 'claude-code')?.installed).toBe(true) + + const del = await api('/api/agents/uninstall', { method: 'POST', body: JSON.stringify({ id: 'claude-code' }) }) + expect(del.status).toBe(200) + expect(JSON.parse(fs.readFileSync(path.join(app.tmp, '.mcp.json'), 'utf8')).mcpServers.cate).toBeUndefined() + }) + + it('rejects an unsupported agent and a missing id', async () => { + const anti = await api('/api/agents/install', { method: 'POST', body: JSON.stringify({ id: 'pi' }) }) + expect(anti.status).toBe(400) + const bad = await api('/api/agents/install', { method: 'POST', body: JSON.stringify({}) }) + expect(bad.status).toBe(400) + }) +}) + describe('live fixture end to end', () => { it('add + start a real stdio server, call a tool, then reach it through /mcp', async () => { const add = await api('/api/servers', { @@ -322,6 +356,22 @@ describe('live fixture end to end', () => { await client.close() } + // The recorder logged both calls through the aggregator: right server/tool, + // the isError flag, and the connecting client's advertised name. + const activity = (await (await api('/api/activity')).json()) as { + ok: boolean + entries: { server: string; tool: string; isError: boolean; client?: string }[] + summary: { total: number; errors: number } + } + expect(activity.ok).toBe(true) + const echo = activity.entries.find((e) => e.server === 'fix' && e.tool === 'echo' && !e.isError) + expect(echo).toBeTruthy() + expect(echo?.client).toBe('external-agent') + const failedEntry = activity.entries.find((e) => e.server === 'fix' && e.tool === 'fail') + expect(failedEntry?.isError).toBe(true) + expect(activity.summary.total).toBeGreaterThanOrEqual(2) + expect(activity.summary.errors).toBeGreaterThanOrEqual(1) + // Stop: the endpoint no longer lists it. expect((await api('/api/servers/fix/stop', { method: 'POST', body: '{}' })).status).toBe(200) await waitFor(async () => (await getState()).servers.find((s) => s.name === 'fix')?.status === 'stopped', 10_000, 'stopped') diff --git a/extensions/cate.mcp/src/server/http-app.ts b/extensions/cate.mcp/src/server/http-app.ts index 2f9fe24..1ccd40a 100644 --- a/extensions/cate.mcp/src/server/http-app.ts +++ b/extensions/cate.mcp/src/server/http-app.ts @@ -9,6 +9,7 @@ // the single-use `state` parameter) // GET / panel HTML + static assets // GET /api/state?known= config + runtime status + inventories +// GET /api/activity?limit= recent tool calls through /mcp + summary // POST /api/servers add server { name, config } // PATCH /api/servers/:name update server { config } // DELETE /api/servers/:name delete server @@ -17,6 +18,9 @@ // POST /api/resources/read { server, uri } // POST /api/prompts/get { server, prompt, args } // GET /api/registry/search?q=&cursor= +// GET /api/agents install status per coding agent +// POST /api/agents/install { id } write endpoint into agent config +// POST /api/agents/uninstall { id } remove endpoint from agent config // POST /api/oauth/start { server } // ALL /mcp aggregated MCP endpoint (streamable HTTP) // ============================================================================= @@ -25,6 +29,7 @@ import http from 'http' import type { Manager } from './manager' import type { Aggregator } from './aggregate' import type { RegistrySearcher } from './registry-client' +import type { AgentInstaller } from './agent-install' import type { ContentBlockView, PromptGetResponse, @@ -47,6 +52,7 @@ export interface AppOptions { manager: Manager aggregator: Aggregator registry: RegistrySearcher + installer: AgentInstaller token: string publicDir: string } @@ -100,7 +106,13 @@ function callbackHtml(ok: boolean, message: string): string { // --- the app -------------------------------------------------------------------------- export function createRequestHandler(opts: AppOptions): (req: http.IncomingMessage, res: http.ServerResponse) => void { - const { manager, aggregator, registry, token, publicDir } = opts + const { manager, aggregator, registry, installer, token, publicDir } = opts + + /** The current aggregated endpoint as an install target (url + bare token). */ + function currentEndpoint(): { url: string; token: string } { + const ep = manager.getState().endpoint + return { url: ep.url, token: ep.authHeader.replace(/^Bearer /, '') } + } async function handle(req: http.IncomingMessage, res: http.ServerResponse): Promise { const url = new URL(req.url || '/', 'http://127.0.0.1') @@ -167,6 +179,17 @@ export function createRequestHandler(opts: AppOptions): (req: http.IncomingMessa return } + // --- activity feed ----------------------------------------------------------- + // Separate from /api/state on purpose: recording a call must not bump the + // manager serial, so the state poll stays quiet while this churns. + if (pathname === '/api/activity' && method === 'GET') { + const raw = Number(url.searchParams.get('limit') ?? NaN) + const limit = Number.isFinite(raw) && raw > 0 ? raw : undefined + const log = aggregator.activityLog + sendJson(res, 200, { ok: true, entries: log.recent(limit), summary: log.summary() }) + return + } + // --- server CRUD ---------------------------------------------------------------- if (pathname === '/api/servers' && method === 'POST') { const body = await readJsonObject(req) @@ -301,6 +324,25 @@ export function createRequestHandler(opts: AppOptions): (req: http.IncomingMessa return } + // --- install into coding agents ----------------------------------------------------- + if (pathname === '/api/agents' && method === 'GET') { + sendJson(res, 200, { ok: true, agents: installer.list(currentEndpoint()) }) + return + } + if ((pathname === '/api/agents/install' || pathname === '/api/agents/uninstall') && method === 'POST') { + const body = await readJsonObject(req) + if (!body || typeof body.id !== 'string') { + sendJson(res, 400, { ok: false, error: 'body must be { id }' }) + return + } + const result = + pathname === '/api/agents/install' + ? installer.install(body.id, currentEndpoint()) + : installer.uninstall(body.id) + sendJson(res, result.ok ? 200 : 400, result) + return + } + // --- OAuth start -------------------------------------------------------------------- if (pathname === '/api/oauth/start' && method === 'POST') { const body = await readJsonObject(req) diff --git a/extensions/cate.mcp/src/server/main.ts b/extensions/cate.mcp/src/server/main.ts index c0f2dfc..9732845 100644 --- a/extensions/cate.mcp/src/server/main.ts +++ b/extensions/cate.mcp/src/server/main.ts @@ -15,7 +15,9 @@ import http from 'http' import path from 'path' import { Manager } from './manager' import { Aggregator } from './aggregate' +import { ActivityLog } from './activity' import { RegistryClient } from './registry-client' +import { AgentInstaller } from './agent-install' import { createRequestHandler } from './http-app' const PORT = Number(process.env.PORT) @@ -33,14 +35,17 @@ if (!WORKSPACE_ROOT) { } const manager = new Manager({ workspaceRoot: WORKSPACE_ROOT, port: PORT, token: TOKEN }) -const aggregator = new Aggregator(manager) +const activityLog = new ActivityLog() +const aggregator = new Aggregator(manager, activityLog) manager.onInventoryChange(() => aggregator.notifyListsChanged()) const registry = new RegistryClient() +const installer = new AgentInstaller(WORKSPACE_ROOT) const handler = createRequestHandler({ manager, aggregator, registry, + installer, token: TOKEN, publicDir: path.join(__dirname, 'public'), }) diff --git a/extensions/cate.mcp/src/shared/types.ts b/extensions/cate.mcp/src/shared/types.ts index 164c266..09ae709 100644 --- a/extensions/cate.mcp/src/shared/types.ts +++ b/extensions/cate.mcp/src/shared/types.ts @@ -112,6 +112,29 @@ export interface EndpointInfo { authHeader: string } +/** One coding agent the endpoint can be installed into (Endpoint screen). */ +export interface AgentTargetStatus { + id: string + label: string + /** Workspace-relative config file path, for display. */ + path: string + /** False when this agent can't take a workspace-local HTTP MCP config. */ + supported: boolean + /** Why it's unsupported (shown as a tooltip), when supported is false. */ + reason?: string + /** Our endpoint entry is present in the agent's config. */ + installed: boolean + /** Installed, but at a different URL than the current endpoint (needs update). */ + stale: boolean +} + +/** Wire shape of GET /api/agents. */ +export interface AgentsResponse { + ok: boolean + error?: string + agents?: AgentTargetStatus[] +} + export interface StateSnapshot { /** Monotonic change counter; the panel polls with it to skip no-op updates. */ serial: number @@ -133,6 +156,28 @@ export interface StateResponse { state?: StateSnapshot } +/** One recorded tool call through the aggregated /mcp endpoint (Activity feed). */ +export interface ActivityEntry { + /** Epoch ms at call start. */ + at: number + /** Upstream managed server that served the call. */ + server: string + /** Un-namespaced tool name on that server. */ + tool: string + durationMs: number + isError: boolean + /** Connecting MCP client's advertised name, when known. */ + client?: string +} + +/** Wire shape of GET /api/activity?limit=. */ +export interface ActivityResponse { + ok: boolean + error?: string + entries?: ActivityEntry[] + summary?: { total: number; errors: number } +} + /** One playground invocation result content block, pre-digested for the UI. */ export interface ContentBlockView { type: 'text' | 'image' | 'json' | 'other'