diff --git a/docs/MCP_MODE.md b/docs/MCP_MODE.md index 3491a0cb..22a2ca29 100644 --- a/docs/MCP_MODE.md +++ b/docs/MCP_MODE.md @@ -226,7 +226,7 @@ curl -X POST http://127.0.0.1:8765/ -H "Authorization: Bearer " -H "Conte These are reasonable next steps but explicitly out of scope for the initial implementation: -1. **Per-tool input schemas.** Add an `IReadOnlyDictionary InputSchemas` (or per-command descriptor) to `INodeCapability`. The MCP bridge's `HandleToolsList` picks them up automatically. Until then, MCP clients see permissive schemas and the agent has to figure out arg shapes from descriptions and trial-and-error. +1. ~~**Per-tool input schemas.**~~ Implemented. `McpToolBridge.CommandSchemas` carries a full JSON Schema for every known command; `McpToolBridge.KnownSchemas` exposes it for tests and documentation. Unknown commands fall back to a permissive schema. 2. ~~**Authentication.**~~ Implemented. See [Authentication](#authentication) below. 3. **Streamable HTTP / SSE.** For long-running tools (`screen.record`, future `audio.transcribe`), MCP supports streaming progress. The bridge needs to learn about it and the HTTP server needs to optionally upgrade. 4. **Resource and prompt support.** MCP has `resources/*` and `prompts/*` methods we currently no-op. Notifications, recent activity, channel state could be modeled as MCP resources. diff --git a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs index 4dba2ca4..cdd1c012 100644 --- a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs +++ b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs @@ -155,12 +155,14 @@ private object HandleToolsList() description = CommandDescriptions.TryGetValue(cmd, out var desc) ? desc : $"{cap.Category} capability: {cmd}", - inputSchema = new - { - type = "object", - additionalProperties = true, - properties = new { }, - }, + inputSchema = CommandSchemas.TryGetValue(cmd, out var schema) + ? (object)schema + : new + { + type = "object", + additionalProperties = true, + properties = new { }, + }, }); } } @@ -174,6 +176,13 @@ private object HandleToolsList() /// public static IReadOnlyCollection KnownCommands => CommandDescriptions.Keys; + /// + /// Per-command JSON Schema objects advertised via tools/list. + /// Exposed so tests and documentation can verify schema coverage. + /// Commands not present in this dictionary receive a permissive schema. + /// + public static IReadOnlyDictionary KnownSchemas => CommandSchemas; + /// /// Per-command descriptions advertised via tools/list. Sourced from /// the OpenClaw docs (docs/nodes/index.md, docs/platforms/mac/canvas.md) and @@ -239,8 +248,301 @@ private object HandleToolsList() // tts.* ["tts.speak"] = "Speak text aloud on the Windows node. Args: text (string, required), provider ('windows'|'elevenlabs', optional), voiceId (string, optional), model (string, optional), interrupt (bool, default false). Returns { spoken, provider, contentType, durationMs }.", + + // location.* + ["location.get"] = + "Get the current geographic location of the Windows node. Args: accuracy ('default'|'high'|'low', optional), maxAge (int ms, optional, default 30000), locationTimeout (int ms, optional, default 10000). Returns { latitude, longitude, altitude, accuracy, heading, speed, timestamp }.", + + // device.* + ["device.info"] = + "Return static device information. No args. Returns { deviceName, modelIdentifier, systemName, systemVersion, appVersion, appBuild, locale }.", + ["device.status"] = + "Return live system health sections. Args: sections (string[], optional — any of 'os', 'cpu', 'memory', 'disk', 'battery'; omit for all). Returns an object keyed by section name.", + + // browser.* + ["browser.proxy"] = + "Proxy an HTTP request to the OpenClaw gateway browser control endpoint. Args: path (string, required), method ('GET'|'POST'|'DELETE', default 'GET'), body (string, for POST), timeoutMs (int, default 10000). Returns { status, body }.", }; + /// + /// Per-command JSON Schema objects advertised via tools/list inputSchema field. + /// Every key in has an entry here so MCP clients + /// (Cursor, Claude Desktop, etc.) receive accurate parameter types and required-field lists. + /// Commands absent from this dict fall back to a permissive schema (additionalProperties:true). + /// + private static readonly IReadOnlyDictionary CommandSchemas = BuildCommandSchemas(); + + private static IReadOnlyDictionary BuildCommandSchemas() + { + var d = new Dictionary(StringComparer.Ordinal); + + static void Add(Dictionary dict, string cmd, string schemaJson) + { + using var doc = JsonDocument.Parse(schemaJson); + dict[cmd] = doc.RootElement.Clone(); + } + + // ── system.* ────────────────────────────────────────────────────────────── + Add(d, "system.notify", """ + { + "type": "object", + "properties": { + "title": { "type": "string" }, + "body": { "type": "string" }, + "subtitle": { "type": "string" }, + "sound": { "type": "boolean" } + } + } + """); + + const string RunSchema = """ + { + "type": "object", + "properties": { + "command": { "oneOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }] }, + "args": { "type": "array", "items": { "type": "string" } }, + "shell": { "type": "string" }, + "cwd": { "type": "string" }, + "timeoutMs": { "type": "integer", "minimum": 1 }, + "env": { "type": "object", "additionalProperties": { "type": "string" } } + }, + "required": ["command"] + } + """; + Add(d, "system.run", RunSchema); + Add(d, "system.run.prepare", RunSchema); + + Add(d, "system.which", """ + { + "type": "object", + "properties": { + "bins": { "type": "array", "items": { "type": "string" }, "minItems": 1 } + }, + "required": ["bins"] + } + """); + + Add(d, "system.execApprovals.get", """{ "type": "object", "properties": {} }"""); + + Add(d, "system.execApprovals.set", """ + { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "pattern": { "type": "string" }, + "action": { "type": "string", "enum": ["allow", "deny", "prompt"] }, + "shells": { "type": "array", "items": { "type": "string" } }, + "description": { "type": "string" }, + "enabled": { "type": "boolean" } + }, + "required": ["pattern", "action"] + } + }, + "defaultAction": { "type": "string", "enum": ["allow", "deny", "prompt"] } + }, + "required": ["rules"] + } + """); + + // ── canvas.* ────────────────────────────────────────────────────────────── + Add(d, "canvas.present", """ + { + "type": "object", + "properties": { + "url": { "type": "string" }, + "html": { "type": "string" }, + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 }, + "x": { "type": "integer" }, + "y": { "type": "integer" }, + "title": { "type": "string" }, + "alwaysOnTop": { "type": "boolean" } + } + } + """); + + Add(d, "canvas.hide", """{ "type": "object", "properties": {} }"""); + + Add(d, "canvas.navigate", """ + { + "type": "object", + "properties": { + "url": { "type": "string" } + }, + "required": ["url"] + } + """); + + Add(d, "canvas.eval", """ + { + "type": "object", + "properties": { + "script": { "type": "string" }, + "javaScript": { "type": "string" }, + "javascript": { "type": "string" } + } + } + """); + + const string SnapshotSchema = """ + { + "type": "object", + "properties": { + "format": { "type": "string", "enum": ["png", "jpeg"] }, + "maxWidth": { "type": "integer", "minimum": 1 }, + "quality": { "type": "integer", "minimum": 1, "maximum": 100 } + } + } + """; + Add(d, "canvas.snapshot", SnapshotSchema); + + Add(d, "canvas.a2ui.push", """ + { + "type": "object", + "properties": { + "jsonl": { "type": "string" }, + "jsonlPath": { "type": "string" }, + "props": { "type": "object", "additionalProperties": true } + } + } + """); + + Add(d, "canvas.a2ui.reset", """{ "type": "object", "properties": {} }"""); + Add(d, "canvas.a2ui.dump", """{ "type": "object", "properties": {} }"""); + Add(d, "canvas.caps", """{ "type": "object", "properties": {} }"""); + + Add(d, "canvas.a2ui.pushJSONL", """ + { + "type": "object", + "properties": { + "jsonlPath": { "type": "string" }, + "props": { "type": "object", "additionalProperties": true } + }, + "required": ["jsonlPath"] + } + """); + + // ── screen.* ────────────────────────────────────────────────────────────── + Add(d, "screen.snapshot", """ + { + "type": "object", + "properties": { + "format": { "type": "string", "enum": ["png", "jpeg"] }, + "maxWidth": { "type": "integer", "minimum": 1 }, + "quality": { "type": "integer", "minimum": 1, "maximum": 100 }, + "monitor": { "type": "integer", "minimum": 0 }, + "screenIndex": { "type": "integer", "minimum": 0 }, + "includePointer": { "type": "boolean" } + } + } + """); + + Add(d, "screen.record", """ + { + "type": "object", + "properties": { + "durationMs": { "type": "integer", "minimum": 1, "maximum": 300000 }, + "format": { "type": "string", "enum": ["mp4", "webm"] }, + "monitor": { "type": "integer", "minimum": 0 }, + "screenIndex": { "type": "integer", "minimum": 0 }, + "maxWidth": { "type": "integer", "minimum": 1 }, + "fps": { "type": "integer", "minimum": 1, "maximum": 60 } + }, + "required": ["durationMs"] + } + """); + + // ── camera.* ────────────────────────────────────────────────────────────── + Add(d, "camera.list", """{ "type": "object", "properties": {} }"""); + + Add(d, "camera.snap", """ + { + "type": "object", + "properties": { + "deviceId": { "type": "string" }, + "format": { "type": "string", "enum": ["jpeg", "png"] }, + "maxWidth": { "type": "integer", "minimum": 1 }, + "quality": { "type": "integer", "minimum": 1, "maximum": 100 } + } + } + """); + + Add(d, "camera.clip", """ + { + "type": "object", + "properties": { + "deviceId": { "type": "string" }, + "durationMs": { "type": "integer", "minimum": 1, "maximum": 60000 }, + "format": { "type": "string", "enum": ["mp4", "webm"] }, + "maxWidth": { "type": "integer", "minimum": 1 } + }, + "required": ["durationMs"] + } + """); + + // ── tts.* ────────────────────────────────────────────────────────────── + Add(d, "tts.speak", """ + { + "type": "object", + "properties": { + "text": { "type": "string" }, + "provider": { "type": "string", "enum": ["windows", "elevenlabs"] }, + "voiceId": { "type": "string" }, + "model": { "type": "string" }, + "interrupt": { "type": "boolean" } + }, + "required": ["text"] + } + """); + + // ── location.* ──────────────────────────────────────────────────────── + Add(d, "location.get", """ + { + "type": "object", + "properties": { + "accuracy": { "type": "string", "enum": ["default", "high", "low"] }, + "maxAge": { "type": "integer", "minimum": 0 }, + "locationTimeout": { "type": "integer", "minimum": 1 } + } + } + """); + + // ── device.* ────────────────────────────────────────────────────────── + Add(d, "device.info", """{ "type": "object", "properties": {} }"""); + + Add(d, "device.status", """ + { + "type": "object", + "properties": { + "sections": { + "type": "array", + "items": { "type": "string", "enum": ["os", "cpu", "memory", "disk", "battery"] } + } + } + } + """); + + // ── browser.* ───────────────────────────────────────────────────────── + Add(d, "browser.proxy", """ + { + "type": "object", + "properties": { + "path": { "type": "string" }, + "method": { "type": "string", "enum": ["GET", "POST", "DELETE"] }, + "body": { "type": "string" }, + "timeoutMs": { "type": "integer", "minimum": 1 } + }, + "required": ["path"] + } + """); + + return d; + } + private async Task HandleToolsCallAsync(JsonElement parameters, CancellationToken cancellationToken) { if (parameters.ValueKind != JsonValueKind.Object) diff --git a/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs b/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs index 043b8212..a10a6e0b 100644 --- a/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs +++ b/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -381,4 +382,123 @@ public async Task ToolsCall_NullArguments_IsAccepted() Assert.True(doc.RootElement.TryGetProperty("result", out var result)); Assert.False(result.GetProperty("isError").GetBoolean()); } + + // ───────────────────────────────────────────────────────────────────────── + // Input schema coverage + // ───────────────────────────────────────────────────────────────────────── + + /// Returns the inputSchema element for a named tool from tools/list. + private static async Task GetToolSchema(McpToolBridge bridge, string toolName) + { + var resp = await bridge.HandleRequestAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list""}"); + using var doc = JsonDocument.Parse(resp!); + foreach (var t in doc.RootElement.GetProperty("result").GetProperty("tools").EnumerateArray()) + { + if (t.GetProperty("name").GetString() == toolName) + return t.GetProperty("inputSchema").Clone(); + } + throw new InvalidOperationException($"Tool '{toolName}' not found in tools/list"); + } + + [Theory] + [InlineData("system.run", "command")] + [InlineData("system.run.prepare", "command")] + [InlineData("system.which", "bins")] + [InlineData("system.execApprovals.set", "rules")] + [InlineData("screen.record", "durationMs")] + [InlineData("camera.clip", "durationMs")] + [InlineData("tts.speak", "text")] + [InlineData("canvas.navigate", "url")] + [InlineData("canvas.a2ui.pushJSONL", "jsonlPath")] + public async Task ToolsList_KnownCommands_SchemaIncludesRequiredField(string cmd, string requiredField) + { + var caps = new List { new FakeCapability("any", cmd) }; + var schema = await GetToolSchema(CreateBridge(caps), cmd); + + Assert.True(schema.TryGetProperty("required", out var req), + $"{cmd} should have a 'required' array"); + var required = new List(); + foreach (var el in req.EnumerateArray()) + required.Add(el.GetString()!); + Assert.Contains(requiredField, required); + } + + [Fact] + public async Task ToolsList_UnknownCommand_GetsPermissiveSchema() + { + var caps = new List { new FakeCapability("custom", "custom.mystery") }; + var schema = await GetToolSchema(CreateBridge(caps), "custom.mystery"); + + Assert.Equal("object", schema.GetProperty("type").GetString()); + Assert.True(schema.GetProperty("additionalProperties").GetBoolean()); + } + + [Fact] + public async Task ToolsList_KnownCommand_DoesNotHaveAdditionalPropertiesTrue() + { + // Known commands should have curated schemas without the broad permissive flag. + var caps = new List + { + new FakeCapability("system", "system.run"), + new FakeCapability("tts", "tts.speak"), + new FakeCapability("screen", "screen.record"), + }; + var bridge = CreateBridge(caps); + foreach (var cmd in new[] { "system.run", "tts.speak", "screen.record" }) + { + var schema = await GetToolSchema(bridge, cmd); + Assert.False( + schema.TryGetProperty("additionalProperties", out var ap) && ap.ValueKind == JsonValueKind.True, + $"{cmd} should not have additionalProperties:true"); + } + } + + [Fact] + public void KnownSchemas_ContainsAllKnownCommands() + { + // Every command in KnownCommands must have a curated schema. + var missing = McpToolBridge.KnownCommands + .Except(McpToolBridge.KnownSchemas.Keys) + .ToList(); + Assert.Empty(missing); + } + + [Fact] + public void KnownSchemas_SystemRun_HasPropertiesForAllDocumentedArgs() + { + var schema = McpToolBridge.KnownSchemas["system.run"]; + var props = schema.GetProperty("properties"); + Assert.True(props.TryGetProperty("command", out _)); + Assert.True(props.TryGetProperty("shell", out _)); + Assert.True(props.TryGetProperty("cwd", out _)); + Assert.True(props.TryGetProperty("timeoutMs", out _)); + Assert.True(props.TryGetProperty("env", out _)); + } + + [Fact] + public void KnownSchemas_TtsSpeak_ProviderEnumMatchesDocumentation() + { + var schema = McpToolBridge.KnownSchemas["tts.speak"]; + var providerEnum = schema.GetProperty("properties").GetProperty("provider").GetProperty("enum"); + var values = new List(); + foreach (var v in providerEnum.EnumerateArray()) + values.Add(v.GetString()!); + Assert.Contains("windows", values); + Assert.Contains("elevenlabs", values); + } + + [Fact] + public void KnownSchemas_ExecApprovalsSet_RuleItemsHaveRequiredPatternAndAction() + { + var schema = McpToolBridge.KnownSchemas["system.execApprovals.set"]; + var ruleItems = schema + .GetProperty("properties") + .GetProperty("rules") + .GetProperty("items"); + var required = new List(); + foreach (var el in ruleItems.GetProperty("required").EnumerateArray()) + required.Add(el.GetString()!); + Assert.Contains("pattern", required); + Assert.Contains("action", required); + } }