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
+136
View File
@@ -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<String>)> 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<String>();
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<void> post(String sid, Map<String, Object?> 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<String, Object?>;
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<String, Object?>;
final content = ((reply['result'] as Map)['content'] as List).cast<Map<String, Object?>>();
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<String, Object?>;
final result = reply['result'] as Map<String, Object?>;
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<String>();
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<String, Object?>;
expect((reply['result'] as Map)['isError'], isTrue);
});
}