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/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
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 = "1.5.0";
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" is a deprecated alias for "http")',
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
- constructor(baseUrl, gatewayToken, timeoutMs = DEFAULT_TIMEOUT_MS, model = "openclaw") {
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 timeout = setTimeout(() => controller.abort(), this.timeoutMs);
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
- if (error instanceof DOMException && error.name === "AbortError") {
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
- const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
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
- if (error instanceof DOMException && error.name === "AbortError") {
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, sessionId) {
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
- const completion = await this.request("/v1/chat/completions", {
350
- method: "POST",
351
- body: JSON.stringify(body),
352
- headers
353
- });
354
- const content = completion.choices?.[0]?.message?.content ?? "";
355
- return {
356
- response: content,
357
- model: completion.model,
358
- usage: completion.usage
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/tools-registration.ts
478
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
479
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
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
- * Cancel a pending task
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 openclawChatAsyncTool = {
801
- name: "openclaw_chat_async",
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, input.session_id);
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
- if (!processorRegistry) return;
1043
+ const concurrency = resolveConcurrency();
1044
+ log(`Task processor concurrency: ${concurrency}`);
900
1045
  while (processorRunning) {
901
- const task = taskManager.getNextPending();
902
- if (task) {
903
- await processTask(task, processorRegistry);
904
- } else {
905
- await new Promise((resolve) => setTimeout(resolve, 100));
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/tools-registration.ts
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 server = new Server(
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
- registerTools(server, deps2);
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 { randomUUID as randomUUID2 } from "crypto";
1162
- import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
1163
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
1164
- import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
1165
- import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
1166
- import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
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 ALLOW_ANY_REDIRECT = new Proxy([], {
1179
- get(target, prop) {
1180
- if (prop === "includes") return () => true;
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
- // Assigned conditionally in the constructor. The MCP SDK probes
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
- async getClient(clientId) {
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: generate auth code and redirect immediately.
1472
+ * Auto-approve: issue an auth code and return the redirect URL the
1473
+ * router should send the user-agent to.
1264
1474
  */
1265
- async authorize(client, params, res) {
1475
+ authorize(client, params) {
1266
1476
  const code = randomUUID();
1267
1477
  this.codes.set(code, { client, params, createdAt: Date.now() });
1268
- const searchParams = new URLSearchParams({ code });
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
- const targetUrl = new URL(params.redirectUri);
1273
- targetUrl.search = searchParams.toString();
1274
- res.redirect(targetUrl.toString());
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
- async challengeForAuthorizationCode(_client, authorizationCode) {
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 InvalidRequestError("Invalid authorization code");
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.codeChallenge;
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
- async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, _redirectUri, resource) {
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 InvalidRequestError("Invalid authorization code");
1518
+ throw new OAuthError(OAuthErrorCode.InvalidGrant, "Invalid authorization code");
1289
1519
  }
1290
1520
  if (codeData.client.client_id !== client.client_id) {
1291
- throw new InvalidRequestError("Authorization code was not issued to this client");
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: resource || codeData.params.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: resource || codeData.params.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
- async exchangeRefreshToken(client, refreshToken, scopes, resource) {
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 InvalidRequestError("Invalid refresh token");
1559
+ throw new OAuthError(OAuthErrorCode.InvalidGrant, "Invalid refresh token");
1323
1560
  }
1324
1561
  if (data.clientId !== client.client_id) {
1325
- throw new InvalidRequestError("Refresh token was not issued to this client");
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 tokenScopes = scopes || data.scopes;
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: resource || data.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: resource || data.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 InvalidTokenError("Invalid or expired token");
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
- async revokeToken(client, request) {
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
- if (refreshData && refreshData.clientId === client.client_id) {
1372
- this.refreshTokens.delete(request.token);
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 legacySseSessions = /* @__PURE__ */ new Map();
1427
- const streamableSessions = /* @__PURE__ */ new Map();
1428
- const app = createMcpExpressApp({ host: config.host });
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
- "Content-Type, Authorization, Mcp-Session-Id, mcp-protocol-version"
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
- mcpAuthRouter({
1461
- provider,
1462
- issuerUrl,
1463
- scopesSupported: ["mcp:tools"]
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
- resource: `${issuerUrl.toString()}${req.params.path}`,
1469
- authorization_servers: [issuerUrl.toString().replace(/\/$/, "")],
1470
- scopes_supported: ["mcp:tools"]
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: true,
2113
+ legacySseSupported: false,
1480
2114
  auth: authEnabled
1481
2115
  });
1482
2116
  });
1483
- const withAuth = (handler) => {
1484
- if (authMiddleware) {
1485
- return [authMiddleware, async (req, res) => handler(req, res)];
1486
- }
1487
- return [async (req, res) => handler(req, res)];
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
- app.get(
1490
- "/sse",
1491
- ...withAuth(async (req, res) => {
1492
- log("Legacy SSE transport is deprecated; prefer Streamable HTTP at /mcp");
1493
- const transport = new SSEServerTransport("/messages", res);
1494
- const server = createMcpServer(deps2);
1495
- const sessionId = transport.sessionId;
1496
- legacySseSessions.set(sessionId, { transport, server });
1497
- log(`SSE session connected: ${sessionId}`);
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("/mcp", ...withAuth(handleStreamableRequest));
1581
- app.post("/mcp", ...withAuth(handleStreamableRequest));
1582
- app.delete("/mcp", ...withAuth(handleStreamableRequest));
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(" POST /authorize - Authorization");
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 (primary)");
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
- for (const [id, session] of legacySseSessions) {
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" || args.transport === "http") {
1662
- if (args.transport === "sse") {
1663
- log("WARNING: --transport sse is deprecated; use --transport http instead");
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 server = createMcpServer(deps);
1722
- const transport = new StdioServerTransport();
1723
- await server.connect(transport);
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
  }