openclaw-mcp 1.4.0 → 1.5.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
@@ -1,3 +1,5 @@
1
+ <!-- mcp-name: io.github.freema/openclaw-mcp -->
2
+
1
3
  # OpenClaw MCP Server
2
4
 
3
5
  [![npm version](https://badge.fury.io/js/openclaw-mcp.svg)](https://www.npmjs.com/package/openclaw-mcp)
@@ -52,6 +54,7 @@ services:
52
54
  - MCP_CLIENT_ID=openclaw
53
55
  - MCP_CLIENT_SECRET=${MCP_CLIENT_SECRET}
54
56
  - MCP_ISSUER_URL=${MCP_ISSUER_URL:-}
57
+ - TRUST_PROXY=1
55
58
  - CORS_ORIGINS=https://claude.ai
56
59
  extra_hosts:
57
60
  - "host.docker.internal:host-gateway"
@@ -68,7 +71,9 @@ export OPENCLAW_GATEWAY_TOKEN=your-gateway-token
68
71
  docker compose up -d
69
72
  ```
70
73
 
71
- Then in Claude.ai add a custom MCP connector pointing to your server with `MCP_CLIENT_ID=openclaw` and your `MCP_CLIENT_SECRET`.
74
+ 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`.
75
+
76
+ > **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.
72
77
 
73
78
  > **Tip:** Pin a specific version instead of `latest` for production: `ghcr.io/freema/openclaw-mcp:1.1.0`
74
79
 
@@ -103,10 +108,12 @@ Add to your Claude Desktop config:
103
108
  AUTH_ENABLED=true MCP_CLIENT_ID=openclaw MCP_CLIENT_SECRET=your-secret \
104
109
  MCP_ISSUER_URL=https://mcp.your-domain.com \
105
110
  CORS_ORIGINS=https://claude.ai OPENCLAW_GATEWAY_TOKEN=your-gateway-token \
106
- npx openclaw-mcp --transport sse --port 3000
111
+ npx openclaw-mcp --transport http --port 3000
107
112
  ```
108
113
 
109
- > **Important:** When running behind a reverse proxy (Caddy, nginx, etc.), you **must** set `MCP_ISSUER_URL` (or `--issuer-url`) to your public HTTPS URL. Without this, OAuth metadata will advertise `http://localhost:3000` and clients will fail to authenticate.
114
+ > **Important:** When running behind a reverse proxy (Caddy, nginx, Traefik, Cloudflare Tunnel, etc.) you **must** set:
115
+ > - `MCP_ISSUER_URL` (or `--issuer-url`) to your public HTTPS URL — otherwise OAuth metadata advertises `http://localhost:3000` and clients fail to authenticate.
116
+ > - `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`.
110
117
 
111
118
  See [Installation Guide](docs/installation.md) for details.
112
119
 
@@ -248,7 +255,7 @@ export MCP_CLIENT_SECRET=$(openssl rand -hex 32)
248
255
 
249
256
  # Run with auth enabled
250
257
  AUTH_ENABLED=true MCP_CLIENT_ID=openclaw MCP_CLIENT_SECRET=$MCP_CLIENT_SECRET \
251
- openclaw-mcp --transport sse
258
+ openclaw-mcp --transport http
252
259
  ```
253
260
 
254
261
  Configure CORS to restrict access:
@@ -259,6 +266,36 @@ CORS_ORIGINS=https://claude.ai,https://your-app.com
259
266
 
260
267
  See [Configuration](docs/configuration.md) for all security options.
261
268
 
269
+ ## Migrating from SSE to HTTP transport
270
+
271
+ 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.
272
+
273
+ ### What changed
274
+
275
+ | Before | After |
276
+ |--------|-------|
277
+ | `--transport sse` | `--transport http` (recommended) |
278
+ | Primary endpoint: `GET /sse` | Primary endpoint: `POST/GET/DELETE /mcp` |
279
+ | Health: `"transport": "sse"` | Health: `"transport": "streamable-http"` |
280
+
281
+ ### Migration steps
282
+
283
+ 1. **CLI / Docker**: Replace `--transport sse` with `--transport http`
284
+ ```bash
285
+ # Before
286
+ openclaw-mcp --transport sse --port 3000
287
+ # After
288
+ openclaw-mcp --transport http --port 3000
289
+ ```
290
+
291
+ 2. **Claude.ai connector URL**: No change needed — Claude.ai already uses `/mcp` (Streamable HTTP)
292
+
293
+ 3. **Legacy clients**: The `/sse` and `/messages` endpoints still work. A deprecation warning is logged on each SSE connection.
294
+
295
+ 4. **Dockerfile ENTRYPOINT**: Updated automatically if using the official Docker image
296
+
297
+ > **Note:** `--transport sse` will continue to work as a deprecated alias. Both transports are served simultaneously regardless of which flag you use.
298
+
262
299
  ## Requirements
263
300
 
264
301
  - 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.4.0";
8
+ var SERVER_VERSION = "1.5.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: "Transport mode (stdio for local, sse for remote)",
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 SSE server",
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 SSE server",
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 (SSE mode)",
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/sse.ts
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";
@@ -1369,7 +1374,23 @@ var OpenClawAuthProvider = class {
1369
1374
  }
1370
1375
  };
1371
1376
 
1372
- // src/server/sse.ts
1377
+ // src/server/http.ts
1378
+ function parseTrustProxy(value) {
1379
+ if (value === void 0 || value === "") {
1380
+ return void 0;
1381
+ }
1382
+ const trimmed = value.trim();
1383
+ if (trimmed === "") {
1384
+ return void 0;
1385
+ }
1386
+ const lower = trimmed.toLowerCase();
1387
+ if (lower === "true") return true;
1388
+ if (lower === "false") return false;
1389
+ if (/^\d+$/.test(trimmed)) {
1390
+ return parseInt(trimmed, 10);
1391
+ }
1392
+ return trimmed;
1393
+ }
1373
1394
  function loadCorsConfig() {
1374
1395
  const corsOrigins = process.env.CORS_ORIGINS;
1375
1396
  if (!corsOrigins || corsOrigins === "*") {
@@ -1399,12 +1420,16 @@ function isOriginAllowed(origin, allowedOrigins) {
1399
1420
  return origin === allowed || origin === `https://${allowed}` || origin === `http://${allowed}`;
1400
1421
  });
