openclaw-mcp 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,6 +54,7 @@ services:
54
54
  - MCP_CLIENT_ID=openclaw
55
55
  - MCP_CLIENT_SECRET=${MCP_CLIENT_SECRET}
56
56
  - MCP_ISSUER_URL=${MCP_ISSUER_URL:-}
57
+ - MCP_REDIRECT_URIS=https://claude.ai/api/mcp/auth_callback,https://claude.com/api/mcp/auth_callback
57
58
  - TRUST_PROXY=1
58
59
  - CORS_ORIGINS=https://claude.ai
59
60
  extra_hosts:
@@ -115,6 +116,8 @@ AUTH_ENABLED=true MCP_CLIENT_ID=openclaw MCP_CLIENT_SECRET=your-secret \
115
116
  > - `MCP_ISSUER_URL` (or `--issuer-url`) to your public HTTPS URL — otherwise OAuth metadata advertises `http://localhost:3000` and clients fail to authenticate.
116
117
  > - `TRUST_PROXY=1` (or `--trust-proxy 1`) — otherwise `express-rate-limit` rejects the proxy's `X-Forwarded-For` header and `/token` crashes with `ERR_ERL_UNEXPECTED_X_FORWARDED_FOR`.
117
118
 
119
+ > **Recommended:** Set `MCP_REDIRECT_URIS=https://claude.ai/api/mcp/auth_callback,https://claude.com/api/mcp/auth_callback` so authorization codes can only be delivered to Claude's callbacks. Note the exact `/api/mcp/auth_callback` path — matching is exact, and getting it wrong fails OAuth with `Unregistered redirect_uri` (see [Troubleshooting](docs/deployment.md#invalid_request--unregistered-redirect_uri-on-authorize)).
120
+
118
121
  See [Installation Guide](docs/installation.md) for details.
119
122
 
120
123
  ## Architecture
@@ -158,9 +161,15 @@ See [Installation Guide](docs/installation.md) for details.
158
161
  |------|-------------|
159
162
  | `openclaw_chat_async` | Queue a message, get task_id immediately |
160
163
  | `openclaw_task_status` | Check task progress and get results |
161
- | `openclaw_task_list` | List all tasks with filtering |
164
+ | `openclaw_task_list` | List your tasks with filtering |
162
165
  | `openclaw_task_cancel` | Cancel a pending task |
163
166
 
167
+ Tasks are scoped to the MCP connection that created them. In HTTP mode, where
168
+ one process serves many clients, a client can only see and cancel its own
169
+ tasks — another client's `task_id` reads as "not found" even if it is known.
170
+ Reconnecting starts a fresh scope, so poll a task on the connection that
171
+ queued it.
172
+
164
173
  ## Multi-Instance Mode
165
174
 
166
175
  Orchestrate multiple OpenClaw gateways from a single MCP server. One bridge, many claws — route requests to prod, staging, dev, or whatever you name them (lobster-supreme and the-claw-abides are perfectly valid names).
@@ -258,7 +267,8 @@ AUTH_ENABLED=true MCP_CLIENT_ID=openclaw MCP_CLIENT_SECRET=$MCP_CLIENT_SECRET \
258
267
  openclaw-mcp --transport http
259
268
  ```
260
269
 
261
- Configure CORS to restrict access:
270
+ CORS is disabled unless you opt in. Set `CORS_ORIGINS` only when a browser
271
+ client needs to reach the server directly:
262
272
 
263
273
  ```bash
264
274
  CORS_ORIGINS=https://claude.ai,https://your-app.com
@@ -266,6 +276,19 @@ CORS_ORIGINS=https://claude.ai,https://your-app.com
266
276
 
267
277
  See [Configuration](docs/configuration.md) for all security options.
268
278
 
