Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/public/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
DoctorRoute,
EnvarsRoute,
GeneralRoute,
McpRoute,
ModelsRoute,
NodesRoute,
RouteRedirect,
Expand Down Expand Up @@ -454,6 +455,9 @@ const App = () => {
onNavigateToBrowseFile=${browseActions.navigateToBrowseFile}
/>
</${Route}>
<${Route} path="/mcp">
<${McpRoute} />
</${Route}>
<${Route}>
<${RouteRedirect} to="/general" />
</${Route}>
Expand Down
11 changes: 11 additions & 0 deletions lib/public/js/components/icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -529,3 +529,14 @@ export const ComputerLineIcon = ({ className = "" }) => html`
<path d="M4 16H20V5H4V16ZM13 18V20H17V22H7V20H11V18H2.9918C2.44405 18 2 17.5511 2 16.9925V4.00748C2 3.45107 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44892 22 4.00748V16.9925C22 17.5489 21.5447 18 21.0082 18H13Z" />
</svg>
`;

export const PlugLineIcon = ({ className = "" }) => html`
<svg
class=${className}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M13 18V20H17V22H7V20H11V18H6C5.44772 18 5 17.5523 5 17V10.0001H3V8.00005H5V6C5 5.44772 5.44772 5 6 5H9V2H11V5H13V2H15V5H18C18.5523 5 19 5.44772 19 6V8.00005H21V10.0001H19V17C19 17.5523 18.5523 18 18 18H13ZM7 7V16H17V7H7Z" />
</svg>
`;
236 changes: 236 additions & 0 deletions lib/public/js/components/mcp-tab/index.js
Original file line number Diff line number Diff line change
@@ -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`
<span
class="inline-block w-2 h-2 rounded-full shrink-0 ${active
? "bg-green-500"
: "bg-gray-600"}"
/>
`;

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 || "<gateway-token>",
});

if (loading && !info) {
return html`
<${PaneShell} header=${html`<${PageHeader} title="MCP" />`}>
<div class="bg-surface border border-border rounded-xl p-4 text-sm text-fg-muted">
Loading...
</div>
</${PaneShell}>
`;
}

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 -->
<div class="bg-surface border border-border rounded-xl overflow-hidden">
<h3 class="card-label text-xs px-4 pt-3 pb-2">Status</h3>
<div class="px-4 pb-3 space-y-2">
<div class="flex items-center gap-2 text-sm">
<${StatusDot} active=${running} />
<span class="text-body">
MCP Bridge: ${running ? "Running" : "Stopped"}
</span>
${running && info?.pid
? html`<span class="text-fg-dim text-xs">(PID ${info.pid})</span>`
: null}
</div>
<div class="flex items-center gap-2 text-sm">
<${StatusDot} active=${tokenAvailable} />
<span class="text-body">
Gateway token: ${tokenAvailable ? "Configured" : "Not set"}
</span>
</div>
${info?.gatewayWsUrl
? html`<div class="text-xs text-fg-dim">
Gateway: <code class="bg-field px-1 rounded">${info.gatewayWsUrl}</code>
</div>`
: null}
</div>
</div>

<!-- Config Snippet -->
<div class="bg-surface border border-border rounded-xl overflow-hidden">
<div class="flex items-center justify-between px-4 pt-3 pb-2">
<h3 class="card-label text-xs">Client Config</h3>
<button
onclick=${handleCopy}
class="text-xs px-2 py-0.5 rounded border border-border text-fg-muted hover:text-body hover:border-fg-muted"
>
Copy
</button>
</div>
<div class="px-4 pb-3">
<p class="text-xs text-fg-dim mb-2">
Add this to your MCP client config (Cursor, Claude Desktop, etc.):
</p>
<pre
class="bg-field border border-border rounded-lg p-3 text-xs text-body font-mono overflow-x-auto whitespace-pre"
>${configSnippet}</pre>
${!running
? html`<p class="text-xs text-status-warning-muted mt-2">
Start the MCP bridge above before connecting a client.
</p>`
: null}
</div>
</div>

<!-- Available Tools -->
<div class="bg-surface border border-border rounded-xl overflow-hidden">
<h3 class="card-label text-xs px-4 pt-3 pb-2">Available Tools</h3>
<div class="divide-y divide-border">
${kMcpTools.map(
(tool) => html`
<div class="flex items-start gap-3 px-4 py-2">
<code class="text-xs shrink-0 pt-0.5" style="min-width: 170px"
>${tool.name}</code
>
<span class="text-xs text-fg-dim">${tool.desc}</span>
</div>
`,
)}
</div>
</div>
</${PaneShell}>
`;
};
1 change: 1 addition & 0 deletions lib/public/js/components/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
7 changes: 7 additions & 0 deletions lib/public/js/components/routes/mcp-route.js
Original file line number Diff line number Diff line change
@@ -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} />`;
2 changes: 2 additions & 0 deletions lib/public/js/components/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
ComputerLineIcon,
EyeLineIcon,
FolderLineIcon,
PlugLineIcon,
HomeLineIcon,
PulseLineIcon,
RobotLineIcon,
Expand Down Expand Up @@ -59,6 +60,7 @@ const kSidebarNavIconsById = {
envars: BracesLineIcon,
webhooks: SignalTowerLineIcon,
nodes: ComputerLineIcon,
mcp: PlugLineIcon,
};

const readStoredBrowseBottomPanelHeight = () => {
Expand Down
17 changes: 17 additions & 0 deletions lib/public/js/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
2 changes: 2 additions & 0 deletions lib/public/js/lib/app-navigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const kNavSections = [
{ id: "envars", label: "Envars" },
{ id: "webhooks", label: "Webhooks" },
{ id: "nodes", label: "Nodes" },
{ id: "mcp", label: "MCP" },
],
},
];
Expand All @@ -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;
};
7 changes: 7 additions & 0 deletions lib/server/init/register-server-routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -249,6 +250,12 @@ const registerServerRoutes = ({
gatewayToken: constants.GATEWAY_TOKEN,
fsModule: fs,
});
registerMcpRoutes({
app,
requireAuth,
constants,
gatewayEnv,
});
registerProxyRoutes({
app,
proxy,
Expand Down
Loading
Loading