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 +25 -2
- package/dist/index.js +91 -58
- package/docs/configuration.md +24 -5
- package/docs/deployment.md +20 -1
- package/package.json +2 -2
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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(
|
|
1046
|
-
|
|
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
|
|
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
|
|
1179
|
-
|
|
1180
|
-
|
|
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 =
|
|
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 =
|
|
1295
|
-
const refreshToken =
|
|
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 =
|
|
1329
|
-
const newRefreshToken =
|
|
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: () =>
|
|
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:");
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
-
-
|
|
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).
|
package/docs/deployment.md
CHANGED
|
@@ -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.
|
|
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.
|
|
43
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
44
44
|
"yargs": "^17.7.2"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|