279
+ ### Upgrading to 1.7.0
280
+
281
+ Two defaults changed for security. Both only affect HTTP mode; stdio is
282
+ unchanged.
283
+
284
+ - **CORS is now off by default.** Previously an unset `CORS_ORIGINS` sent
285
+ `Access-Control-Allow-Origin: *`. If a browser client depends on that, set
286
+ the origins explicitly (`CORS_ORIGINS=https://your-app.com`), or
287
+ `CORS_ORIGINS=*` to restore the old behaviour.
288
+ - **Async tasks are scoped to the connection that created them.** A client
289
+ that used to poll a `task_id` queued by a different connection will now get
290
+ "not found".
291
+
269
292
  ## Migrating from SSE to HTTP transport
270
293
 
271
294
  Starting with v1.5.0, the primary transport is **Streamable HTTP** (`--transport http`). The legacy SSE transport (`--transport sse`) is deprecated but still works for backward compatibility.
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
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 = "1.7.0";
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==";
@@ -475,6 +475,7 @@ var InstanceRegistry = class {
475
475
  };
476
476
 
477
477
  // src/server/tools-registration.ts
478
+ import { randomUUID as randomUUID2 } from "crypto";
478
479
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
479
480
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
480
481
 
@@ -641,12 +642,12 @@ async function handleOpenclawInstances(registry2, _input) {
641
642
  }
642
643
 
643
644
  // src/mcp/tasks/manager.ts
645
+ import { randomUUID } from "crypto";
644
646
  var MAX_TASKS = 1e3;
645
647
  var CLEANUP_INTERVAL_MS = 10 * 60 * 1e3;
646
648
  var CLEANUP_MAX_AGE_MS = 60 * 60 * 1e3;