1401
1422
  }
1402
- async function createSSEServer(config, deps2) {
1423
+ async function createHttpServer(config, deps2) {
1403
1424
  const authEnabled = !!config.authConfig?.clientId;
1404
1425
  const corsConfig = loadCorsConfig();
1405
- const sseSessions = /* @__PURE__ */ new Map();
1426
+ const legacySseSessions = /* @__PURE__ */ new Map();
1406
1427
  const streamableSessions = /* @__PURE__ */ new Map();
1407
1428
  const app = createMcpExpressApp({ host: config.host });
1429
+ if (config.trustProxy !== void 0) {
1430
+ app.set("trust proxy", config.trustProxy);
1431
+ log(`Trust proxy: ${JSON.stringify(config.trustProxy)}`);
1432
+ }
1408
1433
  app.use((req, res, next) => {
1409
1434
  if (!corsConfig.enabled) {
1410
1435
  next();
@@ -1450,7 +1475,8 @@ async function createSSEServer(config, deps2) {
1450
1475
  app.get("/health", (_req, res) => {
1451
1476
  res.json({
1452
1477
  status: "ok",
1453
- transport: "sse",
1478
+ transport: "streamable-http",
1479
+ legacySseSupported: true,
1454
1480
  auth: authEnabled
1455
1481
  });
1456
1482
  });
@@ -1463,19 +1489,20 @@ async function createSSEServer(config, deps2) {
1463
1489
  app.get(
1464
1490
  "/sse",
1465
1491
  ...withAuth(async (req, res) => {
1492
+ log("Legacy SSE transport is deprecated; prefer Streamable HTTP at /mcp");
1466
1493
  const transport = new SSEServerTransport("/messages", res);
1467
1494
  const server = createMcpServer(deps2);
1468
1495
  const sessionId = transport.sessionId;
1469
- sseSessions.set(sessionId, { transport, server });
1496
+ legacySseSessions.set(sessionId, { transport, server });
1470
1497
  log(`SSE session connected: ${sessionId}`);
1471
1498
  transport.onclose = () => {
1472
- sseSessions.delete(sessionId);
1499
+ legacySseSessions.delete(sessionId);
1473
1500
  log(`SSE session disconnected: ${sessionId}`);
1474
1501
  };
1475
1502
  try {
1476
1503
  await server.connect(transport);
1477
1504
  } catch (error) {
1478
- sseSessions.delete(sessionId);
1505
+ legacySseSessions.delete(sessionId);
1479
1506
  logError(`Failed to connect SSE session ${sessionId}`, error);
1480
1507
  }
1481
1508
  })
@@ -1484,7 +1511,7 @@ async function createSSEServer(config, deps2) {
1484
1511
  "/messages",
1485
1512
  ...withAuth(async (req, res) => {
1486
1513
  const sessionId = req.query.sessionId;
1487
- const session = sseSessions.get(sessionId);
1514
+ const session = legacySseSessions.get(sessionId);
1488
1515
  if (!session) {
1489
1516
  res.status(404).json({ error: "Session not found" });
1490
1517
  return;
@@ -1554,7 +1581,7 @@ async function createSSEServer(config, deps2) {
1554
1581
  app.post("/mcp", ...withAuth(handleStreamableRequest));
1555
1582
  app.delete("/mcp", ...withAuth(handleStreamableRequest));
1556
1583
  const httpServer = app.listen(config.port, config.host, () => {
1557
- log(`SSE server listening on ${config.host}:${config.port}`);
1584
+ log(`HTTP server listening on ${config.host}:${config.port}`);
1558
1585
  log(`Auth enabled: ${authEnabled}`);
1559
1586
  log(`CORS origins: ${corsConfig.enabled ? corsConfig.origins.join(", ") : "disabled"}`);
1560
1587
  if (authEnabled) {
@@ -1569,20 +1596,20 @@ async function createSSEServer(config, deps2) {
1569
1596
  }
1570
1597
  log("MCP Endpoints:");
1571
1598
  log(" GET /health - Health check (no auth)");
1572
- log(" GET /sse - Legacy SSE stream");
1573
- log(" POST /messages - Legacy SSE messages");
1574
- log(" ALL /mcp - Streamable HTTP");
1599
+ log(" ALL /mcp - Streamable HTTP (primary)");
1600
+ log(" GET /sse - Legacy SSE stream (deprecated)");
1601
+ log(" POST /messages - Legacy SSE messages (deprecated)");
1575
1602
  });
1576
1603
  const shutdown = async () => {
1577
- log("Shutting down SSE server...");
1578
- for (const [id, session] of sseSessions) {
1604
+ log("Shutting down HTTP server...");
1605
+ for (const [id, session] of legacySseSessions) {
1579
1606
  try {
1580
1607
  await session.server.close();
1581
1608
  } catch (error) {
1582
1609
  logError(`Error closing SSE session ${id}`, error);
1583
1610
  }
1584
1611
  }
1585
- sseSessions.clear();
1612
+ legacySseSessions.clear();
1586
1613
  for (const [id, session] of streamableSessions) {
1587
1614
  try {
1588
1615
  await session.server.close();
@@ -1592,7 +1619,7 @@ async function createSSEServer(config, deps2) {
1592
1619
  }
1593
1620
  streamableSessions.clear();
1594
1621
  httpServer.close(() => {
1595
- log("SSE server stopped");
1622
+ log("HTTP server stopped");
1596
1623
  process.exit(0);
1597
1624
  });
1598
1625
  setTimeout(() => {
@@ -1631,11 +1658,15 @@ async function main() {
1631
1658
  const defaultLabel = instance.isDefault ? " (default)" : "";
1632
1659
  log(`Instance "${instance.name}": ${instance.url}${defaultLabel}`);
1633
1660
  }
1634
- if (args.transport === "sse") {
1635
- const sseConfig = {
1661
+ if (args.transport === "sse" || args.transport === "http") {
1662
+ if (args.transport === "sse") {
1663
+ log("WARNING: --transport sse is deprecated; use --transport http instead");
1664
+ }
1665
+ const httpConfig = {
1636
1666
  port: args.port,
1637
1667
  host: args.host,
1638
- issuerUrl: args.issuerUrl
1668
+ issuerUrl: args.issuerUrl,
1669
+ trustProxy: parseTrustProxy(args.trustProxy)
1639
1670
  };
1640
1671
  if (args.authEnabled && args.clientId) {
1641
1672
  const clientIdRegex = /^[a-zA-Z0-9][a-zA-Z0-9_-]{2,63}$/;
@@ -1651,7 +1682,7 @@ async function main() {
1651
1682
  );
1652
1683
  process.exit(1);
1653
1684
  }
1654
- sseConfig.authConfig = {
1685
+ httpConfig.authConfig = {
1655
1686
  clientId: args.clientId,
1656
1687
  clientSecret: args.clientSecret,
1657
1688
  redirectUris: args.redirectUris,
@@ -1685,7 +1716,7 @@ async function main() {
1685
1716
  logError("AUTH_ENABLED=true but MCP_CLIENT_ID is not set. Refusing to start without auth.");
1686
1717
  process.exit(1);
1687
1718
  }
1688
- await createSSEServer(sseConfig, deps);
1719
+ await createHttpServer(httpConfig, deps);
1689
1720
  } else {
1690
1721
  const server = createMcpServer(deps);
1691
1722
  const transport = new StdioServerTransport();
@@ -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 (SSE transport)
104
-
105
- | Variable | Description | Default |
106
- | -------- | -------------------- | --------- |
107
- | `PORT` | SSE server port | `3000` |
108
- | `HOST` | SSE server host | `0.0.0.0` |
109
- | `DEBUG` | Enable debug logging | `false` |
103
+ ### Server Settings (HTTP transport)
104
+
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
 
@@ -50,7 +50,7 @@ OPENCLAW_GATEWAY_TOKEN=your-gateway-token
50
50
  MCP_CLIENT_ID=openclaw
51
51
  MCP_CLIENT_SECRET=your-client-secret
52
52
 
53
- # Enable OAuth (required for production SSE)
53
+ # Enable OAuth (required for production HTTP transport)
54
54
  AUTH_ENABLED=true
55
55
 
56
56
  # Public URL (required when behind a reverse proxy)
@@ -78,6 +78,7 @@ docker compose up -d
78
78
  - [ ] `MCP_CLIENT_ID` is valid (3–64 chars, alphanumeric/dashes/underscores)
79
79
  - [ ] `MCP_CLIENT_SECRET` generated securely (`openssl rand -hex 32`, min 32 chars)
80
80
  - [ ] `MCP_ISSUER_URL` set to public HTTPS URL (when behind reverse proxy)
81
+ - [ ] `TRUST_PROXY` set to the right hop count / CIDR (when behind reverse proxy)
81
82
  - [ ] `MCP_REDIRECT_URIS` restricted to known callback URLs
82
83
  - [ ] CORS restricted to known origins (`CORS_ORIGINS=https://claude.ai`)
83
84
  - [ ] `OPENCLAW_GATEWAY_TOKEN` set for gateway authentication
@@ -88,7 +89,9 @@ docker compose up -d
88
89
 
89
90
  The MCP bridge must be served over HTTPS for production use. Use a reverse proxy that handles TLS termination.
90
91
 
91
- > **Important:** You **must** set `MCP_ISSUER_URL` to your public HTTPS URL. Without this, OAuth metadata endpoints will advertise `http://localhost:3000` and MCP clients (including Claude.ai) will fail to authenticate with the error: `Protected resource http://localhost:3000/mcp does not match expected https://your-domain.com/mcp`.
92
+ > **Important:** When running behind a reverse proxy you **must** set both:
93
+ > - `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`.
94
+ > - `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
95
 
93
96
  ### Caddy (recommended)
94
97
 
@@ -122,6 +125,7 @@ services:
122
125
  - "3000"
123
126
  environment:
124
127
  - MCP_ISSUER_URL=https://mcp.your-domain.com
128
+ - TRUST_PROXY=1
125
129
  # ... other env vars
126
130
 
127
131
  volumes:
@@ -197,6 +201,14 @@ The OpenClaw gateway's HTTP chat completions endpoint is disabled by default. En
197
201
 
198
202
  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
203
 
204
+ ### `POST /` or `GET /` returns 404 after OAuth succeeds
205
+
206
+ 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
+
208
+ ### `ValidationError: ERR_ERL_UNEXPECTED_X_FORWARDED_FOR` on `/token`
209
+
210
+ 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).
211
+
200
212
  ### `fetch failed` / MCP bridge can't reach gateway
201
213
 
202
214
  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.
@@ -48,7 +48,7 @@ src/
48
48
  ├── mcp/
49
49
  │ └── tools/ # MCP tool implementations
50
50
  ├── openclaw/ # OpenClaw API client
51
- ├── server/ # SSE server (for remote access)
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
@@ -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 SSE transport and OAuth 2.1 authentication.
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 sse --port 3000
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 SSE server [default: 3000]
97
- --host Host for SSE server [default: "0.0.0.0"]
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 SSE)
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 (SSE mode)
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
- | SSE/HTTP | stderr | Same format, same destination |
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
@@ -5,7 +5,7 @@
5
5
  ```
6
6
  Claude (Desktop / Claude.ai)
7
7
  |
8
- | MCP Protocol (stdio or SSE/Streamable HTTP)
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
- - **SSE/HTTP transport (remote):** Untrusted network — requires OAuth 2.1 authentication, HTTPS (via reverse proxy), and CORS restrictions.
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 (SSE transport)
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,7 @@
1
1
  {
2
2
  "name": "openclaw-mcp",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
+ "mcpName": "io.github.freema/openclaw-mcp",
4
5
  "description": "Model Context Protocol (MCP) server for OpenClaw AI assistant integration",
5
6
  "author": "Tomáš Grasl <https://www.tomasgrasl.cz/>",
6
7
  "license": "MIT",