ae67bff5a3
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.
584 lines
40 KiB
TypeScript
584 lines
40 KiB
TypeScript
"use client";
|
|
/* Connect (MCP) screen - expose this project's tools as an MCP server for external clients.
|
|
(Consuming external MCP servers lives in the BUILD → External MCP tab.) */
|
|
import { ReactNode, useCallback, useEffect, useState } from "react";
|
|
import { Icon } from "../icons";
|
|
import { CodeBlock, Field, Segmented, Toggle } from "../primitives";
|
|
import { api, type McpToken, type MyConnection, type ToolSet } from "@/lib/api";
|
|
import { EmbedPanel } from "./embed";
|
|
|
|
/* Collapsible detail section - keeps the deep integration reference tucked away so the
|
|
Connect screen stays scannable; expand only what you need. */
|
|
function Collapse({ title, sub, defaultOpen = false, children }: { title: string; sub?: string; defaultOpen?: boolean; children: ReactNode }) {
|
|
const [open, setOpen] = useState(defaultOpen);
|
|
return (
|
|
<div className="card" style={{ padding: 0, marginBottom: 10, overflow: "hidden" }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((o) => !o)}
|
|
aria-expanded={open}
|
|
className="row spread"
|
|
style={{ width: "100%", background: "none", border: "none", cursor: "pointer", padding: "12px 16px", textAlign: "left", fontFamily: "var(--font-ui)", color: "inherit" }}
|
|
>
|
|
<div>
|
|
<div className="t-h3">{title}</div>
|
|
{sub && <div className="t-caption fg-2" style={{ marginTop: 2 }}>{sub}</div>}
|
|
</div>
|
|
<Icon name={open ? "chevdown" : "chevright"} size={16} style={{ color: "var(--fg-2)", flex: "none", marginLeft: 12 }} />
|
|
</button>
|
|
{open && <div style={{ padding: "0 16px 16px" }}>{children}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* One SSE frame documented in the streaming reference: event name + what its data carries. */
|
|
function FrameRow({ event, children }: { event: string; children: ReactNode }) {
|
|
return (
|
|
<div className="row gap2" style={{ alignItems: "baseline", padding: "5px 0", borderTop: "1px solid var(--line)" }}>
|
|
<span className="mono-sm" style={{ minWidth: 92, flex: "none", color: "var(--fg-2)" }}>{event}</span>
|
|
<span className="t-caption fg-1">{children}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* Left secondary-nav sections (mirrors the Settings screen layout) so the Connect screen
|
|
is navigable instead of one long scroll. */
|
|
type ConnSection = "run" | "reference" | "mcp" | "embed";
|
|
const CONN_SECTIONS: { id: ConnSection; label: string; icon: string; sub: string; child?: boolean }[] = [
|
|
{ id: "run", label: "Run API", icon: "bolt", sub: "Call this project's workflow from your backend over one endpoint." },
|
|
{ id: "reference", label: "Integration reference", icon: "traces", sub: "The wire format for streaming and non-streaming responses.", child: true },
|
|
{ id: "mcp", label: "MCP server", icon: "connect", sub: "Expose this project's tools to Claude Desktop, Cursor, or VS Code." },
|
|
{ id: "embed", label: "Embed", icon: "grid", sub: "Drop this project's chatbot into any website as a widget." },
|
|
];
|
|
|
|
/* A per-user credential connect row: the CURRENT user pastes their own downstream token for a
|
|
per-user auth provider. Stored server-side keyed by their user id (the identity their MCP PAT
|
|
resolves to), so tool calls act as them without a shared secret. */
|
|
function MyConnectionCard({ project, ap }: { project: any; ap: MyConnection }) {
|
|
const [status, setStatus] = useState<{ connected: boolean } | null>(null);
|
|
const [token, setToken] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
const refresh = useCallback(() => { api.getMyConnection(project.id, ap.id).then(setStatus).catch(() => setStatus(null)); }, [project.id, ap.id]);
|
|
useEffect(() => { refresh(); }, [refresh]);
|
|
|
|
async function save() {
|
|
setErr(null); setBusy(true);
|
|
try {
|
|
const res = await api.setMyConnection(project.id, ap.id, token.trim());
|
|
if (!res.ok) throw new Error("Could not save token.");
|
|
setToken(""); refresh();
|
|
} catch (e: any) { setErr(e?.message || String(e)); } finally { setBusy(false); }
|
|
}
|
|
async function clear() { try { await api.clearMyConnection(project.id, ap.id); } catch { /* best-effort */ } refresh(); }
|
|
|
|
return (
|
|
<div className="card" style={{ padding: 12, background: "var(--bg-2)" }}>
|
|
<div className="row spread" style={{ marginBottom: 8 }}>
|
|
<div className="row gap2" style={{ alignItems: "center", minWidth: 0 }}>
|
|
<Icon name="auth" size={13} style={{ color: "var(--fg-2)" }} />
|
|
<span className="mono-sm truncate">{ap.name}</span>
|
|
{status?.connected ? <span className="pill pill-ok" style={{ height: 16 }}>connected</span> : <span className="pill pill-muted" style={{ height: 16 }}>not connected</span>}
|
|
</div>
|
|
{status?.connected && <button className="btn btn-ghost btn-sm" onClick={clear}>Clear</button>}
|
|
</div>
|
|
<div className="row gap2">
|
|
<input className="input mono" type="password" value={token} onChange={(e) => setToken(e.target.value)} placeholder="paste your token…" style={{ flex: 1 }} />
|
|
<button className="btn btn-primary btn-sm" onClick={save} disabled={busy || !token.trim()}>{busy ? "Saving…" : "Save"}</button>
|
|
</div>
|
|
{err && <div className="t-caption" style={{ color: "var(--err)", marginTop: 6 }}>{err}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============ CONNECT (MCP) ============ */
|
|
export function ConnectScreen({ project }: { project: any }) {
|
|
const [section, setSection] = useState<ConnSection>("run");
|
|
const [tools, setTools] = useState<any[]>([]);
|
|
const [toolSets, setToolSets] = useState<ToolSet[]>([]);
|
|
const [tsSave, setTsSave] = useState<"idle" | "saving" | "saved">("idle");
|
|
const [excluded, setExcluded] = useState<string[]>([]);
|
|
const [openSets, setOpenSets] = useState<Set<string>>(new Set());
|
|
const [mcpTokens, setMcpTokens] = useState<McpToken[]>([]);
|
|
// Per-user ("external") auth providers for this project - each lets the current user connect their
|
|
// own downstream token so MCP tool calls act as them (see the "Connect your accounts" card).
|
|
const [perUserAps, setPerUserAps] = useState<MyConnection[]>([]);
|
|
const [newToken, setNewToken] = useState("");
|
|
const [apiKey, setApiKey] = useState("");
|
|
const [save, setSave] = useState<"idle" | "saving" | "saved">("idle");
|
|
const [credTab, setCredTab] = useState<"key" | "pat">("key"); // which credential to paste - it's either/or
|
|
// Project-level MCP tools (mirror the server's project.config flags in mcp_server.py): the whole
|
|
// workflow as one tool, plus knowledge-base + curated-Q&A search. Independent of toolsets.
|
|
const [exposeWf, setExposeWf] = useState(false);
|
|
const [wfToolName, setWfToolName] = useState("run_workflow");
|
|
const [exposeKnowledge, setExposeKnowledge] = useState(false);
|
|
const [exposeFaq, setExposeFaq] = useState(false);
|
|
const [cfgSave, setCfgSave] = useState<"idle" | "saving" | "saved">("idle");
|
|
// Run-API panel: pick the workflow this project's API runs (a saved setting) + the
|
|
// backend-facing base URL, then show the one ready-to-copy endpoint.
|
|
const [workflows, setWorkflows] = useState<any[]>([]);
|
|
const [wfId, setWfId] = useState("");
|
|
const [apiSave, setApiSave] = useState<"idle" | "saving" | "saved">("idle");
|
|
const [apiBase, setApiBase] = useState(
|
|
(process.env.NEXT_PUBLIC_FORGE_API_URL || "http://localhost:8000").replace(/\/$/, ""),
|
|
);
|
|
useEffect(() => {
|
|
if (!project?.id) return;
|
|
api.listTools(project.id).then(setTools).catch(() => {});
|
|
api.listToolSets(project.id).then(setToolSets).catch(() => {});
|
|
api.listMcpTokens(project.id).then(setMcpTokens).catch(() => {});
|
|
api.listMyConnections(project.id).then(setPerUserAps).catch(() => {});
|
|
}, [project?.id]);
|
|
useEffect(() => {
|
|
if (!project?.id) return;
|
|
Promise.all([api.getProject(project.id), api.listWorkflows(project.id)]).then(([p, ws]) => {
|
|
setApiKey((p.config as any)?.mcp_api_key || "");
|
|
setExcluded(((p.config as any)?.mcp_excluded_tools as string[]) || []);
|
|
const cfg = (p.config as any) || {};
|
|
setExposeWf(!!cfg.mcp_expose_workflow);
|
|
setWfToolName(cfg.mcp_workflow_tool_name || "run_workflow");
|
|
setExposeKnowledge(!!cfg.mcp_expose_knowledge);
|
|
setExposeFaq(!!cfg.mcp_expose_faq);
|
|
setWorkflows(ws);
|
|
// Default the picker to the saved API workflow; else the active one, else the first.
|
|
const saved = (p.config as any)?.api_workflow_id;
|
|
const chosen = ws.find((w: any) => w.id === saved) || ws.find((w: any) => w.status === "active") || ws[0];
|
|
setWfId(chosen ? chosen.id : "");
|
|
}).catch(() => {});
|
|
}, [project?.id]);
|
|
|
|
// MCP clients must reach the API DIRECTLY, not the same-origin /api/forge console proxy: the
|
|
// OAuth discovery + dynamic-registration endpoints live at the API root (/.well-known/*,
|
|
// /v1/oauth/*), which the proxy does NOT forward. A proxied :3000 URL works for API-key/PAT
|
|
// (the JSON-RPC tunnels through the proxy) but breaks the OAuth sign-in flow - the client
|
|
// tries to discover the auth server at :3000 and gives up. Use the same base as the Run API.
|
|
const mcpBase = (apiBase || "http://localhost:8000").replace(/\/$/, "");
|
|
const url = `${mcpBase}/v1/mcp/${project?.id || "<project>"}`;
|
|
const claudeConfig = JSON.stringify({ mcpServers: { [project?.slug || "forge"]: { url, headers: { Authorization: "Bearer <PAT or API key>" } } } }, null, 2);
|
|
// The MCP surface = enabled tools of EXPOSED sets, minus individually excluded ones (mirrors
|
|
// the server; see mcp_server.py._exposed_names).
|
|
const toolById = new Map(tools.map((t) => [t.id, t]));
|
|
const exposedToolIds = new Set(toolSets.filter((s) => s.exposed).flatMap((s) => s.tool_ids).filter((id) => !excluded.includes(id)));
|
|
const exposedTools = tools.filter((t) => t.enabled && exposedToolIds.has(t.id));
|
|
// Project-level tools published alongside the toolset tools (see mcp_server.py._capability_tools
|
|
// + _workflow_tool_name). Shown in the "Currently exposed" summary so the whole surface is visible.
|
|
const projectTools = [
|
|
...(exposeWf ? [{ name: wfToolName || "run_workflow", kind: "workflow" }] : []),
|
|
...(exposeKnowledge ? [{ name: "search_knowledge_base", kind: "knowledge" }] : []),
|
|
...(exposeFaq ? [{ name: "lookup_faq", kind: "qa" }] : []),
|
|
];
|
|
|
|
// Run API (server-to-server): a backend hits the Forge API DIRECTLY, not the web proxy.
|
|
// ONE endpoint per project - it runs the workflow chosen above; `stream` is the only
|
|
// per-request knob, and HITL flows through the same call (workflow-driven).
|
|
const base = (apiBase || "http://localhost:8000").replace(/\/$/, "");
|
|
const pid = project?.id || "<projectId>";
|
|
const runUrl = `${base}/v1/projects/${pid}/run`;
|
|
const curl = [
|
|
"# ONE endpoint. Auth with the service token; pass the caller's per-user secrets in",
|
|
"# X-Forge-Context (used by tools as {{ctx.*}}) - never put secrets in the body.",
|
|
`curl -sN "${runUrl}" \\`,
|
|
` -H "Authorization: Bearer $FORGE_SERVICE_API_TOKEN" \\`,
|
|
` -H "Content-Type: application/json" \\`,
|
|
` -H 'X-Forge-Context: {"jsessionid":"<user session>","csrf":"<user csrf>"}' \\`,
|
|
` -H "Accept: text/event-stream" \\`,
|
|
` -d '{"input":{"messages":[{"role":"user","content":"hello"}]},"end_user":{"id":"user-123"},"stream":true}'`,
|
|
'# -> SSE frames; the "ready" frame gives you a thread_id to continue the conversation.',
|
|
"",
|
|
"# stream:false returns a single JSON reply instead of SSE.",
|
|
"# answer a human-in-the-loop step the workflow raised (reuse the thread_id):",
|
|
`# -d '{"thread_id":"<thread>","resume":{"value":"approve"}}'`,
|
|
].join("\n");
|
|
|
|
// --- Reference payloads for the "How the integration works" section ---
|
|
// A trimmed SSE transcript: one `ready` frame, a couple of token deltas, then `done`.
|
|
const sampleStream = [
|
|
"event: ready",
|
|
'data: {"run_id":"run_a1b2","thread_id":"thr_x9"}',
|
|
"",
|
|
"event: node_start",
|
|
'data: {"node":"assistant"}',
|
|
"",
|
|
"event: messages",
|
|
'data: {"content":"Hi","type":"AIMessageChunk","node":"assistant"}',
|
|
"",
|
|
"event: messages",
|
|
'data: {"content":" there!","type":"AIMessageChunk","node":"assistant"}',
|
|
"",
|
|
"event: done",
|
|
'data: {"status":"done","answer":"Hi there!","total_tokens":812,"total_cost_usd":0.0021}',
|
|
].join("\n");
|
|
// The single object returned when stream:false (thread_id is added by the endpoint).
|
|
const sampleJson = JSON.stringify(
|
|
{
|
|
run_id: "run_a1b2", thread_id: "thr_x9", status: "done",
|
|
answer: "Hi there!", components: [], interrupted: false, interrupts: [],
|
|
total_tokens: 812, total_cost_usd: 0.0021,
|
|
},
|
|
null,
|
|
2,
|
|
);
|
|
|
|
async function saveApiWorkflow(next: string) {
|
|
setWfId(next);
|
|
setApiSave("saving");
|
|
const p = await api.getProject(project.id);
|
|
await api.updateProject(project.id, { config: { ...(p.config || {}), api_workflow_id: next || undefined } });
|
|
setApiSave("saved");
|
|
setTimeout(() => setApiSave("idle"), 1200);
|
|
}
|
|
|
|
async function saveKey(next: string) {
|
|
setApiKey(next); setSave("saving");
|
|
const p = await api.getProject(project.id);
|
|
await api.updateProject(project.id, { config: { ...(p.config || {}), mcp_api_key: next || undefined } });
|
|
setSave("saved"); setTimeout(() => setSave("idle"), 1200);
|
|
}
|
|
// Merge a patch into project.config (undefined values drop the key). Used by the project-tool
|
|
// toggles; re-reads config first so concurrent edits to other keys aren't clobbered.
|
|
async function saveCfg(patch: Record<string, unknown>) {
|
|
setCfgSave("saving");
|
|
const p = await api.getProject(project.id);
|
|
await api.updateProject(project.id, { config: { ...(p.config || {}), ...patch } });
|
|
setCfgSave("saved"); setTimeout(() => setCfgSave("idle"), 1200);
|
|
}
|
|
const genKey = () => {
|
|
// A shared MCP API key is a credential, so use the CSPRNG (crypto.getRandomValues),
|
|
// never Math.random() - the latter is predictable and unsafe for secret material.
|
|
const bytes = new Uint8Array(24);
|
|
crypto.getRandomValues(bytes);
|
|
saveKey("fmcp_" + Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""));
|
|
};
|
|
|
|
// Publish a subset of tool sets on the base MCP endpoint (project.config.mcp_published_toolsets,
|
|
// a list of set slugs). Empty => the base endpoint exposes every enabled tool (prior behavior).
|
|
async function toggleExposed(ts: ToolSet) {
|
|
setTsSave("saving");
|
|
const updated = await api.updateToolSet(project.id, ts.id, { exposed: !ts.exposed });
|
|
setToolSets((prev) => prev.map((x) => (x.id === ts.id ? updated : x)));
|
|
setTsSave("saved");
|
|
setTimeout(() => setTsSave("idle"), 1200);
|
|
}
|
|
async function saveExcluded(next: string[]) {
|
|
setExcluded(next);
|
|
setTsSave("saving");
|
|
const p = await api.getProject(project.id);
|
|
await api.updateProject(project.id, { config: { ...(p.config || {}), mcp_excluded_tools: next.length ? next : undefined } });
|
|
setTsSave("saved");
|
|
setTimeout(() => setTsSave("idle"), 1200);
|
|
}
|
|
const toggleToolExcluded = (tid: string) =>
|
|
saveExcluded(excluded.includes(tid) ? excluded.filter((x) => x !== tid) : [...excluded, tid]);
|
|
const toggleOpenSet = (id: string) =>
|
|
setOpenSets((prev) => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; });
|
|
|
|
async function genToken() {
|
|
const t = await api.createMcpToken(project.id, {});
|
|
setNewToken(t.token || "");
|
|
api.listMcpTokens(project.id).then(setMcpTokens).catch(() => {});
|
|
}
|
|
async function revokeToken(id: string) {
|
|
await api.revokeMcpToken(project.id, id);
|
|
setMcpTokens((prev) => prev.filter((t) => t.id !== id));
|
|
}
|
|
|
|
const activeMeta = CONN_SECTIONS.find((s) => s.id === section)!;
|
|
return (
|
|
<div className="col" style={{ flex: 1, minHeight: 0 }}>
|
|
<div className="row" style={{ flex: 1, minHeight: 0, alignItems: "stretch" }}>
|
|
{/* secondary nav (same pattern as Settings) */}
|
|
<nav className="scroll-y" style={{ width: 224, flex: "none", borderRight: "1px solid var(--line)", background: "var(--bg-1)", padding: 10 }}>
|
|
<div className="t-micro" style={{ padding: "6px 8px 8px" }}>Connect</div>
|
|
{CONN_SECTIONS.map((s) => {
|
|
const on = section === s.id;
|
|
return (
|
|
<button key={s.id} onClick={() => setSection(s.id)} className={"sidenav-item" + (on ? " active" : "")}
|
|
style={{ display: "flex", alignItems: "center", gap: 10, width: "100%", height: 34, padding: "0 10px", paddingLeft: s.child ? 20 : 10, marginBottom: 1, borderRadius: 7, border: "none", cursor: "pointer", textAlign: "left", color: on ? "var(--accent)" : "var(--fg-1)", fontSize: 13, fontWeight: on ? 600 : 500, fontFamily: "var(--font-ui)" }}>
|
|
{s.child && <span aria-hidden style={{ color: "var(--fg-2)", fontSize: 13, lineHeight: 1, marginRight: -4, flex: "none" }}>└</span>}
|
|
<Icon name={s.icon as any} size={16} style={{ flex: "none" }} />
|
|
<span className="grow truncate">{s.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
{/* content */}
|
|
<div className="scroll-y grow" style={{ minWidth: 0 }}>
|
|
<div className="fade-up" style={{ maxWidth: 960, margin: "0 auto", padding: "24px 28px" }}>
|
|
<div style={{ marginBottom: 18 }}>
|
|
<div className="t-display">{activeMeta.label}</div>
|
|
<div className="fg-1" style={{ marginTop: 3 }}>{activeMeta.sub}</div>
|
|
</div>
|
|
|
|
{section === "run" && (
|
|
<>
|
|
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
|
<div className="row gap3" style={{ flexWrap: "wrap" }}>
|
|
<div style={{ flex: 1, minWidth: 240 }}>
|
|
<Field label="Forge API base URL" help="Where your backend reaches the Forge API directly (NOT the web console). Dev: http://localhost:8000. From another container on Forge's network: http://api:8000.">
|
|
<input className="input mono" value={apiBase} onChange={(e) => setApiBase(e.target.value)} placeholder="http://localhost:8000" />
|
|
</Field>
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 240 }}>
|
|
<Field label="Workflow this API runs" help="Saved on the project. The /run endpoint always executes this workflow — callers never pick one.">
|
|
<div className="row gap2" style={{ alignItems: "center" }}>
|
|
<select className="select" style={{ flex: 1 }} value={wfId} onChange={(e) => saveApiWorkflow(e.target.value)}>
|
|
{workflows.length === 0 && <option value="">No workflows yet</option>}
|
|
{workflows.map((w) => <option key={w.id} value={w.id}>{w.name}{w.status !== "active" ? ` (${w.status})` : ""}</option>)}
|
|
</select>
|
|
<span className="t-caption fg-2" style={{ minWidth: 52 }}>{apiSave === "saving" ? "Saving…" : apiSave === "saved" ? "Saved ✓" : ""}</span>
|
|
</div>
|
|
</Field>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
|
<div className="t-h3" style={{ marginBottom: 8 }}>Endpoint · POST</div>
|
|
<CodeBlock code={runUrl} />
|
|
<div className="field-help" style={{ marginTop: 8 }}>
|
|
One call does everything. Send <span className="mono-sm">{"{ input, stream }"}</span> for a new turn (reuse the returned <span className="mono-sm">thread_id</span> to continue a conversation), or <span className="mono-sm">{"{ thread_id, resume }"}</span> to answer a human-in-the-loop step the workflow raised. <span className="mono-sm">stream: true</span> streams SSE (tokens, steps, tools); <span className="mono-sm">false</span> returns one JSON reply. Authenticate with <span className="mono-sm">Authorization: Bearer <FORGE_SERVICE_API_TOKEN></span>; pass the caller's per-user session/CSRF as <span className="mono-sm">X-Forge-Context</span> so tools act on their behalf — never put secrets in the body.
|
|
</div>
|
|
</div>
|
|
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
|
<div className="t-h3" style={{ marginBottom: 8 }}>Example (curl)</div>
|
|
<CodeBlock code={curl} />
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{section === "reference" && (
|
|
<>
|
|
<div className="fg-2 t-caption" style={{ marginBottom: 10 }}>The same endpoint responds two ways depending on the <span className="mono-sm">stream</span> flag. Expand a section for the wire format.</div>
|
|
|
|
<Collapse title="Streaming — stream: true" sub="Server-Sent Events (text/event-stream): tokens, steps and tool activity as they happen." defaultOpen>
|
|
<div className="field-help" style={{ margin: "10px 0" }}>
|
|
The response stays open and emits <span className="mono-sm">event:</span> / <span className="mono-sm">data:</span> frames (data is JSON). Read it with any SSE client and keep the connection until a <span className="mono-sm">done</span>, <span className="mono-sm">error</span> or <span className="mono-sm">interrupt</span> frame arrives. Build the reply by concatenating each <span className="mono-sm">messages</span> frame's <span className="mono-sm">content</span> in order; the final <span className="mono-sm">done</span> frame also carries the whole <span className="mono-sm">answer</span> (authoritative — it covers non-LLM steps that never stream tokens).
|
|
</div>
|
|
<div style={{ margin: "10px 0" }}>
|
|
<FrameRow event="ready">First frame. <span className="mono-sm">{"{ run_id, thread_id }"}</span> — save <span className="mono-sm">thread_id</span> to continue this conversation.</FrameRow>
|
|
<FrameRow event="node_start">A workflow step began. <span className="mono-sm">{"{ node }"}</span>.</FrameRow>
|
|
<FrameRow event="messages">Assistant answer token delta. <span className="mono-sm">{"{ content, type, node }"}</span> — concatenate <span className="mono-sm">content</span>.</FrameRow>
|
|
<FrameRow event="updates">Top-level step output/state change (nested sub-steps are omitted to keep it clean).</FrameRow>
|
|
<FrameRow event="custom">App-emitted data a node chose to stream (e.g. rich components).</FrameRow>
|
|
<FrameRow event="interrupt">A human-in-the-loop step is waiting. Answer it with <span className="mono-sm">{"{ thread_id, resume }"}</span>.</FrameRow>
|
|
<FrameRow event="node_error">A step failed. <span className="mono-sm">{"{ node, message }"}</span>.</FrameRow>
|
|
<FrameRow event="done">Terminal. <span className="mono-sm">{"{ status, answer, total_tokens, total_cost_usd }"}</span>.</FrameRow>
|
|
<FrameRow event="error">Terminal error. <span className="mono-sm">{"{ message }"}</span>.</FrameRow>
|
|
</div>
|
|
<CodeBlock code={sampleStream} />
|
|
</Collapse>
|
|
|
|
<Collapse title="Non-streaming — stream: false" sub="One JSON object, returned once the run finishes.">
|
|
<div className="field-help" style={{ margin: "10px 0" }}>
|
|
Simplest to consume: a normal <span className="mono-sm">application/json</span> response after the run completes. <span className="mono-sm">status</span> is one of <span className="mono-sm">done · interrupted · error · busy</span>. When <span className="mono-sm">interrupted</span>, <span className="mono-sm">interrupts</span> holds the human-in-the-loop payload — answer it by re-calling with <span className="mono-sm">{"{ thread_id, resume }"}</span>. <span className="mono-sm">answer</span> is the full reply; <span className="mono-sm">components</span> carries any structured UI a node produced.
|
|
</div>
|
|
<CodeBlock code={sampleJson} />
|
|
</Collapse>
|
|
|
|
<Collapse title="Continue a conversation & human-in-the-loop" sub="Reuse thread_id across turns; resume interrupts on the same thread.">
|
|
<div className="field-help" style={{ margin: "10px 0" }}>
|
|
Chat memory is keyed by <span className="mono-sm">thread_id</span>. Take it from the <span className="mono-sm">ready</span> frame (streaming) or the JSON reply (non-streaming) and send it back in the next request's body to keep context across turns — omit it to start fresh. To answer an interrupt the workflow raised, POST the same endpoint with the interrupted thread and a resume value:
|
|
</div>
|
|
<CodeBlock code={'{ "thread_id": "thr_x9", "resume": { "value": "approve" } }'} />
|
|
</Collapse>
|
|
|
|
<Collapse title="Per-user identity — X-Forge-Context" sub="Pass the caller's secrets out-of-band; tools read them as {{ctx.*}}.">
|
|
<div className="field-help" style={{ margin: "10px 0" }}>
|
|
Authenticate the call itself with the service token (<span className="mono-sm">Authorization: Bearer <FORGE_SERVICE_API_TOKEN></span>). Anything the workflow's tools need to act <em>as the end user</em> — a session cookie, CSRF token, downstream bearer — goes in the <span className="mono-sm">X-Forge-Context</span> header as a JSON object, and tools reference it with <span className="mono-sm">{"{{ctx.*}}"}</span>. It is never written to the body, never persisted, and never echoed back.
|
|
</div>
|
|
<CodeBlock code={"X-Forge-Context: {\"jsessionid\":\"<user session>\",\"csrf\":\"<user csrf>\"}"} />
|
|
<div className="field-help" style={{ marginTop: 8 }}>Identify the end user for quotas/analytics with <span className="mono-sm">{"{ \"end_user\": { \"id\": \"user-123\" } }"}</span> in the body.</div>
|
|
</Collapse>
|
|
|
|
</>
|
|
)}
|
|
|
|
{section === "mcp" && (
|
|
<>
|
|
<Collapse title="How to use this MCP server" sub="Publish toolsets, connect a client, and pick how each user authenticates.">
|
|
<ol className="field-help" style={{ margin: "10px 0", paddingLeft: 18, lineHeight: 1.75 }}>
|
|
<li><b>Curate what's exposed.</b> Everything is published by default over the single endpoint below — under <b>Toolsets</b>, untick a whole set, or open a set and untick individual tools, to leave them out. (MCP shows the client a flat tool list; toolsets are just how you organize it.)</li>
|
|
<li><b>Set an API key</b> below — the shared server-to-server credential (<span className="mono-sm">Authorization: Bearer <key></span>). Without a key the server is closed.</li>
|
|
<li><b>Add the endpoint</b> to your MCP client (Claude Desktop, Cursor, VS Code) with the config block below — that one URL is all a client needs.</li>
|
|
<li><b>Authenticate each user</b> — pick one: a <b>personal access token</b> (each user generates one below and pastes it into their client); <b>OAuth 2.1</b> (when enabled, the client discovers Forge and the user logs in — nothing to copy); or <b>your own backend</b> (mint a session token / use Connect and pass the user's session in <span className="mono-sm">X-Forge-Context</span>).</li>
|
|
<li><b>Act as the user downstream.</b> So a tool calls <em>your</em> app as that user, connect each user's account under <b>Auth providers</b> (Forge stores a per-user credential) or inject their session via <span className="mono-sm">{"{{ctx.*}}"}</span>. The MCP token itself is never forwarded. Your app owns its users & sessions; Forge only carries the identity.</li>
|
|
</ol>
|
|
</Collapse>
|
|
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
|
<div className="t-h3" style={{ marginBottom: 8 }}>MCP endpoint</div>
|
|
<CodeBlock code={url} />
|
|
<div className="field-help">JSON-RPC over HTTP (initialize / tools/list / tools/call) · this is the Forge API host directly (not the console) so OAuth sign-in / auto-registration work · authenticate with the API key or a personal token below.</div>
|
|
</div>
|
|
{/* Authentication: the API key and personal access token are alternatives - a client
|
|
pastes ONE of them as its Bearer token - so they live behind a two-tab switch. */}
|
|
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
|
<div className="row spread" style={{ marginBottom: 10, alignItems: "center" }}>
|
|
<div className="t-h3">Authentication</div>
|
|
<Segmented options={[{ value: "key", label: "API key" }, { value: "pat", label: "Personal access token" }]} value={credTab} onChange={(v) => setCredTab(v as "key" | "pat")} />
|
|
</div>
|
|
<div className="field-help" style={{ marginBottom: 12 }}>Use <b>one</b> of these as the <span className="mono-sm">Bearer</span> token in the config below — the shared <b>API key</b> (server-to-server, one identity) <b>or</b> your own <b>personal access token</b> (acts as you).</div>
|
|
{credTab === "key" ? (
|
|
<div className="fade-in">
|
|
<div className="row gap2">
|
|
<input className="input mono" style={{ flex: 1 }} type="text" value={apiKey} onChange={(e) => setApiKey(e.target.value)} onBlur={(e) => saveKey(e.target.value)} placeholder="Set a key to expose the server" />
|
|
<button className="btn btn-secondary btn-sm" onClick={genKey}><Icon name="refresh" size={13} />Generate</button>
|
|
<span className="t-caption fg-2" style={{ alignSelf: "center", whiteSpace: "nowrap" }}>{save === "saving" ? "Saving…" : save === "saved" ? "Saved ✓" : ""}</span>
|
|
</div>
|
|
<div className="field-help">Shared server-to-server credential. Without a key the endpoint is closed to everyone.</div>
|
|
</div>
|
|
) : (
|
|
<div className="fade-in">
|
|
<div className="field-help" style={{ marginBottom: 10 }}>
|
|
A per-user token to paste into your own MCP client instead of the shared key — the server then acts as <b>you</b> (your entitlements). Shown once on creation; store it safely.
|
|
</div>
|
|
{newToken && (
|
|
<div className="card" style={{ padding: 10, marginBottom: 10, background: "var(--bg-2)" }}>
|
|
<div className="t-caption fg-2" style={{ marginBottom: 4 }}>New token — copy now, it won't be shown again:</div>
|
|
<CodeBlock code={newToken} />
|
|
</div>
|
|
)}
|
|
<div className="row gap2" style={{ marginBottom: 10 }}>
|
|
<button className="btn btn-secondary btn-sm" onClick={genToken}><Icon name="plus" size={13} />Generate token</button>
|
|
</div>
|
|
<div className="col gap1">
|
|
{mcpTokens.map((t) => (
|
|
<div key={t.id} className="row spread" style={{ padding: "6px 0", borderTop: "1px solid var(--line)" }}>
|
|
<div className="row gap2" style={{ alignItems: "center", minWidth: 0 }}>
|
|
<Icon name="auth" size={13} style={{ color: "var(--fg-2)" }} />
|
|
<span className="mono-sm truncate">{t.name}</span>
|
|
<span className="typechip">{t.prefix}…</span>
|
|
{t.status !== "active" && <span className="pill pill-muted" style={{ height: 16 }}>{t.status}</span>}
|
|
</div>
|
|
{t.status === "active" && <button className="iconbtn" title="Revoke token" onClick={() => revokeToken(t.id)}><Icon name="trash" size={14} /></button>}
|
|
</div>
|
|
))}
|
|
{mcpTokens.length === 0 && <div className="fg-2 t-caption">No personal tokens yet.</div>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{perUserAps.length > 0 && (
|
|
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
|
<div className="t-h3" style={{ marginBottom: 6 }}>Connect your accounts</div>
|
|
<div className="field-help" style={{ marginBottom: 12 }}>
|
|
These tools call downstream systems <b>as you</b>. Paste your own token for each — stored per-user and encrypted, used only for your calls. This is what lets your MCP tool calls act as you, with no shared secret and no admin setup.
|
|
</div>
|
|
<div className="col gap2">
|
|
{perUserAps.map((ap) => <MyConnectionCard key={ap.id} project={project} ap={ap} />)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
|
<div className="t-h3" style={{ marginBottom: 8 }}>Claude Desktop / Cursor config</div>
|
|
<CodeBlock code={claudeConfig} />
|
|
</div>
|
|
{/* Project-level tools: the whole workflow, knowledge-base search, and curated Q&A lookup,
|
|
each a project.config flag on the server (mcp_server.py). Independent of toolsets. */}
|
|
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
|
<div className="row spread" style={{ marginBottom: 8 }}>
|
|
<div className="t-h3">Project tools</div>
|
|
<span className="t-caption fg-2">{cfgSave === "saving" ? "Saving…" : cfgSave === "saved" ? "Saved ✓" : ""}</span>
|
|
</div>
|
|
<div className="field-help" style={{ marginBottom: 12 }}>
|
|
Publish this project's built-in capabilities as MCP tools, independent of toolsets. Knowledge and Q&A search this project's knowledge base; the workflow tool runs the workflow chosen under <b>Run API</b>.
|
|
</div>
|
|
<div className="col gap1">
|
|
<div 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: exposeWf ? "var(--fg-0)" : "var(--fg-2)" }}>{wfToolName || "run_workflow"}</span>
|
|
<span className="t-caption fg-2">Run the whole configured workflow as a single tool.</span>
|
|
{exposeWf && (
|
|
<div className="row gap2" style={{ marginTop: 6, alignItems: "center" }}>
|
|
<span className="t-caption fg-2">Tool name</span>
|
|
<input className="input mono" style={{ maxWidth: 240, height: 30 }} value={wfToolName}
|
|
onChange={(e) => setWfToolName(e.target.value)}
|
|
onBlur={(e) => { const v = e.target.value.trim().replace(/[^a-zA-Z0-9_-]/g, "_"); setWfToolName(v || "run_workflow"); saveCfg({ mcp_workflow_tool_name: v && v !== "run_workflow" ? v : undefined }); }}
|
|
placeholder="run_workflow" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Toggle on={exposeWf} onChange={(v) => { setExposeWf(v); saveCfg({ mcp_expose_workflow: v || undefined }); }} />
|
|
</div>
|
|
<div 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: exposeKnowledge ? "var(--fg-0)" : "var(--fg-2)" }}>search_knowledge_base</span>
|
|
<span className="t-caption fg-2">Vector search over this project's knowledge-base documents.</span>
|
|
</div>
|
|
<Toggle on={exposeKnowledge} onChange={(v) => { setExposeKnowledge(v); saveCfg({ mcp_expose_knowledge: v || undefined }); }} />
|
|
</div>
|
|
<div 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: exposeFaq ? "var(--fg-0)" : "var(--fg-2)" }}>lookup_faq</span>
|
|
<span className="t-caption fg-2">Semantic match over this project's curated Q&A / FAQ pairs.</span>
|
|
</div>
|
|
<Toggle on={exposeFaq} onChange={(v) => { setExposeFaq(v); saveCfg({ mcp_expose_faq: v || undefined }); }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
|
<div className="row spread" style={{ marginBottom: 8 }}>
|
|
<div className="t-h3">Toolsets</div>
|
|
<span className="t-caption fg-2">{tsSave === "saving" ? "Saving…" : tsSave === "saved" ? "Saved ✓" : ""}</span>
|
|
</div>
|
|
<div className="field-help" style={{ marginBottom: 10 }}>
|
|
Everything is exposed by default over the single MCP endpoint above — untick a toolset, or open one and untick individual tools, to leave them out. Create and fill sets on the <b>Tools</b> screen.
|
|
</div>
|
|
{toolSets.length === 0 && <div className="fg-2 t-caption">No tool sets yet — create one on the Tools screen.</div>}
|
|
<div className="col gap1">
|
|
{toolSets.map((ts) => {
|
|
const open = openSets.has(ts.id);
|
|
const members = ts.tool_ids.map((id) => toolById.get(id)).filter(Boolean) as typeof tools;
|
|
const shown = ts.exposed ? members.filter((t) => !excluded.includes(t.id)).length : 0;
|
|
return (
|
|
<div key={ts.id} className="card" style={{ padding: 0, overflow: "hidden" }}>
|
|
<div className="row spread" style={{ padding: "8px 10px", cursor: "pointer" }} onClick={() => toggleOpenSet(ts.id)}>
|
|
<div className="row gap2" style={{ alignItems: "center", minWidth: 0 }}>
|
|
<Icon name={open ? "chevdown" : "chevright"} size={14} style={{ color: "var(--fg-2)", flex: "none" }} />
|
|
<span className="mono-sm truncate">{ts.name}</span>
|
|
<span className="t-caption fg-2">{shown}/{members.length}</span>
|
|
{!ts.exposed && <span className="pill pill-muted" style={{ height: 16 }}>excluded</span>}
|
|
</div>
|
|
<label className="row gap1" style={{ alignItems: "center", cursor: "pointer", flex: "none" }} onClick={(e) => e.stopPropagation()} title="Expose this whole toolset over MCP">
|
|
<input type="checkbox" checked={ts.exposed} onChange={() => toggleExposed(ts)} />
|
|
<span className="t-caption fg-2">Expose</span>
|
|
</label>
|
|
</div>
|
|
{open && (
|
|
<div className="col gap1" style={{ padding: "6px 12px 10px 30px", borderTop: "1px solid var(--line)" }}>
|
|
{members.length === 0 && <div className="t-caption fg-2">No tools in this set yet.</div>}
|
|
{members.map((t) => (
|
|
<label key={t.id} className="row gap2" style={{ alignItems: "center", cursor: ts.exposed ? "pointer" : "default", opacity: ts.exposed ? 1 : 0.5 }}>
|
|
<input type="checkbox" disabled={!ts.exposed} checked={ts.exposed && !excluded.includes(t.id)} onChange={() => toggleToolExcluded(t.id)} />
|
|
<span className="mono-sm">{t.name}</span><span className="typechip">{t.kind}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div className="card" style={{ padding: 16 }}>
|
|
<div className="t-h3" style={{ marginBottom: 10 }}>Currently exposed tools ({exposedTools.length + projectTools.length})</div>
|
|
<div className="col gap2">
|
|
{projectTools.map((t) => (
|
|
<div key={t.name} className="row gap2"><Icon name="connect" size={14} style={{ color: "var(--accent)" }} /><span className="mono-sm">{t.name}</span><span className="typechip">{t.kind}</span></div>
|
|
))}
|
|
{exposedTools.map((t) => (
|
|
<div key={t.id} className="row gap2"><Icon name="tools" size={14} style={{ color: "var(--fg-2)" }} /><span className="mono-sm">{t.name}</span><span className="typechip">{t.kind}</span></div>
|
|
))}
|
|
{exposedTools.length === 0 && projectTools.length === 0 && <div className="fg-2 t-caption">Nothing exposed yet — enable a project tool above, or put tools in a set and toggle it on.</div>}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{section === "embed" && <EmbedPanel project={project} />}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|