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
29 changes: 19 additions & 10 deletions docs/MCP_MODE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The implementation is structured so that **adding a new node capability automati
## Goals

1. **Single source of truth for capabilities.** A new `INodeCapability` registered with `WindowsNodeClient.RegisterCapability(...)` is reachable via every transport the tray supports. Today: gateway WebSocket and local MCP HTTP. Future transports (named pipe, gRPC, whatever) plug in the same way.
2. **Local-first development.** Capabilities can be exercised on Windows without standing up an OpenClaw gateway, without an account, without auth, without a tunnel.
2. **Local-first development.** Capabilities can be exercised on Windows without standing up an OpenClaw gateway, without an account, without a gateway token, without pairing, and without a tunnel.
3. **Make MCP clients first-class consumers** of the OpenClaw native node, not afterthoughts. The tooling investment in capabilities (camera consent flows, exec approval policy, canvas WebView2 plumbing) pays off in both directions: agent-via-gateway and agent-via-local-MCP.

## Non-goals (for this iteration)
Expand Down Expand Up @@ -119,7 +119,7 @@ The tray's most interesting code lives in capabilities — `system.run` (LocalCo

Local MCP changes that. Concrete benefits:

- **Manual smoke tests in seconds.** `curl -s -X POST http://127.0.0.1:8765/ -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'` validates that the capability dispatch path works, the WinUI dispatcher marshaling is correct, the result shape matches expectations. No gateway, no token, no SSH tunnel.
- **Manual smoke tests in seconds.** `curl -s -X POST http://127.0.0.1:8765/ -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'` validates that the capability dispatch path works, the WinUI dispatcher marshaling is correct, the result shape matches expectations. No gateway, no gateway token, no pairing, no SSH tunnel.
- **Reproducible bug reports.** A repro becomes a `tools/call` body the bug filer can paste verbatim. No "what was the gateway doing at the time."
- **Integration tests against a real instance.** A future `tests/integration/` project can spin up the tray in MCP-only mode, fire JSON-RPC, assert results. The same test bodies a developer runs by hand are the same ones CI runs. (Harnessing WinUI itself in CI is harder, but the bridge logic — `McpToolBridge` — is already covered by `McpToolBridgeTests` with no UI involvement.)
- **Coverage for the dispatch path itself.** `WindowsNodeClient`'s capability-routing logic (`CanHandle` → `ExecuteAsync`) was previously only exercised against a live gateway. The MCP server hits the same code paths, so any local MCP test is implicit coverage of the gateway dispatch.
Expand Down Expand Up @@ -168,7 +168,7 @@ With MCP in-process the workflow shortens to:
2. Wire it into `NodeService.RegisterCapabilities()`.
3. Restart the tray. The new tool is *immediately* visible to any local MCP client (`tools/list` re-reads the registry every call), and to manual `curl` tests.

The dev loop for capabilities is now identical to the dev loop for any local HTTP server: edit, restart, hit the endpoint, observe. No gateway, no agent, no auth.
The dev loop for capabilities is now identical to the dev loop for any local HTTP server: edit, restart, hit the endpoint with the local MCP bearer token, observe. No gateway, no agent, no gateway auth.

This compounds when you stack it with Claude Code or Cursor on the same machine. A contributor can:

Expand All @@ -181,7 +181,7 @@ It also reduces the cost of "speculative" capabilities. Today, adding a capabili

## Security model

The server is built on **three** defensive layers, not just one. Loopback alone is *not* sufficient — a browser tab the user opens is also on the loopback interface, so a malicious page could otherwise reach `http://127.0.0.1:8765/` directly.
The server is built on several defensive layers, not just one. Loopback alone is *not* sufficient — a browser tab the user opens is also on the loopback interface, so a malicious page could otherwise reach `http://127.0.0.1:8765/` directly.

1. **Loopback bind.** `HttpListener` is registered with the prefix `http://127.0.0.1:8765/`. The Windows kernel binds the listening socket to the loopback interface only — packets from other interfaces are not delivered to it. Firewall configuration is irrelevant. Defends against: another machine on the network.
2. **Defensive `IsLoopback` check.** Each incoming request validates `ctx.Request.RemoteEndPoint.Address`. Belt-and-suspenders for #1.
Expand All @@ -192,10 +192,11 @@ The server is built on **three** defensive layers, not just one. Loopback alone
- the request body exceeds 4 MiB (DoS / OOM cap).

Together these three checks force a malicious cross-origin browser fetch into a CORS preflight that we deliberately do not honor (no `Access-Control-Allow-*` is ever emitted), so the actual call is blocked before reaching capability code.
4. **Concurrency cap.** A semaphore limits in-flight handlers to 8. A misbehaving local client cannot pin every threadpool thread on long-running screen/camera calls.
5. **Capability-level controls remain in force.** `SystemCapability.SetApprovalPolicy(...)` (the exec approval policy) still gates `system.run`. Camera and screen capture still go through Windows consent flows. MCP doesn't bypass any of those.
4. **Bearer token.** Every request must include the persistent local MCP bearer token (`Authorization: Bearer <token>`) once the server has created `%APPDATA%\OpenClawTray\mcp-token.txt`. This blocks drive-by local clients that know the port but cannot read the per-user token file.
5. **Concurrency cap.** A semaphore limits in-flight handlers to 8. A misbehaving local client cannot pin every threadpool thread on long-running screen/camera calls.
6. **Capability-level controls remain in force.** `SystemCapability.SetApprovalPolicy(...)` (the exec approval policy) still gates `system.run`. Camera and screen capture still go through Windows consent flows. MCP doesn't bypass any of those.

**Still no authentication.** Any user-context local process with a TCP socket and the port number can drive any capability. This is the same trust boundary as anything that runs as the user — a malicious process on the box could already invoke arbitrary Win32 APIs without going through MCP. We don't try to stop user-context processes from talking to MCP. If that turns out to matter (multi-user shared boxes, low-trust local processes), the right answer is per-call bearer tokens issued by the tray (one-time copy-to-clipboard from the Settings UI), not URL ACLs or HTTPS — both add deployment pain without solving the actual problem.
**Authentication is local bearer-token based.** The token is persistent, generated by the tray, stored in the current user's OpenClawTray data directory, and verified before MCP method dispatch. It is defense-in-depth rather than a hard sandbox boundary: a malicious process already running as the same user may still be able to read user-profile files or invoke native APIs directly. If we need stronger isolation for shared machines or low-trust local processes, the next step is scoped or per-call tokens issued by the tray, not URL ACLs or HTTPS — both add deployment pain without solving the same-user trust problem.

### Verifying the gate

Expand All @@ -212,7 +213,7 @@ curl -X POST http://127.0.0.1:8765/ -H "Host: evil.com" -H "Content-Type: applic
This should be **rejected** with `415`:

```powershell
curl -X POST http://127.0.0.1:8765/ -H "Content-Type: text/plain" --data '{"jsonrpc":"2.0","id":1,"method":"ping"}'
curl -X POST http://127.0.0.1:8765/ -H "Authorization: Bearer <token>" -H "Content-Type: text/plain" --data '{"jsonrpc":"2.0","id":1,"method":"ping"}'
```

These should **succeed**:
Expand Down Expand Up @@ -252,27 +253,35 @@ With the tray running and `EnableMcpServer = true`:

```powershell
# Server is up
curl http://127.0.0.1:8765/
curl http://127.0.0.1:8765/ -H "Authorization: Bearer <token>"

# List tools
curl -s -X POST http://127.0.0.1:8765/ `
-H "Authorization: Bearer <token>" `
-H "Content-Type: application/json" `
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

