openclaw-mcp 1.4.1 → 1.6.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 +42 -4
- package/dist/index.js +67 -34
- package/docs/configuration.md +31 -6
- package/docs/deployment.md +34 -3
- package/docs/development.md +1 -1
- package/docs/installation.md +8 -5
- package/docs/logging.md +3 -3
- package/docs/threat-model.md +3 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -54,6 +54,8 @@ 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
|
|
58
|
+
- TRUST_PROXY=1
|
|
57
59
|
- CORS_ORIGINS=https://claude.ai
|
|
58
60
|
extra_hosts:
|
|
59
61
|
- "host.docker.internal:host-gateway"
|
|
@@ -70,7 +72,9 @@ export OPENCLAW_GATEWAY_TOKEN=your-gateway-token
|
|
|
70
72
|
docker compose up -d
|
|
71
73
|
```
|
|
72
74
|
|
|
73
|
-
Then in Claude.ai add a custom MCP connector pointing to your
|
|
75
|
+
Then in Claude.ai add a custom MCP connector pointing to `https://your-domain.com/mcp` with `MCP_CLIENT_ID=openclaw` and your `MCP_CLIENT_SECRET`.
|
|
76
|
+
|
|
77
|
+
> **Important:** The connector URL **must end with `/mcp`** — that's the Streamable HTTP endpoint. A bare domain (`https://your-domain.com`) hits the server root and returns 404 after OAuth completes.
|
|
74
78
|
|
|
75
79
|
> **Tip:** Pin a specific version instead of `latest` for production: `ghcr.io/freema/openclaw-mcp:1.1.0`
|
|
76
80
|
|
|
@@ -105,10 +109,14 @@ Add to your Claude Desktop config:
|
|
|
105
109
|
AUTH_ENABLED=true MCP_CLIENT_ID=openclaw MCP_CLIENT_SECRET=your-secret \
|
|
106
110
|
MCP_ISSUER_URL=https://mcp.your-domain.com \
|
|
107
111
|
CORS_ORIGINS=https://claude.ai OPENCLAW_GATEWAY_TOKEN=your-gateway-token \
|
|
108
|
-
npx openclaw-mcp --transport
|
|
112
|
+
npx openclaw-mcp --transport http --port 3000
|
|
109
113
|
```
|
|
110
114
|
|
|
111
|
-
> **Important:** When running behind a reverse proxy (Caddy, nginx, etc.)
|
|
115
|
+
> **Important:** When running behind a reverse proxy (Caddy, nginx, Traefik, Cloudflare Tunnel, etc.) you **must** set:
|
|
116
|
+
> - `MCP_ISSUER_URL` (or `--issuer-url`) to your public HTTPS URL — otherwise OAuth metadata advertises `http://localhost:3000` and clients fail to authenticate.
|
|
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`.
|
|
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)).
|
|
112
120
|
|
|
113
121
|
See [Installation Guide](docs/installation.md) for details.
|
|
114
122
|
|
|
@@ -250,7 +258,7 @@ export MCP_CLIENT_SECRET=$(openssl rand -hex 32)
|
|
|
250
258
|
|
|
251
259
|
# Run with auth enabled
|
|
252
260
|
AUTH_ENABLED=true MCP_CLIENT_ID=openclaw MCP_CLIENT_SECRET=$MCP_CLIENT_SECRET \
|
|
253
|
-
openclaw-mcp --transport
|
|
261
|
+
openclaw-mcp --transport http
|
|
254
262
|
```
|
|
255
263
|
|
|
256
264
|
Configure CORS to restrict access:
|
|
@@ -261,6 +269,36 @@ CORS_ORIGINS=https://claude.ai,https://your-app.com
|
|
|
261
269
|
|
|
262
270
|
See [Configuration](docs/configuration.md) for all security options.
|
|
263
271
|
|
|
272
|
+
## Migrating from SSE to HTTP transport
|
|
273
|
+
|
|
274
|
+
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.
|
|
275
|
+
|
|
276
|
+
### What changed
|
|
277
|
+
|
|
278
|
+
| Before | After |
|
|
279
|
+
|--------|-------|
|
|
280
|
+
| `--transport sse` | `--transport http` (recommended) |
|
|
281
|
+
| Primary endpoint: `GET /sse` | Primary endpoint: `POST/GET/DELETE /mcp` |
|
|
282
|
+
| Health: `"transport": "sse"` | Health: `"transport": "streamable-http"` |
|
|
283
|
+
|
|
284
|
+
### Migration steps
|
|
285
|
+
|
|
286
|
+
1. **CLI / Docker**: Replace `--transport sse` with `--transport http`
|
|
287
|
+
```bash
|
|
288
|
+
# Before
|
|
289
|
+
openclaw-mcp --transport sse --port 3000
|
|
290
|
+
# After
|
|
291
|
+
openclaw-mcp --transport http --port 3000
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
2. **Claude.ai connector URL**: No change needed — Claude.ai already uses `/mcp` (Streamable HTTP)
|
|
295
|
+
|
|
296
|
+
3. **Legacy clients**: The `/sse` and `/messages` endpoints still work. A deprecation warning is logged on each SSE connection.
|
|
297
|
+
|
|
298
|
+
4. **Dockerfile ENTRYPOINT**: Updated automatically if using the official Docker image
|
|
299
|
+
|
|
300
|
+
> **Note:** `--transport sse` will continue to work as a deprecated alias. Both transports are served simultaneously regardless of which flag you use.
|
|
301
|
+
|
|
264
302
|
## Requirements
|
|
265
303
|
|
|
266
304
|
- Node.js ≥ 20
|
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.6.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==";
|
|
@@ -74,17 +74,17 @@ function parseArguments(version) {
|
|
|
74
74
|
}).option("transport", {
|
|
75
75
|
alias: "t",
|
|
76
76
|
type: "string",
|
|
77
|
-
choices: ["stdio", "sse"],
|
|
78
|
-
description:
|
|
77
|
+
choices: ["stdio", "http", "sse"],
|
|
78
|
+
description: 'Transport mode (stdio for local, http for remote; "sse" is a deprecated alias for "http")',
|
|
79
79
|
default: "stdio"
|
|
80
80
|
}).option("port", {
|
|
81
81
|
alias: "p",
|
|
82
82
|
type: "number",
|
|
83
|
-
description: "Port for
|
|
83
|
+
description: "Port for HTTP server",
|
|
84
84
|
default: parseInt(process.env.PORT || "3000", 10)
|
|
85
85
|
}).option("host", {
|
|
86
86
|
type: "string",
|
|
87
|
-
description: "Host for
|
|
87
|
+
description: "Host for HTTP server",
|
|
88
88
|
default: process.env.HOST || "0.0.0.0"
|
|
89
89
|
}).option("timeout", {
|
|
90
90
|
type: "number",
|
|
@@ -96,7 +96,7 @@ function parseArguments(version) {
|
|
|
96
96
|
default: process.env.DEBUG === "true" || process.env.NODE_ENV === "development"
|
|
97
97
|
}).option("auth", {
|
|
98
98
|
type: "boolean",
|
|
99
|
-
description: "Enable OAuth authentication (
|
|
99
|
+
description: "Enable OAuth authentication (HTTP mode)",
|
|
100
100
|
default: process.env.AUTH_ENABLED === "true" || process.env.OAUTH_ENABLED === "true"
|
|
101
101
|
}).option("client-id", {
|
|
102
102
|
type: "string",
|
|
@@ -118,6 +118,10 @@ function parseArguments(version) {
|
|
|
118
118
|
type: "boolean",
|
|
119
119
|
description: "Allow OAuth Dynamic Client Registration (Cursor/Windsurf compatibility, dev-only)",
|
|
120
120
|
default: process.env.MCP_DANGEROUSLY_ALLOW_DCR === "true"
|
|
121
|
+
}).option("trust-proxy", {
|
|
122
|
+
type: "string",
|
|
123
|
+
description: 'Express trust proxy setting when behind a reverse proxy (e.g. "1", "true", or a CIDR)',
|
|
124
|
+
default: process.env.TRUST_PROXY || void 0
|
|
121
125
|
}).help().parseSync();
|
|
122
126
|
let instances;
|
|
123
127
|
const instancesEnv = process.env.OPENCLAW_INSTANCES;
|
|
@@ -173,6 +177,7 @@ function parseArguments(version) {
|
|
|
173
177
|
issuerUrl: argv["issuer-url"],
|
|
174
178
|
redirectUris: argv["redirect-uris"] ? argv["redirect-uris"].split(",").map((s) => s.trim()).filter(Boolean) : void 0,
|
|
175
179
|
allowDcr: argv["allow-dcr"],
|
|
180
|
+
trustProxy: argv["trust-proxy"],
|
|
176
181
|
instances
|
|
177
182
|
};
|
|
178
183
|
}
|
|
@@ -1152,7 +1157,7 @@ function registerTools(server, deps2) {
|
|
|
1152
1157
|
});
|
|
1153
1158
|
}
|
|
1154
1159
|
|
|
1155
|
-
// src/server/
|
|
1160
|
+
// src/server/http.ts
|
|
1156
1161
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
1157
1162
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
1158
1163
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
@@ -1170,13 +1175,15 @@ var TOKEN_TTL_MS = 60 * 60 * 1e3;
|
|
|
1170
1175
|
var AUTH_CODE_TTL_MS = 10 * 60 * 1e3;
|
|
1171
1176
|
var REFRESH_TOKEN_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
1172
1177
|
var REAPER_INTERVAL_MS = 5 * 60 * 1e3;
|
|
1173
|
-
var
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
if (prop === "length") return 1;
|
|
1177
|
-
return Reflect.get(target, prop);
|
|
1178
|
+
var AllowAnyRedirectUris = class extends Array {
|
|
1179
|
+
includes(_searchElement, _fromIndex) {
|
|
1180
|
+
return true;
|
|
1178
1181
|
}
|
|
1179
|
-
|
|
1182
|
+
some(_predicate, _thisArg) {
|
|
1183
|
+
return true;
|
|
1184
|
+
}
|
|
1185
|
+
};
|
|
1186
|
+
var ALLOW_ANY_REDIRECT = new AllowAnyRedirectUris();
|
|
1180
1187
|
var MAX_DYNAMIC_CLIENTS = 100;
|
|
1181
1188
|
var OpenClawClientsStore = class {
|
|
1182
1189
|
client;
|
|
@@ -1369,7 +1376,23 @@ var OpenClawAuthProvider = class {
|
|
|
1369
1376
|
}
|
|
1370
1377
|
};
|
|
1371
1378
|
|
|
1372
|
-
// src/server/
|
|
1379
|
+
// src/server/http.ts
|
|
1380
|
+
function parseTrustProxy(value) {
|
|
1381
|
+
if (value === void 0 || value === "") {
|
|
1382
|
+
return void 0;
|
|
1383
|
+
}
|
|
1384
|
+
const trimmed = value.trim();
|
|
1385
|
+
if (trimmed === "") {
|
|
1386
|
+
return void 0;
|
|
1387
|
+
}
|
|
1388
|
+
const lower = trimmed.toLowerCase();
|
|
1389
|
+
if (lower === "true") return true;
|
|
1390
|
+
if (lower === "false") return false;
|
|
1391
|
+
if (/^\d+$/.test(trimmed)) {
|
|
1392
|
+
return parseInt(trimmed, 10);
|
|
1393
|
+
}
|
|
1394
|
+
return trimmed;
|
|
1395
|
+
}
|
|
1373
1396
|
function loadCorsConfig() {
|
|
1374
1397
|
const corsOrigins = process.env.CORS_ORIGINS;
|
|
1375
1398
|
if (!corsOrigins || corsOrigins === "*") {
|
|
@@ -1399,12 +1422,16 @@ function isOriginAllowed(origin, allowedOrigins) {
|
|
|
1399
1422
|
return origin === allowed || origin === `https://${allowed}` || origin === `http://${allowed}`;
|
|
1400
1423
|
});
|
|
1401
1424
|
}
|
|
1402
|
-
async function
|
|
1425
|
+
async function createHttpServer(config, deps2) {
|
|
1403
1426
|
const authEnabled = !!config.authConfig?.clientId;
|
|
1404
1427
|
const corsConfig = loadCorsConfig();
|
|
1405
|
-
const
|
|
1428
|
+
const legacySseSessions = /* @__PURE__ */ new Map();
|
|
1406
1429
|
const streamableSessions = /* @__PURE__ */ new Map();
|
|
1407
1430
|
const app = createMcpExpressApp({ host: config.host });
|
|
1431
|
+
if (config.trustProxy !== void 0) {
|
|
1432
|
+
app.set("trust proxy", config.trustProxy);
|
|
1433
|
+
log(`Trust proxy: ${JSON.stringify(config.trustProxy)}`);
|
|
1434
|
+
}
|
|
1408
1435
|
app.use((req, res, next) => {
|
|
1409
1436
|
if (!corsConfig.enabled) {
|
|
1410
1437
|
next();
|
|
@@ -1450,7 +1477,8 @@ async function createSSEServer(config, deps2) {
|
|
|
1450
1477
|
app.get("/health", (_req, res) => {
|
|
1451
1478
|
res.json({
|
|
1452
1479
|
status: "ok",
|
|
1453
|
-
transport: "
|
|
1480
|
+
transport: "streamable-http",
|
|
1481
|
+
legacySseSupported: true,
|
|
1454
1482
|
auth: authEnabled
|
|
1455
1483
|
});
|
|
1456
1484
|
});
|
|
@@ -1463,19 +1491,20 @@ async function createSSEServer(config, deps2) {
|
|
|
1463
1491
|
app.get(
|
|
1464
1492
|
"/sse",
|
|
1465
1493
|
...withAuth(async (req, res) => {
|
|
1494
|
+
log("Legacy SSE transport is deprecated; prefer Streamable HTTP at /mcp");
|
|
1466
1495
|
const transport = new SSEServerTransport("/messages", res);
|
|
1467
1496
|
const server = createMcpServer(deps2);
|
|
1468
1497
|
const sessionId = transport.sessionId;
|
|
1469
|
-
|
|
1498
|
+
legacySseSessions.set(sessionId, { transport, server });
|
|
1470
1499
|
log(`SSE session connected: ${sessionId}`);
|
|
1471
1500
|
transport.onclose = () => {
|
|
1472
|
-
|
|
1501
|
+
legacySseSessions.delete(sessionId);
|
|
1473
1502
|
log(`SSE session disconnected: ${sessionId}`);
|
|
1474
1503
|
};
|
|
1475
1504
|
try {
|
|
1476
1505
|
await server.connect(transport);
|
|
1477
1506
|
} catch (error) {
|
|
1478
|
-
|
|
1507
|
+
legacySseSessions.delete(sessionId);
|
|
1479
1508
|
logError(`Failed to connect SSE session ${sessionId}`, error);
|
|
1480
1509
|
}
|
|
1481
1510
|
})
|
|
@@ -1484,7 +1513,7 @@ async function createSSEServer(config, deps2) {
|
|
|
1484
1513
|
"/messages",
|
|
1485
1514
|
...withAuth(async (req, res) => {
|
|
1486
1515
|
const sessionId = req.query.sessionId;
|
|
1487
|
-
const session =
|
|
1516
|
+
const session = legacySseSessions.get(sessionId);
|
|
1488
1517
|
if (!session) {
|
|
1489
1518
|
res.status(404).json({ error: "Session not found" });
|
|
1490
1519
|
return;
|
|
@@ -1554,7 +1583,7 @@ async function createSSEServer(config, deps2) {
|
|
|
1554
1583
|
app.post("/mcp", ...withAuth(handleStreamableRequest));
|
|
1555
1584
|
app.delete("/mcp", ...withAuth(handleStreamableRequest));
|
|
1556
1585
|
const httpServer = app.listen(config.port, config.host, () => {
|
|
1557
|
-
log(`
|
|
1586
|
+
log(`HTTP server listening on ${config.host}:${config.port}`);
|
|
1558
1587
|
log(`Auth enabled: ${authEnabled}`);
|
|
1559
1588
|
log(`CORS origins: ${corsConfig.enabled ? corsConfig.origins.join(", ") : "disabled"}`);
|
|
1560
1589
|
if (authEnabled) {
|
|
@@ -1569,20 +1598,20 @@ async function createSSEServer(config, deps2) {
|
|
|
1569
1598
|
}
|
|
1570
1599
|
log("MCP Endpoints:");
|
|
1571
1600
|
log(" GET /health - Health check (no auth)");
|
|
1572
|
-
log("
|
|
1573
|
-
log("
|
|
1574
|
-
log("
|
|
1601
|
+
log(" ALL /mcp - Streamable HTTP (primary)");
|
|
1602
|
+
log(" GET /sse - Legacy SSE stream (deprecated)");
|
|
1603
|
+
log(" POST /messages - Legacy SSE messages (deprecated)");
|
|
1575
1604
|
});
|
|
1576
1605
|
const shutdown = async () => {
|
|
1577
|
-
log("Shutting down
|
|
1578
|
-
for (const [id, session] of
|
|
1606
|
+
log("Shutting down HTTP server...");
|
|
1607
|
+
for (const [id, session] of legacySseSessions) {
|
|
1579
1608
|
try {
|
|
1580
1609
|
await session.server.close();
|
|
1581
1610
|
} catch (error) {
|
|
1582
1611
|
logError(`Error closing SSE session ${id}`, error);
|
|
1583
1612
|
}
|
|
1584
1613
|
}
|
|
1585
|
-
|
|
1614
|
+
legacySseSessions.clear();
|
|
1586
1615
|
for (const [id, session] of streamableSessions) {
|
|
1587
1616
|
try {
|
|
1588
1617
|
await session.server.close();
|
|
@@ -1592,7 +1621,7 @@ async function createSSEServer(config, deps2) {
|
|
|
1592
1621
|
}
|
|
1593
1622
|
streamableSessions.clear();
|
|
1594
1623
|
httpServer.close(() => {
|
|
1595
|
-
log("
|
|
1624
|
+
log("HTTP server stopped");
|
|
1596
1625
|
process.exit(0);
|
|
1597
1626
|
});
|
|
1598
1627
|
setTimeout(() => {
|
|
@@ -1631,11 +1660,15 @@ async function main() {
|
|
|
1631
1660
|
const defaultLabel = instance.isDefault ? " (default)" : "";
|
|
1632
1661
|
log(`Instance "${instance.name}": ${instance.url}${defaultLabel}`);
|
|
1633
1662
|
}
|
|
1634
|
-
if (args.transport === "sse") {
|
|
1635
|
-
|
|
1663
|
+
if (args.transport === "sse" || args.transport === "http") {
|
|
1664
|
+
if (args.transport === "sse") {
|
|
1665
|
+
log("WARNING: --transport sse is deprecated; use --transport http instead");
|
|
1666
|
+
}
|
|
1667
|
+
const httpConfig = {
|
|
1636
1668
|
port: args.port,
|
|
1637
1669
|
host: args.host,
|
|
1638
|
-
issuerUrl: args.issuerUrl
|
|
1670
|
+
issuerUrl: args.issuerUrl,
|
|
1671
|
+
trustProxy: parseTrustProxy(args.trustProxy)
|
|
1639
1672
|
};
|
|
1640
1673
|
if (args.authEnabled && args.clientId) {
|
|
1641
1674
|
const clientIdRegex = /^[a-zA-Z0-9][a-zA-Z0-9_-]{2,63}$/;
|
|
@@ -1651,7 +1684,7 @@ async function main() {
|
|
|
1651
1684
|
);
|
|
1652
1685
|
process.exit(1);
|
|
1653
1686
|
}
|
|
1654
|
-
|
|
1687
|
+
httpConfig.authConfig = {
|
|
1655
1688
|
clientId: args.clientId,
|
|
1656
1689
|
clientSecret: args.clientSecret,
|
|
1657
1690
|
redirectUris: args.redirectUris,
|
|
@@ -1685,7 +1718,7 @@ async function main() {
|
|
|
1685
1718
|
logError("AUTH_ENABLED=true but MCP_CLIENT_ID is not set. Refusing to start without auth.");
|
|
1686
1719
|
process.exit(1);
|
|
1687
1720
|
}
|
|
1688
|
-
await
|
|
1721
|
+
await createHttpServer(httpConfig, deps);
|
|
1689
1722
|
} else {
|
|
1690
1723
|
const server = createMcpServer(deps);
|
|
1691
1724
|
const transport = new StdioServerTransport();
|
package/docs/configuration.md
CHANGED
|
@@ -100,13 +100,26 @@ services:
|
|
|
100
100
|
|
|
101
101
|
**Backward compatibility:** When `OPENCLAW_INSTANCES` is not set, the server creates a single `"default"` instance from `OPENCLAW_URL` + `OPENCLAW_GATEWAY_TOKEN`. Existing deployments work without any configuration change — zero migration required.
|
|
102
102
|
|
|
103
|
-
### Server Settings (
|
|
103
|
+
### Server Settings (HTTP transport)
|
|
104
104
|
|
|
105
|
-
| Variable
|
|
106
|
-
|
|
|
107
|
-
| `PORT`
|
|
108
|
-
| `HOST`
|
|
109
|
-
| `DEBUG`
|
|
105
|
+
| Variable | Description | Default |
|
|
106
|
+
| ------------- | ----------------------------------------------------------------- | ------------------------ |
|
|
107
|
+
| `PORT` | HTTP server port | `3000` |
|
|
108
|
+
| `HOST` | HTTP server host | `0.0.0.0` |
|
|
109
|
+
| `DEBUG` | Enable debug logging | `false` |
|
|
110
|
+
| `TRUST_PROXY` | Express `trust proxy` setting (required behind a reverse proxy) | (unset — trust nothing) |
|
|
111
|
+
|
|
112
|
+
**`TRUST_PROXY` values:**
|
|
113
|
+
|
|
114
|
+
| Value | Meaning |
|
|
115
|
+
| -------------- | ------------------------------------------------------------------------------ |
|
|
116
|
+
| (unset) | Express default — do not trust any proxy |
|
|
117
|
+
| `1` | Trust one hop — typical when there is a single reverse proxy in front |
|
|
118
|
+
| `true` | Trust every proxy — only safe on a fully private network |
|
|
119
|
+
| `loopback` | Trust `127.0.0.1` and `::1` |
|
|
120
|
+
| `10.0.0.0/8` | Trust a specific IP or CIDR range |
|
|
121
|
+
|
|
122
|
+
When `TRUST_PROXY` is unset and a reverse proxy injects an `X-Forwarded-For` header, the MCP SDK's `/token` handler crashes with `ERR_ERL_UNEXPECTED_X_FORWARDED_FOR` and OAuth fails. Set this whenever the server is behind Caddy, nginx, Traefik, Cloudflare Tunnel, ngrok, or any other proxy.
|
|
110
123
|
|
|
111
124
|
### CORS Configuration
|
|
112
125
|
|
|
@@ -157,6 +170,18 @@ When auth is enabled, the server exposes these OAuth 2.1 endpoints:
|
|
|
157
170
|
|
|
158
171
|
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.
|
|
159
172
|
|
|
173
|
+
#### Redirect URI allow-list
|
|
174
|
+
|
|
175
|
+
`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.
|
|
176
|
+
|
|
177
|
+
Claude.ai uses exactly this callback (note the `/api/mcp/auth_callback` path):
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
MCP_REDIRECT_URIS=https://claude.ai/api/mcp/auth_callback,https://claude.com/api/mcp/auth_callback
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
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.
|
|
184
|
+
|
|
160
185
|
#### Cursor / Windsurf compatibility (dev only)
|
|
161
186
|
|
|
162
187
|
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:
|
|
@@ -50,12 +51,18 @@ OPENCLAW_GATEWAY_TOKEN=your-gateway-token
|
|
|
50
51
|
MCP_CLIENT_ID=openclaw
|
|
51
52
|
MCP_CLIENT_SECRET=your-client-secret
|
|
52
53
|
|
|
53
|
-
# Enable OAuth (required for production
|
|
54
|
+
# Enable OAuth (required for production HTTP transport)
|
|
54
55
|
AUTH_ENABLED=true
|
|
55
56
|
|
|
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
|
```
|
|
@@ -78,7 +85,8 @@ docker compose up -d
|
|
|
78
85
|
- [ ] `MCP_CLIENT_ID` is valid (3–64 chars, alphanumeric/dashes/underscores)
|
|
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)
|
|
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`)
|
|
82
90
|
- [ ] CORS restricted to known origins (`CORS_ORIGINS=https://claude.ai`)
|
|
83
91
|
- [ ] `OPENCLAW_GATEWAY_TOKEN` set for gateway authentication
|
|
84
92
|
- [ ] Dynamic client registration is disabled (default — no `/register` endpoint)
|
|
@@ -88,7 +96,9 @@ docker compose up -d
|
|
|
88
96
|
|
|
89
97
|
The MCP bridge must be served over HTTPS for production use. Use a reverse proxy that handles TLS termination.
|
|
90
98
|
|
|
91
|
-
> **Important:**
|
|
99
|
+
> **Important:** When running behind a reverse proxy you **must** set both:
|
|
100
|
+
> - `MCP_ISSUER_URL` to your public HTTPS URL — otherwise OAuth metadata endpoints advertise `http://localhost:3000` and MCP clients (including Claude.ai) fail to authenticate with `Protected resource http://localhost:3000/mcp does not match expected https://your-domain.com/mcp`.
|
|
101
|
+
> - `TRUST_PROXY=1` so Express trusts the proxy's `X-Forwarded-For` header — otherwise `express-rate-limit` (used by the MCP SDK auth handlers) crashes `/token` with `ERR_ERL_UNEXPECTED_X_FORWARDED_FOR`.
|
|
92
102
|
|
|
93
103
|
### Caddy (recommended)
|
|
94
104
|
|
|
@@ -122,6 +132,7 @@ services:
|
|
|
122
132
|
- "3000"
|
|
123
133
|
environment:
|
|
124
134
|
- MCP_ISSUER_URL=https://mcp.your-domain.com
|
|
135
|
+
- TRUST_PROXY=1
|
|
125
136
|
# ... other env vars
|
|
126
137
|
|
|
127
138
|
volumes:
|
|
@@ -197,6 +208,26 @@ The OpenClaw gateway's HTTP chat completions endpoint is disabled by default. En
|
|
|
197
208
|
|
|
198
209
|
You're running behind a reverse proxy but haven't set `MCP_ISSUER_URL`. The OAuth metadata endpoints are advertising `http://localhost:3000` instead of your public HTTPS URL. Set `MCP_ISSUER_URL` to your public URL (e.g., `https://mcp.your-domain.com`) or pass `--issuer-url` on the CLI.
|
|
199
210
|
|
|
211
|
+
### `POST /` or `GET /` returns 404 after OAuth succeeds
|
|
212
|
+
|
|
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`.
|
|
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
|
+
|
|
227
|
+
### `ValidationError: ERR_ERL_UNEXPECTED_X_FORWARDED_FOR` on `/token`
|
|
228
|
+
|
|
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).
|
|
230
|
+
|
|
200
231
|
### `fetch failed` / MCP bridge can't reach gateway
|
|
201
232
|
|
|
202
233
|
When both services run in Docker, the MCP bridge must connect via the Docker network hostname (e.g., `http://openclaw-gateway:18789`), not `localhost`. Make sure both containers are on the same Docker network.
|
package/docs/development.md
CHANGED
|
@@ -48,7 +48,7 @@ src/
|
|
|
48
48
|
├── mcp/
|
|
49
49
|
│ └── tools/ # MCP tool implementations
|
|
50
50
|
├── openclaw/ # OpenClaw API client
|
|
51
|
-
├── server/ #
|
|
51
|
+
├── server/ # HTTP server (for remote access)
|
|
52
52
|
├── utils/ # Logging, errors, helpers
|
|
53
53
|
├── cli.ts # CLI argument parsing
|
|
54
54
|
└── index.ts # Main entry point
|
package/docs/installation.md
CHANGED
|
@@ -38,7 +38,7 @@ For local use with Claude Desktop, use stdio transport (default):
|
|
|
38
38
|
|
|
39
39
|
## Claude.ai (Remote Access)
|
|
40
40
|
|
|
41
|
-
For remote access via Claude.ai, deploy with
|
|
41
|
+
For remote access via Claude.ai, deploy with HTTP transport and OAuth 2.1 authentication.
|
|
42
42
|
|
|
43
43
|
### 1. Generate credentials
|
|
44
44
|
|
|
@@ -57,7 +57,7 @@ MCP_CLIENT_ID=openclaw \
|
|
|
57
57
|
MCP_CLIENT_SECRET=your-secret \
|
|
58
58
|
CORS_ORIGINS=https://claude.ai \
|
|
59
59
|
OPENCLAW_GATEWAY_TOKEN=your-gateway-token \
|
|
60
|
-
openclaw-mcp --transport
|
|
60
|
+
openclaw-mcp --transport http --port 3000
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
### 3. Add to Claude.ai
|
|
@@ -92,17 +92,20 @@ Options:
|
|
|
92
92
|
--openclaw-url, -u OpenClaw gateway URL [default: "http://127.0.0.1:18789"]
|
|
93
93
|
--gateway-token Bearer token for gateway [default: none]
|
|
94
94
|
--model, -m Model name for chat [default: "openclaw"]
|
|
95
|
-
--transport, -t Transport mode [choices: "stdio", "sse"] [default: "stdio"]
|
|
96
|
-
--port, -p Port for
|
|
97
|
-
--host Host for
|
|
95
|
+
--transport, -t Transport mode [choices: "stdio", "http", "sse"] [default: "stdio"]
|
|
96
|
+
--port, -p Port for HTTP server [default: 3000]
|
|
97
|
+
--host Host for HTTP server [default: "0.0.0.0"]
|
|
98
98
|
--debug Enable debug logging [default: false]
|
|
99
99
|
--auth Enable OAuth [default: false]
|
|
100
100
|
--client-id MCP OAuth client ID [env: MCP_CLIENT_ID]
|
|
101
101
|
--client-secret MCP OAuth client secret [env: MCP_CLIENT_SECRET]
|
|
102
102
|
--issuer-url OAuth issuer URL [env: MCP_ISSUER_URL]
|
|
103
103
|
--redirect-uris Allowed redirect URIs [env: MCP_REDIRECT_URIS]
|
|
104
|
+
--trust-proxy Express trust proxy [env: TRUST_PROXY]
|
|
104
105
|
--version Show version number
|
|
105
106
|
--help Show help
|
|
106
107
|
```
|
|
107
108
|
|
|
108
109
|
> **Note:** `--issuer-url` is required when running behind a reverse proxy (Caddy, nginx, etc.) so that OAuth metadata endpoints return the correct public HTTPS URL instead of `http://localhost:3000`.
|
|
110
|
+
|
|
111
|
+
> **Note:** `--trust-proxy` (or `TRUST_PROXY`) is also required when running behind a reverse proxy. Use `1` for a single proxy, `true` on a fully private network, or a CIDR/keyword (`loopback`, `linklocal`, `uniquelocal`) for more specific setups. Without it, `/token` crashes with `ERR_ERL_UNEXPECTED_X_FORWARDED_FOR`.
|
package/docs/logging.md
CHANGED
|
@@ -18,10 +18,10 @@ The MCP server logs operational events to **stderr** using the `[openclaw-mcp]`
|
|
|
18
18
|
|
|
19
19
|
- Server name and version
|
|
20
20
|
- OpenClaw gateway URL (host only, no tokens)
|
|
21
|
-
- Transport type (stdio or
|
|
21
|
+
- Transport type (stdio or HTTP)
|
|
22
22
|
- Whether a gateway token is configured (yes/no, not the token itself)
|
|
23
23
|
- OAuth client ID (when auth is enabled)
|
|
24
|
-
- Listening address and port (
|
|
24
|
+
- Listening address and port (HTTP mode)
|
|
25
25
|
- CORS origins configuration
|
|
26
26
|
|
|
27
27
|
### Connections (SSE/HTTP transport)
|
|
@@ -71,7 +71,7 @@ This is a safety net — the code avoids logging sensitive values in the first p
|
|
|
71
71
|
| Transport | Destination | Notes |
|
|
72
72
|
|-----------|-------------|-------|
|
|
73
73
|
| stdio | stderr | Cannot use stdout (reserved for MCP protocol) |
|
|
74
|
-
|
|
|
74
|
+
| HTTP | stderr | Same format, same destination |
|
|
75
75
|
| Docker | `docker logs openclaw-mcp` | stderr is captured by Docker's log driver |
|
|
76
76
|
|
|
77
77
|
## Docker Log Management
|
package/docs/threat-model.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
```
|
|
6
6
|
Claude (Desktop / Claude.ai)
|
|
7
7
|
|
|
|
8
|
-
| MCP Protocol (stdio or
|
|
8
|
+
| MCP Protocol (stdio or Streamable HTTP)
|
|
9
9
|
v
|
|
10
10
|
OpenClaw MCP Server (this project)
|
|
11
11
|
|
|
|
@@ -43,7 +43,7 @@ The MCP server is a **stateless proxy** — it translates MCP tool calls into Op
|
|
|
43
43
|
### Boundary 1: Claude <-> MCP Server
|
|
44
44
|
|
|
45
45
|
- **stdio transport (local):** Trusted — communication stays on the local machine. No authentication required.
|
|
46
|
-
- **
|
|
46
|
+
- **HTTP transport (remote):** Untrusted network — requires OAuth 2.1 authentication, HTTPS (via reverse proxy), and CORS restrictions.
|
|
47
47
|
|
|
48
48
|
### Boundary 2: MCP Server <-> OpenClaw Gateway
|
|
49
49
|
|
|
@@ -75,7 +75,7 @@ The MCP server is a **stateless proxy** — it translates MCP tool calls into Op
|
|
|
75
75
|
- Responses are parsed as JSON — no script execution
|
|
76
76
|
- Error messages from the gateway are sanitized before being returned to Claude
|
|
77
77
|
|
|
78
|
-
### 3. Network-Level Attacks (
|
|
78
|
+
### 3. Network-Level Attacks (HTTP transport)
|
|
79
79
|
|
|
80
80
|
**Risk:** Man-in-the-middle, replay attacks, unauthorized access.
|
|
81
81
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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": {
|