openclaw-mcp 1.5.0 → 2.0.0-beta.1
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.
- package/README.md +30 -10
- package/dist/index.js +996 -425
- package/docs/configuration.md +14 -1
- package/docs/deployment.md +21 -2
- package/docs/index.html +1 -1
- package/package.json +9 -3
package/dist/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import {
|
|
4
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
5
5
|
|
|
6
6
|
// src/config/constants.ts
|
|
7
7
|
var SERVER_NAME = "openclaw-mcp";
|
|
8
|
-
var SERVER_VERSION = "
|
|
8
|
+
var SERVER_VERSION = "2.0.0-beta.1";
|
|
9
9
|
var DEFAULT_OPENCLAW_URL = "http://127.0.0.1:18789";
|
|
10
10
|
var DEFAULT_MODEL = "openclaw";
|
|
11
11
|
var SERVER_ICON_SVG_BASE64 = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiB2aWV3Qm94PSIwIDAgMTI4IDEyOCIgZmlsbD0ibm9uZSI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJiZyIgeDE9IjAlIiB5MT0iMCUiIHgyPSIxMDAlIiB5Mj0iMTAwJSI+PHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iIzFhMWEyZSIvPjxzdG9wIG9mZnNldD0iMTAwJSIgc3RvcC1jb2xvcj0iIzE2MjEzZSIvPjwvbGluZWFyR3JhZGllbnQ+PGxpbmVhckdyYWRpZW50IGlkPSJjbGF3IiB4MT0iMCUiIHkxPSIwJSIgeDI9IjEwMCUiIHkyPSIxMDAlIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjZmYzMzMzIi8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjY2MwMDAwIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHJlY3Qgd2lkdGg9IjEyOCIgaGVpZ2h0PSIxMjgiIHJ4PSIyNCIgZmlsbD0idXJsKCNiZykiLz48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSg2NCA2NCkiIHN0cm9rZT0idXJsKCNjbGF3KSIgc3Ryb2tlLXdpZHRoPSI3IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGZpbGw9Im5vbmUiPjxwYXRoIGQ9Ik0tMjggLTM4YzAgMCAtMTAgMjAgMCAzMiIvPjxwYXRoIGQ9Ik0tMTIgLTQwYzAgMCAtNiAyMiA0IDM0Ii8+PHBhdGggZD0iTTI4IC0zOGMwIDAgMTAgMjAgMCAzMiIvPjxwYXRoIGQ9Ik0xMiAtNDBjMCAwIDYgMjIgLTQgMzQiLz48Y2lyY2xlIGN4PSIwIiBjeT0iMTAiIHI9IjIwIiBzdHJva2Utd2lkdGg9IjYiLz48cGF0aCBkPSJNLTEwIDR2LTQiIHN0cm9rZS13aWR0aD0iNCIvPjxwYXRoIGQ9Ik0xMCA0di00IiBzdHJva2Utd2lkdGg9IjQiLz48cGF0aCBkPSJNLTggMjBjNCA2IDEyIDYgMTYgMCIgc3Ryb2tlLXdpZHRoPSIzIi8+PC9nPjwvc3ZnPg==";
|
|
@@ -75,7 +75,7 @@ function parseArguments(version) {
|
|
|
75
75
|
alias: "t",
|
|
76
76
|
type: "string",
|
|
77
77
|
choices: ["stdio", "http", "sse"],
|
|
78
|
-
description: 'Transport mode (stdio for local, http for remote; "sse"
|
|
78
|
+
description: 'Transport mode (stdio for local, http for remote; "sse" was removed in v2.0 and exits with an error)',
|
|
79
79
|
default: "stdio"
|
|
80
80
|
}).option("port", {
|
|
81
81
|
alias: "p",
|
|
@@ -208,16 +208,26 @@ var OpenClawApiError = class extends OpenClawError {
|
|
|
208
208
|
var DEFAULT_TIMEOUT_MS = 12e4;
|
|
209
209
|
var MAX_RESPONSE_SIZE_BYTES = 10 * 1024 * 1024;
|
|
210
210
|
var MAX_DEBUG_BODY_LENGTH = 4096;
|
|
211
|
+
var DEFAULT_MAX_STREAM_MS = 30 * 60 * 1e3;
|
|
212
|
+
var FINISH_GRACE_MS = 2e3;
|
|
213
|
+
var OpenClawCancelledError = class extends Error {
|
|
214
|
+
constructor(message = "Request was cancelled") {
|
|
215
|
+
super(message);
|
|
216
|
+
this.name = "OpenClawCancelledError";
|
|
217
|
+
}
|
|
218
|
+
};
|
|
211
219
|
var OpenClawClient = class {
|
|
212
220
|
baseUrl;
|
|
213
221
|
gatewayToken;
|
|
214
222
|
timeoutMs;
|
|
215
223
|
model;
|
|
216
|
-
|
|
224
|
+
maxStreamMs;
|
|
225
|
+
constructor(baseUrl, gatewayToken, timeoutMs = DEFAULT_TIMEOUT_MS, model = "openclaw", maxStreamMs = DEFAULT_MAX_STREAM_MS) {
|
|
217
226
|
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
218
227
|
this.gatewayToken = gatewayToken;
|
|
219
228
|
this.timeoutMs = timeoutMs;
|
|
220
229
|
this.model = model;
|
|
230
|
+
this.maxStreamMs = Math.max(maxStreamMs, timeoutMs);
|
|
221
231
|
}
|
|
222
232
|
buildHeaders() {
|
|
223
233
|
const headers = {
|
|
@@ -232,6 +242,43 @@ var OpenClawClient = class {
|
|
|
232
242
|
if (value.length <= MAX_DEBUG_BODY_LENGTH) return value;
|
|
233
243
|
return value.slice(0, MAX_DEBUG_BODY_LENGTH) + `... (truncated, ${value.length} chars total)`;
|
|
234
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Wire an external AbortSignal to the internal controller so both
|
|
247
|
+
* timeout aborts and caller-initiated cancels stop the request.
|
|
248
|
+
*/
|
|
249
|
+
linkSignal(controller, signal) {
|
|
250
|
+
if (!signal) return () => {
|
|
251
|
+
};
|
|
252
|
+
if (signal.aborted) {
|
|
253
|
+
controller.abort(signal.reason);
|
|
254
|
+
return () => {
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const onAbort = () => controller.abort(signal.reason);
|
|
258
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
259
|
+
return () => signal.removeEventListener("abort", onAbort);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Classify an aborted request. The caller's signal wins: only when the
|
|
263
|
+
* external signal is the one that fired do we report a cancellation —
|
|
264
|
+
* otherwise the abort came from our own timers.
|
|
265
|
+
*/
|
|
266
|
+
toConnectionError(error, cause, callerSignal) {
|
|
267
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
268
|
+
if (callerSignal?.aborted && cause === void 0) {
|
|
269
|
+
return new OpenClawCancelledError();
|
|
270
|
+
}
|
|
271
|
+
if (cause === "max-duration") {
|
|
272
|
+
return new OpenClawConnectionError(
|
|
273
|
+
`Request to OpenClaw exceeded the maximum duration of ${this.maxStreamMs}ms`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
return new OpenClawConnectionError(`Request to OpenClaw timed out after ${this.timeoutMs}ms`);
|
|
277
|
+
}
|
|
278
|
+
return new OpenClawConnectionError(
|
|
279
|
+
`Failed to connect to OpenClaw at ${this.baseUrl}: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
280
|
+
);
|
|
281
|
+
}
|
|
235
282
|
async request(path, options = {}) {
|
|
236
283
|
const url = `${this.baseUrl}${path}`;
|
|
237
284
|
logDebug(() => `Request: ${options.method ?? "GET"} ${url}`);
|
|
@@ -239,7 +286,12 @@ var OpenClawClient = class {
|
|
|
239
286
|
logDebug(() => `Request body: ${this.truncateForLog(options.body)}`);
|
|
240
287
|
}
|
|
241
288
|
const controller = new AbortController();
|
|
242
|
-
const
|
|
289
|
+
const unlink = this.linkSignal(controller, options.signal ?? void 0);
|
|
290
|
+
let abortCause;
|
|
291
|
+
const timeout = setTimeout(() => {
|
|
292
|
+
abortCause = "idle";
|
|
293
|
+
controller.abort();
|
|
294
|
+
}, this.timeoutMs);
|
|
243
295
|
try {
|
|
244
296
|
const response = await fetch(url, {
|
|
245
297
|
...options,
|
|
@@ -280,16 +332,10 @@ var OpenClawClient = class {
|
|
|
280
332
|
if (error instanceof OpenClawApiError) {
|
|
281
333
|
throw error;
|
|
282
334
|
}
|
|
283
|
-
|
|
284
|
-
throw new OpenClawConnectionError(
|
|
285
|
-
`Request to OpenClaw timed out after ${this.timeoutMs}ms`
|
|
286
|
-
);
|
|
287
|
-
}
|
|
288
|
-
throw new OpenClawConnectionError(
|
|
289
|
-
`Failed to connect to OpenClaw at ${this.baseUrl}: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
290
|
-
);
|
|
335
|
+
throw this.toConnectionError(error, abortCause, options.signal ?? void 0);
|
|
291
336
|
} finally {
|
|
292
337
|
clearTimeout(timeout);
|
|
338
|
+
unlink();
|
|
293
339
|
}
|
|
294
340
|
}
|
|
295
341
|
/**
|
|
@@ -301,7 +347,11 @@ var OpenClawClient = class {
|
|
|
301
347
|
async health() {
|
|
302
348
|
const url = `${this.baseUrl}/v1/chat/completions`;
|
|
303
349
|
const controller = new AbortController();
|
|
304
|
-
|
|
350
|
+
let abortCause;
|
|
351
|
+
const timeout = setTimeout(() => {
|
|
352
|
+
abortCause = "idle";
|
|
353
|
+
controller.abort();
|
|
354
|
+
}, this.timeoutMs);
|
|
305
355
|
try {
|
|
306
356
|
const response = await fetch(url, {
|
|
307
357
|
method: "POST",
|
|
@@ -318,27 +368,27 @@ var OpenClawClient = class {
|
|
|
318
368
|
}
|
|
319
369
|
return { status: "error", message: `Gateway error (HTTP ${response.status})` };
|
|
320
370
|
} catch (error) {
|
|
321
|
-
|
|
322
|
-
throw new OpenClawConnectionError(
|
|
323
|
-
`Request to OpenClaw timed out after ${this.timeoutMs}ms`
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
throw new OpenClawConnectionError(
|
|
327
|
-
`Failed to connect to OpenClaw at ${this.baseUrl}: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
328
|
-
);
|
|
371
|
+
throw this.toConnectionError(error, abortCause);
|
|
329
372
|
} finally {
|
|
330
373
|
clearTimeout(timeout);
|
|
331
374
|
}
|
|
332
375
|
}
|
|
333
376
|
/**
|
|
334
377
|
* Send a chat message via the OpenAI-compatible /v1/chat/completions endpoint.
|
|
378
|
+
* With `options.onDelta` set, the request streams (SSE) and deltas are
|
|
379
|
+
* surfaced as they arrive; otherwise a single blocking JSON response is used.
|
|
335
380
|
*/
|
|
336
|
-
async chat(message,
|
|
381
|
+
async chat(message, options = {}) {
|
|
382
|
+
const { sessionId, signal, onDelta } = options;
|
|
383
|
+
const streaming = onDelta !== void 0;
|
|
337
384
|
const body = {
|
|
338
385
|
model: this.model,
|
|
339
386
|
messages: [{ role: "user", content: message }],
|
|
340
387
|
max_tokens: 4096
|
|
341
388
|
};
|
|
389
|
+
if (streaming) {
|
|
390
|
+
body.stream = true;
|
|
391
|
+
}
|
|
342
392
|
if (sessionId) {
|
|
343
393
|
body.session_id = sessionId;
|
|
344
394
|
}
|
|
@@ -346,17 +396,173 @@ var OpenClawClient = class {
|
|
|
346
396
|
if (sessionId) {
|
|
347
397
|
headers["x-openclaw-session-key"] = sessionId;
|
|
348
398
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
399
|
+
if (streaming) {
|
|
400
|
+
headers["Accept"] = "text/event-stream";
|
|
401
|
+
}
|
|
402
|
+
if (!streaming) {
|
|
403
|
+
const completion = await this.request("/v1/chat/completions", {
|
|
404
|
+
method: "POST",
|
|
405
|
+
body: JSON.stringify(body),
|
|
406
|
+
headers,
|
|
407
|
+
signal: signal ?? null
|
|
408
|
+
});
|
|
409
|
+
const content = completion.choices?.[0]?.message?.content ?? "";
|
|
410
|
+
return {
|
|
411
|
+
response: content,
|
|
412
|
+
model: completion.model,
|
|
413
|
+
usage: completion.usage
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
return this.chatStreaming(body, headers, signal, onDelta);
|
|
417
|
+
}
|
|
418
|
+
async chatStreaming(body, extraHeaders, signal, onDelta) {
|
|
419
|
+
const url = `${this.baseUrl}/v1/chat/completions`;
|
|
420
|
+
logDebug(() => `Request (stream): POST ${url}`);
|
|
421
|
+
const controller = new AbortController();
|
|
422
|
+
const unlink = this.linkSignal(controller, signal);
|
|
423
|
+
let abortCause;
|
|
424
|
+
let idleTimer = setTimeout(() => {
|
|
425
|
+
abortCause = "idle";
|
|
426
|
+
controller.abort();
|
|
427
|
+
}, this.timeoutMs);
|
|
428
|
+
const resetIdle = () => {
|
|
429
|
+
clearTimeout(idleTimer);
|
|
430
|
+
idleTimer = setTimeout(() => {
|
|
431
|
+
abortCause = "idle";
|
|
432
|
+
controller.abort();
|
|
433
|
+
}, this.timeoutMs);
|
|
434
|
+
};
|
|
435
|
+
const maxTimer = setTimeout(() => {
|
|
436
|
+
abortCause = "max-duration";
|
|
437
|
+
controller.abort();
|
|
438
|
+
}, this.maxStreamMs);
|
|
439
|
+
const startFinishGrace = () => {
|
|
440
|
+
clearTimeout(idleTimer);
|
|
441
|
+
idleTimer = setTimeout(() => {
|
|
442
|
+
abortCause = "finish-grace";
|
|
443
|
+
controller.abort();
|
|
444
|
+
}, FINISH_GRACE_MS);
|
|
359
445
|
};
|
|
446
|
+
let accumulated = "";
|
|
447
|
+
let model;
|
|
448
|
+
let usage;
|
|
449
|
+
let finishReason;
|
|
450
|
+
try {
|
|
451
|
+
const response = await fetch(url, {
|
|
452
|
+
method: "POST",
|
|
453
|
+
signal: controller.signal,
|
|
454
|
+
headers: { ...this.buildHeaders(), ...extraHeaders },
|
|
455
|
+
body: JSON.stringify(body)
|
|
456
|
+
});
|
|
457
|
+
if (!response.ok) {
|
|
458
|
+
throw new OpenClawApiError(
|
|
459
|
+
`API request failed: ${response.status} ${response.statusText}`,
|
|
460
|
+
response.status
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
if (!response.body) {
|
|
464
|
+
throw new OpenClawConnectionError("Streaming response has no body");
|
|
465
|
+
}
|
|
466
|
+
const decoder = new TextDecoder();
|
|
467
|
+
let buffer = "";
|
|
468
|
+
let receivedBytes = 0;
|
|
469
|
+
let done = false;
|
|
470
|
+
const handleLine = (rawLine) => {
|
|
471
|
+
const line = rawLine.trim();
|
|
472
|
+
if (!line.startsWith("data:")) return false;
|
|
473
|
+
const payload = line.slice(5).trim();
|
|
474
|
+
if (payload === "[DONE]") return true;
|
|
475
|
+
let chunk;
|
|
476
|
+
try {
|
|
477
|
+
chunk = JSON.parse(payload);
|
|
478
|
+
} catch {
|
|
479
|
+
logDebug(() => `Skipping unparseable SSE line: ${this.truncateForLog(payload)}`);
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
if (chunk.error) {
|
|
483
|
+
throw new OpenClawApiError(
|
|
484
|
+
`Gateway reported an error: ${chunk.error.message ?? "unknown error"}`,
|
|
485
|
+
502
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
if (chunk.model) model = chunk.model;
|
|
489
|
+
if (chunk.usage) {
|
|
490
|
+
usage = chunk.usage;
|
|
491
|
+
if (finishReason) return true;
|
|
492
|
+
}
|
|
493
|
+
const choice = chunk.choices?.[0];
|
|
494
|
+
const delta = choice?.delta?.content;
|
|
495
|
+
if (delta) {
|
|
496
|
+
accumulated += delta;
|
|
497
|
+
onDelta(delta, accumulated);
|
|
498
|
+
}
|
|
499
|
+
if (choice?.finish_reason) {
|
|
500
|
+
finishReason = choice.finish_reason;
|
|
501
|
+
if (usage) return true;
|
|
502
|
+
startFinishGrace();
|
|
503
|
+
}
|
|
504
|
+
return false;
|
|
505
|
+
};
|
|
506
|
+
const nextBreak = (s) => {
|
|
507
|
+
const lf = s.indexOf("\n");
|
|
508
|
+
const cr = s.indexOf("\r");
|
|
509
|
+
if (lf === -1) return cr;
|
|
510
|
+
if (cr === -1) return lf;
|
|
511
|
+
return Math.min(lf, cr);
|
|
512
|
+
};
|
|
513
|
+
const reader = response.body.getReader();
|
|
514
|
+
try {
|
|
515
|
+
while (!done) {
|
|
516
|
+
const { value, done: readerDone } = await reader.read();
|
|
517
|
+
if (readerDone) break;
|
|
518
|
+
resetIdle();
|
|
519
|
+
receivedBytes += value.byteLength;
|
|
520
|
+
if (receivedBytes > MAX_RESPONSE_SIZE_BYTES) {
|
|
521
|
+
throw new OpenClawApiError("Response exceeds maximum allowed size (10MB)", 413);
|
|
522
|
+
}
|
|
523
|
+
buffer += decoder.decode(value, { stream: true });
|
|
524
|
+
let breakIndex;
|
|
525
|
+
while ((breakIndex = nextBreak(buffer)) !== -1) {
|
|
526
|
+
const line = buffer.slice(0, breakIndex);
|
|
527
|
+
buffer = buffer.slice(breakIndex + 1);
|
|
528
|
+
if (handleLine(line)) {
|
|
529
|
+
done = true;
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (!done) {
|
|
535
|
+
buffer += decoder.decode();
|
|
536
|
+
if (buffer.trim()) {
|
|
537
|
+
handleLine(buffer);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
} finally {
|
|
541
|
+
await reader.cancel().catch(() => {
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
if (!accumulated && !finishReason) {
|
|
545
|
+
throw new OpenClawApiError(
|
|
546
|
+
"Gateway returned no streamed content (not a valid SSE response)",
|
|
547
|
+
502
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
logDebug(() => `Stream complete (${accumulated.length} chars, finish=${finishReason})`);
|
|
551
|
+
return { response: accumulated, model, usage };
|
|
552
|
+
} catch (error) {
|
|
553
|
+
if (error instanceof OpenClawApiError) {
|
|
554
|
+
throw error;
|
|
555
|
+
}
|
|
556
|
+
if (abortCause === "finish-grace" && finishReason) {
|
|
557
|
+
logDebug(() => `Stream complete without trailing usage (${accumulated.length} chars)`);
|
|
558
|
+
return { response: accumulated, model, usage };
|
|
559
|
+
}
|
|
560
|
+
throw this.toConnectionError(error, abortCause, signal);
|
|
561
|
+
} finally {
|
|
562
|
+
clearTimeout(idleTimer);
|
|
563
|
+
clearTimeout(maxTimer);
|
|
564
|
+
unlink();
|
|
565
|
+
}
|
|
360
566
|
}
|
|
361
567
|
};
|
|
362
568
|
|
|
@@ -474,9 +680,9 @@ var InstanceRegistry = class {
|
|
|
474
680
|
}
|
|
475
681
|
};
|
|
476
682
|
|
|
477
|
-
// src/server/
|
|
478
|
-
import {
|
|
479
|
-
import {
|
|
683
|
+
// src/server/mcp-server.ts
|
|
684
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
685
|
+
import { z } from "zod";
|
|
480
686
|
|
|
481
687
|
// src/utils/response-helpers.ts
|
|
482
688
|
function successResponse(text) {
|
|
@@ -529,28 +735,6 @@ function validateId(value, fieldName) {
|
|
|
529
735
|
}
|
|
530
736
|
|
|
531
737
|
// src/mcp/tools/chat.ts
|
|
532
|
-
var openclawChatTool = {
|
|
533
|
-
name: "openclaw_chat",
|
|
534
|
-
description: "Send a message to OpenClaw and get a response",
|
|
535
|
-
inputSchema: {
|
|
536
|
-
type: "object",
|
|
537
|
-
properties: {
|
|
538
|
-
message: {
|
|
539
|
-
type: "string",
|
|
540
|
-
description: "The message to send to OpenClaw"
|
|
541
|
-
},
|
|
542
|
-
session_id: {
|
|
543
|
-
type: "string",
|
|
544
|
-
description: "Optional session ID for conversation context"
|
|
545
|
-
},
|
|
546
|
-
instance: {
|
|
547
|
-
type: "string",
|
|
548
|
-
description: "Target OpenClaw instance name. Use openclaw_instances to list available instances. Defaults to the default instance."
|
|
549
|
-
}
|
|
550
|
-
},
|
|
551
|
-
required: ["message"]
|
|
552
|
-
}
|
|
553
|
-
};
|
|
554
738
|
async function handleOpenclawChat(registry2, input) {
|
|
555
739
|
if (!validateInputIsObject(input)) {
|
|
556
740
|
return errorResponse("Invalid input: expected an object");
|
|
@@ -577,7 +761,7 @@ async function handleOpenclawChat(registry2, input) {
|
|
|
577
761
|
}
|
|
578
762
|
try {
|
|
579
763
|
const { client } = registry2.resolve(instanceName);
|
|
580
|
-
const response = await client.chat(msgResult.value, sessionId);
|
|
764
|
+
const response = await client.chat(msgResult.value, { sessionId });
|
|
581
765
|
return successResponse(response.response);
|
|
582
766
|
} catch (error) {
|
|
583
767
|
return errorResponse(error instanceof Error ? error.message : "Failed to chat with OpenClaw");
|
|
@@ -585,19 +769,6 @@ async function handleOpenclawChat(registry2, input) {
|
|
|
585
769
|
}
|
|
586
770
|
|
|
587
771
|
// src/mcp/tools/status.ts
|
|
588
|
-
var openclawStatusTool = {
|
|
589
|
-
name: "openclaw_status",
|
|
590
|
-
description: "Get OpenClaw gateway status and health information",
|
|
591
|
-
inputSchema: {
|
|
592
|
-
type: "object",
|
|
593
|
-
properties: {
|
|
594
|
-
instance: {
|
|
595
|
-
type: "string",
|
|
596
|
-
description: "Target OpenClaw instance name. Defaults to the default instance."
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
};
|
|
601
772
|
async function handleOpenclawStatus(registry2, input) {
|
|
602
773
|
if (!validateInputIsObject(input)) {
|
|
603
774
|
return errorResponse("Invalid input: expected an object");
|
|
@@ -625,14 +796,6 @@ async function handleOpenclawStatus(registry2, input) {
|
|
|
625
796
|
}
|
|
626
797
|
|
|
627
798
|
// src/mcp/tools/instances.ts
|
|
628
|
-
var openclawInstancesTool = {
|
|
629
|
-
name: "openclaw_instances",
|
|
630
|
-
description: "List all configured OpenClaw instances. Shows instance names, URLs, and which is the default. Use instance names in other tools to target a specific OpenClaw gateway.",
|
|
631
|
-
inputSchema: {
|
|
632
|
-
type: "object",
|
|
633
|
-
properties: {}
|
|
634
|
-
}
|
|
635
|
-
};
|
|
636
799
|
async function handleOpenclawInstances(registry2, _input) {
|
|
637
800
|
return jsonResponse({
|
|
638
801
|
instances: registry2.list(),
|
|
@@ -721,26 +884,55 @@ var TaskManager = class {
|
|
|
721
884
|
task.status = status;
|
|
722
885
|
if (status === "running" && !task.startedAt) {
|
|
723
886
|
task.startedAt = /* @__PURE__ */ new Date();
|
|
887
|
+
task.progressChars = 0;
|
|
888
|
+
task.lastActivityAt = task.startedAt;
|
|
724
889
|
}
|
|
725
890
|
if (status === "completed" || status === "failed" || status === "cancelled") {
|
|
726
891
|
task.completedAt = /* @__PURE__ */ new Date();
|
|
727
892
|
}
|
|
728
893
|
if (result !== void 0) task.result = result;
|
|
729
894
|
if (error !== void 0) task.error = error;
|
|
895
|
+
if (status === "completed" || status === "failed" || status === "cancelled") {
|
|
896
|
+
task.abortController = void 0;
|
|
897
|
+
}
|
|
730
898
|
log(`Task ${id} status: ${status}`);
|
|
731
899
|
return true;
|
|
732
900
|
}
|
|
733
901
|
/**
|
|
734
|
-
*
|
|
902
|
+
* Record streaming progress for a running task. Pass no character count to
|
|
903
|
+
* record liveness only (a heartbeat with no new content).
|
|
904
|
+
*/
|
|
905
|
+
updateProgress(id, progressChars) {
|
|
906
|
+
const task = this.tasks.get(id);
|
|
907
|
+
if (!task || task.status !== "running") return false;
|
|
908
|
+
if (progressChars !== void 0) {
|
|
909
|
+
task.progressChars = progressChars;
|
|
910
|
+
}
|
|
911
|
+
task.lastActivityAt = /* @__PURE__ */ new Date();
|
|
912
|
+
return true;
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Attach the abort controller of the in-flight request to a task
|
|
916
|
+
*/
|
|
917
|
+
attachAbortController(id, controller) {
|
|
918
|
+
const task = this.tasks.get(id);
|
|
919
|
+
if (task) task.abortController = controller;
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Cancel a pending or running task. Running tasks have their
|
|
923
|
+
* in-flight gateway request aborted.
|
|
735
924
|
*/
|
|
736
925
|
cancel(id) {
|
|
737
926
|
const task = this.tasks.get(id);
|
|
738
927
|
if (!task) return false;
|
|
739
|
-
if (task.status !== "pending") {
|
|
928
|
+
if (task.status !== "pending" && task.status !== "running") {
|
|
740
929
|
return false;
|
|
741
930
|
}
|
|
931
|
+
const controller = task.abortController;
|
|
742
932
|
task.status = "cancelled";
|
|
743
933
|
task.completedAt = /* @__PURE__ */ new Date();
|
|
934
|
+
task.abortController = void 0;
|
|
935
|
+
controller?.abort();
|
|
744
936
|
log(`Task cancelled: ${id}`);
|
|
745
937
|
return true;
|
|
746
938
|
}
|
|
@@ -797,85 +989,23 @@ var TaskManager = class {
|
|
|
797
989
|
var taskManager = new TaskManager();
|
|
798
990
|
|
|
799
991
|
// src/mcp/tools/tasks.ts
|
|
800
|
-
var
|
|
801
|
-
|
|
802
|
-
description: "Send a message to OpenClaw asynchronously. Returns a task_id immediately that can be polled for results. Use this for potentially long-running conversations.",
|
|
803
|
-
inputSchema: {
|
|
804
|
-
type: "object",
|
|
805
|
-
properties: {
|
|
806
|
-
message: {
|
|
807
|
-
type: "string",
|
|
808
|
-
description: "The message to send to OpenClaw"
|
|
809
|
-
},
|
|
810
|
-
session_id: {
|
|
811
|
-
type: "string",
|
|
812
|
-
description: "Optional session ID for conversation context"
|
|
813
|
-
},
|
|
814
|
-
priority: {
|
|
815
|
-
type: "number",
|
|
816
|
-
description: "Task priority (higher = processed first). Default: 0"
|
|
817
|
-
},
|
|
818
|
-
instance: {
|
|
819
|
-
type: "string",
|
|
820
|
-
description: "Target OpenClaw instance name. Defaults to the default instance."
|
|
821
|
-
}
|
|
822
|
-
},
|
|
823
|
-
required: ["message"]
|
|
824
|
-
}
|
|
825
|
-
};
|
|
826
|
-
var openclawTaskStatusTool = {
|
|
827
|
-
name: "openclaw_task_status",
|
|
828
|
-
description: "Check the status of an async task. Returns status, and result if completed.",
|
|
829
|
-
inputSchema: {
|
|
830
|
-
type: "object",
|
|
831
|
-
properties: {
|
|
832
|
-
task_id: {
|
|
833
|
-
type: "string",
|
|
834
|
-
description: "The task ID returned from openclaw_chat_async"
|
|
835
|
-
}
|
|
836
|
-
},
|
|
837
|
-
required: ["task_id"]
|
|
838
|
-
}
|
|
839
|
-
};
|
|
840
|
-
var openclawTaskListTool = {
|
|
841
|
-
name: "openclaw_task_list",
|
|
842
|
-
description: "List all tasks. Optionally filter by status, session, or instance.",
|
|
843
|
-
inputSchema: {
|
|
844
|
-
type: "object",
|
|
845
|
-
properties: {
|
|
846
|
-
status: {
|
|
847
|
-
type: "string",
|
|
848
|
-
enum: ["pending", "running", "completed", "failed", "cancelled"],
|
|
849
|
-
description: "Filter by task status"
|
|
850
|
-
},
|
|
851
|
-
session_id: {
|
|
852
|
-
type: "string",
|
|
853
|
-
description: "Filter by session ID"
|
|
854
|
-
},
|
|
855
|
-
instance: {
|
|
856
|
-
type: "string",
|
|
857
|
-
description: "Filter by instance name"
|
|
858
|
-
}
|
|
859
|
-
},
|
|
860
|
-
required: []
|
|
861
|
-
}
|
|
862
|
-
};
|
|
863
|
-
var openclawTaskCancelTool = {
|
|
864
|
-
name: "openclaw_task_cancel",
|
|
865
|
-
description: "Cancel a pending task. Only works for tasks that haven't started yet.",
|
|
866
|
-
inputSchema: {
|
|
867
|
-
type: "object",
|
|
868
|
-
properties: {
|
|
869
|
-
task_id: {
|
|
870
|
-
type: "string",
|
|
871
|
-
description: "The task ID to cancel"
|
|
872
|
-
}
|
|
873
|
-
},
|
|
874
|
-
required: ["task_id"]
|
|
875
|
-
}
|
|
876
|
-
};
|
|
992
|
+
var DEFAULT_TASK_CONCURRENCY = 3;
|
|
993
|
+
var MAX_TASK_CONCURRENCY = 20;
|
|
877
994
|
var processorRunning = false;
|
|
878
995
|
var processorRegistry = null;
|
|
996
|
+
var runningTasks = /* @__PURE__ */ new Set();
|
|
997
|
+
function resolveConcurrency() {
|
|
998
|
+
const raw = process.env.OPENCLAW_TASK_CONCURRENCY;
|
|
999
|
+
if (!raw) return DEFAULT_TASK_CONCURRENCY;
|
|
1000
|
+
const parsed = parseInt(raw, 10);
|
|
1001
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_TASK_CONCURRENCY) {
|
|
1002
|
+
log(
|
|
1003
|
+
`Invalid OPENCLAW_TASK_CONCURRENCY="${raw}" (must be 1-${MAX_TASK_CONCURRENCY}), using ${DEFAULT_TASK_CONCURRENCY}`
|
|
1004
|
+
);
|
|
1005
|
+
return DEFAULT_TASK_CONCURRENCY;
|
|
1006
|
+
}
|
|
1007
|
+
return parsed;
|
|
1008
|
+
}
|
|
879
1009
|
async function processTask(task, registry2) {
|
|
880
1010
|
taskManager.updateStatus(task.id, "running");
|
|
881
1011
|
let client;
|
|
@@ -886,24 +1016,41 @@ async function processTask(task, registry2) {
|
|
|
886
1016
|
taskManager.updateStatus(task.id, "failed", void 0, errorMsg);
|
|
887
1017
|
return;
|
|
888
1018
|
}
|
|
1019
|
+
const controller = new AbortController();
|
|
1020
|
+
taskManager.attachAbortController(task.id, controller);
|
|
889
1021
|
try {
|
|
890
1022
|
const input = task.input;
|
|
891
|
-
const response = await client.chat(input.message,
|
|
1023
|
+
const response = await client.chat(input.message, {
|
|
1024
|
+
sessionId: input.session_id,
|
|
1025
|
+
signal: controller.signal,
|
|
1026
|
+
onDelta: (_delta, accumulated) => {
|
|
1027
|
+
taskManager.updateProgress(task.id, accumulated.length);
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
if (taskManager.get(task.id)?.status === "cancelled") {
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
892
1033
|
taskManager.updateStatus(task.id, "completed", response.response);
|
|
893
1034
|
} catch (error) {
|
|
1035
|
+
if (taskManager.get(task.id)?.status === "cancelled") {
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
894
1038
|
const errorMsg = error instanceof Error ? error.message : "Unknown error";
|
|
895
1039
|
taskManager.updateStatus(task.id, "failed", void 0, errorMsg);
|
|
896
1040
|
}
|
|
897
1041
|
}
|
|
898
1042
|
async function taskProcessor() {
|
|
899
|
-
|
|
1043
|
+
const concurrency = resolveConcurrency();
|
|
1044
|
+
log(`Task processor concurrency: ${concurrency}`);
|
|
900
1045
|
while (processorRunning) {
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1046
|
+
while (runningTasks.size < concurrency && processorRegistry) {
|
|
1047
|
+
const task = taskManager.getNextPending();
|
|
1048
|
+
if (!task) break;
|
|
1049
|
+
runningTasks.add(task.id);
|
|
1050
|
+
void processTask(task, processorRegistry).catch(() => {
|
|
1051
|
+
}).finally(() => runningTasks.delete(task.id));
|
|
906
1052
|
}
|
|
1053
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
907
1054
|
}
|
|
908
1055
|
}
|
|
909
1056
|
function startTaskProcessor(registry2) {
|
|
@@ -997,6 +1144,10 @@ async function handleOpenclawTaskStatus(_registry, input) {
|
|
|
997
1144
|
if (task.startedAt) {
|
|
998
1145
|
response.started_at = task.startedAt.toISOString();
|
|
999
1146
|
}
|
|
1147
|
+
if (task.status === "running" && task.progressChars !== void 0) {
|
|
1148
|
+
response.progress_chars = task.progressChars;
|
|
1149
|
+
response.last_activity_at = task.lastActivityAt?.toISOString();
|
|
1150
|
+
}
|
|
1000
1151
|
if (task.completedAt) {
|
|
1001
1152
|
response.completed_at = task.completedAt.toISOString();
|
|
1002
1153
|
}
|
|
@@ -1077,9 +1228,9 @@ async function handleOpenclawTaskCancel(_registry, input) {
|
|
|
1077
1228
|
if (!task) {
|
|
1078
1229
|
return errorResponse(`Task not found: ${task_id}`);
|
|
1079
1230
|
}
|
|
1080
|
-
if (task.status !== "pending") {
|
|
1231
|
+
if (task.status !== "pending" && task.status !== "running") {
|
|
1081
1232
|
return errorResponse(
|
|
1082
|
-
`Cannot cancel task with status: ${task.status}. Only pending tasks can be cancelled.`
|
|
1233
|
+
`Cannot cancel task with status: ${task.status}. Only pending or running tasks can be cancelled.`
|
|
1083
1234
|
);
|
|
1084
1235
|
}
|
|
1085
1236
|
const cancelled = taskManager.cancel(task_id);
|
|
@@ -1099,99 +1250,155 @@ async function handleOpenclawTaskCancel(_registry, input) {
|
|
|
1099
1250
|
);
|
|
1100
1251
|
}
|
|
1101
1252
|
|
|
1102
|
-
// src/server/
|
|
1253
|
+
// src/server/mcp-server.ts
|
|
1254
|
+
var SERVER_ICONS = [
|
|
1255
|
+
{
|
|
1256
|
+
src: `data:image/svg+xml;base64,${SERVER_ICON_SVG_BASE64}`,
|
|
1257
|
+
mimeType: "image/svg+xml",
|
|
1258
|
+
sizes: ["128x128"]
|
|
1259
|
+
}
|
|
1260
|
+
];
|
|
1261
|
+
var TASK_STATUS_VALUES = ["pending", "running", "completed", "failed", "cancelled"];
|
|
1262
|
+
var instanceField = z.string().optional().describe(
|
|
1263
|
+
"Target OpenClaw instance name. Use openclaw_instances to list available instances. Defaults to the default instance."
|
|
1264
|
+
);
|
|
1265
|
+
function wrapHandler(name, handler) {
|
|
1266
|
+
return async (args2) => {
|
|
1267
|
+
log(`Executing tool: ${name}`);
|
|
1268
|
+
try {
|
|
1269
|
+
return await handler(args2 ?? {});
|
|
1270
|
+
} catch (error) {
|
|
1271
|
+
logError(`Error executing tool ${name}`, error);
|
|
1272
|
+
throw error;
|
|
1273
|
+
}
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1103
1276
|
function createMcpServer(deps2) {
|
|
1104
|
-
const
|
|
1277
|
+
const { registry: registry2 } = deps2;
|
|
1278
|
+
const server = new McpServer(
|
|
1105
1279
|
{
|
|
1106
1280
|
name: deps2.serverName,
|
|
1107
1281
|
version: deps2.serverVersion,
|
|
1108
|
-
icons:
|
|
1109
|
-
{
|
|
1110
|
-
src: `data:image/svg+xml;base64,${SERVER_ICON_SVG_BASE64}`,
|
|
1111
|
-
mimeType: "image/svg+xml",
|
|
1112
|
-
sizes: ["128x128"]
|
|
1113
|
-
}
|
|
1114
|
-
]
|
|
1282
|
+
icons: SERVER_ICONS
|
|
1115
1283
|
},
|
|
1116
1284
|
{ capabilities: { tools: {} } }
|
|
1117
1285
|
);
|
|
1118
|
-
|
|
1286
|
+
server.registerTool(
|
|
1287
|
+
"openclaw_chat",
|
|
1288
|
+
{
|
|
1289
|
+
description: "Send a message to OpenClaw and get a response",
|
|
1290
|
+
inputSchema: z.object({
|
|
1291
|
+
message: z.string().describe("The message to send to OpenClaw"),
|
|
1292
|
+
session_id: z.string().optional().describe("Optional session ID for conversation context"),
|
|
1293
|
+
instance: instanceField
|
|
1294
|
+
})
|
|
1295
|
+
},
|
|
1296
|
+
wrapHandler("openclaw_chat", (input) => handleOpenclawChat(registry2, input))
|
|
1297
|
+
);
|
|
1298
|
+
server.registerTool(
|
|
1299
|
+
"openclaw_status",
|
|
1300
|
+
{
|
|
1301
|
+
description: "Check OpenClaw gateway health",
|
|
1302
|
+
annotations: { readOnlyHint: true },
|
|
1303
|
+
inputSchema: z.object({
|
|
1304
|
+
instance: instanceField
|
|
1305
|
+
})
|
|
1306
|
+
},
|
|
1307
|
+
wrapHandler("openclaw_status", (input) => handleOpenclawStatus(registry2, input))
|
|
1308
|
+
);
|
|
1309
|
+
server.registerTool(
|
|
1310
|
+
"openclaw_instances",
|
|
1311
|
+
{
|
|
1312
|
+
description: "List all configured OpenClaw instances. Shows instance names, URLs, and which is the default. Use instance names in other tools to target a specific OpenClaw gateway.",
|
|
1313
|
+
annotations: { readOnlyHint: true },
|
|
1314
|
+
inputSchema: z.object({})
|
|
1315
|
+
},
|
|
1316
|
+
wrapHandler("openclaw_instances", (input) => handleOpenclawInstances(registry2, input))
|
|
1317
|
+
);
|
|
1318
|
+
server.registerTool(
|
|
1319
|
+
"openclaw_chat_async",
|
|
1320
|
+
{
|
|
1321
|
+
description: "Send a message to OpenClaw asynchronously. Returns a task_id immediately that can be polled for results. Use this for potentially long-running conversations.",
|
|
1322
|
+
inputSchema: z.object({
|
|
1323
|
+
message: z.string().describe("The message to send to OpenClaw"),
|
|
1324
|
+
session_id: z.string().optional().describe("Optional session ID for conversation context"),
|
|
1325
|
+
priority: z.number().int().optional().describe("Task priority (higher = processed first). Default: 0"),
|
|
1326
|
+
instance: instanceField
|
|
1327
|
+
})
|
|
1328
|
+
},
|
|
1329
|
+
wrapHandler("openclaw_chat_async", (input) => handleOpenclawChatAsync(registry2, input))
|
|
1330
|
+
);
|
|
1331
|
+
server.registerTool(
|
|
1332
|
+
"openclaw_task_status",
|
|
1333
|
+
{
|
|
1334
|
+
description: "Check the status of an async task. Returns status, and result if completed.",
|
|
1335
|
+
annotations: { readOnlyHint: true },
|
|
1336
|
+
inputSchema: z.object({
|
|
1337
|
+
task_id: z.string().describe("The task ID returned from openclaw_chat_async")
|
|
1338
|
+
})
|
|
1339
|
+
},
|
|
1340
|
+
wrapHandler("openclaw_task_status", (input) => handleOpenclawTaskStatus(registry2, input))
|
|
1341
|
+
);
|
|
1342
|
+
server.registerTool(
|
|
1343
|
+
"openclaw_task_list",
|
|
1344
|
+
{
|
|
1345
|
+
description: "List all tasks. Optionally filter by status, session, or instance.",
|
|
1346
|
+
annotations: { readOnlyHint: true },
|
|
1347
|
+
inputSchema: z.object({
|
|
1348
|
+
status: z.enum(TASK_STATUS_VALUES).optional().describe("Filter by task status"),
|
|
1349
|
+
session_id: z.string().optional().describe("Filter by session ID"),
|
|
1350
|
+
instance: z.string().optional().describe("Filter by instance name")
|
|
1351
|
+
})
|
|
1352
|
+
},
|
|
1353
|
+
wrapHandler("openclaw_task_list", (input) => handleOpenclawTaskList(registry2, input))
|
|
1354
|
+
);
|
|
1355
|
+
server.registerTool(
|
|
1356
|
+
"openclaw_task_cancel",
|
|
1357
|
+
{
|
|
1358
|
+
description: "Cancel a pending or running task. Running tasks have their in-flight gateway request aborted.",
|
|
1359
|
+
inputSchema: z.object({
|
|
1360
|
+
task_id: z.string().describe("The task ID to cancel")
|
|
1361
|
+
})
|
|
1362
|
+
},
|
|
1363
|
+
wrapHandler("openclaw_task_cancel", (input) => handleOpenclawTaskCancel(registry2, input))
|
|
1364
|
+
);
|
|
1119
1365
|
return server;
|
|
1120
1366
|
}
|
|
1121
|
-
function registerTools(server, deps2) {
|
|
1122
|
-
const { registry: registry2 } = deps2;
|
|
1123
|
-
const toolHandlers = /* @__PURE__ */ new Map([
|
|
1124
|
-
["openclaw_chat", (input) => handleOpenclawChat(registry2, input)],
|
|
1125
|
-
["openclaw_status", (input) => handleOpenclawStatus(registry2, input)],
|
|
1126
|
-
["openclaw_chat_async", (input) => handleOpenclawChatAsync(registry2, input)],
|
|
1127
|
-
["openclaw_task_status", (input) => handleOpenclawTaskStatus(registry2, input)],
|
|
1128
|
-
["openclaw_task_list", (input) => handleOpenclawTaskList(registry2, input)],
|
|
1129
|
-
["openclaw_task_cancel", (input) => handleOpenclawTaskCancel(registry2, input)],
|
|
1130
|
-
["openclaw_instances", (input) => handleOpenclawInstances(registry2, input)]
|
|
1131
|
-
]);
|
|
1132
|
-
const allTools = [
|
|
1133
|
-
openclawChatTool,
|
|
1134
|
-
openclawStatusTool,
|
|
1135
|
-
openclawChatAsyncTool,
|
|
1136
|
-
openclawTaskStatusTool,
|
|
1137
|
-
openclawTaskListTool,
|
|
1138
|
-
openclawTaskCancelTool,
|
|
1139
|
-
openclawInstancesTool
|
|
1140
|
-
];
|
|
1141
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
1142
|
-
return { tools: allTools };
|
|
1143
|
-
});
|
|
1144
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1145
|
-
const { name, arguments: toolArgs } = request.params;
|
|
1146
|
-
log(`Executing tool: ${name}`);
|
|
1147
|
-
const handler = toolHandlers.get(name);
|
|
1148
|
-
if (!handler) {
|
|
1149
|
-
throw new Error(`Unknown tool: ${name}`);
|
|
1150
|
-
}
|
|
1151
|
-
try {
|
|
1152
|
-
return await handler(toolArgs);
|
|
1153
|
-
} catch (error) {
|
|
1154
|
-
logError(`Error executing tool ${name}`, error);
|
|
1155
|
-
throw error;
|
|
1156
|
-
}
|
|
1157
|
-
});
|
|
1158
|
-
}
|
|
1159
1367
|
|
|
1160
1368
|
// src/server/http.ts
|
|
1161
|
-
import
|
|
1162
|
-
import {
|
|
1163
|
-
import {
|
|
1164
|
-
import {
|
|
1165
|
-
|
|
1166
|
-
|
|
1369
|
+
import express from "express";
|
|
1370
|
+
import { createMcpHandler } from "@modelcontextprotocol/server";
|
|
1371
|
+
import { toNodeHandler } from "@modelcontextprotocol/node";
|
|
1372
|
+
import {
|
|
1373
|
+
requireBearerAuth,
|
|
1374
|
+
localhostHostValidation,
|
|
1375
|
+
localhostOriginValidation,
|
|
1376
|
+
getOAuthProtectedResourceMetadataUrl
|
|
1377
|
+
} from "@modelcontextprotocol/express";
|
|
1167
1378
|
|
|
1168
1379
|
// src/auth/provider.ts
|
|
1169
1380
|
import { randomUUID } from "crypto";
|
|
1170
|
-
import {
|
|
1171
|
-
InvalidRequestError,
|
|
1172
|
-
InvalidTokenError
|
|
1173
|
-
} from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
1381
|
+
import { OAuthError, OAuthErrorCode } from "@modelcontextprotocol/server";
|
|
1174
1382
|
var TOKEN_TTL_MS = 60 * 60 * 1e3;
|
|
1175
1383
|
var AUTH_CODE_TTL_MS = 10 * 60 * 1e3;
|
|
1176
1384
|
var REFRESH_TOKEN_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
1177
1385
|
var REAPER_INTERVAL_MS = 5 * 60 * 1e3;
|
|
1178
|
-
var
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
if (prop === "length") return 1;
|
|
1182
|
-
return Reflect.get(target, prop);
|
|
1386
|
+
var AllowAnyRedirectUris = class extends Array {
|
|
1387
|
+
includes(_searchElement, _fromIndex) {
|
|
1388
|
+
return true;
|
|
1183
1389
|
}
|
|
1184
|
-
|
|
1390
|
+
some(_predicate, _thisArg) {
|
|
1391
|
+
return true;
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
var ALLOW_ANY_REDIRECT = new AllowAnyRedirectUris();
|
|
1185
1395
|
var MAX_DYNAMIC_CLIENTS = 100;
|
|
1186
1396
|
var OpenClawClientsStore = class {
|
|
1187
1397
|
client;
|
|
1188
1398
|
dynamicClients = /* @__PURE__ */ new Map();
|
|
1189
|
-
|
|
1190
|
-
// `clientsStore.registerClient` at router-setup time and only advertises
|
|
1191
|
-
// `/register` when the property is defined, so we must NOT define a no-op
|
|
1192
|
-
// method on the prototype — it has to be instance-level and gated on config.
|
|
1193
|
-
registerClient;
|
|
1399
|
+
allowDynamicRegistration;
|
|
1194
1400
|
constructor(config) {
|
|
1401
|
+
this.allowDynamicRegistration = !!config.allowDynamicRegistration;
|
|
1195
1402
|
if (config.clientId && config.clientSecret) {
|
|
1196
1403
|
const redirectUris = config.redirectUris && config.redirectUris.length > 0 ? config.redirectUris : ALLOW_ANY_REDIRECT;
|
|
1197
1404
|
this.client = {
|
|
@@ -1205,25 +1412,27 @@ var OpenClawClientsStore = class {
|
|
|
1205
1412
|
client_id_issued_at: Math.floor(Date.now() / 1e3)
|
|
1206
1413
|
};
|
|
1207
1414
|
}
|
|
1208
|
-
if (config.allowDynamicRegistration) {
|
|
1209
|
-
this.registerClient = (client) => {
|
|
1210
|
-
if (this.dynamicClients.size >= MAX_DYNAMIC_CLIENTS) {
|
|
1211
|
-
const oldestKey = this.dynamicClients.keys().next().value;
|
|
1212
|
-
if (oldestKey !== void 0) {
|
|
1213
|
-
this.dynamicClients.delete(oldestKey);
|
|
1214
|
-
}
|
|
1215
|
-
}
|
|
1216
|
-
this.dynamicClients.set(client.client_id, client);
|
|
1217
|
-
return client;
|
|
1218
|
-
};
|
|
1219
|
-
}
|
|
1220
1415
|
}
|
|
1221
|
-
|
|
1416
|
+
getClient(clientId) {
|
|
1222
1417
|
if (this.client && this.client.client_id === clientId) {
|
|
1223
1418
|
return this.client;
|
|
1224
1419
|
}
|
|
1225
1420
|
return this.dynamicClients.get(clientId);
|
|
1226
1421
|
}
|
|
1422
|
+
/** RFC 7591 dynamic registration. Throws unless DCR is enabled. */
|
|
1423
|
+
registerClient(client) {
|
|
1424
|
+
if (!this.allowDynamicRegistration) {
|
|
1425
|
+
throw new OAuthError(OAuthErrorCode.InvalidRequest, "Dynamic registration is disabled");
|
|
1426
|
+
}
|
|
1427
|
+
if (this.dynamicClients.size >= MAX_DYNAMIC_CLIENTS) {
|
|
1428
|
+
const oldestKey = this.dynamicClients.keys().next().value;
|
|
1429
|
+
if (oldestKey !== void 0) {
|
|
1430
|
+
this.dynamicClients.delete(oldestKey);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
this.dynamicClients.set(client.client_id, client);
|
|
1434
|
+
return client;
|
|
1435
|
+
}
|
|
1227
1436
|
};
|
|
1228
1437
|
var OpenClawAuthProvider = class {
|
|
1229
1438
|
clientsStore;
|
|
@@ -1260,52 +1469,80 @@ var OpenClawAuthProvider = class {
|
|
|
1260
1469
|
}
|
|
1261
1470
|
}
|
|
1262
1471
|
/**
|
|
1263
|
-
* Auto-approve:
|
|
1472
|
+
* Auto-approve: issue an auth code and return the redirect URL the
|
|
1473
|
+
* router should send the user-agent to.
|
|
1264
1474
|
*/
|
|
1265
|
-
|
|
1475
|
+
authorize(client, params) {
|
|
1266
1476
|
const code = randomUUID();
|
|
1267
1477
|
this.codes.set(code, { client, params, createdAt: Date.now() });
|
|
1268
|
-
const
|
|
1478
|
+
const targetUrl = new URL(params.redirectUri);
|
|
1479
|
+
targetUrl.searchParams.set("code", code);
|
|
1269
1480
|
if (params.state !== void 0) {
|
|
1270
|
-
searchParams.set("state", params.state);
|
|
1481
|
+
targetUrl.searchParams.set("state", params.state);
|
|
1271
1482
|
}
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1483
|
+
return targetUrl.toString();
|
|
1484
|
+
}
|
|
1485
|
+
/** PKCE challenge recorded for a still-valid authorization code. */
|
|
1486
|
+
challengeForAuthorizationCode(client, authorizationCode) {
|
|
1487
|
+
return this.getAuthorizationGrant(client, authorizationCode).codeChallenge;
|
|
1275
1488
|
}
|
|
1276
|
-
|
|
1489
|
+
/**
|
|
1490
|
+
* The authorize-time parameters a code was issued with, for the checks the
|
|
1491
|
+
* token endpoint has to make before redeeming it.
|
|
1492
|
+
*/
|
|
1493
|
+
getAuthorizationGrant(client, authorizationCode) {
|
|
1277
1494
|
const codeData = this.codes.get(authorizationCode);
|
|
1278
1495
|
if (!codeData || Date.now() - codeData.createdAt > AUTH_CODE_TTL_MS) {
|
|
1279
1496
|
if (codeData) this.codes.delete(authorizationCode);
|
|
1280
|
-
throw new
|
|
1497
|
+
throw new OAuthError(OAuthErrorCode.InvalidGrant, "Invalid authorization code");
|
|
1498
|
+
}
|
|
1499
|
+
if (codeData.client.client_id !== client.client_id) {
|
|
1500
|
+
throw new OAuthError(
|
|
1501
|
+
OAuthErrorCode.InvalidGrant,
|
|
1502
|
+
"Authorization code was not issued to this client"
|
|
1503
|
+
);
|
|
1281
1504
|
}
|
|
1282
|
-
return codeData.params
|
|
1505
|
+
return codeData.params;
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Drop a code without redeeming it. Used when a redemption attempt looks
|
|
1509
|
+
* like an attack (bad PKCE verifier, mismatched redirect_uri).
|
|
1510
|
+
*/
|
|
1511
|
+
invalidateAuthorizationCode(authorizationCode) {
|
|
1512
|
+
this.codes.delete(authorizationCode);
|
|
1283
1513
|
}
|
|
1284
|
-
|
|
1514
|
+
exchangeAuthorizationCode(client, authorizationCode, resource) {
|
|
1285
1515
|
const codeData = this.codes.get(authorizationCode);
|
|
1286
1516
|
if (!codeData || Date.now() - codeData.createdAt > AUTH_CODE_TTL_MS) {
|
|
1287
1517
|
if (codeData) this.codes.delete(authorizationCode);
|
|
1288
|
-
throw new
|
|
1518
|
+
throw new OAuthError(OAuthErrorCode.InvalidGrant, "Invalid authorization code");
|
|
1289
1519
|
}
|
|
1290
1520
|
if (codeData.client.client_id !== client.client_id) {
|
|
1291
|
-
throw new
|
|
1521
|
+
throw new OAuthError(
|
|
1522
|
+
OAuthErrorCode.InvalidGrant,
|
|
1523
|
+
"Authorization code was not issued to this client"
|
|
1524
|
+
);
|
|
1292
1525
|
}
|
|
1293
1526
|
this.codes.delete(authorizationCode);
|
|
1294
1527
|
const accessToken = randomUUID();
|
|
1295
1528
|
const refreshToken = randomUUID();
|
|
1529
|
+
const grantId = randomUUID();
|
|
1296
1530
|
const scopes = codeData.params.scopes || [];
|
|
1531
|
+
const grantResource = codeData.params.resource ?? resource;
|
|
1297
1532
|
this.tokens.set(accessToken, {
|
|
1298
1533
|
token: accessToken,
|
|
1299
1534
|
clientId: client.client_id,
|
|
1300
1535
|
scopes,
|
|
1301
1536
|
expiresAt: Date.now() + TOKEN_TTL_MS,
|
|
1302
|
-
resource:
|
|
1537
|
+
resource: grantResource,
|
|
1538
|
+
grantId
|
|
1303
1539
|
});
|
|
1304
1540
|
this.refreshTokens.set(refreshToken, {
|
|
1305
1541
|
clientId: client.client_id,
|
|
1306
1542
|
scopes,
|
|
1307
1543
|
expiresAt: Date.now() + REFRESH_TOKEN_TTL_MS,
|
|
1308
|
-
resource:
|
|
1544
|
+
resource: grantResource,
|
|
1545
|
+
grantId
|
|
1309
1546
|
});
|
|
1310
1547
|
return {
|
|
1311
1548
|
access_token: accessToken,
|
|
@@ -1315,31 +1552,47 @@ var OpenClawAuthProvider = class {
|
|
|
1315
1552
|
scope: scopes.join(" ")
|
|
1316
1553
|
};
|
|
1317
1554
|
}
|
|
1318
|
-
|
|
1555
|
+
exchangeRefreshToken(client, refreshToken, scopes, resource) {
|
|
1319
1556
|
const data = this.refreshTokens.get(refreshToken);
|
|
1320
1557
|
if (!data || data.expiresAt < Date.now()) {
|
|
1321
1558
|
if (data) this.refreshTokens.delete(refreshToken);
|
|
1322
|
-
throw new
|
|
1559
|
+
throw new OAuthError(OAuthErrorCode.InvalidGrant, "Invalid refresh token");
|
|
1323
1560
|
}
|
|
1324
1561
|
if (data.clientId !== client.client_id) {
|
|
1325
|
-
throw new
|
|
1562
|
+
throw new OAuthError(
|
|
1563
|
+
OAuthErrorCode.InvalidGrant,
|
|
1564
|
+
"Refresh token was not issued to this client"
|
|
1565
|
+
);
|
|
1566
|
+
}
|
|
1567
|
+
let tokenScopes = data.scopes;
|
|
1568
|
+
if (scopes && scopes.length > 0) {
|
|
1569
|
+
const escalated = scopes.filter((scope) => !data.scopes.includes(scope));
|
|
1570
|
+
if (escalated.length > 0) {
|
|
1571
|
+
throw new OAuthError(
|
|
1572
|
+
OAuthErrorCode.InvalidScope,
|
|
1573
|
+
`Requested scope exceeds the original grant: ${escalated.join(" ")}`
|
|
1574
|
+
);
|
|
1575
|
+
}
|
|
1576
|
+
tokenScopes = scopes;
|
|
1326
1577
|
}
|
|
1327
1578
|
this.refreshTokens.delete(refreshToken);
|
|
1328
1579
|
const accessToken = randomUUID();
|
|
1329
1580
|
const newRefreshToken = randomUUID();
|
|
1330
|
-
const
|
|
1581
|
+
const grantResource = data.resource ?? resource;
|
|
1331
1582
|
this.tokens.set(accessToken, {
|
|
1332
1583
|
token: accessToken,
|
|
1333
1584
|
clientId: client.client_id,
|
|
1334
1585
|
scopes: tokenScopes,
|
|
1335
1586
|
expiresAt: Date.now() + TOKEN_TTL_MS,
|
|
1336
|
-
resource:
|
|
1587
|
+
resource: grantResource,
|
|
1588
|
+
grantId: data.grantId
|
|
1337
1589
|
});
|
|
1338
1590
|
this.refreshTokens.set(newRefreshToken, {
|
|
1339
1591
|
clientId: client.client_id,
|
|
1340
1592
|
scopes: tokenScopes,
|
|
1341
1593
|
expiresAt: Date.now() + REFRESH_TOKEN_TTL_MS,
|
|
1342
|
-
resource:
|
|
1594
|
+
resource: grantResource,
|
|
1595
|
+
grantId: data.grantId
|
|
1343
1596
|
});
|
|
1344
1597
|
return {
|
|
1345
1598
|
access_token: accessToken,
|
|
@@ -1349,10 +1602,11 @@ var OpenClawAuthProvider = class {
|
|
|
1349
1602
|
scope: tokenScopes.join(" ")
|
|
1350
1603
|
};
|
|
1351
1604
|
}
|
|
1605
|
+
/** SDK v2 `OAuthTokenVerifier` contract — plugs into `requireBearerAuth`. */
|
|
1352
1606
|
async verifyAccessToken(token) {
|
|
1353
1607
|
const tokenData = this.tokens.get(token);
|
|
1354
1608
|
if (!tokenData || tokenData.expiresAt < Date.now()) {
|
|
1355
|
-
throw new
|
|
1609
|
+
throw new OAuthError(OAuthErrorCode.InvalidToken, "Invalid or expired token");
|
|
1356
1610
|
}
|
|
1357
1611
|
return {
|
|
1358
1612
|
token,
|
|
@@ -1362,19 +1616,374 @@ var OpenClawAuthProvider = class {
|
|
|
1362
1616
|
resource: tokenData.resource
|
|
1363
1617
|
};
|
|
1364
1618
|
}
|
|
1365
|
-
|
|
1619
|
+
revokeToken(client, request) {
|
|
1366
1620
|
const tokenData = this.tokens.get(request.token);
|
|
1367
|
-
if (tokenData && tokenData.clientId === client.client_id) {
|
|
1368
|
-
this.tokens.delete(request.token);
|
|
1369
|
-
}
|
|
1370
1621
|
const refreshData = this.refreshTokens.get(request.token);
|
|
1371
|
-
|
|
1372
|
-
|
|
1622
|
+
const grantId = tokenData && tokenData.clientId === client.client_id ? tokenData.grantId : refreshData && refreshData.clientId === client.client_id ? refreshData.grantId : void 0;
|
|
1623
|
+
if (grantId === void 0) return;
|
|
1624
|
+
this.revokeGrant(grantId);
|
|
1625
|
+
}
|
|
1626
|
+
/** Drop every access and refresh token minted from one authorization. */
|
|
1627
|
+
revokeGrant(grantId) {
|
|
1628
|
+
for (const [token, data] of this.tokens) {
|
|
1629
|
+
if (data.grantId === grantId) this.tokens.delete(token);
|
|
1630
|
+
}
|
|
1631
|
+
for (const [token, data] of this.refreshTokens) {
|
|
1632
|
+
if (data.grantId === grantId) this.refreshTokens.delete(token);
|
|
1373
1633
|
}
|
|
1374
1634
|
}
|
|
1375
1635
|
};
|
|
1376
1636
|
|
|
1637
|
+
// src/auth/router.ts
|
|
1638
|
+
import { createHash, randomUUID as randomUUID2, timingSafeEqual } from "crypto";
|
|
1639
|
+
import { Router, json, urlencoded } from "express";
|
|
1640
|
+
import { rateLimit } from "express-rate-limit";
|
|
1641
|
+
import { OAuthError as OAuthError2, OAuthErrorCode as OAuthErrorCode2 } from "@modelcontextprotocol/server";
|
|
1642
|
+
var ERROR_STATUS = {
|
|
1643
|
+
[OAuthErrorCode2.InvalidClient]: 401,
|
|
1644
|
+
[OAuthErrorCode2.InvalidToken]: 401,
|
|
1645
|
+
[OAuthErrorCode2.InsufficientScope]: 403,
|
|
1646
|
+
[OAuthErrorCode2.MethodNotAllowed]: 405,
|
|
1647
|
+
[OAuthErrorCode2.ServerError]: 500,
|
|
1648
|
+
[OAuthErrorCode2.TemporarilyUnavailable]: 503
|
|
1649
|
+
};
|
|
1650
|
+
function sendOAuthError(res, error) {
|
|
1651
|
+
if (error instanceof OAuthError2) {
|
|
1652
|
+
const status = ERROR_STATUS[error.code] ?? 400;
|
|
1653
|
+
res.status(status).json(error.toResponseObject());
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
logError("OAuth endpoint error", error);
|
|
1657
|
+
res.status(500).json(new OAuthError2(OAuthErrorCode2.ServerError, "Internal Server Error").toResponseObject());
|
|
1658
|
+
}
|
|
1659
|
+
function timingSafeStringEqual(a, b) {
|
|
1660
|
+
const hashA = createHash("sha256").update(a).digest();
|
|
1661
|
+
const hashB = createHash("sha256").update(b).digest();
|
|
1662
|
+
return timingSafeEqual(hashA, hashB);
|
|
1663
|
+
}
|
|
1664
|
+
function verifyPkce(codeVerifier, codeChallenge) {
|
|
1665
|
+
const computed = createHash("sha256").update(codeVerifier).digest("base64url");
|
|
1666
|
+
return timingSafeStringEqual(computed, codeChallenge);
|
|
1667
|
+
}
|
|
1668
|
+
function isValidPkceValue(value) {
|
|
1669
|
+
return /^[A-Za-z0-9\-._~]{43,128}$/.test(value);
|
|
1670
|
+
}
|
|
1671
|
+
function firstString(value) {
|
|
1672
|
+
if (typeof value === "string") return value;
|
|
1673
|
+
if (Array.isArray(value) && typeof value[0] === "string") return value[0];
|
|
1674
|
+
return void 0;
|
|
1675
|
+
}
|
|
1676
|
+
function parseResource(value) {
|
|
1677
|
+
if (!value) return void 0;
|
|
1678
|
+
try {
|
|
1679
|
+
return new URL(value);
|
|
1680
|
+
} catch {
|
|
1681
|
+
throw new OAuthError2(OAuthErrorCode2.InvalidRequest, "Invalid resource parameter");
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
function authenticateClient(provider, req) {
|
|
1685
|
+
const body = req.body ?? {};
|
|
1686
|
+
let clientId = firstString(body.client_id);
|
|
1687
|
+
let clientSecret = firstString(body.client_secret);
|
|
1688
|
+
const authHeader = req.headers.authorization;
|
|
1689
|
+
if (authHeader?.startsWith("Basic ")) {
|
|
1690
|
+
const formDecode = (value) => decodeURIComponent(value.replace(/\+/g, " "));
|
|
1691
|
+
let decoded;
|
|
1692
|
+
try {
|
|
1693
|
+
decoded = Buffer.from(authHeader.slice(6), "base64").toString("utf8");
|
|
1694
|
+
} catch {
|
|
1695
|
+
throw new OAuthError2(OAuthErrorCode2.InvalidClient, "Malformed Basic authorization header");
|
|
1696
|
+
}
|
|
1697
|
+
const separator = decoded.indexOf(":");
|
|
1698
|
+
if (separator === -1) {
|
|
1699
|
+
throw new OAuthError2(OAuthErrorCode2.InvalidClient, "Malformed Basic authorization header");
|
|
1700
|
+
}
|
|
1701
|
+
try {
|
|
1702
|
+
clientId = formDecode(decoded.slice(0, separator));
|
|
1703
|
+
clientSecret = formDecode(decoded.slice(separator + 1));
|
|
1704
|
+
} catch {
|
|
1705
|
+
throw new OAuthError2(OAuthErrorCode2.InvalidClient, "Malformed Basic authorization header");
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
if (!clientId) {
|
|
1709
|
+
throw new OAuthError2(OAuthErrorCode2.InvalidClient, "Missing client_id");
|
|
1710
|
+
}
|
|
1711
|
+
const client = provider.clientsStore.getClient(clientId);
|
|
1712
|
+
if (!client) {
|
|
1713
|
+
throw new OAuthError2(OAuthErrorCode2.InvalidClient, "Unknown client");
|
|
1714
|
+
}
|
|
1715
|
+
if (client.client_secret) {
|
|
1716
|
+
if (!clientSecret || !timingSafeStringEqual(client.client_secret, clientSecret)) {
|
|
1717
|
+
throw new OAuthError2(OAuthErrorCode2.InvalidClient, "Invalid client credentials");
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
return client;
|
|
1721
|
+
}
|
|
1722
|
+
function isLoopbackHost(hostname) {
|
|
1723
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
|
1724
|
+
}
|
|
1725
|
+
function isSafeRedirectScheme(url) {
|
|
1726
|
+
if (url.protocol === "https:") return true;
|
|
1727
|
+
if (url.protocol === "http:") return isLoopbackHost(url.hostname);
|
|
1728
|
+
return false;
|
|
1729
|
+
}
|
|
1730
|
+
function isRedirectUriAllowed(client, redirectUri) {
|
|
1731
|
+
const uris = client.redirect_uris;
|
|
1732
|
+
if (!uris) return false;
|
|
1733
|
+
if (uris.some((registered) => registered === redirectUri)) return true;
|
|
1734
|
+
let requested;
|
|
1735
|
+
try {
|
|
1736
|
+
requested = new URL(redirectUri);
|
|
1737
|
+
} catch {
|
|
1738
|
+
return false;
|
|
1739
|
+
}
|
|
1740
|
+
if (!isLoopbackHost(requested.hostname)) return false;
|
|
1741
|
+
return uris.some((registered) => {
|
|
1742
|
+
try {
|
|
1743
|
+
const candidate = new URL(registered);
|
|
1744
|
+
return isLoopbackHost(candidate.hostname) && candidate.protocol === requested.protocol && candidate.hostname === requested.hostname && candidate.pathname === requested.pathname;
|
|
1745
|
+
} catch {
|
|
1746
|
+
return false;
|
|
1747
|
+
}
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
function createAuthRouter(options) {
|
|
1751
|
+
const { provider, issuerUrl } = options;
|
|
1752
|
+
const scopesSupported = options.scopesSupported ?? ["mcp:tools"];
|
|
1753
|
+
const issuer = issuerUrl.toString().replace(/\/$/, "");
|
|
1754
|
+
const router = Router();
|
|
1755
|
+
const makeLimiter = (limit) => rateLimit({
|
|
1756
|
+
windowMs: 60 * 60 * 1e3,
|
|
1757
|
+
limit,
|
|
1758
|
+
standardHeaders: true,
|
|
1759
|
+
legacyHeaders: false
|
|
1760
|
+
});
|
|
1761
|
+
const authorizeLimiter = makeLimiter(100);
|
|
1762
|
+
const tokenLimiter = makeLimiter(100);
|
|
1763
|
+
const revokeLimiter = makeLimiter(100);
|
|
1764
|
+
const registerLimiter = makeLimiter(20);
|
|
1765
|
+
router.get("/.well-known/oauth-authorization-server", (_req, res) => {
|
|
1766
|
+
const metadata = {
|
|
1767
|
+
issuer,
|
|
1768
|
+
authorization_endpoint: `${issuer}/authorize`,
|
|
1769
|
+
token_endpoint: `${issuer}/token`,
|
|
1770
|
+
revocation_endpoint: `${issuer}/revoke`,
|
|
1771
|
+
response_types_supported: ["code"],
|
|
1772
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
1773
|
+
code_challenge_methods_supported: ["S256"],
|
|
1774
|
+
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic", "none"],
|
|
1775
|
+
scopes_supported: scopesSupported
|
|
1776
|
+
};
|
|
1777
|
+
if (provider.clientsStore.allowDynamicRegistration) {
|
|
1778
|
+
metadata.registration_endpoint = `${issuer}/register`;
|
|
1779
|
+
}
|
|
1780
|
+
res.json(metadata);
|
|
1781
|
+
});
|
|
1782
|
+
router.get("/authorize", authorizeLimiter, (req, res) => {
|
|
1783
|
+
try {
|
|
1784
|
+
const clientId = firstString(req.query.client_id);
|
|
1785
|
+
if (!clientId) {
|
|
1786
|
+
throw new OAuthError2(OAuthErrorCode2.InvalidRequest, "Missing client_id");
|
|
1787
|
+
}
|
|
1788
|
+
const client = provider.clientsStore.getClient(clientId);
|
|
1789
|
+
if (!client) {
|
|
1790
|
+
throw new OAuthError2(OAuthErrorCode2.InvalidClient, "Unknown client");
|
|
1791
|
+
}
|
|
1792
|
+
const redirectUri = firstString(req.query.redirect_uri);
|
|
1793
|
+
if (!redirectUri) {
|
|
1794
|
+
throw new OAuthError2(OAuthErrorCode2.InvalidRequest, "Missing redirect_uri");
|
|
1795
|
+
}
|
|
1796
|
+
let parsedRedirect;
|
|
1797
|
+
try {
|
|
1798
|
+
parsedRedirect = new URL(redirectUri);
|
|
1799
|
+
} catch {
|
|
1800
|
+
throw new OAuthError2(OAuthErrorCode2.InvalidRequest, "Invalid redirect_uri");
|
|
1801
|
+
}
|
|
1802
|
+
if (!isSafeRedirectScheme(parsedRedirect)) {
|
|
1803
|
+
throw new OAuthError2(
|
|
1804
|
+
OAuthErrorCode2.InvalidRequest,
|
|
1805
|
+
"redirect_uri must use https, or http on loopback"
|
|
1806
|
+
);
|
|
1807
|
+
}
|
|
1808
|
+
if (!isRedirectUriAllowed(client, redirectUri)) {
|
|
1809
|
+
throw new OAuthError2(OAuthErrorCode2.InvalidRequest, "Unregistered redirect_uri");
|
|
1810
|
+
}
|
|
1811
|
+
const redirectError = (code, description) => {
|
|
1812
|
+
const url = new URL(redirectUri);
|
|
1813
|
+
url.searchParams.set("error", code);
|
|
1814
|
+
url.searchParams.set("error_description", description);
|
|
1815
|
+
const state = firstString(req.query.state);
|
|
1816
|
+
if (state !== void 0) url.searchParams.set("state", state);
|
|
1817
|
+
res.redirect(url.toString());
|
|
1818
|
+
};
|
|
1819
|
+
const responseType = firstString(req.query.response_type);
|
|
1820
|
+
if (responseType !== "code") {
|
|
1821
|
+
redirectError(OAuthErrorCode2.UnsupportedResponseType, 'response_type must be "code"');
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
const codeChallenge = firstString(req.query.code_challenge);
|
|
1825
|
+
if (!codeChallenge) {
|
|
1826
|
+
redirectError(OAuthErrorCode2.InvalidRequest, "code_challenge is required (PKCE)");
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
if (!isValidPkceValue(codeChallenge)) {
|
|
1830
|
+
redirectError(
|
|
1831
|
+
OAuthErrorCode2.InvalidRequest,
|
|
1832
|
+
"code_challenge must be 43-128 characters (RFC 7636)"
|
|
1833
|
+
);
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1836
|
+
const challengeMethod = firstString(req.query.code_challenge_method) ?? "S256";
|
|
1837
|
+
if (challengeMethod !== "S256") {
|
|
1838
|
+
redirectError(OAuthErrorCode2.InvalidRequest, "code_challenge_method must be S256");
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
let resource;
|
|
1842
|
+
try {
|
|
1843
|
+
resource = parseResource(firstString(req.query.resource));
|
|
1844
|
+
} catch {
|
|
1845
|
+
redirectError(OAuthErrorCode2.InvalidRequest, "Invalid resource parameter");
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
const parsedScopes = firstString(req.query.scope)?.split(" ").filter(Boolean);
|
|
1849
|
+
const requestedScopes = parsedScopes && parsedScopes.length > 0 ? parsedScopes : void 0;
|
|
1850
|
+
if (requestedScopes) {
|
|
1851
|
+
const unknown = requestedScopes.filter((scope) => !scopesSupported.includes(scope));
|
|
1852
|
+
if (unknown.length > 0) {
|
|
1853
|
+
redirectError(OAuthErrorCode2.InvalidScope, `Unsupported scope: ${unknown.join(" ")}`);
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
const params = {
|
|
1858
|
+
redirectUri,
|
|
1859
|
+
codeChallenge,
|
|
1860
|
+
state: firstString(req.query.state),
|
|
1861
|
+
// Clients that omit `scope` get the full supported set, so the
|
|
1862
|
+
// resource server can require a scope without breaking them.
|
|
1863
|
+
scopes: requestedScopes ?? [...scopesSupported],
|
|
1864
|
+
resource
|
|
1865
|
+
};
|
|
1866
|
+
res.redirect(provider.authorize(client, params));
|
|
1867
|
+
} catch (error) {
|
|
1868
|
+
sendOAuthError(res, error);
|
|
1869
|
+
}
|
|
1870
|
+
});
|
|
1871
|
+
router.post("/token", tokenLimiter, urlencoded({ extended: false }), (req, res) => {
|
|
1872
|
+
try {
|
|
1873
|
+
const client = authenticateClient(provider, req);
|
|
1874
|
+
const body = req.body ?? {};
|
|
1875
|
+
const grantType = firstString(body.grant_type);
|
|
1876
|
+
const resource = parseResource(firstString(body.resource));
|
|
1877
|
+
if (grantType === "authorization_code") {
|
|
1878
|
+
const code = firstString(body.code);
|
|
1879
|
+
if (!code) {
|
|
1880
|
+
throw new OAuthError2(OAuthErrorCode2.InvalidRequest, "Missing code");
|
|
1881
|
+
}
|
|
1882
|
+
const codeVerifier = firstString(body.code_verifier);
|
|
1883
|
+
if (!codeVerifier) {
|
|
1884
|
+
throw new OAuthError2(OAuthErrorCode2.InvalidRequest, "Missing code_verifier (PKCE)");
|
|
1885
|
+
}
|
|
1886
|
+
if (!isValidPkceValue(codeVerifier)) {
|
|
1887
|
+
provider.invalidateAuthorizationCode(code);
|
|
1888
|
+
throw new OAuthError2(
|
|
1889
|
+
OAuthErrorCode2.InvalidGrant,
|
|
1890
|
+
"code_verifier must be 43-128 characters (RFC 7636)"
|
|
1891
|
+
);
|
|
1892
|
+
}
|
|
1893
|
+
const grant = provider.getAuthorizationGrant(client, code);
|
|
1894
|
+
const presentedRedirectUri = firstString(body.redirect_uri);
|
|
1895
|
+
if (presentedRedirectUri !== grant.redirectUri) {
|
|
1896
|
+
provider.invalidateAuthorizationCode(code);
|
|
1897
|
+
throw new OAuthError2(
|
|
1898
|
+
OAuthErrorCode2.InvalidGrant,
|
|
1899
|
+
"redirect_uri does not match the authorization request"
|
|
1900
|
+
);
|
|
1901
|
+
}
|
|
1902
|
+
if (!verifyPkce(codeVerifier, grant.codeChallenge)) {
|
|
1903
|
+
provider.invalidateAuthorizationCode(code);
|
|
1904
|
+
throw new OAuthError2(OAuthErrorCode2.InvalidGrant, "PKCE verification failed");
|
|
1905
|
+
}
|
|
1906
|
+
res.json(provider.exchangeAuthorizationCode(client, code, resource));
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
if (grantType === "refresh_token") {
|
|
1910
|
+
const refreshToken = firstString(body.refresh_token);
|
|
1911
|
+
if (!refreshToken) {
|
|
1912
|
+
throw new OAuthError2(OAuthErrorCode2.InvalidRequest, "Missing refresh_token");
|
|
1913
|
+
}
|
|
1914
|
+
const scopes = firstString(body.scope)?.split(" ").filter(Boolean);
|
|
1915
|
+
res.json(provider.exchangeRefreshToken(client, refreshToken, scopes, resource));
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
throw new OAuthError2(OAuthErrorCode2.UnsupportedGrantType, "Unsupported grant_type");
|
|
1919
|
+
} catch (error) {
|
|
1920
|
+
sendOAuthError(res, error);
|
|
1921
|
+
}
|
|
1922
|
+
});
|
|
1923
|
+
router.post("/revoke", revokeLimiter, urlencoded({ extended: false }), (req, res) => {
|
|
1924
|
+
try {
|
|
1925
|
+
const client = authenticateClient(provider, req);
|
|
1926
|
+
const token = firstString(req.body?.token);
|
|
1927
|
+
if (!token) {
|
|
1928
|
+
throw new OAuthError2(OAuthErrorCode2.InvalidRequest, "Missing token");
|
|
1929
|
+
}
|
|
1930
|
+
provider.revokeToken(client, { token });
|
|
1931
|
+
res.status(200).json({});
|
|
1932
|
+
} catch (error) {
|
|
1933
|
+
sendOAuthError(res, error);
|
|
1934
|
+
}
|
|
1935
|
+
});
|
|
1936
|
+
if (provider.clientsStore.allowDynamicRegistration) {
|
|
1937
|
+
router.post("/register", registerLimiter, json(), (req, res) => {
|
|
1938
|
+
try {
|
|
1939
|
+
const body = req.body ?? {};
|
|
1940
|
+
const redirectUris = body.redirect_uris;
|
|
1941
|
+
if (!Array.isArray(redirectUris) || redirectUris.length === 0 || !redirectUris.every((uri) => typeof uri === "string")) {
|
|
1942
|
+
throw new OAuthError2(
|
|
1943
|
+
OAuthErrorCode2.InvalidRequest,
|
|
1944
|
+
"redirect_uris must be a non-empty array of strings"
|
|
1945
|
+
);
|
|
1946
|
+
}
|
|
1947
|
+
for (const uri of redirectUris) {
|
|
1948
|
+
let parsed;
|
|
1949
|
+
try {
|
|
1950
|
+
parsed = new URL(uri);
|
|
1951
|
+
} catch {
|
|
1952
|
+
throw new OAuthError2(OAuthErrorCode2.InvalidRequest, `Invalid redirect_uri: ${uri}`);
|
|
1953
|
+
}
|
|
1954
|
+
if (!isSafeRedirectScheme(parsed)) {
|
|
1955
|
+
throw new OAuthError2(
|
|
1956
|
+
OAuthErrorCode2.InvalidRequest,
|
|
1957
|
+
`redirect_uri must use https, or http on loopback: ${uri}`
|
|
1958
|
+
);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
const authMethod = firstString(body.token_endpoint_auth_method) ?? "client_secret_post";
|
|
1962
|
+
const isPublic = authMethod === "none";
|
|
1963
|
+
const client = {
|
|
1964
|
+
client_id: randomUUID2(),
|
|
1965
|
+
redirect_uris: redirectUris,
|
|
1966
|
+
token_endpoint_auth_method: authMethod,
|
|
1967
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
1968
|
+
response_types: ["code"],
|
|
1969
|
+
client_name: firstString(body.client_name) ?? "Dynamically Registered Client",
|
|
1970
|
+
client_id_issued_at: Math.floor(Date.now() / 1e3)
|
|
1971
|
+
};
|
|
1972
|
+
if (!isPublic) {
|
|
1973
|
+
client.client_secret = randomUUID2() + randomUUID2();
|
|
1974
|
+
}
|
|
1975
|
+
res.status(201).json(provider.clientsStore.registerClient(client));
|
|
1976
|
+
} catch (error) {
|
|
1977
|
+
sendOAuthError(res, error);
|
|
1978
|
+
}
|
|
1979
|
+
});
|
|
1980
|
+
}
|
|
1981
|
+
return router;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1377
1984
|
// src/server/http.ts
|
|
1985
|
+
var MCP_MAX_BODY_SIZE = "4mb";
|
|
1986
|
+
var DRAIN_GRACE_MS = 3e3;
|
|
1378
1987
|
function parseTrustProxy(value) {
|
|
1379
1988
|
if (value === void 0 || value === "") {
|
|
1380
1989
|
return void 0;
|
|
@@ -1423,12 +2032,31 @@ function isOriginAllowed(origin, allowedOrigins) {
|
|
|
1423
2032
|
async function createHttpServer(config, deps2) {
|
|
1424
2033
|
const authEnabled = !!config.authConfig?.clientId;
|
|
1425
2034
|
const corsConfig = loadCorsConfig();
|
|
1426
|
-
const
|
|
1427
|
-
|
|
1428
|
-
const
|
|
2035
|
+
const app = express();
|
|
2036
|
+
app.set("query parser", "simple");
|
|
2037
|
+
const isLoopbackBind = config.host === "127.0.0.1" || config.host === "localhost" || config.host === "::1";
|
|
2038
|
+
if (isLoopbackBind) {
|
|
2039
|
+
app.use(localhostHostValidation());
|
|
2040
|
+
app.use(localhostOriginValidation());
|
|
2041
|
+
log("DNS-rebinding protection: enabled (loopback bind)");
|
|
2042
|
+
} else {
|
|
2043
|
+
log(
|
|
2044
|
+
`DNS-rebinding protection: disabled (HOST=${config.host} is not loopback) \u2014 rely on your reverse proxy and CORS_ORIGINS to restrict access`
|
|
2045
|
+
);
|
|
2046
|
+
}
|
|
1429
2047
|
if (config.trustProxy !== void 0) {
|
|
2048
|
+
if (config.trustProxy === true && authEnabled) {
|
|
2049
|
+
logError(
|
|
2050
|
+
"TRUST_PROXY=true is unsafe with authentication enabled: the client-controlled X-Forwarded-For entry becomes the rate-limit key, disabling rate limiting on the OAuth endpoints. Set TRUST_PROXY to the number of proxy hops instead (TRUST_PROXY=1 for a single reverse proxy). Refusing to start."
|
|
2051
|
+
);
|
|
2052
|
+
process.exit(1);
|
|
2053
|
+
}
|
|
1430
2054
|
app.set("trust proxy", config.trustProxy);
|
|
1431
2055
|
log(`Trust proxy: ${JSON.stringify(config.trustProxy)}`);
|
|
2056
|
+
} else if (authEnabled) {
|
|
2057
|
+
log(
|
|
2058
|
+
"WARNING: TRUST_PROXY is not set. Behind a reverse proxy every request appears to come from the proxy's IP, so the OAuth rate limits apply to all clients as one shared budget. Set TRUST_PROXY=1 when running behind a proxy."
|
|
2059
|
+
);
|
|
1432
2060
|
}
|
|
1433
2061
|
app.use((req, res, next) => {
|
|
1434
2062
|
if (!corsConfig.enabled) {
|
|
@@ -1442,7 +2070,9 @@ async function createHttpServer(config, deps2) {
|
|
|
1442
2070
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
1443
2071
|
res.setHeader(
|
|
1444
2072
|
"Access-Control-Allow-Headers",
|
|
1445
|
-
|
|
2073
|
+
// Mcp-Method / Mcp-Name are required request headers in the
|
|
2074
|
+
// 2026-07-28 Streamable HTTP transport; Mcp-Session-Id is 2025-era.
|
|
2075
|
+
"Content-Type, Authorization, Mcp-Session-Id, Mcp-Protocol-Version, Mcp-Method, Mcp-Name"
|
|
1446
2076
|
);
|
|
1447
2077
|
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
1448
2078
|
}
|
|
@@ -1456,130 +2086,65 @@ async function createHttpServer(config, deps2) {
|
|
|
1456
2086
|
if (authEnabled) {
|
|
1457
2087
|
const provider = new OpenClawAuthProvider(config.authConfig);
|
|
1458
2088
|
const issuerUrl = config.issuerUrl ? new URL(config.issuerUrl) : new URL(`http://${config.host === "0.0.0.0" ? "localhost" : config.host}:${config.port}`);
|
|
1459
|
-
app.use(
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
);
|
|
2089
|
+
app.use(createAuthRouter({ provider, issuerUrl, scopesSupported: ["mcp:tools"] }));
|
|
2090
|
+
const protectedResourceMetadata = (resourcePath) => ({
|
|
2091
|
+
resource: `${issuerUrl.toString().replace(/\/$/, "")}${resourcePath}`,
|
|
2092
|
+
authorization_servers: [issuerUrl.toString().replace(/\/$/, "")],
|
|
2093
|
+
scopes_supported: ["mcp:tools"]
|
|
2094
|
+
});
|
|
1466
2095
|
app.get("/.well-known/oauth-protected-resource/:path", (req, res) => {
|
|
1467
|
-
res.json({
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
2096
|
+
res.json(protectedResourceMetadata(`/${req.params.path}`));
|
|
2097
|
+
});
|
|
2098
|
+
app.get("/.well-known/oauth-protected-resource", (_req, res) => {
|
|
2099
|
+
res.json(protectedResourceMetadata("/mcp"));
|
|
2100
|
+
});
|
|
2101
|
+
authMiddleware = requireBearerAuth({
|
|
2102
|
+
verifier: provider,
|
|
2103
|
+
// Actually enforce the scope the metadata advertises; without this any
|
|
2104
|
+
// non-expired token authorizes every tool call regardless of its grant.
|
|
2105
|
+
requiredScopes: ["mcp:tools"],
|
|
2106
|
+
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(new URL("/mcp", issuerUrl))
|
|
1472
2107
|
});
|
|
1473
|
-
authMiddleware = requireBearerAuth({ verifier: provider });
|
|
1474
2108
|
}
|
|
1475
2109
|
app.get("/health", (_req, res) => {
|
|
1476
2110
|
res.json({
|
|
1477
2111
|
status: "ok",
|
|
1478
2112
|
transport: "streamable-http",
|
|
1479
|
-
legacySseSupported:
|
|
2113
|
+
legacySseSupported: false,
|
|
1480
2114
|
auth: authEnabled
|
|
1481
2115
|
});
|
|
1482
2116
|
});
|
|
1483
|
-
const
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
2117
|
+
const mcpHandler = createMcpHandler(() => createMcpServer(deps2), {
|
|
2118
|
+
legacy: "stateless",
|
|
2119
|
+
onerror: (error) => logError("MCP handler error", error)
|
|
2120
|
+
});
|
|
2121
|
+
const nodeHandler = toNodeHandler(mcpHandler, {
|
|
2122
|
+
onerror: (error) => logError("MCP transport error", error)
|
|
2123
|
+
});
|
|
2124
|
+
const mcpRoute = (req, res) => {
|
|
2125
|
+
void nodeHandler(req, res, req.body);
|
|
1488
2126
|
};
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
transport.onclose = () => {
|
|
1499
|
-
legacySseSessions.delete(sessionId);
|
|
1500
|
-
log(`SSE session disconnected: ${sessionId}`);
|
|
1501
|
-
};
|
|
1502
|
-
try {
|
|
1503
|
-
await server.connect(transport);
|
|
1504
|
-
} catch (error) {
|
|
1505
|
-
legacySseSessions.delete(sessionId);
|
|
1506
|
-
logError(`Failed to connect SSE session ${sessionId}`, error);
|
|
1507
|
-
}
|
|
1508
|
-
})
|
|
1509
|
-
);
|
|
1510
|
-
app.post(
|
|
1511
|
-
"/messages",
|
|
1512
|
-
...withAuth(async (req, res) => {
|
|
1513
|
-
const sessionId = req.query.sessionId;
|
|
1514
|
-
const session = legacySseSessions.get(sessionId);
|
|
1515
|
-
if (!session) {
|
|
1516
|
-
res.status(404).json({ error: "Session not found" });
|
|
1517
|
-
return;
|
|
1518
|
-
}
|
|
1519
|
-
try {
|
|
1520
|
-
await session.transport.handlePostMessage(
|
|
1521
|
-
req,
|
|
1522
|
-
res,
|
|
1523
|
-
req.body
|
|
1524
|
-
);
|
|
1525
|
-
} catch (error) {
|
|
1526
|
-
logError(`Error handling message for session ${sessionId}`, error);
|
|
1527
|
-
if (!res.headersSent) {
|
|
1528
|
-
res.status(500).json({ error: "Internal server error" });
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
})
|
|
1532
|
-
);
|
|
1533
|
-
const handleStreamableRequest = async (req, res) => {
|
|
1534
|
-
const sessionId = req.headers["mcp-session-id"];
|
|
1535
|
-
if (sessionId && streamableSessions.has(sessionId)) {
|
|
1536
|
-
const session = streamableSessions.get(sessionId);
|
|
1537
|
-
try {
|
|
1538
|
-
await session.transport.handleRequest(
|
|
1539
|
-
req,
|
|
1540
|
-
res,
|
|
1541
|
-
req.body
|
|
1542
|
-
);
|
|
1543
|
-
} catch (error) {
|
|
1544
|
-
logError(`Error in streamable session ${sessionId}`, error);
|
|
1545
|
-
if (!res.headersSent) {
|
|
1546
|
-
res.status(500).json({ error: "Internal server error" });
|
|
1547
|
-
}
|
|
1548
|
-
}
|
|
1549
|
-
return;
|
|
1550
|
-
}
|
|
1551
|
-
const transport = new StreamableHTTPServerTransport({
|
|
1552
|
-
sessionIdGenerator: () => randomUUID2(),
|
|
1553
|
-
onsessioninitialized: (newSessionId) => {
|
|
1554
|
-
streamableSessions.set(newSessionId, { transport, server });
|
|
1555
|
-
log(`Streamable session initialized: ${newSessionId}`);
|
|
1556
|
-
}
|
|
2127
|
+
const mcpBodyParser = express.json({ limit: MCP_MAX_BODY_SIZE });
|
|
2128
|
+
if (authMiddleware) {
|
|
2129
|
+
app.all("/mcp", authMiddleware, mcpBodyParser, mcpRoute);
|
|
2130
|
+
} else {
|
|
2131
|
+
app.all("/mcp", mcpBodyParser, mcpRoute);
|
|
2132
|
+
}
|
|
2133
|
+
const legacyGone = (_req, res) => {
|
|
2134
|
+
res.status(410).json({
|
|
2135
|
+
error: "The HTTP+SSE transport was removed in openclaw-mcp 2.0. Connect via /mcp (Streamable HTTP)."
|
|
1557
2136
|
});
|
|
1558
|
-
transport.onclose = () => {
|
|
1559
|
-
const sid = transport.sessionId;
|
|
1560
|
-
if (sid) {
|
|
1561
|
-
streamableSessions.delete(sid);
|
|
1562
|
-
log(`Streamable session closed: ${sid}`);
|
|
1563
|
-
}
|
|
1564
|
-
};
|
|
1565
|
-
const server = createMcpServer(deps2);
|
|
1566
|
-
try {
|
|
1567
|
-
await server.connect(transport);
|
|
1568
|
-
await transport.handleRequest(
|
|
1569
|
-
req,
|
|
1570
|
-
res,
|
|
1571
|
-
req.body
|
|
1572
|
-
);
|
|
1573
|
-
} catch (error) {
|
|
1574
|
-
logError("Failed to initialize streamable session", error);
|
|
1575
|
-
if (!res.headersSent) {
|
|
1576
|
-
res.status(500).json({ error: "Internal server error" });
|
|
1577
|
-
}
|
|
1578
|
-
}
|
|
1579
2137
|
};
|
|
1580
|
-
app.get("/
|
|
1581
|
-
app.post("/
|
|
1582
|
-
app.
|
|
2138
|
+
app.get("/sse", legacyGone);
|
|
2139
|
+
app.post("/messages", legacyGone);
|
|
2140
|
+
app.use(
|
|
2141
|
+
(err, _req, res, _next) => {
|
|
2142
|
+
logError("Request error", err);
|
|
2143
|
+
if (res.headersSent) return;
|
|
2144
|
+
const status = err.status ?? err.statusCode ?? 400;
|
|
2145
|
+
res.status(status).json({ error: status === 413 ? "Payload too large" : "Bad Request" });
|
|
2146
|
+
}
|
|
2147
|
+
);
|
|
1583
2148
|
const httpServer = app.listen(config.port, config.host, () => {
|
|
1584
2149
|
log(`HTTP server listening on ${config.host}:${config.port}`);
|
|
1585
2150
|
log(`Auth enabled: ${authEnabled}`);
|
|
@@ -1589,43 +2154,41 @@ async function createHttpServer(config, deps2) {
|
|
|
1589
2154
|
log("Endpoints:");
|
|
1590
2155
|
log(" GET /.well-known/oauth-authorization-server - OAuth metadata");
|
|
1591
2156
|
log(" GET /.well-known/oauth-protected-resource/mcp - Protected resource metadata");
|
|
1592
|
-
log("
|
|
2157
|
+
log(" GET /authorize - Authorization");
|
|
1593
2158
|
log(" POST /token - Token exchange");
|
|
1594
2159
|
} else {
|
|
1595
2160
|
log("WARNING: Auth is DISABLED - server is open to anyone!");
|
|
1596
2161
|
}
|
|
1597
2162
|
log("MCP Endpoints:");
|
|
1598
2163
|
log(" GET /health - Health check (no auth)");
|
|
1599
|
-
log(" ALL /mcp - Streamable HTTP (
|
|
1600
|
-
log(" GET /sse - Legacy SSE stream (deprecated)");
|
|
1601
|
-
log(" POST /messages - Legacy SSE messages (deprecated)");
|
|
2164
|
+
log(" ALL /mcp - Streamable HTTP (stateless)");
|
|
1602
2165
|
});
|
|
2166
|
+
let shuttingDown = false;
|
|
1603
2167
|
const shutdown = async () => {
|
|
2168
|
+
if (shuttingDown) return;
|
|
2169
|
+
shuttingDown = true;
|
|
1604
2170
|
log("Shutting down HTTP server...");
|
|
1605
|
-
|
|
1606
|
-
try {
|
|
1607
|
-
await session.server.close();
|
|
1608
|
-
} catch (error) {
|
|
1609
|
-
logError(`Error closing SSE session ${id}`, error);
|
|
1610
|
-
}
|
|
1611
|
-
}
|
|
1612
|
-
legacySseSessions.clear();
|
|
1613
|
-
for (const [id, session] of streamableSessions) {
|
|
1614
|
-
try {
|
|
1615
|
-
await session.server.close();
|
|
1616
|
-
} catch (error) {
|
|
1617
|
-
logError(`Error closing streamable session ${id}`, error);
|
|
1618
|
-
}
|
|
1619
|
-
}
|
|
1620
|
-
streamableSessions.clear();
|
|
1621
|
-
httpServer.close(() => {
|
|
1622
|
-
log("HTTP server stopped");
|
|
1623
|
-
process.exit(0);
|
|
1624
|
-
});
|
|
1625
|
-
setTimeout(() => {
|
|
2171
|
+
const forceExit = setTimeout(() => {
|
|
1626
2172
|
logError("Forced shutdown after timeout");
|
|
1627
2173
|
process.exit(1);
|
|
1628
2174
|
}, 5e3);
|
|
2175
|
+
forceExit.unref?.();
|
|
2176
|
+
const drained = new Promise((resolve) => httpServer.close(() => resolve()));
|
|
2177
|
+
try {
|
|
2178
|
+
await mcpHandler.close();
|
|
2179
|
+
} catch (error) {
|
|
2180
|
+
logError("Error closing MCP handler", error);
|
|
2181
|
+
}
|
|
2182
|
+
const cutConnections = setTimeout(() => {
|
|
2183
|
+
log("Draining timed out; closing remaining connections");
|
|
2184
|
+
httpServer.closeAllConnections?.();
|
|
2185
|
+
}, DRAIN_GRACE_MS);
|
|
2186
|
+
cutConnections.unref?.();
|
|
2187
|
+
await drained;
|
|
2188
|
+
clearTimeout(cutConnections);
|
|
2189
|
+
clearTimeout(forceExit);
|
|
2190
|
+
log("HTTP server stopped");
|
|
2191
|
+
process.exit(0);
|
|
1629
2192
|
};
|
|
1630
2193
|
process.on("SIGTERM", shutdown);
|
|
1631
2194
|
process.on("SIGINT", shutdown);
|
|
@@ -1658,10 +2221,13 @@ async function main() {
|
|
|
1658
2221
|
const defaultLabel = instance.isDefault ? " (default)" : "";
|
|
1659
2222
|
log(`Instance "${instance.name}": ${instance.url}${defaultLabel}`);
|
|
1660
2223
|
}
|
|
1661
|
-
if (args.transport === "sse"
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
2224
|
+
if (args.transport === "sse") {
|
|
2225
|
+
logError(
|
|
2226
|
+
"The legacy SSE transport was removed in v2.0. Use --transport http (Streamable HTTP)."
|
|
2227
|
+
);
|
|
2228
|
+
process.exit(1);
|
|
2229
|
+
}
|
|
2230
|
+
if (args.transport === "http") {
|
|
1665
2231
|
const httpConfig = {
|
|
1666
2232
|
port: args.port,
|
|
1667
2233
|
host: args.host,
|
|
@@ -1718,9 +2284,14 @@ async function main() {
|
|
|
1718
2284
|
}
|
|
1719
2285
|
await createHttpServer(httpConfig, deps);
|
|
1720
2286
|
} else {
|
|
1721
|
-
const
|
|
1722
|
-
|
|
1723
|
-
|
|
2287
|
+
const handle = serveStdio(() => createMcpServer(deps), {
|
|
2288
|
+
onerror: (error) => logError("stdio transport error", error)
|
|
2289
|
+
});
|
|
2290
|
+
const shutdownStdio = () => {
|
|
2291
|
+
void Promise.resolve(handle.close?.()).finally(() => process.exit(0));
|
|
2292
|
+
};
|
|
2293
|
+
process.on("SIGTERM", shutdownStdio);
|
|
2294
|
+
process.on("SIGINT", shutdownStdio);
|
|
1724
2295
|
log("OpenClaw MCP server running on stdio");
|
|
1725
2296
|
}
|
|
1726
2297
|
}
|