Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion extensions/cate.mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `<server>__<name>`. 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.
Expand All @@ -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
```

Expand Down
2 changes: 1 addition & 1 deletion extensions/cate.mcp/manifest.json
Original file line number Diff line number Diff line change
@@ -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": [
{
Expand Down
2 changes: 1 addition & 1 deletion extensions/cate.mcp/package.json
Original file line number Diff line number Diff line change
@@ -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})\"",
Expand Down
18 changes: 18 additions & 0 deletions extensions/cate.mcp/src/public/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
// =============================================================================

import type {
ActivityResponse,
AgentsResponse,
PromptGetResponse,
ResourceReadResponse,
ServerConfigInput,
Expand Down Expand Up @@ -91,6 +93,22 @@ export function registrySearch(q: string, cursor: string | null): Promise<Regist
return request<RegistrySearchResult>(`api/registry/search?${params.toString()}`) as Promise<RegistrySearchResult>
}

export function fetchAgents(): Promise<AgentsResponse> {
return request<AgentsResponse>('api/agents') as Promise<AgentsResponse>
}

export function fetchActivity(limit?: number): Promise<ActivityResponse> {
return request<ActivityResponse>(limit ? `api/activity?limit=${limit}` : 'api/activity') as Promise<ActivityResponse>
}

export function installAgent(id: string): Promise<SimpleResult> {
return postJson('api/agents/install', { id }) as Promise<SimpleResult>
}

export function uninstallAgent(id: string): Promise<SimpleResult> {
return postJson('api/agents/uninstall', { id }) as Promise<SimpleResult>
}

export interface OAuthStartResult {
ok: boolean
error?: string
Expand Down
140 changes: 140 additions & 0 deletions extensions/cate.mcp/src/public/components/DashboardView.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mcp-dash__stat">
<span className={`mcp-dash__statval${warn && value > 0 ? ' mcp-dash__statval--warn' : ''}`}>{value}</span>
<span className="mcp-dash__statlabel">{label}</span>
</div>
)
}

export function DashboardView({ state }: { state: StateSnapshot }) {
const [entries, setEntries] = useState<ActivityEntry[]>([])
const [summary, setSummary] = useState<{ total: number; errors: number }>({ total: 0, errors: 0 })

const load = useCallback(async (): Promise<void> => {
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 (
<div className="mcp-view mcp-dash">
<div className="mcp-dash__strip">
<span className="mcp-dot mcp-dot--running" />
<span className="mcp-dash__striplabel">Endpoint live</span>
<span className="mcp-dash__stripurl mcp-mono" title={state.endpoint.url}>
{state.endpoint.url}
</span>
<span className="mcp-dash__stripcount">
{servers.length} servers · {totalTools} tools
</span>
</div>

<div className="mcp-dash__stats">
<Stat label="running" value={running} />
<Stat label="degraded" value={degraded} warn />
<Stat label="needs auth" value={needsAuth} warn />
<Stat label="tools" value={totalTools} />
<Stat label="recent calls" value={summary.total} />
<Stat label="errors" value={summary.errors} warn />
</div>

{servers.length === 0 ? (
<div className="mcp-dash__hint">
No servers yet. Add a server, or open Discover to browse the registry.
</div>
) : (
<>
<div className="cate-grouplabel mcp-grouplabel">Servers</div>
<div className="mcp-dash__grid">
{servers.map((server) => {
const meta = serverMeta(server)
return (
<div className="mcp-dash__server" key={server.name}>
<StatusDot status={server.status} />
<span className="mcp-dash__servername" title={server.name}>
{server.name}
</span>
<span className={`mcp-dash__servermeta${meta.warn ? ' mcp-dash__servermeta--warn' : ''}`}>
{meta.text}
</span>
</div>
)
})}
</div>
</>
)}

<div className="cate-grouplabel mcp-grouplabel">
Activity <span className="mcp-dash__note">recent</span>
</div>
{entries.length === 0 ? (
<div className="mcp-dash__empty">No calls yet — connect an agent to the endpoint.</div>
) : (
<div className="mcp-dash__feed">
{entries.map((e, i) => (
<div className="mcp-dash__row" key={i}>
<span className="mcp-dash__time">{new Date(e.at).toLocaleTimeString()}</span>
<span className="mcp-dash__tool mcp-mono" title={`${e.server}__${e.tool}`}>
{e.server}__{e.tool}
</span>
{e.client && <span className="mcp-dash__chip">{e.client}</span>}
<span className="mcp-dash__dur">{e.durationMs}ms</span>
<span className={`mcp-dash__result${e.isError ? ' mcp-dash__result--err' : ''}`}>
{e.isError ? 'err' : 'ok'}
</span>
</div>
))}
</div>
)}
</div>
)
}
98 changes: 95 additions & 3 deletions extensions/cate.mcp/src/public/components/EndpointView.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mcp-agentrow">
<span className="mcp-agentrow__label">{agent.label}</span>
<span className="mcp-agentrow__path mcp-mono" title={agent.reason ?? agent.path}>
{agent.supported ? agent.path : agent.reason}
</span>
<span className="mcp-agentrow__actions">
{!agent.supported ? (
<span className="mcp-muted" title={agent.reason}>
Unavailable
</span>
) : agent.installed ? (
<>
{agent.stale && (
<button className="cate-btn cate-btn--small" type="button" disabled={busy} title="Point at the current endpoint" onClick={onInstall}>
Update
</button>
)}
{!agent.stale && <span className="mcp-agentrow__ok">Installed</span>}
<button className="cate-btn cate-btn--small" type="button" disabled={busy} onClick={onRemove}>
Remove
</button>
</>
) : (
<button className="cate-btn cate-btn--small" type="button" disabled={busy} onClick={onInstall}>
Install
</button>
)}
</span>
</div>
)
}

export function EndpointView({ endpoint }: { endpoint: EndpointInfo }) {
const [revealed, setRevealed] = useState(false)
const [agents, setAgents] = useState<AgentTargetStatus[] | null>(null)
const [busyId, setBusyId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const masked = endpoint.authHeader.replace(/Bearer .*/, 'Bearer ••••••••')

const load = useCallback(async (): Promise<void> => {
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<void> {
setBusyId(id)
setError(null)
const res = await fn()
if (!res.ok) setError(res.error ?? 'action failed')
await load()
setBusyId(null)
}

return (
<div className="mcp-view">
<div className="mcp-def">
Expand All @@ -31,6 +107,22 @@ export function EndpointView({ endpoint }: { endpoint: EndpointInfo }) {
<div className="mcp-muted" title="Streamable HTTP; every running server's tools, resources and prompts, namespaced <server>__<name>">
Any MCP client can connect here.
</div>

<div className="cate-grouplabel mcp-grouplabel">Install into agents</div>
{error && <div className="mcp-inline-error">{error}</div>}
{agents && (
<div className="mcp-agents">
{agents.map((agent) => (
<AgentRow
key={agent.id}
agent={agent}
busy={busyId === agent.id}
onInstall={() => void act(agent.id, () => installAgent(agent.id))}
onRemove={() => void act(agent.id, () => uninstallAgent(agent.id))}
/>
))}
</div>
)}
</div>
)
}
14 changes: 12 additions & 2 deletions extensions/cate.mcp/src/public/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -66,6 +66,16 @@ export function Sidebar({
))}
</div>
<div className="mcp-side__foot">
<button
type="button"
className={`mcp-item${view.kind === 'dashboard' ? ' mcp-item--selected' : ''}`}
onClick={() => onSelect({ kind: 'dashboard' })}
>
<span className="mcp-item__icon">
<DashboardIcon />
</span>
<span className="mcp-item__name">Dashboard</span>
</button>
<button
type="button"
className={`mcp-item${view.kind === 'discover' ? ' mcp-item--selected' : ''}`}
Expand Down
10 changes: 10 additions & 0 deletions extensions/cate.mcp/src/public/components/util.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,16 @@ export const CopyIcon = () =>

export const CheckIcon = () => icon(<path d="M3.5 8.5l3 3 6-6.5" />)

export const DashboardIcon = () =>
icon(
<>
<rect x="2.5" y="2.5" width="4.5" height="4.5" rx="1" />
<rect x="9" y="2.5" width="4.5" height="4.5" rx="1" />
<rect x="2.5" y="9" width="4.5" height="4.5" rx="1" />
<rect x="9" y="9" width="4.5" height="4.5" rx="1" />
</>,
)

export const CompassIcon = () =>
icon(
<>
Expand Down
Loading
Loading