647
649
  var TaskManager = class {
648
650
  tasks = /* @__PURE__ */ new Map();
649
- taskCounter = 0;
650
651
  cleanupInterval;
651
652
  constructor() {
652
653
  this.cleanupInterval = setInterval(() => this.cleanup(CLEANUP_MAX_AGE_MS), CLEANUP_INTERVAL_MS);
@@ -655,16 +656,17 @@ var TaskManager = class {
655
656
  }
656
657
  }
657
658
  /**
658
- * Generate unique task ID
659
+ * Generate an unguessable task ID.
660
+ *
661
+ * Deliberately not sequential: a predictable counter would let a caller
662
+ * enumerate IDs and probe for tasks it does not own. Ownership checks are
663
+ * the real defence, this just removes the oracle.
659
664
  */
660
665
  generateId() {
661
- this.taskCounter++;
662
- const timestamp = Date.now().toString(36);
663
- const counter = this.taskCounter.toString(36).padStart(4, "0");
664
- return `task_${timestamp}_${counter}`;
666
+ return `task_${randomUUID()}`;
665
667
  }
666
668
  /**
667
- * Create a new task
669
+ * Create a new task owned by `options.ownerId`.
668
670
  */
669
671
  create(options) {
670
672
  if (this.tasks.size >= MAX_TASKS) {
@@ -679,6 +681,7 @@ var TaskManager = class {
679
681
  status: "pending",
680
682
  input: options.input,
681
683
  createdAt: /* @__PURE__ */ new Date(),
684
+ ownerId: options.ownerId,
682
685
  sessionId: options.sessionId,
683
686
  instanceId: options.instanceId,
684
687
  priority: options.priority ?? 0
@@ -688,16 +691,21 @@ var TaskManager = class {
688
691
  return task;
689
692
  }
690
693
  /**
691
- * Get task by ID
694
+ * Get a task by ID, but only if `ownerId` owns it.
695
+ *
696
+ * Returns undefined both for unknown IDs and for tasks owned by someone
697
+ * else, so callers cannot distinguish the two.
692
698
  */
693
- get(id) {
694
- return this.tasks.get(id);
699
+ get(id, ownerId) {
700
+ const task = this.tasks.get(id);
701
+ if (!task || task.ownerId !== ownerId) return void 0;
702
+ return task;
695
703
  }
696
704
  /**
697
- * List all tasks, optionally filtered by status
705
+ * List the tasks owned by `ownerId`, optionally filtered further.
698
706
  */
699
- list(filter) {
700
- let tasks = Array.from(this.tasks.values());
707
+ list(ownerId, filter) {
708
+ let tasks = Array.from(this.tasks.values()).filter((t) => t.ownerId === ownerId);
701
709
  if (filter?.status) {
702
710
  tasks = tasks.filter((t) => t.status === filter.status);
703
711
  }
@@ -713,7 +721,10 @@ var TaskManager = class {
713
721
  });
714
722
  }
715
723
  /**
716
- * Update task status
724
+ * Update task status.
725
+ *
726
+ * Internal worker path only — never reachable from a tool handler, so it
727
+ * takes no ownerId.
717
728
  */
718
729
  updateStatus(id, status, result, error) {
719
730
  const task = this.tasks.get(id);
@@ -731,10 +742,10 @@ var TaskManager = class {
731
742
  return true;
732
743
  }
733
744
  /**
734
- * Cancel a pending task
745
+ * Cancel a pending task owned by `ownerId`.
735
746
  */
736
- cancel(id) {
737
- const task = this.tasks.get(id);
747
+ cancel(id, ownerId) {
748
+ const task = this.get(id, ownerId);
738
749
  if (!task) return false;
739
750
  if (task.status !== "pending") {
740
751
  return false;
@@ -745,16 +756,22 @@ var TaskManager = class {
745
756
  return true;
746
757
  }
747
758
  /**
748
- * Delete a task (cleanup)
759
+ * Delete a task (cleanup). Internal only.
749
760
  */
750
761
  delete(id) {
751
762
  return this.tasks.delete(id);
752
763
  }
753
764
  /**
754
- * Get next pending task (for workers)
765
+ * Get next pending task across all owners (for the background worker).
766
+ *
767
+ * Internal only — the worker has to see every queue, which is exactly why
768
+ * tool handlers must go through the owner-scoped methods instead.
755
769
  */
756
770
  getNextPending() {
757
- const pending = this.list({ status: "pending" });
771
+ const pending = Array.from(this.tasks.values()).filter((t) => t.status === "pending").sort((a, b) => {
772
+ if (b.priority !== a.priority) return b.priority - a.priority;
773
+ return a.createdAt.getTime() - b.createdAt.getTime();
774
+ });
758
775
  return pending[0];
759
776
  }
760
777
  /**
@@ -775,9 +792,12 @@ var TaskManager = class {
775
792
  return cleaned;
776
793
  }
777
794
  /**
778
- * Get statistics
795
+ * Get statistics for the tasks owned by `ownerId`.
796
+ *
797
+ * Scoped like everything else: a global total would leak how busy other
798
+ * connections are.
779
799
  */
780
- stats() {
800
+ stats(ownerId) {
781
801
  const byStatus = {
782
802
  pending: 0,
783
803
  running: 0,
@@ -785,13 +805,13 @@ var TaskManager = class {
785
805
  failed: 0,
786
806
  cancelled: 0
787
807
  };
808
+ let total = 0;
788
809
  for (const task of this.tasks.values()) {
810
+ if (task.ownerId !== ownerId) continue;
789
811
  byStatus[task.status]++;
812
+ total++;
790
813
  }
791
- return {
792
- total: this.tasks.size,
793
- byStatus
794
- };
814
+ return { total, byStatus };
795
815
  }
796
816
  };
797
817
  var taskManager = new TaskManager();
@@ -915,7 +935,7 @@ function startTaskProcessor(registry2) {
915
935
  });
916
936
  log("Task processor started");
917
937
  }
918
- async function handleOpenclawChatAsync(registry2, input) {
938
+ async function handleOpenclawChatAsync(registry2, input, ownerId) {
919
939
  if (!validateInputIsObject(input)) {
920
940
  return errorResponse("Invalid input: expected an object");
921
941
  }
@@ -957,6 +977,7 @@ async function handleOpenclawChatAsync(registry2, input) {
957
977
  const task = taskManager.create({
958
978
  type: "chat",
959
979
  input: { message: msgResult.value, session_id: sessionId },
980
+ ownerId,
960
981
  sessionId,
961
982
  priority,
962
983
  instanceId
@@ -974,7 +995,7 @@ async function handleOpenclawChatAsync(registry2, input) {
974
995
  )
975
996
  );
976
997
  }
977
- async function handleOpenclawTaskStatus(_registry, input) {
998
+ async function handleOpenclawTaskStatus(_registry, input, ownerId) {
978
999
  if (!validateInputIsObject(input)) {
979
1000
  return errorResponse("Invalid input: expected an object");
980
1001
  }
@@ -983,7 +1004,7 @@ async function handleOpenclawTaskStatus(_registry, input) {
983
1004
  return errorResponse(tidResult.error);
984
1005
  }
985
1006
  const task_id = tidResult.value;
986
- const task = taskManager.get(task_id);
1007
+ const task = taskManager.get(task_id, ownerId);
987
1008
  if (!task) {
988
1009
  return errorResponse(`Task not found: ${task_id}`);
989
1010
  }
@@ -1015,7 +1036,7 @@ var VALID_TASK_STATUSES = [
1015
1036
  "failed",
1016
1037
  "cancelled"
1017
1038
  ];
1018
- async function handleOpenclawTaskList(_registry, input) {
1039
+ async function handleOpenclawTaskList(_registry, input, ownerId) {
1019
1040
  if (!validateInputIsObject(input)) {
1020
1041
  return errorResponse("Invalid input: expected an object");
1021
1042
  }
@@ -1042,8 +1063,12 @@ async function handleOpenclawTaskList(_registry, input) {
1042
1063
  }
1043
1064
  instanceFilter = instResult.value;
1044
1065
  }
1045
- const tasks = taskManager.list({ status, sessionId: session_id, instanceId: instanceFilter });
1046
- const stats = taskManager.stats();
1066
+ const tasks = taskManager.list(ownerId, {
1067
+ status,
1068
+ sessionId: session_id,
1069
+ instanceId: instanceFilter
1070
+ });
1071
+ const stats = taskManager.stats(ownerId);
1047
1072
  const taskList = tasks.map((t) => ({
1048
1073
  task_id: t.id,
1049
1074
  type: t.type,
@@ -1064,7 +1089,7 @@ async function handleOpenclawTaskList(_registry, input) {
1064
1089
  )
1065
1090
  );
1066
1091
  }
1067
- async function handleOpenclawTaskCancel(_registry, input) {
1092
+ async function handleOpenclawTaskCancel(_registry, input, ownerId) {
1068
1093
  if (!validateInputIsObject(input)) {
1069
1094
  return errorResponse("Invalid input: expected an object");
1070
1095
  }
@@ -1073,7 +1098,7 @@ async function handleOpenclawTaskCancel(_registry, input) {
1073
1098
  return errorResponse(tidResult.error);
1074
1099
  }
1075
1100
  const task_id = tidResult.value;
1076
- const task = taskManager.get(task_id);
1101
+ const task = taskManager.get(task_id, ownerId);
1077
1102
  if (!task) {
1078
1103
  return errorResponse(`Task not found: ${task_id}`);
1079
1104
  }
@@ -1082,7 +1107,7 @@ async function handleOpenclawTaskCancel(_registry, input) {
1082
1107
  `Cannot cancel task with status: ${task.status}. Only pending tasks can be cancelled.`
1083
1108
  );
1084
1109
  }
1085
- const cancelled = taskManager.cancel(task_id);
1110
+ const cancelled = taskManager.cancel(task_id, ownerId);
1086
1111
  if (!cancelled) {
1087
1112
  return errorResponse("Failed to cancel task");
1088
1113
  }
@@ -1120,13 +1145,14 @@ function createMcpServer(deps2) {
1120
1145
  }
1121
1146
  function registerTools(server, deps2) {
1122
1147
  const { registry: registry2 } = deps2;
1148
+ const ownerId = randomUUID2();
1123
1149
  const toolHandlers = /* @__PURE__ */ new Map([
1124
1150
  ["openclaw_chat", (input) => handleOpenclawChat(registry2, input)],
1125
1151
  ["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)],
1152
+ ["openclaw_chat_async", (input) => handleOpenclawChatAsync(registry2, input, ownerId)],
1153
+ ["openclaw_task_status", (input) => handleOpenclawTaskStatus(registry2, input, ownerId)],
1154
+ ["openclaw_task_list", (input) => handleOpenclawTaskList(registry2, input, ownerId)],
1155
+ ["openclaw_task_cancel", (input) => handleOpenclawTaskCancel(registry2, input, ownerId)],
1130
1156
  ["openclaw_instances", (input) => handleOpenclawInstances(registry2, input)]
1131
1157
  ]);
1132
1158
  const allTools = [
@@ -1158,7 +1184,7 @@ function registerTools(server, deps2) {
1158
1184
  }
1159
1185
 
1160
1186
  // src/server/http.ts
1161
- import { randomUUID as randomUUID2 } from "crypto";
1187
+ import { randomUUID as randomUUID4 } from "crypto";
1162
1188
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
1163
1189
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
1164
1190
  import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
@@ -1166,7 +1192,7 @@ import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
1166
1192
  import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
1167
1193
 
1168
1194
  // src/auth/provider.ts
1169
- import { randomUUID } from "crypto";
1195
+ import { randomUUID as randomUUID3 } from "crypto";
1170
1196
  import {
1171
1197
  InvalidRequestError,
1172
1198
  InvalidTokenError
@@ -1175,13 +1201,15 @@ var TOKEN_TTL_MS = 60 * 60 * 1e3;
1175
1201
  var AUTH_CODE_TTL_MS = 10 * 60 * 1e3;
1176
1202
  var REFRESH_TOKEN_TTL_MS = 24 * 60 * 60 * 1e3;
1177
1203
  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);
1204
+ var AllowAnyRedirectUris = class extends Array {
1205
+ includes(_searchElement, _fromIndex) {
1206
+ return true;
1183
1207
  }
1184
- });
1208
+ some(_predicate, _thisArg) {
1209
+ return true;
1210
+ }
1211
+ };
1212
+ var ALLOW_ANY_REDIRECT = new AllowAnyRedirectUris();
1185
1213
  var MAX_DYNAMIC_CLIENTS = 100;
1186
1214
  var OpenClawClientsStore = class {
1187
1215
  client;
@@ -1263,7 +1291,7 @@ var OpenClawAuthProvider = class {
1263
1291
  * Auto-approve: generate auth code and redirect immediately.
1264
1292
  */
1265
1293
  async authorize(client, params, res) {
1266
- const code = randomUUID();
1294
+ const code = randomUUID3();
1267
1295
  this.codes.set(code, { client, params, createdAt: Date.now() });
1268
1296
  const searchParams = new URLSearchParams({ code });
1269
1297
  if (params.state !== void 0) {
@@ -1291,8 +1319,8 @@ var OpenClawAuthProvider = class {
1291
1319
  throw new InvalidRequestError("Authorization code was not issued to this client");
1292
1320
  }
1293
1321
  this.codes.delete(authorizationCode);
1294
- const accessToken = randomUUID();
1295
- const refreshToken = randomUUID();
1322
+ const accessToken = randomUUID3();
1323
+ const refreshToken = randomUUID3();
1296
1324
  const scopes = codeData.params.scopes || [];
1297
1325
  this.tokens.set(accessToken, {
1298
1326
  token: accessToken,
@@ -1325,8 +1353,8 @@ var OpenClawAuthProvider = class {
1325
1353
  throw new InvalidRequestError("Refresh token was not issued to this client");
1326
1354
  }
1327
1355
  this.refreshTokens.delete(refreshToken);
1328
- const accessToken = randomUUID();
1329
- const newRefreshToken = randomUUID();
1356
+ const accessToken = randomUUID3();
1357
+ const newRefreshToken = randomUUID3();
1330
1358
  const tokenScopes = scopes || data.scopes;
1331
1359
  this.tokens.set(accessToken, {
1332
1360
  token: accessToken,
@@ -1393,12 +1421,12 @@ function parseTrustProxy(value) {
1393
1421
  }
1394
1422
  function loadCorsConfig() {
1395
1423
  const corsOrigins = process.env.CORS_ORIGINS;
1396
- if (!corsOrigins || corsOrigins === "*") {
1397
- return { origins: ["*"], enabled: true };
1398
- }
1399
- if (corsOrigins.toLowerCase() === "none" || corsOrigins === "") {
1424
+ if (!corsOrigins || corsOrigins.toLowerCase() === "none") {
1400
1425
  return { origins: [], enabled: false };
1401
1426
  }
1427
+ if (corsOrigins === "*") {
1428
+ return { origins: ["*"], enabled: true };
1429
+ }
1402
1430
  return {
1403
1431
  origins: corsOrigins.split(",").map((s) => s.trim()).filter(Boolean),
1404
1432
  enabled: true
@@ -1549,7 +1577,7 @@ async function createHttpServer(config, deps2) {
1549
1577
  return;
1550
1578
  }
1551
1579
  const transport = new StreamableHTTPServerTransport({
1552
- sessionIdGenerator: () => randomUUID2(),
1580
+ sessionIdGenerator: () => randomUUID4(),
1553
1581
  onsessioninitialized: (newSessionId) => {
1554
1582
  streamableSessions.set(newSessionId, { transport, server });
1555
1583
  log(`Streamable session initialized: ${newSessionId}`);
@@ -1584,6 +1612,11 @@ async function createHttpServer(config, deps2) {
1584
1612
  log(`HTTP server listening on ${config.host}:${config.port}`);
1585
1613
  log(`Auth enabled: ${authEnabled}`);
1586
1614
  log(`CORS origins: ${corsConfig.enabled ? corsConfig.origins.join(", ") : "disabled"}`);
1615
+ if (corsConfig.origins.includes("*")) {
1616
+ logError(
1617
+ "WARNING: CORS_ORIGINS=* allows any web page to call this server from a browser. List explicit origins instead."
1618
+ );
1619
+ }
1587
1620
  if (authEnabled) {
1588
1621
  log("OAuth 2.1 authentication is REQUIRED for all connections");
1589
1622
  log("Endpoints:");
@@ -123,17 +123,24 @@ When `TRUST_PROXY` is unset and a reverse proxy injects an `X-Forwarded-For` hea
123
123
 
124
124
  ### CORS Configuration
125
125
 
126
- | Variable | Description | Default |
127
- | -------------- | --------------------------------- | ------- |
128
- | `CORS_ORIGINS` | Allowed origins (comma-separated) | `*` |
126
+ | Variable | Description | Default |
127
+ | -------------- | --------------------------------- | ------------------ |
128
+ | `CORS_ORIGINS` | Allowed origins (comma-separated) | _(CORS disabled)_ |
129
+
130
+ CORS is **off unless you opt in**. With it off the server sends no
131
+ `Access-Control-Allow-Origin` header, so browsers refuse cross-origin calls to
132
+ it. Non-browser clients — Claude.ai, Claude Desktop, and anything speaking
133
+ stdio — are unaffected; set this only when a web page has to reach the server
134
+ directly.
129
135
 
130
136
  **CORS_ORIGINS examples:**
131
137
 
132
- - `*`Allow all origins (not recommended for production)
133
- - `none` — Disable CORS entirely
138
+ - _(unset)_ or `none` CORS disabled (default)
134
139
  - `https://claude.ai` — Single origin
135
140
  - `https://claude.ai,https://your-app.com` — Multiple origins
136
141
  - `*.example.com` — Wildcard subdomain
142
+ - `*` — Allow all origins. Lets any web page drive this server through a
143
+ visitor's browser; do not use in production.
137
144
 
138
145
  ### Authentication (OAuth 2.1)
139
146
 
@@ -170,6 +177,18 @@ When auth is enabled, the server exposes these OAuth 2.1 endpoints:
170
177
 
171
178
  Dynamic client registration is **disabled by default** — only the pre-configured client (from `MCP_CLIENT_ID` + `MCP_CLIENT_SECRET`) can authenticate. This prevents anyone who knows the server URL from self-registering and bypassing auth.
172
179
 
180
+ #### Redirect URI allow-list
181
+
182
+ `MCP_REDIRECT_URIS` restricts which `redirect_uri` values the `/authorize` endpoint accepts for the pre-configured client. When unset, any redirect URI is accepted (the client secret verified during token exchange remains the actual auth gate) — set it in production so authorization codes can only be delivered to callbacks you trust.
183
+
184
+ Claude.ai uses exactly this callback (note the `/api/mcp/auth_callback` path):
185
+
186
+ ```bash
187
+ MCP_REDIRECT_URIS=https://claude.ai/api/mcp/auth_callback,https://claude.com/api/mcp/auth_callback
188
+ ```
189
+
190
+ Matching is exact (scheme, host, path). A common mistake is registering `https://claude.ai/oauth/callback`, which does **not** match and makes Claude.ai fail with `Unregistered redirect_uri`. If you also connect other clients (e.g. MCP Inspector), append their callbacks to the comma-separated list — loopback callbacks (`http://localhost/...`, `http://127.0.0.1/...`) match on any port per RFC 8252.
191
+
173
192
  #### Cursor / Windsurf compatibility (dev only)
174
193
 
175
194
  Cursor and Windsurf only support MCP servers that expose OAuth 2.0 Dynamic Client Registration (RFC 7591). To let them connect, set `MCP_DANGEROUSLY_ALLOW_DCR=true`. The server will then advertise a `/register` endpoint and accept ad-hoc client registrations (kept in an in-memory FIFO store, capped at 100 entries).
@@ -24,6 +24,7 @@ services:
24
24
  - MCP_CLIENT_SECRET=${MCP_CLIENT_SECRET:-}
25
25
  - MCP_ISSUER_URL=${MCP_ISSUER_URL:-}
26
26
  - MCP_REDIRECT_URIS=${MCP_REDIRECT_URIS:-}
27
+ - TRUST_PROXY=${TRUST_PROXY:-}
27
28
  - CORS_ORIGINS=${CORS_ORIGINS:-https://claude.ai}
28
29
  - NODE_ENV=production
29
30
  extra_hosts:
@@ -56,6 +57,12 @@ AUTH_ENABLED=true
56
57
  # Public URL (required when behind a reverse proxy)
57
58
  MCP_ISSUER_URL=https://mcp.your-domain.com
58
59
 
60
+ # Trust the reverse proxy's X-Forwarded-For (required behind a reverse proxy)
61
+ TRUST_PROXY=1
62
+
63
+ # Allowed OAuth redirect URIs — Claude.ai callbacks (recommended for production)
64
+ MCP_REDIRECT_URIS=https://claude.ai/api/mcp/auth_callback,https://claude.com/api/mcp/auth_callback
65
+
59
66
  # Allowed CORS origins
60
67
  CORS_ORIGINS=https://claude.ai
61
68
  ```
@@ -79,7 +86,7 @@ docker compose up -d
79
86
  - [ ] `MCP_CLIENT_SECRET` generated securely (`openssl rand -hex 32`, min 32 chars)
80
87
  - [ ] `MCP_ISSUER_URL` set to public HTTPS URL (when behind reverse proxy)
81
88
  - [ ] `TRUST_PROXY` set to the right hop count / CIDR (when behind reverse proxy)
82
- - [ ] `MCP_REDIRECT_URIS` restricted to known callback URLs
89
+ - [ ] `MCP_REDIRECT_URIS` restricted to known callback URLs (for Claude.ai: `https://claude.ai/api/mcp/auth_callback,https://claude.com/api/mcp/auth_callback`)
83
90
  - [ ] CORS restricted to known origins (`CORS_ORIGINS=https://claude.ai`)
84
91
  - [ ] `OPENCLAW_GATEWAY_TOKEN` set for gateway authentication
85
92
  - [ ] Dynamic client registration is disabled (default — no `/register` endpoint)
@@ -205,6 +212,18 @@ You're running behind a reverse proxy but haven't set `MCP_ISSUER_URL`. The OAut
205
212
 
206
213
  Your Claude.ai connector URL is missing the `/mcp` path. The MCP Streamable HTTP transport is mounted at `/mcp`, not at the server root (which is intentional — root is reserved for `/health`, `/.well-known/*`, OAuth endpoints, and legacy SSE endpoints). Update the connector URL in Claude.ai to end with `/mcp`, e.g. `https://mcp.your-domain.com/mcp`.
207
214
 
215
+ ### `invalid_request` / `Unregistered redirect_uri` on `/authorize`
216
+
217
+ The `redirect_uri` the client sent is not on the server's allow-list. Two common causes:
218
+
219
+ 1. **`MCP_REDIRECT_URIS` is set but doesn't contain the client's exact callback.** Claude.ai uses `https://claude.ai/api/mcp/auth_callback` (and `https://claude.com/api/mcp/auth_callback`) — a similar-looking entry like `https://claude.ai/oauth/callback` does **not** match. Matching is exact on scheme, host, and path; only loopback callbacks (`localhost`, `127.0.0.1`, `[::1]`) get port relaxation per RFC 8252.
220
+
221
+ 2. **You're on bridge ≤ 1.5.0 with `MCP_REDIRECT_URIS` unset.** The allow-any fallback in those versions was silently broken by a change in MCP SDK 1.29 (the SDK switched its membership check from `.includes()` to `.some()`), so *every* redirect_uri was rejected — fresh `npx openclaw-mcp@latest` installs picked the new SDK up automatically. Upgrade to bridge ≥ 1.6.0, or set `MCP_REDIRECT_URIS` explicitly (recommended for production anyway):
222
+
223
+ ```bash
224
+ MCP_REDIRECT_URIS=https://claude.ai/api/mcp/auth_callback,https://claude.com/api/mcp/auth_callback
225
+ ```
226
+
208
227
  ### `ValidationError: ERR_ERL_UNEXPECTED_X_FORWARDED_FOR` on `/token`
209
228
 
210
229
  The server is behind a reverse proxy that sets `X-Forwarded-For`, but Express's `trust proxy` is left at its default (`false`). The MCP SDK's OAuth handlers use `express-rate-limit`, which refuses to read the forwarded header in that configuration and crashes the request. Set `TRUST_PROXY=1` (single proxy in front) or `--trust-proxy 1`. Use a higher hop count, a CIDR/IP, or a keyword (`loopback`, `linklocal`, `uniquelocal`) for more complex topologies — see [Server Settings](configuration.md#server-settings-http-transport).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-mcp",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "mcpName": "io.github.freema/openclaw-mcp",
5
5
  "description": "Model Context Protocol (MCP) server for OpenClaw AI assistant integration",
6
6
  "author": "Tomáš Grasl <https://www.tomasgrasl.cz/>",
@@ -40,7 +40,7 @@
40
40
  "node": ">=20.0.0"
41
41
  },
42
42
  "dependencies": {
43
- "@modelcontextprotocol/sdk": "^1.0.0",
43
+ "@modelcontextprotocol/sdk": "^1.29.0",
44
44
  "yargs": "^17.7.2"
45
45
  },
46
46
  "devDependencies": {