diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c171fbf..5dad8f37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- External MCP clients (Cursor, Windsurf, Copilot, …) can now drive clide: the + MCP server exposes the full `mcp__clide__*` tool surface, generated from the + command registry that already feeds the CLI + palette (D-86), with a + per-command opt-out. The two `/ide` tools remain stubs. (T-225) - `clide events --since [--filter X]` reads events after a cursor and returns them plus a next-cursor — the pull-based complement to the `tail --events` stream, made for agent poll loops. Reports `gap: true` when diff --git a/lib/main.dart b/lib/main.dart index f2374fd9..594553eb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -151,7 +151,7 @@ Future main() async { ipcLog.error('ipc', 'server start failed', error: e, stackTrace: st); return; } - final mcp = McpServer(workspaceRoot: workRoot.path, log: ipcLog); + final mcp = McpServer(workspaceRoot: workRoot.path, log: ipcLog, dispatcher: dispatcher); mcpServer = mcp; try { await mcp.start(); diff --git a/lib/src/daemon/dispatcher.dart b/lib/src/daemon/dispatcher.dart index def3e1d9..577706ff 100644 --- a/lib/src/daemon/dispatcher.dart +++ b/lib/src/daemon/dispatcher.dart @@ -19,15 +19,27 @@ class DaemonDispatcher { /// unvalidated — schema adoption is opt-in per command. final Map _schemas = {}; + /// Commands withheld from the generated MCP tool surface (D-86). A poor + /// MCP fit — long-lived streams or strongly UI-side-effecting verbs — + /// registers with `mcpExpose: false`. The CLI/palette surfaces are + /// unaffected; only [mcpTools] skips these. + final Set _mcpHidden = {}; + /// Register [handler] for [cmd]. Pass [schema] to have the dispatcher - /// normalise + validate `req.args` before the handler runs (D-74). - void register(String cmd, CommandHandler handler, {CommandSchema? schema}) { + /// normalise + validate `req.args` before the handler runs (D-74). Pass + /// `mcpExpose: false` to keep the command off the MCP tool surface (D-86). + void register(String cmd, CommandHandler handler, {CommandSchema? schema, bool mcpExpose = true}) { _handlers[cmd] = handler; if (schema != null) { _schemas[cmd] = schema; } else { _schemas.remove(cmd); } + if (mcpExpose) { + _mcpHidden.remove(cmd); + } else { + _mcpHidden.add(cmd); + } } /// Built-in commands registered in the constructor; survive [clear] and @@ -38,6 +50,7 @@ class DaemonDispatcher { void clear() { _handlers.removeWhere((k, _) => !_builtins.contains(k)); _schemas.removeWhere((k, _) => !_builtins.contains(k)); + _mcpHidden.removeWhere((k) => !_builtins.contains(k)); } bool get isEmpty => _handlers.length <= _builtins.length; @@ -99,6 +112,69 @@ class DaemonDispatcher { return IpcResponse.ok(id: req.id, data: {'version': clideVersion, 'commands': commands}); } + /// Build the MCP `tools/list` surface from the live command registry + /// (D-86): one tool per registered command (minus [_mcpHidden]), named + /// ``, with an `inputSchema` derived from the same D-74 + /// [CommandSchema] that drives CLI/palette argument validation — so the + /// MCP surface can't drift from what actually dispatches. Server-intercepted + /// commands (`tail`, `events`) aren't registered here, so they're naturally + /// absent. + List> mcpTools({String prefix = 'mcp__clide__'}) { + final names = _handlers.keys.toList()..sort(); + final tools = >[]; + for (final cmd in names) { + if (_mcpHidden.contains(cmd)) continue; + final schema = _schemas[cmd]; + final props = {}; + final required = []; + if (schema != null) { + for (final e in schema.args.entries) { + props[e.key] = _argJsonSchema(e.value); + if (e.value.required) required.add(e.key); + } + } + final dot = cmd.indexOf('.'); + final subsystem = dot >= 0 ? cmd.substring(0, dot) : ''; + final verb = dot >= 0 ? cmd.substring(dot + 1) : cmd; + tools.add({ + 'name': '$prefix$cmd', + 'description': subsystem.isEmpty ? verb : '$subsystem: $verb', + 'inputSchema': { + 'type': 'object', + 'properties': props, + if (required.isNotEmpty) 'required': required, + }, + }); + } + return tools; + } + + /// Map one [ArgSpec] to a JSON-Schema property (MCP `inputSchema` shape). + static Map _argJsonSchema(ArgSpec s) { + switch (s.type) { + case ArgType.string: + return { + 'type': 'string', + if (s.allowed != null) 'enum': (s.allowed!.toList()..sort()), + if (s.pattern != null) 'pattern': s.pattern!.pattern, + }; + case ArgType.number: + return { + 'type': 'number', + if (s.min != null) 'minimum': s.min, + if (s.max != null) 'maximum': s.max, + }; + case ArgType.boolean: + return {'type': 'boolean'}; + case ArgType.stringList: + return { + 'type': 'array', + 'items': const {'type': 'string'}, + if (s.maxItems != null) 'maxItems': s.maxItems, + }; + } + } + static Map _argSpecJson(ArgSpec s) => { 'type': s.type.name, if (s.required) 'required': true, diff --git a/lib/src/daemon/pane_commands.dart b/lib/src/daemon/pane_commands.dart index b6fbf2a4..d613e670 100644 --- a/lib/src/daemon/pane_commands.dart +++ b/lib/src/daemon/pane_commands.dart @@ -41,7 +41,9 @@ void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry, {ViewPaneSo schema: const CommandSchema( positional: ['id', 'cols', 'rows'], args: {'id': ArgSpec(), 'cols': ArgSpec(type: ArgType.number), 'rows': ArgSpec(type: ArgType.number)})); d.register('pane.focus', (req) => _focus(req, registry), schema: idArg); - d.register('pane.tail', (req) => _tail(req, registry)); + // pane.tail is a streaming/no-op verb (events arrive via the tail stream), + // a poor request/response MCP tool — keep it off the MCP surface (D-86). + d.register('pane.tail', (req) => _tail(req, registry), mcpExpose: false); } IpcResponse _userErr(String id, String message, {String? hint}) => IpcResponse.err( diff --git a/lib/src/ipc/mcp_server.dart b/lib/src/ipc/mcp_server.dart index 799c7599..c0199ffa 100644 --- a/lib/src/ipc/mcp_server.dart +++ b/lib/src/ipc/mcp_server.dart @@ -25,6 +25,13 @@ import 'dart:convert'; import 'dart:io'; import 'package:clide/kernel/src/log.dart'; +import 'package:clide/src/daemon/dispatcher.dart'; +import 'package:clide/src/ipc/envelope.dart'; + +/// Prefix for clide's own MCP tools — the full command surface, generated +/// from the dispatcher registry (D-86). The `mcp__ide__*` pair is the +/// separate `/ide` minimum (D-68). +const String _clideToolPrefix = 'mcp__clide__'; /// One connected SSE client. Each session has its own response /// stream; POST /messages routes back to the right one via the @@ -59,6 +66,7 @@ class McpServer { McpServer({ required this.workspaceRoot, required this.log, + this.dispatcher, this.discoveryDirOverride, this.bindHost = '127.0.0.1', this.bindPort = 0, @@ -69,6 +77,11 @@ class McpServer { final String workspaceRoot; final Logger log; + /// The command dispatcher whose registry drives the `mcp__clide__*` tool + /// surface and handles `tools/call` (D-86). Null → only the `/ide` minimum + /// tools are served (tests that don't need the full surface). + final DaemonDispatcher? dispatcher; + /// Override of `$HOME/.claude/ide/` for tests. Production code /// passes null; tests inject a tempdir. final String? discoveryDirOverride; @@ -222,6 +235,9 @@ class McpServer { case 'tools/list': return { 'tools': [ + // The `/ide` minimum (D-68). Both still stubbed — getDiagnostics + // and executeCode (Jupyter) are deferred follow-ups; T-225 wires + // the clide command surface below. { 'name': 'mcp__ide__getDiagnostics', 'description': 'Return diagnostics from the open editor (stubbed).', @@ -242,10 +258,15 @@ class McpServer { }, }, }, + // The full clide surface, generated from the command registry. + ...?dispatcher?.mcpTools(), ], }; case 'tools/call': final name = (params?['name'] as String?) ?? ''; + if (name.startsWith(_clideToolPrefix)) { + return _callClideTool(name, params); + } switch (name) { case 'mcp__ide__getDiagnostics': return { @@ -268,6 +289,39 @@ class McpServer { } } + /// Run a `mcp__clide__` tool by dispatching the underlying command + /// (D-86). The MCP `arguments` object maps straight to the request's named + /// args — the dispatcher's D-74 schema normalises/validates them. The + /// response is rendered as MCP tool content: `data` as JSON text on success, + /// the error message with `isError: true` on failure. + Future _callClideTool(String name, Map? params) async { + final d = dispatcher; + if (d == null) { + return { + 'content': [ + {'type': 'text', 'text': 'clide command surface is not available'}, + ], + 'isError': true, + }; + } + final cmd = name.substring(_clideToolPrefix.length); + final args = (params?['arguments'] as Map?)?.cast() ?? const {}; + final resp = await d.dispatch(IpcRequest(id: 'mcp', cmd: cmd, args: args)); + if (resp.ok) { + return { + 'content': [ + {'type': 'text', 'text': jsonEncode(resp.data)}, + ], + }; + } + return { + 'content': [ + {'type': 'text', 'text': resp.error?.message ?? 'command failed'}, + ], + 'isError': true, + }; + } + // -- discovery file ------------------------------------------------------- Future _writeDiscoveryFile() async { diff --git a/test/daemon/dispatcher_test.dart b/test/daemon/dispatcher_test.dart index b3ff9432..df7e891b 100644 --- a/test/daemon/dispatcher_test.dart +++ b/test/daemon/dispatcher_test.dart @@ -89,6 +89,79 @@ void main() { expect(echo.containsKey('args'), isFalse); }); + test('mcpTools generates the tool surface from the registry (T-225)', () async { + final d = DaemonDispatcher(); + d.register('echo', (req) async => IpcResponse.ok(id: req.id, data: const {})); + d.register( + 'pane.resize', + (req) async => IpcResponse.ok(id: req.id, data: const {}), + schema: const CommandSchema( + positional: ['id', 'cols'], + args: { + 'id': ArgSpec(required: true), + 'cols': ArgSpec(type: ArgType.number, min: 1), + }, + ), + ); + d.register('pane.tail', (req) async => IpcResponse.ok(id: req.id, data: const {}), mcpExpose: false); + d.register( + 'files.read', + (req) async => IpcResponse.ok(id: req.id, data: const {}), + schema: const CommandSchema( + positional: ['path'], + args: { + 'path': ArgSpec(pattern: null, allowed: {'a', 'b'}), + 'recursive': ArgSpec(type: ArgType.boolean), + 'globs': ArgSpec(type: ArgType.stringList, maxItems: 5), + }, + ), + ); + + // Non-const so a RegExp pattern can be supplied (covers the string + // `pattern` mapping). + d.register( + 'git.checkout', + (req) async => IpcResponse.ok(id: req.id, data: const {}), + schema: CommandSchema(positional: const ['ref'], args: {'ref': ArgSpec(pattern: RegExp(r'^\w+$'))}), + ); + + final tools = d.mcpTools(); + final byName = {for (final t in tools) t['name'] as String: t}; + + // Tools are prefixed; built-ins + registered commands are present. + expect(byName.keys, containsAll(['mcp__clide__ping', 'mcp__clide__echo', 'mcp__clide__pane.resize'])); + // The opt-out command is withheld. + expect(byName.containsKey('mcp__clide__pane.tail'), isFalse); + + // Schema → JSON-Schema inputSchema. + final resize = byName['mcp__clide__pane.resize']!; + expect(resize['description'], 'pane: resize'); + final input = resize['inputSchema'] as Map; + expect(input['type'], 'object'); + final props = input['properties'] as Map; + expect((props['cols'] as Map)['type'], 'number'); + expect((props['cols'] as Map)['minimum'], 1); + expect(input['required'], ['id']); + + // A schema-less command gets an empty object input with no required. + final echo = byName['mcp__clide__echo']!; + final echoInput = echo['inputSchema'] as Map; + expect(echoInput['properties'], isEmpty); + expect(echoInput.containsKey('required'), isFalse); + + // Each ArgType maps to its JSON-Schema shape. + final readProps = (byName['mcp__clide__files.read']!['inputSchema'] as Map)['properties'] as Map; + expect((readProps['path'] as Map)['type'], 'string'); + expect((readProps['path'] as Map)['enum'], ['a', 'b']); + expect((readProps['recursive'] as Map)['type'], 'boolean'); + expect((readProps['globs'] as Map)['type'], 'array'); + expect(((readProps['globs'] as Map)['items'] as Map)['type'], 'string'); + expect((readProps['globs'] as Map)['maxItems'], 5); + + final refProps = (byName['mcp__clide__git.checkout']!['inputSchema'] as Map)['properties'] as Map; + expect((refProps['ref'] as Map)['pattern'], r'^\w+$'); + }); + test('clear removes user handlers but keeps the built-ins', () async { final d = DaemonDispatcher(); d.register('extra', (req) async => IpcResponse.ok(id: req.id, data: const {})); diff --git a/test/ipc/mcp_server_test.dart b/test/ipc/mcp_server_test.dart index 869bc410..f41ece15 100644 --- a/test/ipc/mcp_server_test.dart +++ b/test/ipc/mcp_server_test.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:clide/clide.dart'; import 'package:clide/kernel/src/log.dart'; import 'package:clide/src/ipc/mcp_server.dart'; import 'package:test/test.dart'; @@ -202,4 +203,139 @@ void main() { expect(lines, isEmpty); }); }); + + group('McpServer (T-225) clide tool surface', () { + late Directory disc; + late McpServer srv; + + setUp(() async { + disc = await Directory.systemTemp.createTemp('clide-mcp-clide-'); + final dispatcher = DaemonDispatcher(); + dispatcher.register('echo', (req) async => IpcResponse.ok(id: req.id, data: {'echo': req.args['text']})); + // A poor MCP fit — withheld from the tool surface (D-86). + dispatcher.register('pane.tail', (req) async => IpcResponse.ok(id: req.id, data: const {}), mcpExpose: false); + srv = McpServer( + workspaceRoot: '/x', + log: _silent(), + discoveryDirOverride: disc.path, + dispatcher: dispatcher, + ); + await srv.start(); + }); + + tearDown(() async { + try { + await srv.stop(); + } catch (_) {} + if (disc.existsSync()) disc.deleteSync(recursive: true); + }); + + Future<(String, Stream)> connect() async { + final client = HttpClient(); + addTearDown(client.close); + final resp = await (await client.getUrl(Uri.parse('http://127.0.0.1:${srv.port}/sse'))).close(); + final dataLines = + resp.transform(utf8.decoder).transform(const LineSplitter()).where((l) => l.startsWith('data: ')).map((l) => l.substring(6)).asBroadcastStream(); + final endpoint = Completer(); + final sub = dataLines.listen((d) { + final m = RegExp(r'sessionId=([\w-]+)').firstMatch(d); + if (m != null && !endpoint.isCompleted) endpoint.complete(m.group(1)!); + }); + final id = await endpoint.future.timeout(const Duration(seconds: 2)); + await sub.cancel(); + return (id, dataLines); + } + + Future post(String sid, Map body) async { + final client = HttpClient(); + addTearDown(client.close); + final req = await client.postUrl(Uri.parse('http://127.0.0.1:${srv.port}/messages?sessionId=$sid')); + req.headers.contentType = ContentType.json; + req.write(jsonEncode(body)); + final resp = await req.close(); + expect(resp.statusCode, HttpStatus.accepted); + } + + test('tools/list adds generated mcp__clide__ tools alongside the /ide pair', () async { + final (sid, events) = await connect(); + final replyFuture = events.firstWhere((s) => s.contains('"id":10')); + await post(sid, {'jsonrpc': '2.0', 'id': 10, 'method': 'tools/list'}); + final reply = jsonDecode(await replyFuture.timeout(const Duration(seconds: 2))) as Map; + final names = ((reply['result'] as Map)['tools'] as List).map((t) => (t as Map)['name']).toSet(); + expect(names, containsAll(['mcp__ide__getDiagnostics', 'mcp__clide__echo', 'mcp__clide__ping'])); + // The opt-out command is withheld. + expect(names.contains('mcp__clide__pane.tail'), isFalse); + }); + + test('tools/call routes mcp__clide__ tools to the dispatcher', () async { + final (sid, events) = await connect(); + final replyFuture = events.firstWhere((s) => s.contains('"id":11')); + await post(sid, { + 'jsonrpc': '2.0', + 'id': 11, + 'method': 'tools/call', + 'params': { + 'name': 'mcp__clide__echo', + 'arguments': {'text': 'hi'}, + }, + }); + final reply = jsonDecode(await replyFuture.timeout(const Duration(seconds: 2))) as Map; + final content = ((reply['result'] as Map)['content'] as List).cast>(); + expect(jsonDecode(content.first['text'] as String), {'echo': 'hi'}); + }); + + test('a failing clide tool surfaces isError with the error message', () async { + // An unknown command → dispatcher returns a not-found error. + final (sid, events) = await connect(); + final replyFuture = events.firstWhere((s) => s.contains('"id":12')); + await post(sid, { + 'jsonrpc': '2.0', + 'id': 12, + 'method': 'tools/call', + 'params': {'name': 'mcp__clide__nope.verb', 'arguments': const {}}, + }); + final reply = jsonDecode(await replyFuture.timeout(const Duration(seconds: 2))) as Map; + final result = reply['result'] as Map; + expect(result['isError'], isTrue); + expect((result['content'] as List).first['text'], contains('unknown command')); + }); + }); + + test('calling a clide tool with no dispatcher wired surfaces isError', () async { + final disc = await Directory.systemTemp.createTemp('clide-mcp-nodisp-'); + final srv = McpServer(workspaceRoot: '/x', log: _silent(), discoveryDirOverride: disc.path); + await srv.start(); + addTearDown(() async { + await srv.stop(); + if (disc.existsSync()) disc.deleteSync(recursive: true); + }); + final client = HttpClient(); + addTearDown(client.close); + final resp = await (await client.getUrl(Uri.parse('http://127.0.0.1:${srv.port}/sse'))).close(); + final data = resp + .transform(utf8.decoder) + .transform(const LineSplitter()) + .where((l) => l.startsWith('data: ')) + .map((l) => l.substring(6)) + .asBroadcastStream(); + final ep = Completer(); + final sub = data.listen((d) { + final m = RegExp(r'sessionId=([\w-]+)').firstMatch(d); + if (m != null && !ep.isCompleted) ep.complete(m.group(1)!); + }); + final sid = await ep.future.timeout(const Duration(seconds: 2)); + await sub.cancel(); + final replyFuture = data.firstWhere((s) => s.contains('"id":13')); + final post = await client.postUrl(Uri.parse('http://127.0.0.1:${srv.port}/messages?sessionId=$sid')); + post.headers.contentType = ContentType.json; + post.write(jsonEncode({ + 'jsonrpc': '2.0', + 'id': 13, + 'method': 'tools/call', + 'params': {'name': 'mcp__clide__echo', 'arguments': const {}}, + })); + await post.close(); + final reply = jsonDecode(await replyFuture.timeout(const Duration(seconds: 2))) as Map; + expect((reply['result'] as Map)['isError'], isTrue); + }); }