Files
clide/lib/src/ipc/mcp_server.dart
T
jpmschweitzerandClaude Opus 4.8 2ac1603b55 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>
2026-06-06 21:37:38 +02:00

344 lines
11 KiB
Dart

/// MCP server for `/ide`-compatible Claude Code integrations
/// (T-99 / T-130, per D-68 + D-73).
///
/// **Transport:** HTTP + Server-Sent Events (D-73). The server binds
/// an HTTP listener on a random localhost port at startup and writes
/// a discovery file at `$HOME/.claude/ide/<pid>.lock` so Claude
/// Code's `/ide` command can find us.
///
/// **Protocol:** JSON-RPC 2.0 carried over SSE.
/// - `GET /sse` opens a long-lived stream. The server pushes
/// JSON-RPC responses + notifications as `data: <json>\n\n`
/// events.
/// - `POST /messages?sessionId=<id>` accepts a JSON-RPC request
/// and returns 202; the response lands on the matching session's
/// SSE stream.
///
/// **Surface:** the two minimum tools per D-68 — `mcp__ide__getDiagnostics`
/// and `mcp__ide__executeCode`. Both stubbed today; real
/// implementations land as follow-up tickets once the analyzer
/// integration is ready and we have a clide-side eval surface.
library;
import 'dart:async';
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
/// `sessionId` query param.
class _McpSession {
_McpSession(this.id, this.response);
final String id;
final HttpResponse response;
bool closed = false;
void send(Map<String, Object?> payload) {
if (closed) return;
try {
response.write('data: ${jsonEncode(payload)}\n\n');
} catch (_) {
closed = true;
}
}
Future<void> close() async {
if (closed) return;
closed = true;
try {
await response.close();
} catch (_) {}
}
}
/// HTTP + SSE MCP server. Lifecycle mirrors [IpcServer]: `start()`
/// binds + writes the discovery file; `stop()` unbinds + removes it.
class McpServer {
McpServer({
required this.workspaceRoot,
required this.log,
this.dispatcher,
this.discoveryDirOverride,
this.bindHost = '127.0.0.1',
this.bindPort = 0,
});
/// Workspace root reported in the discovery file. Helps Claude
/// Code show "which clide is this" when multiple are running.
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;
/// Bind host. localhost-only by default per D-73 (no remote
/// access; the threat model matches D-71's `0600`).
final String bindHost;
/// Bind port. 0 ⇒ kernel picks a random free port.
final int bindPort;
HttpServer? _http;
String? _lockFile;
int? _port;
final Map<String, _McpSession> _sessions = {};
int _sessionCounter = 0;
bool get isRunning => _http != null;
int? get port => _port;
String? get lockFilePath => _lockFile;
Future<void> start() async {
if (isRunning) return;
final server = await HttpServer.bind(bindHost, bindPort);
_http = server;
_port = server.port;
_lockFile = await _writeDiscoveryFile();
server.listen(_route, onError: (Object e, StackTrace st) {
log.warn('mcp', 'http error: $e');
});
log.info('mcp', 'MCP/SSE listening at http://$bindHost:${server.port} (workspace: $workspaceRoot)');
}
Future<void> stop() async {
final s = _http;
if (s == null) return;
_http = null;
_port = null;
for (final session in List<_McpSession>.from(_sessions.values)) {
await session.close();
}
_sessions.clear();
await s.close(force: true);
final lock = _lockFile;
_lockFile = null;
if (lock != null) {
try {
final f = File(lock);
if (f.existsSync()) f.deleteSync();
} catch (e) {
log.warn('mcp', 'failed to unlink lock $lock: $e');
}
}
}
// -- routing --------------------------------------------------------------
Future<void> _route(HttpRequest req) async {
final path = req.uri.path;
if (path == '/sse' && req.method == 'GET') {
await _openSseStream(req);
return;
}
if (path == '/messages' && req.method == 'POST') {
await _receivePost(req);
return;
}
req.response.statusCode = HttpStatus.notFound;
await req.response.close();
}
Future<void> _openSseStream(HttpRequest req) async {
final sessionId = 's${_sessionCounter++}';
req.response.headers.contentType = ContentType('text', 'event-stream');
req.response.headers.set('Cache-Control', 'no-cache');
req.response.headers.set('Connection', 'keep-alive');
req.response.headers.set('X-Accel-Buffering', 'no');
req.response.bufferOutput = false;
final session = _McpSession(sessionId, req.response);
_sessions[sessionId] = session;
// Initial endpoint event tells the client where to POST.
req.response.write('event: endpoint\n');
req.response.write('data: /messages?sessionId=$sessionId\n\n');
// Keep alive until the client closes.
try {
await req.response.done;
} catch (_) {}
session.closed = true;
_sessions.remove(sessionId);
}
Future<void> _receivePost(HttpRequest req) async {
final sessionId = req.uri.queryParameters['sessionId'];
final session = sessionId == null ? null : _sessions[sessionId];
if (session == null) {
req.response.statusCode = HttpStatus.notFound;
await req.response.close();
return;
}
final body = await utf8.decodeStream(req);
Map<String, Object?>? msg;
try {
msg = jsonDecode(body) as Map<String, Object?>;
} catch (_) {
req.response.statusCode = HttpStatus.badRequest;
await req.response.close();
return;
}
// Acknowledge the POST immediately; the actual JSON-RPC reply
// travels back over the SSE channel.
req.response.statusCode = HttpStatus.accepted;
await req.response.close();
final reply = await _dispatchJsonRpc(msg);
if (reply != null) session.send(reply);
}
// -- JSON-RPC handlers ---------------------------------------------------
Future<Map<String, Object?>?> _dispatchJsonRpc(Map<String, Object?> msg) async {
final id = msg['id'];
final method = msg['method'] as String?;
if (method == null) {
// Notifications without a method are ignored.
return null;
}
try {
final result = await _handleMethod(method, msg['params'] as Map<String, Object?>?);
if (id == null) return null; // notification — no reply
return {'jsonrpc': '2.0', 'id': id, 'result': result};
} catch (e, st) {
log.warn('mcp', 'method $method threw: $e');
log.debug('mcp', '$st');
return {
'jsonrpc': '2.0',
'id': id,
'error': {'code': -32000, 'message': '$e'},
};
}
}
Future<Object?> _handleMethod(String method, Map<String, Object?>? params) async {
switch (method) {
case 'initialize':
return {
'protocolVersion': '2024-11-05',
'capabilities': {
'tools': {'listChanged': false},
},
'serverInfo': {'name': 'clide', 'version': 'dev'},
};
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).',
'inputSchema': {
'type': 'object',
'properties': {
'uri': {'type': 'string'},
},
},
},
{
'name': 'mcp__ide__executeCode',
'description': 'Execute a code cell in clide (stubbed; clide has no eval surface yet).',
'inputSchema': {
'type': 'object',
'properties': {
'code': {'type': 'string'},
},
},
},
// 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 {
'content': [
{'type': 'text', 'text': '[]'},
],
};
case 'mcp__ide__executeCode':
return {
'content': [
{'type': 'text', 'text': 'executeCode is not implemented in clide today.'},
],
'isError': true,
};
default:
throw StateError('unknown tool: $name');
}
default:
throw StateError('unknown method: $method');
}
}
/// 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 {
final dir = discoveryDirOverride ?? '${Platform.environment['HOME'] ?? '/tmp'}/.claude/ide';
final dirHandle = Directory(dir);
if (!dirHandle.existsSync()) {
dirHandle.createSync(recursive: true);
}
final path = '$dir/$pid.lock';
final body = jsonEncode({
'pid': pid,
'workspace': workspaceRoot,
'transport': 'sse',
'url': 'http://$bindHost:$_port/sse',
});
File(path).writeAsStringSync(body);
return path;
}
}