@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.
@@ -0,0 +1,232 @@
1
+ const { randomUUID } = require("crypto");
2
+ const { createRequire } = require("module");
3
+
4
+ // Load the SDK through openclaw's dependency tree so its express@5 peer
5
+ // stays nested and never hoists over AlphaClaw's express@4 at the app root.
6
+ const openclawRequire = createRequire(require.resolve("openclaw"));
7
+ const {
8
+ StreamableHTTPServerTransport,
9
+ } = openclawRequire("@modelcontextprotocol/sdk/server/streamableHttp.js");
10
+
11
+ const {
12
+ isMcpBridgeRunning,
13
+ getMcpBridgeStatus,
14
+ startMcpBridge,
15
+ stopMcpBridge,
16
+ writeToMcpBridge,
17
+ setOnMcpMessage,
18
+ } = require("../mcp-bridge");
19
+ const { getGatewayPort } = require("../gateway");
20
+ const { readOpenclawConfig } = require("../openclaw-config");
21
+
22
+ const resolveGatewayWsUrl = ({ openclawDir, gatewayPort }) => {
23
+ const cfg = readOpenclawConfig({ openclawDir, fallback: {} });
24
+ const gatewayTlsEnabled = cfg?.gateway?.tls?.enabled === true;
25
+ const scheme = gatewayTlsEnabled ? "wss" : "ws";
26
+ return `${scheme}://127.0.0.1:${gatewayPort}`;
27
+ };
28
+
29
+ let activeTransport = null;
30
+
31
+ const closeActiveTransport = async () => {
32
+ if (!activeTransport) return;
33
+ const t = activeTransport;
34
+ activeTransport = null;
35
+ try {
36
+ await t.close();
37
+ } catch {
38
+ /* already closed */
39
+ }
40
+ };
41
+
42
+ const registerMcpRoutes = ({
43
+ app,
44
+ requireAuth,
45
+ constants,
46
+ gatewayEnv,
47
+ openclawDir,
48
+ }) => {
49
+ // Wire bridge stdout messages → active transport
50
+ setOnMcpMessage((message) => {
51
+ if (!activeTransport) return;
52
+ activeTransport.send(message).catch((err) => {
53
+ console.error("[mcp] Failed to forward to transport:", err?.message);
54
+ });
55
+ });
56
+
57
+ // ── Internal API (session auth) ────────────────────────────────
58
+
59
+ app.get("/api/mcp/info", requireAuth, (_req, res) => {
60
+ const port = getGatewayPort();
61
+ const gatewayWsUrl = resolveGatewayWsUrl({
62
+ openclawDir,
63
+ gatewayPort: port,
64
+ });
65
+ res.json({
66
+ ok: true,
67
+ ...getMcpBridgeStatus(),
68
+ gatewayPort: port,
69
+ gatewayWsUrl,
70
+ tokenAvailable: !!constants.GATEWAY_TOKEN,
71
+ gatewayToken: constants.GATEWAY_TOKEN || "",
72
+ });
73
+ });
74
+
75
+ app.post("/api/mcp/start", requireAuth, (_req, res) => {
76
+ const port = getGatewayPort();
77
+ const result = startMcpBridge({
78
+ gatewayEnv,
79
+ gatewayWsUrl: resolveGatewayWsUrl({
80
+ openclawDir,
81
+ gatewayPort: port,
82
+ }),
83
+ gatewayToken: constants.GATEWAY_TOKEN,
84
+ });
85
+ res.json(result);
86
+ });
87
+
88
+ app.post("/api/mcp/stop", requireAuth, async (_req, res) => {
89
+ await closeActiveTransport();
90
+ const result = stopMcpBridge();
91
+ res.json(result);
92
+ });
93
+
94
+ // ── MCP transport endpoint (token auth) ────────────────────────
95
+
96
+ const validateMcpToken = (req, res) => {
97
+ const bearerToken = String(req.get("authorization") || "")
98
+ .replace(/^Bearer\s+/i, "")
99
+ .trim();
100
+ const queryToken = String(req.query?.token || "");
101
+ const rawToken = bearerToken || queryToken;
102
+ const normalizedToken = rawToken.replace(/ /g, "+");
103
+ if (!constants.GATEWAY_TOKEN) {
104
+ res
105
+ .status(503)
106
+ .json({ error: "Gateway token is not configured for MCP transport" });
107
+ return false;
108
+ }
109
+ if (!normalizedToken || normalizedToken !== constants.GATEWAY_TOKEN) {
110
+ res.status(401).json({ error: "Invalid or missing token" });
111
+ return false;
112
+ }
113
+ return true;
114
+ };
115
+
116
+ // Primary MCP endpoint – Streamable HTTP (GET / POST / DELETE)
117
+ app.all("/mcp/sse", async (req, res) => {
118
+ if (!validateMcpToken(req, res)) return;
119
+
120
+ if (!isMcpBridgeRunning()) {
121
+ res.status(503).json({ error: "MCP bridge is not running" });
122
+ return;
123
+ }
124
+
125
+ const sessionId = req.headers["mcp-session-id"];
126
+
127
+ // ── Existing session ───────────────────────────────────────
128
+ if (sessionId && activeTransport) {
129
+ try {
130
+ await activeTransport.handleRequest(req, res, req.body);
131
+ } catch (err) {
132
+ console.error("[mcp] handleRequest error (existing session):", err?.message);
133
+ if (!res.headersSent) {
134
+ res.status(500).json({ error: "Internal transport error" });
135
+ }
136
+ }
137
+ return;
138
+ }
139
+
140
+ // ── Stale / unknown session ────────────────────────────────
141
+ if (sessionId && !activeTransport) {
142
+ res.status(404).json({
143
+ jsonrpc: "2.0",
144
+ error: {
145
+ code: -32000,
146
+ message: "Session not found. The server may have been restarted.",
147
+ },
148
+ id: null,
149
+ });
150
+ return;
151
+ }
152
+
153
+ // ── New session (POST without session ID) ────────────────
154
+ // Let the transport validate the body internally — it will reject
155
+ // non-initialize requests and return the proper JSON-RPC error.
156
+ if (!sessionId && req.method === "POST") {
157
+ const transport = new StreamableHTTPServerTransport({
158
+ sessionIdGenerator: () => randomUUID(),
159
+ enableJsonResponse: true,
160
+ });
161
+
162
+ transport.onmessage = (message) => {
163
+ writeToMcpBridge(message);
164
+ };
165
+
166
+ transport.onclose = () => {
167
+ if (activeTransport === transport) {
168
+ activeTransport = null;
169
+ console.log("[mcp] Transport closed");
170
+ }
171
+ };
172
+
173
+ transport.onerror = (err) => {
174
+ console.error("[mcp] Transport error:", err?.message);
175
+ };
176
+
177
+ await transport.start();
178
+
179
+ // Set activeTransport BEFORE handleRequest so the onMcpMessage
180
+ // callback can forward the child's response to this transport.
181
+ const prev = activeTransport;
182
+ activeTransport = transport;
183
+
184
+ try {
185
+ await transport.handleRequest(req, res, req.body);
186
+ } catch (err) {
187
+ console.error("[mcp] handleRequest error (new session):", err?.message);
188
+ if (!res.headersSent) {
189
+ res.status(500).json({ error: "Failed to initialize MCP session" });
190
+ }
191
+ }
192
+
193
+ if (transport.sessionId) {
194
+ if (prev) prev.close().catch(() => {});
195
+ console.log(`[mcp] Session established: ${transport.sessionId}`);
196
+ } else {
197
+ // Session was not established — roll back
198
+ if (activeTransport === transport) activeTransport = prev;
199
+ }
200
+ return;
201
+ }
202
+
203
+ res.status(400).json({
204
+ jsonrpc: "2.0",
205
+ error: { code: -32600, message: "Bad Request" },
206
+ id: null,
207
+ });
208
+ });
209
+
210
+ // Legacy endpoint for SSE-transport clients that POST to /mcp/message
211
+ app.post("/mcp/message", async (req, res) => {
212
+ if (!validateMcpToken(req, res)) return;
213
+ if (!isMcpBridgeRunning()) {
214
+ res.status(503).json({ error: "MCP bridge is not running" });
215
+ return;
216
+ }
217
+ if (!activeTransport) {
218
+ res.status(503).json({ error: "No active MCP session" });
219
+ return;
220
+ }
221
+ try {
222
+ await activeTransport.handleRequest(req, res, req.body);
223
+ } catch (err) {
224
+ console.error("[mcp] handleRequest error (/mcp/message):", err?.message);
225
+ if (!res.headersSent) {
226
+ res.status(500).json({ error: "Internal transport error" });
227
+ }
228
+ }
229
+ });
230
+ };
231
+
232
+ module.exports = { registerMcpRoutes };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrysb/alphaclaw",
3
- "version": "0.8.3",
3
+ "version": "0.9.0-beta.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },