Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ OPENCLAW_GATEWAY_TOKEN=
# Increase for complex operations that take longer (e.g., 300000 = 5 minutes)
# OPENCLAW_TIMEOUT_MS=120000

# Model name for chat completions (default: openclaw)
# Gateway versions 2026.3.24+ require "openclaw" or "openclaw/<agentId>"
# OPENCLAW_MODEL=openclaw

# =============================================================================
# Server Settings (SSE transport only)
# =============================================================================
Expand All @@ -27,7 +31,8 @@ PORT=3000
# Host to bind to (0.0.0.0 for all interfaces)
HOST=0.0.0.0

# Enable debug logging
# Enable debug logging (logs request/response bodies for troubleshooting, credentials redacted)
# Also enabled when NODE_ENV=development
DEBUG=false

# =============================================================================
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ services:
environment:
- OPENCLAW_URL=http://host.docker.internal:18789
- OPENCLAW_GATEWAY_TOKEN=${OPENCLAW_GATEWAY_TOKEN}
- OPENCLAW_MODEL=openclaw
- AUTH_ENABLED=true
- MCP_CLIENT_ID=openclaw
- MCP_CLIENT_SECRET=${MCP_CLIENT_SECRET}
Expand Down Expand Up @@ -88,6 +89,7 @@ Add to your Claude Desktop config:
"env": {
"OPENCLAW_URL": "http://127.0.0.1:18789",
"OPENCLAW_GATEWAY_TOKEN": "your-gateway-token",
"OPENCLAW_MODEL": "openclaw",
"OPENCLAW_TIMEOUT_MS": "300000"
}
}
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ services:
environment:
- OPENCLAW_URL=${OPENCLAW_URL:-http://host.docker.internal:18789}
- OPENCLAW_GATEWAY_TOKEN=${OPENCLAW_GATEWAY_TOKEN:-}
- OPENCLAW_MODEL=${OPENCLAW_MODEL:-openclaw}
- DEBUG=${DEBUG:-false}
- AUTH_ENABLED=${AUTH_ENABLED:-true}
- MCP_CLIENT_ID=${MCP_CLIENT_ID:-openclaw}
- MCP_CLIENT_SECRET=${MCP_CLIENT_SECRET:-}
Expand Down
3 changes: 2 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ All configuration can be done via environment variables. Copy `.env.example` to
| `OPENCLAW_URL` | OpenClaw gateway URL | `http://127.0.0.1:18789` |
| `OPENCLAW_GATEWAY_TOKEN` | Bearer token for gateway authentication | (none) |
| `OPENCLAW_TIMEOUT_MS` | Request timeout in milliseconds | `120000` (2 min) |
| `OPENCLAW_MODEL` | Model name for chat completions | `openclaw` |

### Multi-Instance Mode

Expand Down Expand Up @@ -135,7 +136,7 @@ The server uses the MCP SDK's built-in OAuth 2.1 server with authorization code

**Client ID validation rules:**

- 364 characters
- 3-64 characters
- Alphanumeric, dashes, underscores only
- Must start with a letter or digit

Expand Down
18 changes: 18 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ services:
environment:
- OPENCLAW_URL=${OPENCLAW_URL:-http://host.docker.internal:18789}
- OPENCLAW_GATEWAY_TOKEN=${OPENCLAW_GATEWAY_TOKEN:-}
- OPENCLAW_MODEL=${OPENCLAW_MODEL:-openclaw}
- DEBUG=${DEBUG:-false}
- AUTH_ENABLED=${AUTH_ENABLED:-true}
- MCP_CLIENT_ID=${MCP_CLIENT_ID:-openclaw}
- MCP_CLIENT_SECRET=${MCP_CLIENT_SECRET:-}
Expand Down Expand Up @@ -169,8 +171,24 @@ The MCP bridge communicates with the OpenClaw gateway via its OpenAI-compatible

Without this, the MCP bridge will receive `405 Method Not Allowed` from the gateway.

## Bridge / Gateway Compatibility

| MCP Bridge | Gateway | Result |
|------------|---------|--------|
| ≤ 1.2.2 | ≥ 2026.3.24 | `400 Bad Request` — bridge sends `model: "claude-opus-4-5"`, gateway rejects it |
| ≥ 1.3.0 | ≥ 2026.3.24 | Works — bridge defaults to `model: "openclaw"` |
| ≥ 1.3.0 | older | Works — set `OPENCLAW_MODEL` to whatever the older gateway expects |

If you're running a non-standard gateway setup with custom agent routing, set `OPENCLAW_MODEL=openclaw/<agentId>` to match your configuration.

## Troubleshooting

### `400 Bad Request` from gateway on `openclaw_chat`

Gateway versions 2026.3.24+ require `model: "openclaw"` (or `"openclaw/<agentId>"`). The MCP bridge defaults to `"openclaw"` since v1.3.0. If you're using an older bridge version, upgrade or set `OPENCLAW_MODEL=openclaw`. If you need custom model routing, set `OPENCLAW_MODEL` to the value your gateway expects.

To diagnose, enable debug logging (`DEBUG=true`) which logs the outgoing request body and gateway error responses.

### `405 Method Not Allowed` from gateway

The OpenClaw gateway's HTTP chat completions endpoint is disabled by default. Enable it in `openclaw.json` — see [Gateway Prerequisites](#openclaw-gateway-prerequisites) above.
Expand Down
2 changes: 2 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,11 @@ openclaw-mcp --help
Options:
--openclaw-url, -u OpenClaw gateway URL [default: "http://127.0.0.1:18789"]
--gateway-token Bearer token for gateway [default: none]
--model, -m Model name for chat [default: "openclaw"]
--transport, -t Transport mode [choices: "stdio", "sse"] [default: "stdio"]
--port, -p Port for SSE server [default: 3000]
--host Host for SSE server [default: "0.0.0.0"]
--debug Enable debug logging [default: false]
--auth Enable OAuth [default: false]
--client-id MCP OAuth client ID [env: MCP_CLIENT_ID]
--client-secret MCP OAuth client secret [env: MCP_CLIENT_SECRET]
Expand Down
15 changes: 13 additions & 2 deletions docs/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,24 @@ The MCP server logs operational events to **stderr** using the `[openclaw-mcp]`
- Invalid client configuration (missing secrets, bad client ID format)
- Session errors

### What Is NOT Logged
### What Is NOT Logged (Info/Error levels)

- **Message content** — user messages and OpenClaw responses are never logged
- **Authentication tokens** — Bearer tokens, client secrets, gateway tokens
- **Request/response bodies** — only error messages, not full payloads
- **User-identifiable information** — no IPs, user agents, or personal data

### Debug Level (`DEBUG=true`)

> **Warning:** Debug mode is a diagnostic tool. It logs request and response payloads which may contain user message content. Do not enable in production under normal operation — use it only for active troubleshooting, then disable it.

When debug logging is enabled, the following **are** logged for troubleshooting:

- **Request bodies** — outgoing payloads sent to the gateway (truncated to 4096 chars)
- **Error response bodies** — full error responses from the gateway (truncated to 4096 chars)

Credentials are still redacted by the sanitization layer. Headers (including Authorization) are never logged, even in debug mode.

## Sensitive Data Redaction

The logger automatically redacts patterns that look like credentials:
Expand Down Expand Up @@ -99,4 +110,4 @@ DEBUG=true # Explicit debug flag
NODE_ENV=development # Development mode
```

Debug logs include additional operational detail but still never log message content or credentials.
Debug logs include request/response bodies for troubleshooting (truncated to 4096 chars). Credentials are still redacted, and headers (including Authorization) are never logged.
29 changes: 28 additions & 1 deletion src/__tests__/openclaw/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,38 @@ describe('OpenClawClient', () => {
expect(callArgs[1].method).toBe('POST');

const body = JSON.parse(callArgs[1].body);
expect(body.model).toBe('claude-opus-4-5');
expect(body.model).toBe('openclaw');
expect(body.messages).toEqual([{ role: 'user', content: 'Hi' }]);
expect(body.max_tokens).toBe(4096);
});

it('uses custom model from constructor', async () => {
const customClient = new OpenClawClient(
'https://openclaw.example.com',
'test-token',
120_000,
'openclaw/my-agent'
);
const openaiResponse = {
id: 'chatcmpl-custom',
object: 'chat.completion',
created: 1234567890,
model: 'openclaw/my-agent',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Hello!' },
finish_reason: 'stop',
},
],
};
mockJsonResponse(openaiResponse);

await customClient.chat('Hi');
const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
expect(body.model).toBe('openclaw/my-agent');
});

it('returns empty string when no choices in response', async () => {
const openaiResponse = {
id: 'chatcmpl-456',
Expand Down
66 changes: 66 additions & 0 deletions src/__tests__/utils/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,72 @@ describe('logger', () => {
});
});

describe('logDebug', () => {
it('does not log when debug is disabled (default)', async () => {
const { logDebug } = await loadLogger();
logDebug('should not appear');
expect(consoleSpy).not.toHaveBeenCalled();
});

it('logs with DEBUG prefix when enabled', async () => {
const { logDebug, setDebugEnabled } = await loadLogger();
setDebugEnabled(true);
logDebug('test message');
expect(consoleSpy).toHaveBeenCalledWith('[openclaw-mcp] DEBUG: test message');
setDebugEnabled(false);
});

it('sanitizes sensitive data in debug messages', async () => {
const { logDebug, setDebugEnabled } = await loadLogger();
setDebugEnabled(true);
logDebug('Auth: Bearer eyJhbGciOiJIUzI1NiJ9.secret');
const output = consoleSpy.mock.calls[0][0] as string;
expect(output).toContain('[REDACTED]');
expect(output).not.toContain('eyJhbGciOiJIUzI1NiJ9');
setDebugEnabled(false);
});

it('accepts a callback and only calls it when debug is enabled', async () => {
const { logDebug, setDebugEnabled } = await loadLogger();
const factory = vi.fn(() => 'lazy message');

logDebug(factory);
expect(factory).not.toHaveBeenCalled();
expect(consoleSpy).not.toHaveBeenCalled();

setDebugEnabled(true);
logDebug(factory);
expect(factory).toHaveBeenCalledTimes(1);
expect(consoleSpy).toHaveBeenCalledWith('[openclaw-mcp] DEBUG: lazy message');
setDebugEnabled(false);
});
});

describe('setDebugEnabled / isDebugEnabled', () => {
it('can toggle debug on and off', async () => {
const { logDebug, setDebugEnabled } = await loadLogger();

setDebugEnabled(true);
logDebug('visible');
expect(consoleSpy).toHaveBeenCalledTimes(1);

consoleSpy.mockClear();
setDebugEnabled(false);
logDebug('invisible');
expect(consoleSpy).not.toHaveBeenCalled();
});

it('isDebugEnabled returns current state', async () => {
const { isDebugEnabled, setDebugEnabled } = await loadLogger();

expect(isDebugEnabled()).toBe(false);
setDebugEnabled(true);
expect(isDebugEnabled()).toBe(true);
setDebugEnabled(false);
expect(isDebugEnabled()).toBe(false);
});
});

describe('sanitization', () => {
it('redacts Bearer tokens', async () => {
const { log } = await loadLogger();
Expand Down
17 changes: 16 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { DEFAULT_OPENCLAW_URL } from './config/constants.js';
import { DEFAULT_OPENCLAW_URL, DEFAULT_MODEL } from './config/constants.js';
import type { InstanceConfig } from './openclaw/types.js';

export interface CliArgs {
openclawUrl: string;
gatewayToken: string | undefined;
model: string;
transport: 'stdio' | 'sse';
port: number;
host: string;
timeout: number;
debug: boolean;
authEnabled: boolean;
clientId: string | undefined;
clientSecret: string | undefined;
Expand All @@ -32,6 +34,12 @@ export function parseArguments(version: string): CliArgs {
description: 'Bearer token for OpenClaw gateway authentication',
default: process.env.OPENCLAW_GATEWAY_TOKEN || undefined,
})
.option('model', {
alias: 'm',
type: 'string',
description: 'Model name for chat completions',
default: process.env.OPENCLAW_MODEL || DEFAULT_MODEL,
})
Comment on lines +37 to +42
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new --model / OPENCLAW_MODEL value is accepted as-is, including empty/whitespace strings (e.g., OPENCLAW_MODEL=), which would produce requests with model: "" and likely lead to hard-to-diagnose 400s. Consider validating model after parsing (non-empty, trimmed) and failing fast with a clear error message.

Copilot uses AI. Check for mistakes.
.option('transport', {
alias: 't',
type: 'string',
Expand All @@ -55,6 +63,11 @@ export function parseArguments(version: string): CliArgs {
description: 'Request timeout in milliseconds',
default: parseInt(process.env.OPENCLAW_TIMEOUT_MS || '120000', 10),
})
.option('debug', {
type: 'boolean',
description: 'Enable debug logging',
default: process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development',
})
.option('auth', {
type: 'boolean',
description: 'Enable OAuth authentication (SSE mode)',
Expand Down Expand Up @@ -131,10 +144,12 @@ export function parseArguments(version: string): CliArgs {
return {
openclawUrl: argv['openclaw-url'] as string,
gatewayToken: argv['gateway-token'] as string | undefined,
model: argv.model as string,
transport: argv.transport as 'stdio' | 'sse',
port: argv.port,
host: argv.host,
timeout: argv.timeout,
debug: argv.debug,
authEnabled: argv.auth,
clientId: argv['client-id'] as string | undefined,
clientSecret: argv['client-secret'] as string | undefined,
Expand Down
1 change: 1 addition & 0 deletions src/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ declare const __PKG_VERSION__: string;
export const SERVER_NAME = 'openclaw-mcp';
export const SERVER_VERSION = __PKG_VERSION__;
export const DEFAULT_OPENCLAW_URL = 'http://127.0.0.1:18789';
export const DEFAULT_MODEL = 'openclaw';

// Server icon: red claw on dark background — Base64 encoded SVG
// 128x128 rounded rect with stylized claw/pincer icon
Expand Down
19 changes: 17 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

import { SERVER_NAME, SERVER_VERSION } from './config/constants.js';
import { log, logError } from './utils/logger.js';
import { log, logError, setDebugEnabled } from './utils/logger.js';
import { parseArguments } from './cli.js';
import { InstanceRegistry } from './openclaw/registry.js';
import { createMcpServer, type ToolRegistrationDeps } from './server/tools-registration.js';
Expand All @@ -10,8 +10,19 @@ import { createSSEServer, type SSEServerConfig } from './server/sse.js';
// Parse CLI arguments
const args = parseArguments(SERVER_VERSION);

// Enable debug logging if requested
setDebugEnabled(args.debug);

// Validate model name
const trimmedModel = args.model.trim();
if (!trimmedModel) {
logError('OPENCLAW_MODEL / --model must be a non-empty string. Default is "openclaw".');
process.exit(1);
}
args.model = trimmedModel;

// Create instance registry (single or multi-instance)
const registry = new InstanceRegistry(args.instances);
const registry = new InstanceRegistry(args.instances, args.model);

// Shared dependencies for tool registration
const deps: ToolRegistrationDeps = {
Expand All @@ -22,8 +33,12 @@ const deps: ToolRegistrationDeps = {

async function main() {
log(`Starting ${SERVER_NAME} v${SERVER_VERSION}`);
log(`Model: ${args.model}`);
log(`Transport: ${args.transport}`);
log(`Request timeout: ${args.timeout}ms`);
if (args.debug) {
log('Debug logging: enabled');
}

// Log instance configuration
for (const instance of registry.list()) {
Expand Down
Loading
Loading