# Take a screenshot of the primary monitor
curl -s -X POST http://127.0.0.1:8765/ `
-H "Authorization: Bearer <token>" `
-H "Content-Type: application/json" `
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"screen.snapshot"}}'
```

For a simpler local CLI smoke test, run `winnode --list-tools`; it loads the
same token file automatically.

For Claude Code, drop this into `.mcp.json` at the repo root or `~/.claude.json`:

```json
{
"mcpServers": {
"openclaw-tray": {
"type": "http",
"url": "http://127.0.0.1:8765/"
"url": "http://127.0.0.1:8765/",
"headers": {
"Authorization": "Bearer <token>"
}
}
}
}
Expand Down
96 changes: 64 additions & 32 deletions src/OpenClaw.WinNode.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ internal sealed class WinNodeOptions
{
public string? Node { get; set; }
public string? Command { get; set; }
public bool ListTools { get; set; }
public string Params { get; set; } = "{}";
public int InvokeTimeoutMs { get; set; } = 15000;
public string? IdempotencyKey { get; set; }
Expand Down Expand Up @@ -68,7 +69,13 @@ public static async Task<int> RunAsync(
return 2;
}

if (string.IsNullOrWhiteSpace(options.Command))
if (options.ListTools && !string.IsNullOrWhiteSpace(options.Command))
{
stderr.WriteLine("--list-tools cannot be combined with --command");
return 2;
}

if (!options.ListTools && string.IsNullOrWhiteSpace(options.Command))
{
stderr.WriteLine("--command is required");
return 2;
Expand All @@ -90,44 +97,47 @@ public static async Task<int> RunAsync(
stderr.WriteLine("[winnode] WARN: --idempotency-key ignored (no idempotency over local MCP); subsequent retries may double-execute side effects.");
}

// F-12: --params @<path> loads a JSON object from disk. Useful for big
// A2UI payloads / canvas.eval scripts that exceed comfortable command-
// line size.
var paramsJson = options.Params;
if (paramsJson.StartsWith('@'))
JsonElement arguments = default;
if (!options.ListTools)
{
var path = paramsJson[1..];
if (string.IsNullOrWhiteSpace(path))
// F-12: --params @<path> loads a JSON object from disk. Useful for big
// A2UI payloads / canvas.eval scripts that exceed comfortable command-
// line size.
var paramsJson = options.Params;
if (paramsJson.StartsWith('@'))
{
stderr.WriteLine("--params @<path>: path is empty");
return 2;
var path = paramsJson[1..];
if (string.IsNullOrWhiteSpace(path))
{
stderr.WriteLine("--params @<path>: path is empty");
return 2;
}
try
{
paramsJson = File.ReadAllText(path);
}
catch (Exception ex)
{
stderr.WriteLine($"--params: failed to read {path}: {ex.Message}");
return 2;
}
}

try
{
paramsJson = File.ReadAllText(path);
}
catch (Exception ex)
{
stderr.WriteLine($"--params: failed to read {path}: {ex.Message}");
return 2;
using var paramsDoc = JsonDocument.Parse(paramsJson);
if (paramsDoc.RootElement.ValueKind != JsonValueKind.Object)
{
stderr.WriteLine("--params must be a JSON object");
return 2;
}
arguments = paramsDoc.RootElement.Clone();
}
}

JsonElement arguments;
try
{
using var paramsDoc = JsonDocument.Parse(paramsJson);
if (paramsDoc.RootElement.ValueKind != JsonValueKind.Object)
catch (JsonException ex)
{
stderr.WriteLine("--params must be a JSON object");
stderr.WriteLine($"--params is not valid JSON: {ex.Message}");
return 2;
}
arguments = paramsDoc.RootElement.Clone();
}
catch (JsonException ex)
{
stderr.WriteLine($"--params is not valid JSON: {ex.Message}");
return 2;
}

// F-09: validate the resolved endpoint as an absolute http(s) URL up
Expand Down Expand Up @@ -180,7 +190,7 @@ public static async Task<int> RunAsync(
if (options.Verbose)
{
stderr.WriteLine($"[winnode] endpoint: {endpoint}");
stderr.WriteLine($"[winnode] command: {options.Command}");
stderr.WriteLine($"[winnode] command: {(options.ListTools ? "tools/list" : options.Command)}");
// F-07: don't echo the token-file path (PII / username leak).
// Source label is enough for debugging.
var authLabel = token.Token is null
Expand All @@ -195,7 +205,9 @@ public static async Task<int> RunAsync(
}
}

var (requestBytes, requestLength) = BuildToolsCallBody(options.Command!, arguments);
var (requestBytes, requestLength) = options.ListTools
? BuildToolsListBody()
: BuildToolsCallBody(options.Command!, arguments);

// F-18: compute the timeout in long arithmetic so very large
// (but in-range) --invoke-timeout values can't overflow into a
Expand Down Expand Up @@ -463,6 +475,20 @@ internal static (byte[] Buffer, int Length) BuildToolsCallBody(string command, J
return (ms.GetBuffer(), (int)ms.Length);
}

internal static (byte[] Buffer, int Length) BuildToolsListBody()
{
using var ms = new MemoryStream();
using (var w = new Utf8JsonWriter(ms))
{
w.WriteStartObject();
w.WriteString("jsonrpc", "2.0");
w.WriteNumber("id", 1);
w.WriteString("method", "tools/list");
w.WriteEndObject();
}
return (ms.GetBuffer(), (int)ms.Length);
}

internal static string ResolveEndpoint(WinNodeOptions options, Func<string, string?> envLookup, TextWriter stderr)
{
if (!string.IsNullOrWhiteSpace(options.McpUrlOverride))
Expand Down Expand Up @@ -716,6 +742,9 @@ internal static WinNodeOptions ParseArgs(string[] args)
case "--command":
options.Command = RequireValue(args, ref i, arg);
break;
case "--list-tools":
options.ListTools = true;
break;
case "--params":
options.Params = RequireValue(args, ref i, arg);
break;
Expand Down Expand Up @@ -784,10 +813,12 @@ internal static void PrintUsage(TextWriter stdout)
stdout.WriteLine();
stdout.WriteLine("Usage:");
stdout.WriteLine(" winnode --command <command> [--params <json>] [options]");
stdout.WriteLine(" winnode --list-tools [options]");
stdout.WriteLine();
stdout.WriteLine("Options:");
stdout.WriteLine(" --node <idOrNameOrIp> Accepted for parity with `openclaw nodes invoke`; ignored");
stdout.WriteLine(" --command <command> Command to invoke (e.g. system.which, canvas.eval) [required]");
stdout.WriteLine(" --list-tools Query the live MCP server and print its advertised tools");
stdout.WriteLine(" --params <json|@path> JSON object string for params (default: {}). Prefix with");
stdout.WriteLine(" @ to load the JSON from a file (e.g. --params @big.json)");
stdout.WriteLine(" --invoke-timeout <ms> Invoke timeout in ms (default: 15000, max: 600000)");
Expand All @@ -802,6 +833,7 @@ internal static void PrintUsage(TextWriter stdout)
stdout.WriteLine();
stdout.WriteLine("Examples:");
stdout.WriteLine(" winnode --command system.which --params '{\"bins\":[\"git\",\"node\"]}'");
stdout.WriteLine(" winnode --list-tools");
stdout.WriteLine(" winnode --command screen.snapshot");
stdout.WriteLine(" winnode --command canvas.present --params '{\"url\":\"https://example.com\"}'");
stdout.WriteLine();
Expand Down
6 changes: 5 additions & 1 deletion src/OpenClaw.WinNode.Cli/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,13 @@ argument shape, and the A2UI v0.8 JSONL grammar. It is shipped alongside

```
winnode --command <name> [--params '<json-object>'] [--invoke-timeout <ms>]
winnode --list-tools [--mcp-url <url>|--mcp-port <port>]
```

- `--command` (required) — node command (e.g. `system.which`, `canvas.a2ui.push`).
- `--list-tools` — query the live MCP server's `tools/list` method and print the
advertised tools. Useful when settings-gated capabilities differ from this
static reference.
- `--params` — single JSON **object** string, default `{}`. Must be a JSON object,
not an array or scalar. **`--params @<path>`** loads the JSON object from a
file on disk (useful for big A2UI payloads / `canvas.eval` scripts).
Expand Down Expand Up @@ -62,7 +66,7 @@ JSON (matches `openclaw nodes invoke`). stderr receives errors. Exit code:
| 1 | Tool error, JSON-RPC error, transport failure, or HTTP non-2xx |
| 2 | Argument error (missing/invalid flags, bad `--params` JSON, out-of-range port/timeout, non-http URL) |

**Off-loapback safety:** when `--mcp-url` points at a non-loopback host, the
**Off-loopback safety:** when `--mcp-url` points at a non-loopback host, the
CLI **refuses to send the auto-loaded local MCP token** (and warns on stderr).
An explicitly supplied `--mcp-token` is honored with a warning. This preserves
the loopback-only threat model the tray's MCP server relies on.
Expand Down
13 changes: 13 additions & 0 deletions tests/OpenClaw.WinNode.Cli.Tests/BuildToolsCallBodyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@ public void Produces_jsonrpc_envelope_with_tools_call_method()
Assert.Equal("git", args.GetProperty("bins")[0].GetString());
}

[Fact]
public void Produces_jsonrpc_envelope_with_tools_list_method()
{
var (buf, len) = CliRunner.BuildToolsListBody();
using var doc = JsonDocument.Parse(Encoding.UTF8.GetString(buf, 0, len));
var root = doc.RootElement;

Assert.Equal("2.0", root.GetProperty("jsonrpc").GetString());
Assert.Equal(1, root.GetProperty("id").GetInt32());
Assert.Equal("tools/list", root.GetProperty("method").GetString());
Assert.False(root.TryGetProperty("params", out _));
}

[Fact]
public void Empty_object_args_round_trip()
{
Expand Down
Loading
Loading