/// 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/.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: \n\n` /// events. /// - `POST /messages?sessionId=` 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 payload) { if (closed) return; try { response.write('data: ${jsonEncode(payload)}\n\n'); } catch (_) { closed = true; } } Future 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 _sessions = {}; int _sessionCounter = 0; bool get isRunning => _http != null; int? get port => _port; String? get lockFilePath => _lockFile; Future 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 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 _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 _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 _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? msg; try { msg = jsonDecode(body) as Map; } 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?> _dispatchJsonRpc(Map 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?); 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 _handleMethod(String method, Map? 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__` 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 { 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; } }