MCP compatibility for AI agents: server registry, JSON-RPC client, gateway, run-time tool catalog
Agents can now use Model Context Protocol servers. End to end: - SharedKernel seam IMcpGateway (ListToolsAsync / CallToolAsync) + McpToolDescriptor / McpToolResult, so the Assembler discovers and can invoke MCP tools without referencing Integrations' tables. - Integrations: McpServerConfig (org-scoped, owner-only; auth headers AES-GCM encrypted, never returned — only their names) + AddMcpServers migration. McpClient: a dependency-free Streamable-HTTP JSON-RPC 2.0 client (initialize → notifications/initialized → tools/list / tools/call), carrying the Mcp-Session-Id and parsing both application/json and text/event-stream replies. McpGateway resolves an org's servers, decrypts headers server-side, and is best-effort: an unreachable server is logged and skipped, never failing the run. CRUD + connectivity-test endpoints (create/test/delete owner-only via ManageApiKeys; list via ConfigureAgents to bind). - OrgBoard: Agent gains McpServerIds (uuid[]; migration backfills existing agents to empty) flowing through ConfigureAgent + AgentRunContext. - Assembler: AgentRunExecutor lists the agent's MCP tools (best-effort) and PromptAssembler renders a "# Tools (MCP)" catalog — labelled as data, never instructions — and records it in the run trace. - Client: SeatsPage gains an MCP servers card (add/test/delete, encrypted auth header) and a per-agent MCP server multi-select; api client gains del(). Note: discovery + the governed call gateway are in place now; the autonomous model-driven tool-call loop (model emits tool_calls → gated execution → feedback) needs a tool-calling model client and is the next increment — the stub model can't drive it. Verified: ArchitectureTests 8/8, IntegrationTests 53/53 (McpClientTests: JSON-RPC handshake/session, json + SSE; McpServerRegistryTests: owner-only, encrypted-header-never-returned, graceful test, Member 403; PromptAssemblerMcpTests: catalog + trace, omitted when empty), client build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -28,4 +28,5 @@ export const api = {
|
||||
get: <T>(url: string) => request<T>('GET', url),
|
||||
post: <T>(url: string, body?: unknown) => request<T>('POST', url, body),
|
||||
patch: <T>(url: string, body?: unknown) => request<T>('PATCH', url, body),
|
||||
del: <T>(url: string) => request<T>('DELETE', url),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { KeyRound, Plus, Bot, Sparkles, Wand2 } from 'lucide-react'
|
||||
import { KeyRound, Plug, Plus, Bot, Sparkles, Trash2, Wand2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { AppShell } from '@/components/AppShell'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -32,6 +32,14 @@ interface ApiConfig {
|
||||
endpoint: string | null
|
||||
}
|
||||
|
||||
interface McpServer {
|
||||
id: string
|
||||
name: string
|
||||
endpoint: string
|
||||
enabled: boolean
|
||||
headerNames: string[]
|
||||
}
|
||||
|
||||
interface Seat {
|
||||
id: string
|
||||
teamId: string
|
||||
@@ -54,6 +62,7 @@ interface Agent {
|
||||
autonomy: string
|
||||
apiConfigId: string
|
||||
skillKeys: string[]
|
||||
mcpServerIds: string[]
|
||||
docs: string[]
|
||||
}
|
||||
|
||||
@@ -69,10 +78,12 @@ export function SeatsPage() {
|
||||
const [teams, setTeams] = useState<Team[]>([])
|
||||
const [teamId, setTeamId] = useState<string | null>(null)
|
||||
const [configs, setConfigs] = useState<ApiConfig[]>([])
|
||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
|
||||
const [seats, setSeats] = useState<Seat[]>([])
|
||||
const [skills, setSkills] = useState<Skill[]>([])
|
||||
|
||||
const [cfg, setCfg] = useState({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '', endpoint: '' })
|
||||
const [mcp, setMcp] = useState({ name: '', endpoint: '', headerName: 'Authorization', headerValue: '' })
|
||||
const [newSeat, setNewSeat] = useState('')
|
||||
const [selectedSeat, setSelectedSeat] = useState<string | null>(null)
|
||||
const [agent, setAgent] = useState({
|
||||
@@ -81,6 +92,7 @@ export function SeatsPage() {
|
||||
autonomy: 'Gated',
|
||||
apiConfigId: '',
|
||||
skillKeys: [] as string[],
|
||||
mcpServerIds: [] as string[],
|
||||
docs: '',
|
||||
})
|
||||
|
||||
@@ -97,6 +109,11 @@ export function SeatsPage() {
|
||||
setConfigs(await api.get<ApiConfig[]>(`/api/integrations/api-configs?organizationId=${organizationId}`))
|
||||
}, [organizationId])
|
||||
|
||||
const loadMcpServers = useCallback(async () => {
|
||||
if (!organizationId) return
|
||||
setMcpServers(await api.get<McpServer[]>(`/api/integrations/mcp-servers?organizationId=${organizationId}`))
|
||||
}, [organizationId])
|
||||
|
||||
const loadSeats = useCallback(async (id: string) => {
|
||||
setSeats(await api.get<Seat[]>(`/api/orgboard/seats?teamId=${id}`))
|
||||
}, [])
|
||||
@@ -112,8 +129,9 @@ export function SeatsPage() {
|
||||
for (const s of lib) if (!byKey.has(s.skillKey)) byKey.set(s.skillKey, s)
|
||||
setSkills([...byKey.values()])
|
||||
await loadConfigs()
|
||||
await loadMcpServers()
|
||||
})
|
||||
}, [organizationId, loadConfigs, run])
|
||||
}, [organizationId, loadConfigs, loadMcpServers, run])
|
||||
|
||||
useEffect(() => {
|
||||
if (teamId) void run(() => loadSeats(teamId))
|
||||
@@ -137,6 +155,44 @@ export function SeatsPage() {
|
||||
: toast.error(`Test failed: ${result.error}`)
|
||||
})
|
||||
|
||||
const createMcpServer = () =>
|
||||
run(async () => {
|
||||
const headers = mcp.headerValue.trim() && mcp.headerName.trim()
|
||||
? { [mcp.headerName.trim()]: mcp.headerValue.trim() }
|
||||
: null
|
||||
await api.post('/api/integrations/mcp-servers', {
|
||||
organizationId,
|
||||
name: mcp.name.trim(),
|
||||
endpoint: mcp.endpoint.trim(),
|
||||
headers,
|
||||
})
|
||||
setMcp({ name: '', endpoint: '', headerName: 'Authorization', headerValue: '' })
|
||||
await loadMcpServers()
|
||||
toast.success('MCP server added (auth header encrypted).')
|
||||
})
|
||||
|
||||
const testMcpServer = (id: string) =>
|
||||
run(async () => {
|
||||
const result = await api.post<{ success: boolean; error?: string; toolCount: number; toolNames: string[] }>(
|
||||
`/api/integrations/mcp-servers/${id}/test`,
|
||||
)
|
||||
result.success
|
||||
? toast.success(`Connected — ${result.toolCount} tool(s): ${result.toolNames.slice(0, 6).join(', ') || 'none'}.`)
|
||||
: toast.error(`MCP test failed: ${result.error}`)
|
||||
})
|
||||
|
||||
const deleteMcpServer = (id: string) =>
|
||||
run(async () => {
|
||||
await api.del(`/api/integrations/mcp-servers/${id}`)
|
||||
await loadMcpServers()
|
||||
})
|
||||
|
||||
const toggleMcp = (id: string) =>
|
||||
setAgent((a) => ({
|
||||
...a,
|
||||
mcpServerIds: a.mcpServerIds.includes(id) ? a.mcpServerIds.filter((x) => x !== id) : [...a.mcpServerIds, id],
|
||||
}))
|
||||
|
||||
const createSeat = () =>
|
||||
run(async () => {
|
||||
if (!teamId) return
|
||||
@@ -159,9 +215,10 @@ export function SeatsPage() {
|
||||
autonomy: existing.autonomy,
|
||||
apiConfigId: existing.apiConfigId,
|
||||
skillKeys: existing.skillKeys,
|
||||
mcpServerIds: existing.mcpServerIds ?? [],
|
||||
docs: existing.docs.join(', '),
|
||||
}
|
||||
: { name: '', monogram: '', autonomy: 'Gated', apiConfigId: configs[0]?.id ?? '', skillKeys: [], docs: '' },
|
||||
: { name: '', monogram: '', autonomy: 'Gated', apiConfigId: configs[0]?.id ?? '', skillKeys: [], mcpServerIds: [], docs: '' },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -174,6 +231,7 @@ export function SeatsPage() {
|
||||
autonomy: agent.autonomy,
|
||||
apiConfigId: agent.apiConfigId,
|
||||
skillKeys: agent.skillKeys,
|
||||
mcpServerIds: agent.mcpServerIds,
|
||||
docs: agent.docs ? agent.docs.split(',').map((d) => d.trim()).filter(Boolean) : [],
|
||||
})
|
||||
if (teamId) await loadSeats(teamId)
|
||||
@@ -252,6 +310,50 @@ export function SeatsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Plug className="size-4" /> MCP servers
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Connect Model Context Protocol servers (Streamable HTTP). Auth headers are encrypted and never
|
||||
shown again. Bind servers to an agent below — their tools are offered to the agent at run time.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<Field label="Name">
|
||||
<Input value={mcp.name} onChange={(e) => setMcp({ ...mcp, name: e.target.value })} className="w-40" placeholder="GitHub MCP" />
|
||||
</Field>
|
||||
<Field label="Endpoint URL">
|
||||
<Input value={mcp.endpoint} onChange={(e) => setMcp({ ...mcp, endpoint: e.target.value })} className="w-72" placeholder="https://host/mcp" />
|
||||
</Field>
|
||||
<Field label="Auth header (optional)">
|
||||
<Input value={mcp.headerName} onChange={(e) => setMcp({ ...mcp, headerName: e.target.value })} className="w-36" placeholder="Authorization" />
|
||||
</Field>
|
||||
<Field label="Header value (optional)">
|
||||
<Input type="password" value={mcp.headerValue} onChange={(e) => setMcp({ ...mcp, headerValue: e.target.value })} className="w-48" placeholder="Bearer …" />
|
||||
</Field>
|
||||
<Button onClick={createMcpServer}><Plus data-icon="inline-start" />Add</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{mcpServers.map((s) => (
|
||||
<div key={s.id} className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
|
||||
<span className="font-medium">{s.name}</span>
|
||||
<span className="truncate text-muted-foreground">
|
||||
{s.endpoint}{s.headerNames.length > 0 ? ` · auth: ${s.headerNames.join(', ')}` : ''}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => testMcpServer(s.id)}>Test</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => deleteMcpServer(s.id)}><Trash2 className="size-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{mcpServers.length === 0 && <p className="text-sm text-muted-foreground">No MCP servers yet.</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Team</CardTitle>
|
||||
@@ -374,6 +476,20 @@ export function SeatsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>MCP servers</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mcpServers.map((s) => (
|
||||
<button key={s.id} onClick={() => toggleMcp(s.id)} title={s.endpoint}>
|
||||
<Badge variant={agent.mcpServerIds.includes(s.id) ? 'default' : 'outline'}>
|
||||
<Plug className="mr-1 size-3" />{s.name}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
{mcpServers.length === 0 && <p className="text-sm text-muted-foreground">No MCP servers connected — add one above.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="Docs (comma-separated)">
|
||||
<Input value={agent.docs} onChange={(e) => setAgent({ ...agent, docs: e.target.value })} placeholder="product-docs, house-style" />
|
||||
</Field>
|
||||
|
||||
Reference in New Issue
Block a user