diff --git a/lib/public/js/app.js b/lib/public/js/app.js index 3b5ecfe9..9cf53979 100644 --- a/lib/public/js/app.js +++ b/lib/public/js/app.js @@ -21,6 +21,7 @@ import { DoctorRoute, EnvarsRoute, GeneralRoute, + McpRoute, ModelsRoute, NodesRoute, RouteRedirect, @@ -454,6 +455,9 @@ const App = () => { onNavigateToBrowseFile=${browseActions.navigateToBrowseFile} /> + <${Route} path="/mcp"> + <${McpRoute} /> + <${Route}> <${RouteRedirect} to="/general" /> diff --git a/lib/public/js/components/icons.js b/lib/public/js/components/icons.js index 05500ccd..63a8860d 100644 --- a/lib/public/js/components/icons.js +++ b/lib/public/js/components/icons.js @@ -529,3 +529,14 @@ export const ComputerLineIcon = ({ className = "" }) => html` `; + +export const PlugLineIcon = ({ className = "" }) => html` + +`; diff --git a/lib/public/js/components/mcp-tab/index.js b/lib/public/js/components/mcp-tab/index.js new file mode 100644 index 00000000..2f3b4f5b --- /dev/null +++ b/lib/public/js/components/mcp-tab/index.js @@ -0,0 +1,236 @@ +import { h } from "preact"; +import { useState, useCallback } from "preact/hooks"; +import htm from "htm"; +import { + fetchMcpInfo, + startMcpBridge, + stopMcpBridge, +} from "../../lib/api.js"; +import { useCachedFetch } from "../../hooks/use-cached-fetch.js"; +import { showToast } from "../toast.js"; +import { PageHeader } from "../page-header.js"; +import { ActionButton } from "../action-button.js"; +import { PaneShell } from "../pane-shell.js"; + +const html = htm.bind(h); + +const kMcpTools = [ + { name: "conversations_list", desc: "List recent routed conversations with filters" }, + { name: "conversation_get", desc: "Return a single conversation by session key" }, + { name: "messages_read", desc: "Retrieve transcript history for a conversation" }, + { name: "attachments_fetch", desc: "Extract non-text content metadata from messages" }, + { name: "events_poll", desc: "Read queued live events since a cursor position" }, + { name: "events_wait", desc: "Long-poll for next matching event with timeout" }, + { name: "messages_send", desc: "Send text replies through existing routes" }, + { name: "permissions_list_open", desc: "List pending exec/plugin approval requests" }, + { name: "permissions_respond", desc: "Resolve approvals (allow-once, allow-always, deny)" }, +]; + +const StatusDot = ({ active }) => html` + +`; + +const buildConfigSnippet = ({ origin, token }) => { + const sseUrl = `${origin}/mcp/sse?token=${token}`; + return JSON.stringify( + { + mcpServers: { + openclaw: { + url: sseUrl, + }, + }, + }, + null, + 2, + ); +}; + +export const McpTab = () => { + const [acting, setActing] = useState(false); + + const { + data: info, + loading, + refresh, + } = useCachedFetch("/api/mcp/info", fetchMcpInfo, { + maxAgeMs: 5000, + }); + + const running = !!info?.running; + const tokenAvailable = !!info?.tokenAvailable; + const gatewayToken = info?.gatewayToken || ""; + + const handleStart = useCallback(async () => { + if (acting) return; + setActing(true); + try { + const result = await startMcpBridge(); + if (result?.ok) { + showToast( + result.alreadyRunning + ? "MCP bridge already running" + : "MCP bridge started", + "success", + ); + } else { + showToast("Failed to start MCP bridge", "error"); + } + await refresh({ force: true }); + } catch (err) { + showToast("Failed to start: " + err.message, "error"); + } finally { + setActing(false); + } + }, [acting, refresh]); + + const handleStop = useCallback(async () => { + if (acting) return; + setActing(true); + try { + const result = await stopMcpBridge(); + if (result?.ok) { + showToast("MCP bridge stopped", "success"); + } else { + showToast("Failed to stop MCP bridge", "error"); + } + await refresh({ force: true }); + } catch (err) { + showToast("Failed to stop: " + err.message, "error"); + } finally { + setActing(false); + } + }, [acting, refresh]); + + const handleCopy = useCallback(() => { + const origin = window.location.origin; + const snippet = buildConfigSnippet({ origin, token: gatewayToken }); + navigator.clipboard + .writeText(snippet) + .then(() => showToast("Copied to clipboard", "success")) + .catch(() => showToast("Failed to copy", "error")); + }, [gatewayToken]); + + const configSnippet = buildConfigSnippet({ + origin: typeof window !== "undefined" ? window.location.origin : "https://your-host", + token: gatewayToken || "", + }); + + if (loading && !info) { + return html` + <${PaneShell} header=${html`<${PageHeader} title="MCP" />`}> +
+ Loading... +
+ + `; + } + + return html` + <${PaneShell} + header=${html` + <${PageHeader} + title="MCP" + actions=${html` + ${running + ? html`<${ActionButton} + onClick=${handleStop} + disabled=${acting} + loading=${acting} + loadingMode="inline" + tone="secondary" + size="sm" + idleLabel="Stop bridge" + loadingLabel="Stopping…" + className="text-xs" + />` + : html`<${ActionButton} + onClick=${handleStart} + disabled=${acting} + loading=${acting} + loadingMode="inline" + tone="primary" + size="sm" + idleLabel="Start bridge" + loadingLabel="Starting…" + className="text-xs" + />`} + `} + /> + `} + > + +
+

Status

+
+
+ <${StatusDot} active=${running} /> + + MCP Bridge: ${running ? "Running" : "Stopped"} + + ${running && info?.pid + ? html`(PID ${info.pid})` + : null} +
+
+ <${StatusDot} active=${tokenAvailable} /> + + Gateway token: ${tokenAvailable ? "Configured" : "Not set"} + +
+ ${info?.gatewayWsUrl + ? html`
+ Gateway: ${info.gatewayWsUrl} +
` + : null} +
+
+ + +
+
+

Client Config

+ +
+
+

+ Add this to your MCP client config (Cursor, Claude Desktop, etc.): +

+
${configSnippet}
+ ${!running + ? html`

+ Start the MCP bridge above before connecting a client. +

` + : null} +
+
+ + +
+

Available Tools

+
+ ${kMcpTools.map( + (tool) => html` +
+ ${tool.name} + ${tool.desc} +
+ `, + )} +
+
+ + `; +}; diff --git a/lib/public/js/components/routes/index.js b/lib/public/js/components/routes/index.js index 312aeb2f..a01d459a 100644 --- a/lib/public/js/components/routes/index.js +++ b/lib/public/js/components/routes/index.js @@ -5,6 +5,7 @@ export { CronRoute } from "./cron-route.js"; export { DoctorRoute } from "./doctor-route.js"; export { EnvarsRoute } from "./envars-route.js"; export { GeneralRoute } from "./general-route.js"; +export { McpRoute } from "./mcp-route.js"; export { ModelsRoute } from "./models-route.js"; export { NodesRoute } from "./nodes-route.js"; export { ProvidersRoute } from "./providers-route.js"; diff --git a/lib/public/js/components/routes/mcp-route.js b/lib/public/js/components/routes/mcp-route.js new file mode 100644 index 00000000..9a6ab538 --- /dev/null +++ b/lib/public/js/components/routes/mcp-route.js @@ -0,0 +1,7 @@ +import { h } from "preact"; +import htm from "htm"; +import { McpTab } from "../mcp-tab/index.js"; + +const html = htm.bind(h); + +export const McpRoute = () => html`<${McpTab} />`; diff --git a/lib/public/js/components/sidebar.js b/lib/public/js/components/sidebar.js index d95bf824..8c9f6bd5 100644 --- a/lib/public/js/components/sidebar.js +++ b/lib/public/js/components/sidebar.js @@ -12,6 +12,7 @@ import { ComputerLineIcon, EyeLineIcon, FolderLineIcon, + PlugLineIcon, HomeLineIcon, PulseLineIcon, RobotLineIcon, @@ -59,6 +60,7 @@ const kSidebarNavIconsById = { envars: BracesLineIcon, webhooks: SignalTowerLineIcon, nodes: ComputerLineIcon, + mcp: PlugLineIcon, }; const readStoredBrowseBottomPanelHeight = () => { diff --git a/lib/public/js/lib/api.js b/lib/public/js/lib/api.js index f3cb4f93..ff86493d 100644 --- a/lib/public/js/lib/api.js +++ b/lib/public/js/lib/api.js @@ -1365,3 +1365,20 @@ export const syncBrowseChanges = async (message = "") => { }); return parseJsonOrThrow(res, "Could not sync changes"); }; + +// ── MCP ────────────────────────────────────────────────────────── + +export const fetchMcpInfo = async () => { + const res = await authFetch("/api/mcp/info"); + return res.json(); +}; + +export const startMcpBridge = async () => { + const res = await authFetch("/api/mcp/start", { method: "POST" }); + return res.json(); +}; + +export const stopMcpBridge = async () => { + const res = await authFetch("/api/mcp/stop", { method: "POST" }); + return res.json(); +}; diff --git a/lib/public/js/lib/app-navigation.js b/lib/public/js/lib/app-navigation.js index cb8fb97b..ce24538b 100644 --- a/lib/public/js/lib/app-navigation.js +++ b/lib/public/js/lib/app-navigation.js @@ -23,6 +23,7 @@ export const kNavSections = [ { id: "envars", label: "Envars" }, { id: "webhooks", label: "Webhooks" }, { id: "nodes", label: "Nodes" }, + { id: "mcp", label: "MCP" }, ], }, ]; @@ -41,5 +42,6 @@ export const getSelectedNavId = ({ isBrowseRoute = false, location = "" } = {}) if (location.startsWith("/nodes")) return "nodes"; if (location.startsWith("/envars")) return "envars"; if (location.startsWith("/webhooks")) return "webhooks"; + if (location.startsWith("/mcp")) return "mcp"; return kDefaultUiTab; }; diff --git a/lib/server/init/register-server-routes.js b/lib/server/init/register-server-routes.js index 5450b869..d6afbcb6 100644 --- a/lib/server/init/register-server-routes.js +++ b/lib/server/init/register-server-routes.js @@ -17,6 +17,7 @@ const { registerDoctorRoutes } = require("../routes/doctor"); const { registerAgentRoutes } = require("../routes/agents"); const { registerCronRoutes } = require("../routes/cron"); const { registerNodeRoutes } = require("../routes/nodes"); +const { registerMcpRoutes } = require("../routes/mcp"); const { createOauthCallbackMiddleware, } = require("../oauth-callback-middleware"); @@ -249,6 +250,12 @@ const registerServerRoutes = ({ gatewayToken: constants.GATEWAY_TOKEN, fsModule: fs, }); + registerMcpRoutes({ + app, + requireAuth, + constants, + gatewayEnv, + }); registerProxyRoutes({ app, proxy, diff --git a/lib/server/mcp-bridge.js b/lib/server/mcp-bridge.js new file mode 100644 index 00000000..2846369c --- /dev/null +++ b/lib/server/mcp-bridge.js @@ -0,0 +1,153 @@ +const { spawn } = require("child_process"); + +const kStderrTailLines = 50; + +let mcpChild = null; +let mcpStartedAt = null; +let mcpStderrTail = []; +let sseClient = null; +let stdoutBuffer = ""; + +const appendStderrTail = (chunk) => { + const text = Buffer.isBuffer(chunk) + ? chunk.toString("utf8") + : String(chunk ?? ""); + for (const line of text.split("\n")) { + const trimmed = line.trimEnd(); + if (!trimmed) continue; + mcpStderrTail.push(trimmed); + } + if (mcpStderrTail.length > kStderrTailLines) { + mcpStderrTail = mcpStderrTail.slice(-kStderrTailLines); + } +}; + +const isMcpBridgeRunning = () => + mcpChild !== null && mcpChild.exitCode === null && !mcpChild.killed; + +const getMcpBridgeStatus = () => ({ + running: isMcpBridgeRunning(), + pid: isMcpBridgeRunning() ? mcpChild.pid : null, + startedAt: isMcpBridgeRunning() ? mcpStartedAt : null, + stderrTail: mcpStderrTail.slice(-10), +}); + +const startMcpBridge = ({ gatewayEnv, gatewayPort, gatewayToken }) => { + if (isMcpBridgeRunning()) { + return { ok: true, alreadyRunning: true, ...getMcpBridgeStatus() }; + } + + const args = ["mcp", "serve"]; + if (gatewayPort) { + args.push("--url", `ws://127.0.0.1:${gatewayPort}`); + } + if (gatewayToken) { + args.push("--token", gatewayToken); + } + + mcpStderrTail = []; + stdoutBuffer = ""; + + const child = spawn("openclaw", args, { + env: gatewayEnv(), + stdio: ["pipe", "pipe", "pipe"], + }); + + mcpChild = child; + mcpStartedAt = Date.now(); + + child.stdout.on("data", (data) => { + const text = Buffer.isBuffer(data) ? data.toString("utf8") : String(data ?? ""); + stdoutBuffer += text; + + // MCP JSON-RPC messages are newline-delimited + let newlineIdx; + while ((newlineIdx = stdoutBuffer.indexOf("\n")) !== -1) { + const line = stdoutBuffer.slice(0, newlineIdx).trim(); + stdoutBuffer = stdoutBuffer.slice(newlineIdx + 1); + if (!line) continue; + if (sseClient) { + try { + sseClient.write(`event: message\ndata: ${line}\n\n`); + } catch {} + } + } + }); + + child.stderr.on("data", (data) => { + appendStderrTail(data); + process.stderr.write(`[mcp-bridge] ${data}`); + }); + + child.on("exit", (code, signal) => { + console.log( + `[mcp-bridge] Process exited with code ${code}${signal ? ` signal ${signal}` : ""}`, + ); + if (mcpChild === child) mcpChild = null; + if (sseClient) { + try { + sseClient.write(`event: error\ndata: ${JSON.stringify({ error: "MCP bridge process exited" })}\n\n`); + sseClient.end(); + } catch {} + sseClient = null; + } + }); + + console.log(`[mcp-bridge] Started MCP bridge (pid ${child.pid})`); + return { ok: true, alreadyRunning: false, ...getMcpBridgeStatus() }; +}; + +const stopMcpBridge = () => { + if (!isMcpBridgeRunning()) { + return { ok: true, wasStopped: true }; + } + const pid = mcpChild.pid; + mcpChild.kill("SIGTERM"); + mcpChild = null; + mcpStartedAt = null; + if (sseClient) { + try { + sseClient.end(); + } catch {} + sseClient = null; + } + console.log(`[mcp-bridge] Stopped MCP bridge (pid ${pid})`); + return { ok: true, wasStopped: false }; +}; + +const attachSseClient = (res) => { + if (sseClient) { + try { + sseClient.end(); + } catch {} + } + sseClient = res; +}; + +const detachSseClient = (res) => { + if (sseClient === res) { + sseClient = null; + } +}; + +const writeToMcpBridge = (jsonRpcMessage) => { + if (!isMcpBridgeRunning()) { + return false; + } + const payload = + typeof jsonRpcMessage === "string" + ? jsonRpcMessage + : JSON.stringify(jsonRpcMessage); + mcpChild.stdin.write(payload + "\n"); + return true; +}; + +module.exports = { + isMcpBridgeRunning, + getMcpBridgeStatus, + startMcpBridge, + stopMcpBridge, + attachSseClient, + detachSseClient, + writeToMcpBridge, +}; diff --git a/lib/server/routes/mcp.js b/lib/server/routes/mcp.js new file mode 100644 index 00000000..20152297 --- /dev/null +++ b/lib/server/routes/mcp.js @@ -0,0 +1,100 @@ +const { + isMcpBridgeRunning, + getMcpBridgeStatus, + startMcpBridge, + stopMcpBridge, + attachSseClient, + detachSseClient, + writeToMcpBridge, +} = require("../mcp-bridge"); +const { getGatewayPort } = require("../gateway"); + +const registerMcpRoutes = ({ + app, + requireAuth, + constants, + gatewayEnv, +}) => { + // ── Internal API (session auth) ──────────────────────────────── + + app.get("/api/mcp/info", requireAuth, (_req, res) => { + const port = getGatewayPort(); + res.json({ + ok: true, + ...getMcpBridgeStatus(), + gatewayPort: port, + gatewayWsUrl: `ws://127.0.0.1:${port}`, + tokenAvailable: !!constants.GATEWAY_TOKEN, + gatewayToken: constants.GATEWAY_TOKEN || "", + }); + }); + + app.post("/api/mcp/start", requireAuth, (_req, res) => { + const result = startMcpBridge({ + gatewayEnv, + gatewayPort: getGatewayPort(), + gatewayToken: constants.GATEWAY_TOKEN, + }); + res.json(result); + }); + + app.post("/api/mcp/stop", requireAuth, (_req, res) => { + const result = stopMcpBridge(); + res.json(result); + }); + + // ── MCP transport endpoints (token auth) ─────────────────────── + + const validateMcpToken = (req, res) => { + const token = req.query?.token || ""; + if (!token || token !== constants.GATEWAY_TOKEN) { + res.status(401).json({ error: "Invalid or missing token" }); + return false; + } + return true; + }; + + app.get("/mcp/sse", (req, res) => { + if (!validateMcpToken(req, res)) return; + + if (!isMcpBridgeRunning()) { + res.status(503).json({ error: "MCP bridge is not running" }); + return; + } + + res.status(200); + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache, no-transform"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("X-Accel-Buffering", "no"); + res.flushHeaders?.(); + + // Send the endpoint event per MCP SSE spec + const messageUrl = `${req.protocol}://${req.get("host")}/mcp/message?token=${encodeURIComponent(req.query.token)}`; + res.write(`event: endpoint\ndata: ${messageUrl}\n\n`); + + attachSseClient(res); + + req.on("close", () => { + detachSseClient(res); + }); + }); + + app.post("/mcp/message", (req, res) => { + if (!validateMcpToken(req, res)) return; + + if (!isMcpBridgeRunning()) { + res.status(503).json({ error: "MCP bridge is not running" }); + return; + } + + const wrote = writeToMcpBridge(req.body); + if (!wrote) { + res.status(503).json({ error: "Failed to write to MCP bridge" }); + return; + } + res.status(202).json({ ok: true }); + }); +}; + +module.exports = { registerMcpRoutes };