Files
nihalashetty ae67bff5a3 feat: deep-agent canvas, live observability, and multi-environment tooling
Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
2026-07-28 01:49:19 +05:30

187 lines
11 KiB
TypeScript

"use client";
/* External MCP - register MCP servers, discover their tools, toggle which are live.
Server-scoped: agents and workflow nodes consume a server's *enabled* tools. */
import { useCallback, useEffect, useState } from "react";
import { Icon } from "../icons";
import { Field, Modal, Tile, Toggle } from "../primitives";
import { api, McpClientT } from "@/lib/api";
export function McpClientsScreen({ project }: { project: any }) {
const [rows, setRows] = useState<McpClientT[]>([]);
const [selId, setSelId] = useState<string | null>(null);
const [addOpen, setAddOpen] = useState(false);
// Session cache of discovered tools per server, so re-selecting one is instant
// (the server is the source of truth - "Re-discover" forces a fresh fetch).
const [toolCache, setToolCache] = useState<Record<string, { name: string; description?: string }[]>>({});
const reload = useCallback(() => {
if (!project?.id) return;
api.listMcpClients(project.id).then((r) => { setRows(r); setSelId((s) => (s && r.some((x) => x.id === s) ? s : (r[0]?.id ?? null))); }).catch(() => {});
}, [project?.id]);
useEffect(() => { reload(); }, [reload]);
const sel = rows.find((r) => r.id === selId) || null;
return (
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
{/* LEFT list */}
<div style={{ width: 280, flex: "none", borderRight: "1px solid var(--line)", display: "flex", flexDirection: "column", background: "var(--bg-1)" }}>
<div className="row spread" style={{ padding: "14px 16px", borderBottom: "1px solid var(--line)" }}>
<div className="t-display">External MCP</div>
<button className="btn btn-primary btn-sm" onClick={() => setAddOpen(true)}><Icon name="plus" size={14} /></button>
</div>
<div className="scroll-y" style={{ flex: 1, padding: 8 }}>
{rows.length === 0 && <div className="fg-2 t-caption" style={{ padding: 12 }}>No MCP servers yet. Click + to connect one (e.g. GitHub).</div>}
{rows.map((m) => {
const on = selId === m.id;
return (
<button key={m.id} onClick={() => setSelId(m.id)} className="col" style={{ width: "100%", textAlign: "left", padding: "11px 12px", borderRadius: 9, marginBottom: 4, border: "1px solid " + (on ? "var(--accent)" : "transparent"), background: on ? "var(--accent-glow)" : "transparent", cursor: "pointer", gap: 4 }}>
<div className="row spread"><span className="mono-sm" style={{ fontWeight: 700, color: "var(--fg-0)" }}>{m.name}</span><span className="typechip">{m.transport}</span></div>
<div className="row spread">
<span className="truncate" style={{ fontSize: 11, color: "var(--fg-2)" }}>{m.url}</span>
<span
className="iconbtn" role="button" title="Remove server"
onClick={async (e) => {
e.stopPropagation();
if (!window.confirm(`Remove MCP server “${m.name}”? Agents and workflows using it will lose those tools.`)) return;
await api.deleteMcpClient(project.id, m.id);
reload();
}}
><Icon name="trash" size={13} /></span>
</div>
</button>
);
})}
</div>
</div>
{/* RIGHT detail */}
<div className="grow scroll-y" style={{ padding: 24, minWidth: 0 }}>
{sel ? <ServerDetail key={sel.id} project={project} server={sel} onChanged={reload} cached={toolCache[sel.id] ?? null} onLoaded={(list) => setToolCache((c) => ({ ...c, [sel.id]: list }))} /> : (
<div className="col center" style={{ height: "100%", gap: 8, color: "var(--fg-2)" }}><Tile icon="connect" color="var(--accent)" size={48} glow /><div className="t-h2">Connect an MCP server</div></div>
)}
</div>
<AddServerModal open={addOpen} project={project} onClose={() => setAddOpen(false)} onAdded={(id) => { setAddOpen(false); reload(); setSelId(id); }} />
</div>
);
}
function ServerDetail({ project, server, onChanged, cached, onLoaded }: { project: any; server: McpClientT; onChanged: () => void; cached: { name: string; description?: string }[] | null; onLoaded: (list: { name: string; description?: string }[]) => void }) {
const [tools, setTools] = useState<{ name: string; description?: string }[] | null>(cached);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [disabled, setDisabled] = useState<Set<string>>(new Set(server.disabled_tools || []));
const [saving, setSaving] = useState(false);
const discover = useCallback(() => {
setLoading(true); setErr(null);
api.discoverMcpTools(project.id, server.id)
.then((r) => { if (r.ok) { const list = r.tools || []; setTools(list); onLoaded(list); } else setErr(r.error || "Could not list tools from that server."); })
.catch((e) => setErr(String(e?.message || e)))
.finally(() => setLoading(false));
}, [project.id, server.id, onLoaded]);
// Only fetch when this server's tools aren't already cached this session; the server is
// the source of truth, so the "Re-discover" button forces a fresh fetch on demand.
useEffect(() => { if (cached === null) discover(); }, [server.id]); // eslint-disable-line react-hooks/exhaustive-deps
async function toggle(name: string) {
const next = new Set(disabled);
if (next.has(name)) next.delete(name); else next.add(name);
setDisabled(next); setSaving(true);
try { await api.updateMcpClient(project.id, server.id, { disabled_tools: [...next] }); onChanged(); }
catch { setDisabled(new Set(server.disabled_tools || [])); }
finally { setSaving(false); }
}
const enabledCount = tools ? tools.filter((t) => !disabled.has(t.name)).length : 0;
return (
<div style={{ maxWidth: 960 }}>
<div className="row spread" style={{ marginBottom: 18 }}>
<div className="row gap3">
<Tile icon="connect" color="var(--accent)" size={40} glow />
<div>
<div className="t-display mono" style={{ fontSize: 18 }}>{server.name}</div>
<div className="fg-2 t-caption">{server.transport} · {server.url}</div>
</div>
</div>
<button className="btn btn-secondary" onClick={discover} disabled={loading}><Icon name="refresh" size={15} style={loading ? { animation: "spin 1s linear infinite" } : {}} />{loading ? "Connecting…" : "Re-discover"}</button>
</div>
<div className="card" style={{ padding: 18 }}>
<div className="row spread" style={{ marginBottom: 12 }}>
<div className="t-h2">Tools{tools ? ` · ${enabledCount}/${tools.length} enabled` : ""}</div>
{saving && <span className="t-caption fg-2">Saving</span>}
</div>
{err && <div className="card" style={{ padding: 12, color: "var(--err)", background: "var(--bg-3)" }}>{err}</div>}
{!err && tools === null && <div className="fg-2 t-caption">Connecting to the server</div>}
{!err && tools && tools.length === 0 && <div className="fg-2 t-caption">This server exposes no tools.</div>}
{!err && tools && tools.length > 0 && (
<div className="col gap2">
{tools.map((t) => {
const on = !disabled.has(t.name);
return (
<div key={t.name} className="row spread" style={{ padding: "10px 12px", border: "1px solid var(--line)", borderRadius: 9, gap: 12, alignItems: "flex-start" }}>
<div className="col" style={{ gap: 2, minWidth: 0 }}>
<span className="mono-sm" style={{ fontWeight: 700, color: on ? "var(--fg-0)" : "var(--fg-2)" }}>{t.name}</span>
{t.description && <span className="t-caption fg-2" style={{ whiteSpace: "normal" }}>{t.description}</span>}
</div>
<Toggle on={on} onChange={() => toggle(t.name)} />
</div>
);
})}
</div>
)}
<div className="fg-2 t-caption" style={{ marginTop: 12 }}>Disabled tools stay hidden from agents and workflow nodes that use this server.</div>
</div>
</div>
);
}
function AddServerModal({ open, onClose, project, onAdded }: { open: boolean; onClose: () => void; project: any; onAdded: (id: string) => void }) {
const [form, setForm] = useState({ name: "github", transport: "streamable_http", url: "", token: "" });
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => { if (open) { setForm({ name: "github", transport: "streamable_http", url: "", token: "" }); setErr(null); } }, [open]);
async function add() {
if (!form.url.trim()) { setErr("Enter the server URL."); return; }
setBusy(true); setErr(null);
try {
const name = (form.name || "mcp_server").trim().replace(/[^a-zA-Z0-9_-]/g, "_");
let headers_ref: string | undefined;
if (form.token.trim()) {
const secName = `${name}_mcp_headers`;
await api.createSecret(project.id, { name: secName, value: { Authorization: `Bearer ${form.token.trim()}` }, kind: "mcp_headers" });
headers_ref = `secret://proj/${secName}`;
}
const created = await api.createMcpClient(project.id, { name, transport: form.transport, url: form.url.trim(), headers_ref });
onAdded(created.id);
} catch (e: any) { setErr(String(e?.message || e)); } finally { setBusy(false); }
}
return (
<Modal open={open} onClose={onClose} title="Connect MCP server" width={520}
footer={<><button className="btn btn-ghost" onClick={onClose}>Cancel</button><button className="btn btn-primary" onClick={add} disabled={busy}>{busy ? "Connecting…" : "Add server"}</button></>}>
<div className="row gap2">
<Field label="Name"><input className="input mono" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="github" /></Field>
<Field label="Transport">
<select className="select" value={form.transport} onChange={(e) => setForm((f) => ({ ...f, transport: e.target.value }))}>
{["streamable_http", "sse", "stdio"].map((t) => <option key={t}>{t}</option>)}
</select>
</Field>
</div>
<Field label="Server URL" help="The MCP endpoint. GitHub's hosted server is https://api.githubcopilot.com/mcp/">
<input className="input mono" value={form.url} onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))} placeholder="https://api.githubcopilot.com/mcp/" />
</Field>
<Field label="Bearer token" help="Optional. For servers that need auth (e.g. a GitHub PAT). Saved to Settings → Secrets (encrypted) and sent as the Authorization header.">
<input className="input mono" type="password" value={form.token} onChange={(e) => setForm((f) => ({ ...f, token: e.target.value }))} placeholder="ghp_…" />
</Field>
{err && <div className="card" style={{ padding: 12, color: "var(--err)", marginTop: 4 }}>{err}</div>}
<div className="fg-2 t-caption" style={{ marginTop: 8 }}>Forge connects and lists the server's tools; toggle which ones agents and workflows can use.</div>
</Modal>
);
}