@chrysb/alphaclaw 0.8.3 → 0.9.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,7 @@ import {
21
21
  DoctorRoute,
22
22
  EnvarsRoute,
23
23
  GeneralRoute,
24
+ McpRoute,
24
25
  ModelsRoute,
25
26
  NodesRoute,
26
27
  RouteRedirect,
@@ -454,6 +455,9 @@ const App = () => {
454
455
  onNavigateToBrowseFile=${browseActions.navigateToBrowseFile}
455
456
  />
456
457
  </${Route}>
458
+ <${Route} path="/mcp">
459
+ <${McpRoute} />
460
+ </${Route}>
457
461
  <${Route}>
458
462
  <${RouteRedirect} to="/general" />
459
463
  </${Route}>
@@ -321,6 +321,19 @@ export const SignalTowerLineIcon = ({ className = "" }) => html`
321
321
  </svg>
322
322
  `;
323
323
 
324
+ export const WebhookLineIcon = ({ className = "" }) => html`
325
+ <svg
326
+ class=${className}
327
+ viewBox="0 0 24 24"
328
+ fill="currentColor"
329
+ aria-hidden="true"
330
+ >
331
+ <path
332
+ d="M8.86874 14.1392C8.6556 14.4912 8.55014 14.7778 8.72043 15.2253C9.1905 16.4613 8.52737 17.664 7.28097 17.9905C6.10556 18.2985 4.96035 17.526 4.72713 16.2676C4.52048 15.1537 5.38488 14.0617 6.61294 13.8877C6.67963 13.8781 6.74717 13.874 6.83351 13.8688C6.88044 13.866 6.93293 13.8628 6.99384 13.8582L8.86194 10.7257C7.687 9.55742 6.98767 8.19164 7.14246 6.49936C7.25188 5.30308 7.72226 4.26933 8.58208 3.42201C10.2288 1.79945 12.7411 1.53667 14.68 2.78212C16.5423 3.97841 17.3951 6.30867 16.6681 8.30311L14.9611 7.84C15.1895 6.73115 15.0206 5.73536 14.2727 4.88234C13.7786 4.31914 13.1446 4.02394 12.4236 3.91516C10.9783 3.69681 9.55922 4.6254 9.13816 6.04399C8.66019 7.65406 9.38355 8.96924 11.3603 9.96029C10.5311 11.3541 9.70859 12.7518 8.86874 14.1392ZM13.7838 8.27337C14.3816 9.32798 14.9886 10.3986 15.5902 11.4593C18.631 10.5186 20.9237 12.2018 21.7462 14.004C22.7396 16.1809 22.0605 18.7593 20.1094 20.1023C18.1067 21.481 15.5741 21.2454 13.7997 19.4744L15.1919 18.3094C16.9444 19.4445 18.4772 19.3911 19.6151 18.047C20.5855 16.9003 20.5644 15.1906 19.5659 14.068C18.4136 12.7726 16.8701 12.7331 15.0044 13.9767C14.2305 12.6037 13.443 11.2413 12.6936 9.85845C12.4409 9.39233 12.1618 9.12196 11.5923 9.0233C10.6411 8.85839 10.027 8.04157 9.99016 7.12642C9.95395 6.22138 10.4871 5.4033 11.3205 5.08455C12.146 4.7688 13.1148 5.02367 13.6701 5.72554C14.1239 6.29901 14.2681 6.94443 14.0293 7.65167C13.9843 7.7852 13.9304 7.91584 13.8713 8.05885C13.8431 8.12694 13.8138 8.19801 13.7838 8.27337ZM11.552 16.895H15.2126C15.2636 16.963 15.3113 17.0303 15.3579 17.0959C15.4551 17.233 15.5474 17.3632 15.6551 17.4788C16.4304 18.3077 17.7395 18.3489 18.5682 17.5795C19.4271 16.7821 19.466 15.4426 18.6544 14.6101C17.8602 13.7955 16.5029 13.7177 15.7655 14.5802C15.3176 15.1044 14.8586 15.166 14.2641 15.1567C12.7414 15.1332 11.2177 15.149 9.69524 15.149C9.79406 17.2909 8.98436 18.6255 7.37841 18.9424C5.80582 19.2528 4.3575 18.4504 3.84759 16.9864C3.26842 15.3229 3.98467 13.9925 6.05421 12.9366C5.89847 12.3725 5.74115 11.8016 5.58541 11.236C3.32977 11.7276 1.63749 13.916 1.8122 16.378C1.96652 18.5514 3.71968 20.4815 5.86369 20.8273C7.02819 21.0153 8.12233 20.82 9.13741 20.2442C10.4433 19.5032 11.2011 18.3381 11.552 16.895Z"
333
+ />
334
+ </svg>
335
+ `;
336
+
324
337
  export const GitBranchLineIcon = ({ className = "" }) => html`
325
338
  <svg
326
339
  class=${className}
@@ -529,3 +542,27 @@ export const ComputerLineIcon = ({ className = "" }) => html`
529
542
  <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" />
530
543
  </svg>
531
544
  `;
545
+
546
+ export const PlugLineIcon = ({ className = "" }) => html`
547
+ <svg
548
+ class=${className}
549
+ viewBox="0 0 24 24"
550
+ fill="currentColor"
551
+ aria-hidden="true"
552
+ >
553
+ <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" />
554
+ </svg>
555
+ `;
556
+
557
+ export const LinksLineIcon = ({ className = "" }) => html`
558
+ <svg
559
+ class=${className}
560
+ viewBox="0 0 24 24"
561
+ fill="currentColor"
562
+ aria-hidden="true"
563
+ >
564
+ <path
565
+ d="M13.0607 8.11097L14.4749 9.52518C17.2086 12.2589 17.2086 16.691 14.4749 19.4247L14.1214 19.7782C11.3877 22.5119 6.95555 22.5119 4.22188 19.7782C1.48821 17.0446 1.48821 12.6124 4.22188 9.87874L5.6361 11.293C3.68348 13.2456 3.68348 16.4114 5.6361 18.364C7.58872 20.3166 10.7545 20.3166 12.7072 18.364L13.0607 18.0105C15.0133 16.0578 15.0133 12.892 13.0607 10.9394L11.6465 9.52518L13.0607 8.11097ZM19.7782 14.1214L18.364 12.7072C20.3166 10.7545 20.3166 7.58872 18.364 5.6361C16.4114 3.68348 13.2456 3.68348 11.293 5.6361L10.9394 5.98965C8.98678 7.94227 8.98678 11.1081 10.9394 13.0607L12.3536 14.4749L10.9394 15.8891L9.52518 14.4749C6.79151 11.7413 6.79151 7.30911 9.52518 4.57544L9.87874 4.22188C12.6124 1.48821 17.0446 1.48821 19.7782 4.22188C22.5119 6.95555 22.5119 11.3877 19.7782 14.1214Z"
566
+ />
567
+ </svg>
568
+ `;
@@ -0,0 +1,237 @@
1
+ import { h } from "preact";
2
+ import { useState, useCallback } from "preact/hooks";
3
+ import htm from "htm";
4
+ import {
5
+ fetchMcpInfo,
6
+ startMcpBridge,
7
+ stopMcpBridge,
8
+ } from "../../lib/api.js";
9
+ import { usePolling } from "../../hooks/usePolling.js";
10
+ import { showToast } from "../toast.js";
11
+ import { PageHeader } from "../page-header.js";
12
+ import { ActionButton } from "../action-button.js";
13
+ import { PaneShell } from "../pane-shell.js";
14
+
15
+ const html = htm.bind(h);
16
+
17
+ const kMcpTools = [
18
+ { name: "conversations_list", desc: "List recent routed conversations with filters" },
19
+ { name: "conversation_get", desc: "Return a single conversation by session key" },
20
+ { name: "messages_read", desc: "Retrieve transcript history for a conversation" },
21
+ { name: "attachments_fetch", desc: "Extract non-text content metadata from messages" },
22
+ { name: "events_poll", desc: "Read queued live events since a cursor position" },
23
+ { name: "events_wait", desc: "Long-poll for next matching event with timeout" },
24
+ { name: "messages_send", desc: "Send text replies through existing routes" },
25
+ { name: "permissions_list_open", desc: "List pending exec/plugin approval requests" },
26
+ { name: "permissions_respond", desc: "Resolve approvals (allow-once, allow-always, deny)" },
27
+ ];
28
+
29
+ const StatusDot = ({ active }) => html`
30
+ <span
31
+ class="inline-block w-2 h-2 rounded-full shrink-0 ${active
32
+ ? "bg-green-500"
33
+ : "bg-gray-600"}"
34
+ />
35
+ `;
36
+
37
+ const buildConfigSnippet = ({ origin, token }) => {
38
+ const encodedToken = encodeURIComponent(String(token || ""));
39
+ const sseUrl = `${origin}/mcp/sse?token=${encodedToken}`;
40
+ return JSON.stringify(
41
+ {
42
+ mcpServers: {
43
+ openclaw: {
44
+ url: sseUrl,
45
+ },
46
+ },
47
+ },
48
+ null,
49
+ 2,
50
+ );
51
+ };
52
+
53
+ export const McpTab = () => {
54
+ const [acting, setActing] = useState(false);
55
+
56
+ const {
57
+ data: info,
58
+ refresh,
59
+ } = usePolling(fetchMcpInfo, 8000, {
60
+ cacheKey: "/api/mcp/info",
61
+ });
62
+ const loading = !info;
63
+
64
+ const running = !!info?.running;
65
+ const tokenAvailable = !!info?.tokenAvailable;
66
+ const gatewayToken = info?.gatewayToken || "";
67
+
68
+ const handleStart = useCallback(async () => {
69
+ if (acting) return;
70
+ setActing(true);
71
+ try {
72
+ const result = await startMcpBridge();
73
+ if (result?.ok) {
74
+ showToast(
75
+ result.alreadyRunning
76
+ ? "MCP bridge already running"
77
+ : "MCP bridge started",
78
+ "success",
79
+ );
80
+ } else {
81
+ showToast("Failed to start MCP bridge", "error");
82
+ }
83
+ await refresh({ force: true });
84
+ } catch (err) {
85
+ showToast("Failed to start: " + err.message, "error");
86
+ } finally {
87
+ setActing(false);
88
+ }
89
+ }, [acting, refresh]);
90
+
91
+ const handleStop = useCallback(async () => {
92
+ if (acting) return;
93
+ setActing(true);
94
+ try {
95
+ const result = await stopMcpBridge();
96
+ if (result?.ok) {
97
+ showToast("MCP bridge stopped", "success");
98
+ } else {
99
+ showToast("Failed to stop MCP bridge", "error");
100
+ }
101
+ await refresh({ force: true });
102
+ } catch (err) {
103
+ showToast("Failed to stop: " + err.message, "error");
104
+ } finally {
105
+ setActing(false);
106
+ }
107
+ }, [acting, refresh]);
108
+
109
+ const handleCopy = useCallback(() => {
110
+ const origin = window.location.origin;
111
+ const snippet = buildConfigSnippet({ origin, token: gatewayToken });
112
+ navigator.clipboard
113
+ .writeText(snippet)
114
+ .then(() => showToast("Copied to clipboard", "success"))
115
+ .catch(() => showToast("Failed to copy", "error"));
116
+ }, [gatewayToken]);
117
+
118
+ const configSnippet = buildConfigSnippet({
119
+ origin: typeof window !== "undefined" ? window.location.origin : "https://your-host",
120
+ token: gatewayToken || "<gateway-token>",
121
+ });
122
+
123
+ if (loading && !info) {
124
+ return html`
125
+ <${PaneShell} header=${html`<${PageHeader} title="MCP" />`}>
126
+ <div class="bg-surface border border-border rounded-xl p-4 text-sm text-fg-muted">
127
+ Loading...
128
+ </div>
129
+ </${PaneShell}>
130
+ `;
131
+ }
132
+
133
+ return html`
134
+ <${PaneShell}
135
+ header=${html`
136
+ <${PageHeader}
137
+ title="MCP"
138
+ actions=${html`
139
+ ${running
140
+ ? html`<${ActionButton}
141
+ onClick=${handleStop}
142
+ disabled=${acting}
143
+ loading=${acting}
144
+ loadingMode="inline"
145
+ tone="secondary"
146
+ size="sm"
147
+ idleLabel="Stop bridge"
148
+ loadingLabel="Stopping…"
149
+ className="text-xs"
150
+ />`
151
+ : html`<${ActionButton}
152
+ onClick=${handleStart}
153
+ disabled=${acting}
154
+ loading=${acting}
155
+ loadingMode="inline"
156
+ tone="primary"
157
+ size="sm"
158
+ idleLabel="Start bridge"
159
+ loadingLabel="Starting…"
160
+ className="text-xs"
161
+ />`}
162
+ `}
163
+ />
164
+ `}
165
+ >
166
+ <!-- Status -->
167
+ <div class="bg-surface border border-border rounded-xl overflow-hidden">
168
+ <h3 class="card-label text-xs px-4 pt-3 pb-2">Status</h3>
169
+ <div class="px-4 pb-3 space-y-2">
170
+ <div class="flex items-center gap-2 text-sm">
171
+ <${StatusDot} active=${running} />
172
+ <span class="text-body">
173
+ MCP Bridge: ${running ? "Running" : "Stopped"}
174
+ </span>
175
+ ${running && info?.pid
176
+ ? html`<span class="text-fg-dim text-xs">(PID ${info.pid})</span>`
177
+ : null}
178
+ </div>
179
+ <div class="flex items-center gap-2 text-sm">
180
+ <${StatusDot} active=${tokenAvailable} />
181
+ <span class="text-body">
182
+ Gateway token: ${tokenAvailable ? "Configured" : "Not set"}
183
+ </span>
184
+ </div>
185
+ ${info?.gatewayWsUrl
186
+ ? html`<div class="text-xs text-fg-dim">
187
+ Gateway: <code class="bg-field px-1 rounded">${info.gatewayWsUrl}</code>
188
+ </div>`
189
+ : null}
190
+ </div>
191
+ </div>
192
+
193
+ <!-- Config Snippet -->
194
+ <div class="bg-surface border border-border rounded-xl overflow-hidden">
195
+ <div class="flex items-center justify-between px-4 pt-3 pb-2">
196
+ <h3 class="card-label text-xs">Client Config</h3>
197
+ <button
198
+ onclick=${handleCopy}
199
+ class="text-xs px-2 py-0.5 rounded border border-border text-fg-muted hover:text-body hover:border-fg-muted"
200
+ >
201
+ Copy
202
+ </button>
203
+ </div>
204
+ <div class="px-4 pb-3">
205
+ <p class="text-xs text-fg-dim mb-2">
206
+ Add this to your MCP client config (Cursor, Claude Desktop, etc.):
207
+ </p>
208
+ <pre
209
+ class="bg-field border border-border rounded-lg p-3 text-xs text-body font-mono overflow-x-auto whitespace-pre"
210
+ >${configSnippet}</pre>
211
+ ${!running
212
+ ? html`<p class="text-xs text-status-warning-muted mt-2">
213
+ Start the MCP bridge above before connecting a client.
214
+ </p>`
215
+ : null}
216
+ </div>
217
+ </div>
218
+
219
+ <!-- Available Tools -->
220
+ <div class="bg-surface border border-border rounded-xl overflow-hidden">
221
+ <h3 class="card-label text-xs px-4 pt-3 pb-2">Available Tools</h3>
222
+ <div class="divide-y divide-border">
223
+ ${kMcpTools.map(
224
+ (tool) => html`
225
+ <div class="flex items-start gap-3 px-4 py-2">
226
+ <code class="text-xs shrink-0 pt-0.5" style="min-width: 170px"
227
+ >${tool.name}</code
228
+ >
229
+ <span class="text-xs text-fg-dim">${tool.desc}</span>
230
+ </div>
231
+ `,
232
+ )}
233
+ </div>
234
+ </div>
235
+ </${PaneShell}>
236
+ `;
237
+ };
@@ -870,8 +870,15 @@ export const ChatRoute = ({ sessions = [], selectedSessionKey = "" }) => {
870
870
  <div>
871
871
  <div class="chat-route-title">Chat</div>
872
872
  <div class="chat-route-subtitle">
873
- ${getSessionDisplayLabel(selectedSession) ||
874
- "Pick a session in the sidebar"}
873
+ <span class="chat-route-subtitle-label"
874
+ >${getSessionDisplayLabel(selectedSession) ||
875
+ "Pick a session in the sidebar"}</span
876
+ >
877
+ ${selectedSessionKey
878
+ ? html`<span class="chat-route-session-key" title="Session key"
879
+ >${selectedSessionKey}</span
880
+ >`
881
+ : null}
875
882
  </div>
876
883
  ${connectionError
877
884
  ? html`<div class="chat-route-warning">${connectionError}</div>`
@@ -5,6 +5,7 @@ export { CronRoute } from "./cron-route.js";
5
5
  export { DoctorRoute } from "./doctor-route.js";
6
6
  export { EnvarsRoute } from "./envars-route.js";
7
7
  export { GeneralRoute } from "./general-route.js";
8
+ export { McpRoute } from "./mcp-route.js";
8
9
  export { ModelsRoute } from "./models-route.js";
9
10
  export { NodesRoute } from "./nodes-route.js";
10
11
  export { ProvidersRoute } from "./providers-route.js";
@@ -0,0 +1,7 @@
1
+ import { h } from "preact";
2
+ import htm from "htm";
3
+ import { McpTab } from "../mcp-tab/index.js";
4
+
5
+ const html = htm.bind(h);
6
+
7
+ export const McpRoute = () => html`<${McpTab} />`;
@@ -12,10 +12,11 @@ import {
12
12
  ComputerLineIcon,
13
13
  EyeLineIcon,
14
14
  FolderLineIcon,
15
+ LinksLineIcon,
15
16
  HomeLineIcon,
16
17
  PulseLineIcon,
17
18
  RobotLineIcon,
18
- SignalTowerLineIcon,
19
+ WebhookLineIcon,
19
20
  } from "./icons.js";
20
21
  import { FileTree } from "./file-tree.js";
21
22
  import { OverflowMenu, OverflowMenuItem } from "./overflow-menu.js";
@@ -57,8 +58,9 @@ const kSidebarNavIconsById = {
57
58
  watchdog: EyeLineIcon,
58
59
  models: Brain2LineIcon,
59
60
  envars: BracesLineIcon,
60
- webhooks: SignalTowerLineIcon,
61
+ webhooks: WebhookLineIcon,
61
62
  nodes: ComputerLineIcon,
63
+ mcp: LinksLineIcon,
62
64
  };
63
65
 
64
66
  const readStoredBrowseBottomPanelHeight = () => {
@@ -1365,3 +1365,20 @@ export const syncBrowseChanges = async (message = "") => {
1365
1365
  });
1366
1366
  return parseJsonOrThrow(res, "Could not sync changes");
1367
1367
  };
1368
+
1369
+ // ── MCP ──────────────────────────────────────────────────────────
1370
+
1371
+ export const fetchMcpInfo = async () => {
1372
+ const res = await authFetch("/api/mcp/info");
1373
+ return res.json();
1374
+ };
1375
+
1376
+ export const startMcpBridge = async () => {
1377
+ const res = await authFetch("/api/mcp/start", { method: "POST" });
1378
+ return res.json();
1379
+ };
1380
+
1381
+ export const stopMcpBridge = async () => {
1382
+ const res = await authFetch("/api/mcp/stop", { method: "POST" });
1383
+ return res.json();
1384
+ };
@@ -23,6 +23,7 @@ export const kNavSections = [
23
23
  { id: "envars", label: "Envars" },
24
24
  { id: "webhooks", label: "Webhooks" },
25
25
  { id: "nodes", label: "Nodes" },
26
+ { id: "mcp", label: "MCP" },
26
27
  ],
27
28
  },
28
29
  ];
@@ -41,5 +42,6 @@ export const getSelectedNavId = ({ isBrowseRoute = false, location = "" } = {})
41
42
  if (location.startsWith("/nodes")) return "nodes";
42
43
  if (location.startsWith("/envars")) return "envars";
43
44
  if (location.startsWith("/webhooks")) return "webhooks";
45
+ if (location.startsWith("/mcp")) return "mcp";
44
46
  return kDefaultUiTab;
45
47
  };
@@ -17,6 +17,7 @@ const { registerDoctorRoutes } = require("../routes/doctor");
17
17
  const { registerAgentRoutes } = require("../routes/agents");
18
18
  const { registerCronRoutes } = require("../routes/cron");
19
19
  const { registerNodeRoutes } = require("../routes/nodes");
20
+ const { registerMcpRoutes } = require("../routes/mcp");
20
21
  const {
21
22
  createOauthCallbackMiddleware,
22
23
  } = require("../oauth-callback-middleware");
@@ -249,6 +250,13 @@ const registerServerRoutes = ({
249
250
  gatewayToken: constants.GATEWAY_TOKEN,
250
251
  fsModule: fs,
251
252
  });
253
+ registerMcpRoutes({
254
+ app,
255
+ requireAuth,
256
+ constants,
257
+ gatewayEnv,
258
+ openclawDir: constants.OPENCLAW_DIR,
259
+ });
252
260
  registerProxyRoutes({
253
261
  app,
254
262
  proxy,
@@ -0,0 +1,158 @@
1
+ const { spawn } = require("child_process");
2
+
3
+ const kStderrTailLines = 50;
4
+
5
+ let mcpChild = null;
6
+ let mcpStartedAt = null;
7
+ let mcpStderrTail = [];
8
+ let stdoutBuffer = Buffer.alloc(0);
9
+ let onMcpMessage = null;
10
+
11
+ const appendStderrTail = (chunk) => {
12
+ const text = Buffer.isBuffer(chunk)
13
+ ? chunk.toString("utf8")
14
+ : String(chunk ?? "");
15
+ for (const line of text.split("\n")) {
16
+ const trimmed = line.trimEnd();
17
+ if (!trimmed) continue;
18
+ mcpStderrTail.push(trimmed);
19
+ }
20
+ if (mcpStderrTail.length > kStderrTailLines) {
21
+ mcpStderrTail = mcpStderrTail.slice(-kStderrTailLines);
22
+ }
23
+ };
24
+
25
+ const isMcpBridgeRunning = () =>
26
+ mcpChild !== null && mcpChild.exitCode === null && !mcpChild.killed;
27
+
28
+ const getMcpBridgeStatus = () => ({
29
+ running: isMcpBridgeRunning(),
30
+ pid: isMcpBridgeRunning() ? mcpChild.pid : null,
31
+ startedAt: isMcpBridgeRunning() ? mcpStartedAt : null,
32
+ stderrTail: mcpStderrTail.slice(-10),
33
+ });
34
+
35
+ const setOnMcpMessage = (callback) => {
36
+ onMcpMessage = typeof callback === "function" ? callback : null;
37
+ };
38
+
39
+ const drainStdoutLines = () => {
40
+ while (stdoutBuffer.length > 0) {
41
+ const newlineIndex = stdoutBuffer.indexOf("\n");
42
+ if (newlineIndex === -1) break;
43
+
44
+ const line = stdoutBuffer.toString("utf8", 0, newlineIndex).replace(/\r$/, "");
45
+ stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
46
+ if (!line.trim()) continue;
47
+
48
+ let parsed = null;
49
+ try {
50
+ parsed = JSON.parse(line);
51
+ } catch {
52
+ appendStderrTail(`Unparseable MCP stdout line: ${line.slice(0, 120)}`);
53
+ continue;
54
+ }
55
+ console.log(`[mcp-bridge] ← child stdout: method=${parsed?.method || "(response)"} id=${parsed?.id ?? "none"}`);
56
+ if (onMcpMessage) {
57
+ try {
58
+ onMcpMessage(parsed);
59
+ } catch (err) {
60
+ appendStderrTail(`onMcpMessage error: ${err?.message || err}`);
61
+ }
62
+ }
63
+ }
64
+ };
65
+
66
+ const startMcpBridge = ({ gatewayEnv, gatewayWsUrl, gatewayToken }) => {
67
+ if (isMcpBridgeRunning()) {
68
+ return { ok: true, alreadyRunning: true, ...getMcpBridgeStatus() };
69
+ }
70
+
71
+ const args = ["mcp", "serve"];
72
+ if (gatewayWsUrl) {
73
+ args.push("--url", gatewayWsUrl);
74
+ }
75
+ const childEnv = gatewayEnv();
76
+ if (gatewayToken) {
77
+ childEnv.OPENCLAW_GATEWAY_TOKEN = String(gatewayToken);
78
+ }
79
+
80
+ mcpStderrTail = [];
81
+ stdoutBuffer = Buffer.alloc(0);
82
+
83
+ const child = spawn("openclaw", args, {
84
+ env: childEnv,
85
+ stdio: ["pipe", "pipe", "pipe"],
86
+ });
87
+
88
+ mcpChild = child;
89
+ mcpStartedAt = Date.now();
90
+
91
+ child.stdout.on("data", (data) => {
92
+ const chunk = Buffer.isBuffer(data)
93
+ ? data
94
+ : Buffer.from(String(data ?? ""), "utf8");
95
+ stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]);
96
+ drainStdoutLines();
97
+ });
98
+
99
+ child.stderr.on("data", (data) => {
100
+ appendStderrTail(data);
101
+ process.stderr.write(`[mcp-bridge] ${data}`);
102
+ });
103
+
104
+ child.on("exit", (code, signal) => {
105
+ console.log(
106
+ `[mcp-bridge] Process exited with code ${code}${signal ? ` signal ${signal}` : ""}`,
107
+ );
108
+ if (mcpChild === child) mcpChild = null;
109
+ mcpStartedAt = null;
110
+ });
111
+
112
+ child.on("error", (error) => {
113
+ appendStderrTail(error?.message || String(error || "unknown process error"));
114
+ console.error(
115
+ `[mcp-bridge] Process failed to start: ${error?.message || error}`,
116
+ );
117
+ });
118
+
119
+ console.log(`[mcp-bridge] Started MCP bridge (pid ${child.pid})`);
120
+ return { ok: true, alreadyRunning: false, ...getMcpBridgeStatus() };
121
+ };
122
+
123
+ const stopMcpBridge = () => {
124
+ if (!isMcpBridgeRunning()) {
125
+ return { ok: true, wasStopped: true };
126
+ }
127
+ const pid = mcpChild.pid;
128
+ mcpChild.kill("SIGTERM");
129
+ mcpChild = null;
130
+ mcpStartedAt = null;
131
+ console.log(`[mcp-bridge] Stopped MCP bridge (pid ${pid})`);
132
+ return { ok: true, wasStopped: false };
133
+ };
134
+
135
+ const writeToMcpBridge = (jsonRpcMessage) => {
136
+ if (!isMcpBridgeRunning()) return false;
137
+ if (jsonRpcMessage == null) return false;
138
+ const payload =
139
+ typeof jsonRpcMessage === "string"
140
+ ? jsonRpcMessage
141
+ : JSON.stringify(jsonRpcMessage);
142
+ if (!payload) return false;
143
+ const method = jsonRpcMessage?.method;
144
+ const id = jsonRpcMessage?.id;
145
+ const payloadBytes = Buffer.byteLength(payload, "utf8");
146
+ console.log(`[mcp-bridge] → child stdin: method=${method || "(response)"} id=${id ?? "none"} bytes=${payloadBytes}`);
147
+ mcpChild.stdin.write(`${payload}\n`);
148
+ return true;
149
+ };
150
+
151
+ module.exports = {
152
+ isMcpBridgeRunning,
153
+ getMcpBridgeStatus,
154
+ startMcpBridge,
155
+ stopMcpBridge,
156
+ writeToMcpBridge,
157
+ setOnMcpMessage,
158
+ };