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:
soroush.asadi
2026-06-13 19:25:43 +03:30
parent 0ac15c7308
commit c5e0e5cfe3
27 changed files with 1506 additions and 8 deletions
+1
View File
@@ -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),
}
+119 -3
View File
@@ -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>