"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 (
{open &&
{children}
}
); } /* One SSE frame documented in the streaming reference: event name + what its data carries. */ function FrameRow({ event, children }: { event: string; children: ReactNode }) { return (
{event} {children}
); } /* 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(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 (
{ap.name} {status?.connected ? connected : not connected}
{status?.connected && }
setToken(e.target.value)} placeholder="paste your token…" style={{ flex: 1 }} />
{err &&
{err}
}
); } /* ============ CONNECT (MCP) ============ */ export function ConnectScreen({ project }: { project: any }) { const [section, setSection] = useState("run"); const [tools, setTools] = useState([]); const [toolSets, setToolSets] = useState([]); const [tsSave, setTsSave] = useState<"idle" | "saving" | "saved">("idle"); const [excluded, setExcluded] = useState([]); const [openSets, setOpenSets] = useState>(new Set()); const [mcpTokens, setMcpTokens] = useState([]); // 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([]); 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([]); 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 || ""}`; const claudeConfig = JSON.stringify({ mcpServers: { [project?.slug || "forge"]: { url, headers: { Authorization: "Bearer " } } } }, 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 || ""; 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":"","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":"","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) { 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 (
{/* secondary nav (same pattern as Settings) */} {/* content */}
{activeMeta.label}
{activeMeta.sub}
{section === "run" && ( <>
setApiBase(e.target.value)} placeholder="http://localhost:8000" />
{apiSave === "saving" ? "Saving…" : apiSave === "saved" ? "Saved ✓" : ""}
Endpoint · POST
One call does everything. Send {"{ input, stream }"} for a new turn (reuse the returned thread_id to continue a conversation), or {"{ thread_id, resume }"} to answer a human-in-the-loop step the workflow raised. stream: true streams SSE (tokens, steps, tools); false returns one JSON reply. Authenticate with Authorization: Bearer <FORGE_SERVICE_API_TOKEN>; pass the caller's per-user session/CSRF as X-Forge-Context so tools act on their behalf — never put secrets in the body.
Example (curl)
)} {section === "reference" && ( <>
The same endpoint responds two ways depending on the stream flag. Expand a section for the wire format.
The response stays open and emits event: / data: frames (data is JSON). Read it with any SSE client and keep the connection until a done, error or interrupt frame arrives. Build the reply by concatenating each messages frame's content in order; the final done frame also carries the whole answer (authoritative — it covers non-LLM steps that never stream tokens).
First frame. {"{ run_id, thread_id }"} — save thread_id to continue this conversation. A workflow step began. {"{ node }"}. Assistant answer token delta. {"{ content, type, node }"} — concatenate content. Top-level step output/state change (nested sub-steps are omitted to keep it clean). App-emitted data a node chose to stream (e.g. rich components). A human-in-the-loop step is waiting. Answer it with {"{ thread_id, resume }"}. A step failed. {"{ node, message }"}. Terminal. {"{ status, answer, total_tokens, total_cost_usd }"}. Terminal error. {"{ message }"}.
Simplest to consume: a normal application/json response after the run completes. status is one of done · interrupted · error · busy. When interrupted, interrupts holds the human-in-the-loop payload — answer it by re-calling with {"{ thread_id, resume }"}. answer is the full reply; components carries any structured UI a node produced.
Chat memory is keyed by thread_id. Take it from the ready 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:
Authenticate the call itself with the service token (Authorization: Bearer <FORGE_SERVICE_API_TOKEN>). Anything the workflow's tools need to act as the end user — a session cookie, CSRF token, downstream bearer — goes in the X-Forge-Context header as a JSON object, and tools reference it with {"{{ctx.*}}"}. It is never written to the body, never persisted, and never echoed back.
\",\"csrf\":\"\"}"} />
Identify the end user for quotas/analytics with {"{ \"end_user\": { \"id\": \"user-123\" } }"} in the body.
)} {section === "mcp" && ( <>
  1. Curate what's exposed. Everything is published by default over the single endpoint below — under Toolsets, 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.)
  2. Set an API key below — the shared server-to-server credential (Authorization: Bearer <key>). Without a key the server is closed.
  3. Add the endpoint to your MCP client (Claude Desktop, Cursor, VS Code) with the config block below — that one URL is all a client needs.
  4. Authenticate each user — pick one: a personal access token (each user generates one below and pastes it into their client); OAuth 2.1 (when enabled, the client discovers Forge and the user logs in — nothing to copy); or your own backend (mint a session token / use Connect and pass the user's session in X-Forge-Context).
  5. Act as the user downstream. So a tool calls your app as that user, connect each user's account under Auth providers (Forge stores a per-user credential) or inject their session via {"{{ctx.*}}"}. The MCP token itself is never forwarded. Your app owns its users & sessions; Forge only carries the identity.
MCP endpoint
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.
{/* 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. */}
Authentication
setCredTab(v as "key" | "pat")} />
Use one of these as the Bearer token in the config below — the shared API key (server-to-server, one identity) or your own personal access token (acts as you).
{credTab === "key" ? (
setApiKey(e.target.value)} onBlur={(e) => saveKey(e.target.value)} placeholder="Set a key to expose the server" /> {save === "saving" ? "Saving…" : save === "saved" ? "Saved ✓" : ""}
Shared server-to-server credential. Without a key the endpoint is closed to everyone.
) : (
A per-user token to paste into your own MCP client instead of the shared key — the server then acts as you (your entitlements). Shown once on creation; store it safely.
{newToken && (
New token — copy now, it won't be shown again:
)}
{mcpTokens.map((t) => (
{t.name} {t.prefix}… {t.status !== "active" && {t.status}}
{t.status === "active" && }
))} {mcpTokens.length === 0 &&
No personal tokens yet.
}
)}
{perUserAps.length > 0 && (
Connect your accounts
These tools call downstream systems as you. 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.
{perUserAps.map((ap) => )}
)}
Claude Desktop / Cursor config
{/* 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. */}
Project tools
{cfgSave === "saving" ? "Saving…" : cfgSave === "saved" ? "Saved ✓" : ""}
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 Run API.
{wfToolName || "run_workflow"} Run the whole configured workflow as a single tool. {exposeWf && (
Tool name 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" />
)}
{ setExposeWf(v); saveCfg({ mcp_expose_workflow: v || undefined }); }} />
search_knowledge_base Vector search over this project's knowledge-base documents.
{ setExposeKnowledge(v); saveCfg({ mcp_expose_knowledge: v || undefined }); }} />
lookup_faq Semantic match over this project's curated Q&A / FAQ pairs.
{ setExposeFaq(v); saveCfg({ mcp_expose_faq: v || undefined }); }} />
Toolsets
{tsSave === "saving" ? "Saving…" : tsSave === "saved" ? "Saved ✓" : ""}
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 Tools screen.
{toolSets.length === 0 &&
No tool sets yet — create one on the Tools screen.
}
{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 (
toggleOpenSet(ts.id)}>
{ts.name} {shown}/{members.length} {!ts.exposed && excluded}
{open && (
{members.length === 0 &&
No tools in this set yet.
} {members.map((t) => ( ))}
)}
); })}
Currently exposed tools ({exposedTools.length + projectTools.length})
{projectTools.map((t) => (
{t.name}{t.kind}
))} {exposedTools.map((t) => (
{t.name}{t.kind}
))} {exposedTools.length === 0 && projectTools.length === 0 &&
Nothing exposed yet — enable a project tool above, or put tools in a set and toggle it on.
}
)} {section === "embed" && }
); }