expose the clide command surface over MCP (T-225)

External MCP clients (Cursor, Windsurf, Copilot) can now drive clide. The
MCP server's tools/list is generated from the co-registered command+schema
registry (D-74) that already feeds the CLI and palette — the full
mcp__clide__* namespace with no hand-maintained second surface (D-86). Each
command's CommandSchema maps to a JSON-Schema inputSchema; tools/call routes
mcp__clide__<cmd> to dispatcher.dispatch and renders the IpcResponse as MCP
content (data as JSON, errors with isError).

register() gains a mcpExpose flag (default true); pane.tail opts out as a
poor request/response fit. tail/events are server-intercepted so they're
naturally absent. The two /ide stubs (getDiagnostics, executeCode) are left
as stubs — making them real (analyzer hook, Jupyter eval) is out of scope
per the ticket. Transport unchanged (SSE, D-73).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 21:37:38 +02:00
co-authored by Claude Opus 4.8
parent 0b72e65eca
commit 2ac1603b55
7 changed files with 349 additions and 4 deletions
+54
View File
@@ -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__<cmd>` 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<Object?> _callClideTool(String name, Map<String, Object?>? 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<String, Object?>() ?? 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<String> _writeDiscoveryFile() async {