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
+78 -2
View File
@@ -19,15 +19,27 @@ class DaemonDispatcher {
/// unvalidated — schema adoption is opt-in per command.
final Map<String, CommandSchema> _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<String> _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
/// `<prefix><cmd>`, 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<Map<String, Object?>> mcpTools({String prefix = 'mcp__clide__'}) {
final names = _handlers.keys.toList()..sort();
final tools = <Map<String, Object?>>[];
for (final cmd in names) {
if (_mcpHidden.contains(cmd)) continue;
final schema = _schemas[cmd];
final props = <String, Object?>{};
final required = <String>[];
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<String, Object?> _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<String, Object?> _argSpecJson(ArgSpec s) => {
'type': s.type.name,
if (s.required) 'required': true,
+3 -1
View File
@@ -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(