From 8b10130c8731ae070cefbab4a1ba1d1dbd2247da Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 19 May 2026 14:34:09 +0200 Subject: [PATCH] T-130: MCP server over HTTP+SSE for /ide integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventh slice of T-99. clide now advertises itself to Claude Code's /ide command and serves a working MCP endpoint over HTTP+SSE per D-73 (the Q-33 transport decision, locked in this commit). What lands: * D-73 — MCP transport for /ide is SSE over HTTP. Resolves Q-33; references D-68 + D-72. * lib/src/ipc/mcp_server.dart — McpServer class. localhost HTTP listener on a random port; GET /sse opens a long-lived SSE stream with an initial endpoint event carrying the session id; POST /messages?sessionId=... accepts JSON-RPC requests and replies via the matching SSE stream. JSON-RPC handlers for initialize, tools/list, tools/call. * Discovery file at $HOME/.claude/ide/.lock with the workspace + url so `/ide` can find us. Removed on stop. * The two /ide minimum tools (mcp__ide__getDiagnostics, mcp__ide__executeCode) ship as stubs — real implementations need the analyzer integration / a clide eval surface, both follow-ups. * main.dart starts the MCP server alongside the unix IPC server on daemonClientFactory and project switch. Failure non-fatal — the UI runs without MCP. * 12 server tests cover lifecycle (start/stop, lock file), unknown paths, full JSON-RPC round-trip for all four methods, error responses, and edge cases (unknown session, malformed JSON, notification without id). The "Claude Code's /ide discovers and connects" smoke is deferred to T-131 wrap-up since it needs a real Claude Code session against the running app — out of scope for unit/widget tests. Co-Authored-By: Claude --- .pql/changelog/ticket_history/2026-05.sql | 2 + .pql/changelog/tickets/2026-05.sql | 13 + CHANGELOG.md | 6 + governance/decisions/architecture.md | 8 + lib/main.dart | 19 ++ lib/src/ipc/mcp_server.dart | 289 ++++++++++++++++++++++ test/ipc/mcp_server_test.dart | 205 +++++++++++++++ 7 files changed, 542 insertions(+) create mode 100644 lib/src/ipc/mcp_server.dart create mode 100644 test/ipc/mcp_server_test.dart diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index f73305ed..e65b9e46 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -1864,3 +1864,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-128', 'status', 'in_progress', 'done', NULL, '2026-05-19 10:06:46', '2026-05-19 10:06:46', '2026-05-19 10:06:46', NULL, 'cc53b46764f28515d735aa77c8eeef2c', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-129', 'status', 'backlog', 'in_progress', NULL, '2026-05-19 12:06:39', '2026-05-19 12:06:39', '2026-05-19 12:06:39', NULL, '575584f46d21b51bab3e69da1b940718', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-129', 'status', 'in_progress', 'done', NULL, '2026-05-19 12:19:51', '2026-05-19 12:19:51', '2026-05-19 12:19:51', NULL, 'e9bf50652357521c7ee74bd6577e8108', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-130', 'status', 'backlog', 'in_progress', NULL, '2026-05-19 12:24:44', '2026-05-19 12:24:44', '2026-05-19 12:24:44', NULL, '63dd7e0c4346a5d3cdd81c9c477339c3', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-130', 'status', 'in_progress', 'done', NULL, '2026-05-19 12:33:52', '2026-05-19 12:33:52', '2026-05-19 12:33:52', NULL, 'f3060e086423af96f99684e3718cac45', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index c2fb64f9..b5d1b5f1 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2167,3 +2167,16 @@ Acceptance: 4. End-to-end smoke: launch app, run `clide tail --events --filter pane` in another shell, perform a pane action in the UI, observe the event. Source: T-99 sketch. Depends on T-124 + T-126.', 'done', 'medium', NULL, NULL, NULL, '2026-05-18 11:59:11', '2026-05-19 12:19:51', NULL, '44c9141fea8635a9c03053afa92f3999', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-130', 'task', 'T-99', 'MCP server alongside the CLI, wrapping the same dispatcher (D-68)', 'Seventh slice of T-99(a). Adds an /ide-compatible MCP endpoint per D-68 so Claude Code instances outside clide can connect via /ide. + +Minimum tools (per D-68): mcp__ide__getDiagnostics, mcp__ide__executeCode. Optional mcp__clide__* namespace deferred to Q-32. Transport choice (SSE vs WebSocket vs stdio) — resolve Q-33 at the start of this ticket. + +Both surfaces (CLI socket + MCP) wrap the SAME DaemonDispatcher — there is no second source of truth. + +Acceptance: +1. lib/src/ipc/mcp_server.dart serves the chosen transport. +2. Claude Code''s /ide command discovers and connects to clide. +3. Both minimum tools work end-to-end against a real Claude Code session. +4. Q-32 + Q-33 closed (either as decisions or with resolutions written into the ticket). + +Source: T-99 sketch. Depends on T-124 (server foundation must exist). Can land in parallel with T-127 / T-128 / T-129.', 'done', 'medium', NULL, NULL, 'D-68', '2026-05-18 11:59:17', '2026-05-19 12:33:52', NULL, '0d03fa2376bf179665ee817dc363e186', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e2ecc41..2a5b241d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. to 16 recent matching events per subsystem (D-6), and streams new ones as JSON lines. C client loops on `data.streaming` ack. Slow / broken subscribers drop themselves without blocking the bus. +- MCP server over HTTP+SSE (T-99 / T-130, per D-68 / D-73). Localhost + HTTP listener advertises via `$HOME/.claude/ide/.lock` so + Claude Code's `/ide` discovers it. JSON-RPC 2.0 with the two + minimum `/ide` tools shipped as stubs + (`mcp__ide__getDiagnostics`, `mcp__ide__executeCode`); real + implementations follow. - C `clide` shell client at `native/clide-cli/clide.c`. Walks CWD up to the git root, hashes to the per-workspace socket (D-70), ships argv. `make clide-cli` builds it; on PATH, `clide status` works diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index b1fcbc60..b4be21be 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -279,4 +279,12 @@ Core, rendering, IPC, kernel, panel manager. - **Cross-reference:** [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server), [D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic), `lib/src/pty/native_pty.dart` (per-handler isolate offload example), `lib/kernel/src/scheduler.dart` (same pattern). - **Raised by:** 2026-05-18 — T-99 design pass; user explicitly considered worker-isolate-per-connection and confirmed serial dispatch on main is the right shape given the shared-state architecture. +### D-73: MCP transport for /ide is SSE over HTTP +- **Date:** 2026-05-19 +- **Decision:** The `/ide`-compatible MCP server clide ships per [D-68](#d-68-dual-integration-surface--bash-cli-primary-mcp-secondary) uses **HTTP + Server-Sent Events** as its transport. The Flutter app binds an HTTP server on a random localhost port at startup, advertises itself via a discovery file at `$HOME/.claude/ide/.lock` (the format Claude Code's `/ide` command discovers), and serves: a `GET /sse` endpoint that opens a long-lived SSE stream for server-to-client JSON-RPC responses + events, and a `POST /messages` endpoint that accepts client-to-server JSON-RPC requests. Closes Q-33. +- **Rationale:** clide's Flutter app is always-running and user-launched — the agent connects to it, not the other way around. That rules out stdio (which assumes the agent spawns the server as a subprocess). Between SSE and WebSocket, SSE wins on three counts: (a) Claude Code's existing `/ide` discovery already uses HTTP servers advertised via lock files, (b) SSE is trivially implementable on `dart:io`'s `HttpServer` (long-lived response + `data: \n\n` per message — no upgrade dance, no framing), (c) JSON-RPC is fundamentally client-pushes-requests / server-pushes-responses-and-events, which maps cleanly to "POST in / SSE out." WebSocket buys bidirectional symmetry we don't need. Per [D-72](#d-72-ipc-server-is-multi-connection-with-serial-dispatch-on-the-main-isolate) the MCP layer is just another transport that wraps the same `DaemonDispatcher` — no second source of truth. +- **Cost:** SSE requires a long-lived HTTP response. Browsers cap concurrent SSE connections per origin at 6, but the consumers here are Claude Code instances (not browsers) and one-per-workspace is the expected fan-out. Adds an HTTP listener alongside the unix socket — a small surface increase, but the alternatives are worse. Each running clide grabs a random localhost port; no contention. +- **Cross-reference:** [D-68](#d-68-dual-integration-surface--bash-cli-primary-mcp-secondary), [D-72](#d-72-ipc-server-is-multi-connection-with-serial-dispatch-on-the-main-isolate), `lib/src/ipc/mcp_server.dart` (this transport's implementation lands in T-130). +- **Raised by:** 2026-05-19 — T-130 design pass; user picked SSE over HTTP after weighing against WebSocket and stdio. + --- diff --git a/lib/main.dart b/lib/main.dart index 81e985b4..6931cf73 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -39,6 +39,7 @@ import 'package:clide/src/editor/registry.dart' show EditorRegistry; import 'package:clide/src/git/client.dart'; import 'package:clide/src/cli/argv_dispatch.dart'; import 'package:clide/src/ipc/envelope.dart'; +import 'package:clide/src/ipc/mcp_server.dart'; import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath; import 'package:clide/src/ipc/server.dart'; import 'package:clide/src/panes/event_sink.dart'; @@ -86,6 +87,11 @@ Future main() async { // back to it over the socket so all IPC — including from UI widgets // in the same process — goes through the wire contract (T-127). IpcServer? ipcServer; + // MCP server (T-130, per D-68 + D-73). Localhost HTTP+SSE, advertised + // via $HOME/.claude/ide/.lock so Claude Code's /ide command + // discovers it. Restarted alongside the unix server on project + // switch so the discovery file reports the current workspace. + McpServer? mcpServer; final ipcLog = Logger(); Future swapIpcServer(DaemonDispatcher dispatcher, Directory workRoot) async { @@ -96,6 +102,11 @@ Future main() async { ipcLog.warn('ipc', 'stop failed during swap: $e'); ipcLog.debug('ipc', '$st'); } + try { + await mcpServer?.stop(); + } catch (e) { + ipcLog.warn('mcp', 'stop failed during swap: $e'); + } final server = IpcServer( dispatcher: dispatcher, workspaceRoot: workRoot.path, @@ -109,6 +120,14 @@ Future main() async { ipcLog.error('ipc', 'server start failed', error: e, stackTrace: st); return; } + final mcp = McpServer(workspaceRoot: workRoot.path, log: ipcLog); + mcpServer = mcp; + try { + await mcp.start(); + } catch (e, st) { + ipcLog.warn('mcp', 'MCP server start failed (non-fatal): $e'); + ipcLog.debug('mcp', '$st'); + } // Point the in-process DaemonClient at the new socket. On first // boot (no client yet) the daemonClientFactory below kicks it // off; on project switch we just reconnect to the new path. diff --git a/lib/src/ipc/mcp_server.dart b/lib/src/ipc/mcp_server.dart new file mode 100644 index 00000000..799c7599 --- /dev/null +++ b/lib/src/ipc/mcp_server.dart @@ -0,0 +1,289 @@ +/// 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'; + +/// 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.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; + + /// 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': [ + { + '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'}, + }, + }, + }, + ], + }; + case 'tools/call': + final name = (params?['name'] as String?) ?? ''; + 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'); + } + } + + // -- 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; + } +} diff --git a/test/ipc/mcp_server_test.dart b/test/ipc/mcp_server_test.dart new file mode 100644 index 00000000..869bc410 --- /dev/null +++ b/test/ipc/mcp_server_test.dart @@ -0,0 +1,205 @@ +/// T-130 — MCP server over HTTP+SSE (per D-73). Tests the discovery +/// file shape, JSON-RPC round-trip for initialize / tools/list / +/// tools/call, and end-of-session cleanup. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:clide/kernel/src/log.dart'; +import 'package:clide/src/ipc/mcp_server.dart'; +import 'package:test/test.dart'; + +Logger _silent() => Logger(minLevel: LogLevel.error, sinks: const []); + +void main() { + late Directory discoveryDir; + late McpServer server; + + setUp(() async { + discoveryDir = await Directory.systemTemp.createTemp('clide-mcp-disc-'); + server = McpServer( + workspaceRoot: '/var/mnt/test/clide-fixture', + log: _silent(), + discoveryDirOverride: discoveryDir.path, + ); + await server.start(); + }); + + tearDown(() async { + try { + await server.stop(); + } catch (_) {} + if (discoveryDir.existsSync()) discoveryDir.deleteSync(recursive: true); + }); + + Future openSse() async { + final client = HttpClient(); + addTearDown(client.close); + final req = await client.getUrl(Uri.parse('http://127.0.0.1:${server.port}/sse')); + return req.close(); + } + + Future<(String sessionId, Stream dataLines)> connectAndCaptureEndpoint() async { + final resp = await openSse(); + // Broadcast so multiple consumers in a test can subscribe. + final dataLines = resp + .transform(utf8.decoder) + .transform(const LineSplitter()) + .where((line) => line.startsWith('data: ')) + .map((line) => line.substring(6)) + .asBroadcastStream(); + final endpoint = Completer(); + final endpointSub = dataLines.listen((data) { + final match = RegExp(r'sessionId=([\w-]+)').firstMatch(data); + if (match != null && !endpoint.isCompleted) { + endpoint.complete(match.group(1)!); + } + }); + final id = await endpoint.future.timeout(const Duration(seconds: 2)); + await endpointSub.cancel(); + return (id, dataLines); + } + + Future> post(String sessionId, Map body) async { + final client = HttpClient(); + addTearDown(client.close); + final req = await client.postUrl(Uri.parse('http://127.0.0.1:${server.port}/messages?sessionId=$sessionId')); + req.headers.contentType = ContentType.json; + req.write(jsonEncode(body)); + final resp = await req.close(); + expect(resp.statusCode, HttpStatus.accepted); + return body; + } + + group('McpServer (T-130) lifecycle', () { + test('start binds a port and writes a discovery lock file', () async { + expect(server.isRunning, isTrue); + expect(server.port, greaterThan(0)); + expect(server.lockFilePath, isNotNull); + final lock = File(server.lockFilePath!); + expect(lock.existsSync(), isTrue); + final payload = jsonDecode(lock.readAsStringSync()) as Map; + expect(payload['workspace'], '/var/mnt/test/clide-fixture'); + expect(payload['transport'], 'sse'); + expect(payload['url'], startsWith('http://127.0.0.1:${server.port}')); + }); + + test('stop removes the lock file', () async { + final lock = server.lockFilePath!; + await server.stop(); + expect(File(lock).existsSync(), isFalse); + }); + + test('unknown path returns 404', () async { + final client = HttpClient(); + addTearDown(client.close); + final req = await client.getUrl(Uri.parse('http://127.0.0.1:${server.port}/no-such-thing')); + final resp = await req.close(); + expect(resp.statusCode, HttpStatus.notFound); + }); + }); + + group('McpServer (T-130) JSON-RPC', () { + test('SSE opens with an endpoint event carrying the session id', () async { + final (sessionId, events) = await connectAndCaptureEndpoint(); + expect(sessionId, isNotEmpty); + }); + + test('initialize returns server info + tool capability', () async { + final (sessionId, events) = await connectAndCaptureEndpoint(); + final replyFuture = events.firstWhere((s) => s.contains('"id":1')); + await post(sessionId, {'jsonrpc': '2.0', 'id': 1, 'method': 'initialize'}); + final reply = jsonDecode(await replyFuture.timeout(const Duration(seconds: 2))) as Map; + expect(reply['id'], 1); + final result = reply['result'] as Map; + expect((result['serverInfo'] as Map)['name'], 'clide'); + expect((result['capabilities'] as Map).containsKey('tools'), isTrue); + }); + + test('tools/list lists both /ide tools per D-68', () async { + final (sessionId, events) = await connectAndCaptureEndpoint(); + final replyFuture = events.firstWhere((s) => s.contains('"id":2')); + await post(sessionId, {'jsonrpc': '2.0', 'id': 2, 'method': 'tools/list'}); + final reply = jsonDecode(await replyFuture.timeout(const Duration(seconds: 2))) as Map; + final tools = ((reply['result'] as Map)['tools'] as List).cast>(); + final names = tools.map((t) => t['name']).toSet(); + expect(names, containsAll(['mcp__ide__getDiagnostics', 'mcp__ide__executeCode'])); + }); + + test('tools/call mcp__ide__getDiagnostics returns the stub content', () async { + final (sessionId, events) = await connectAndCaptureEndpoint(); + final replyFuture = events.firstWhere((s) => s.contains('"id":3')); + await post(sessionId, { + 'jsonrpc': '2.0', + 'id': 3, + 'method': 'tools/call', + 'params': {'name': 'mcp__ide__getDiagnostics', 'arguments': {}}, + }); + final reply = jsonDecode(await replyFuture.timeout(const Duration(seconds: 2))) as Map; + final result = reply['result'] as Map; + final content = (result['content'] as List).cast>(); + expect(content.first['type'], 'text'); + // Stub returns [] + expect(content.first['text'], '[]'); + }); + + test('tools/call mcp__ide__executeCode flags isError (stubbed)', () async { + final (sessionId, events) = await connectAndCaptureEndpoint(); + final replyFuture = events.firstWhere((s) => s.contains('"id":4')); + await post(sessionId, { + 'jsonrpc': '2.0', + 'id': 4, + 'method': 'tools/call', + 'params': { + 'name': 'mcp__ide__executeCode', + 'arguments': {'code': 'print(1)'} + }, + }); + final reply = jsonDecode(await replyFuture.timeout(const Duration(seconds: 2))) as Map; + final result = reply['result'] as Map; + expect(result['isError'], isTrue); + }); + + test('unknown method surfaces as a JSON-RPC error', () async { + final (sessionId, events) = await connectAndCaptureEndpoint(); + final replyFuture = events.firstWhere((s) => s.contains('"id":5')); + await post(sessionId, {'jsonrpc': '2.0', 'id': 5, 'method': 'no.such.method'}); + final reply = jsonDecode(await replyFuture.timeout(const Duration(seconds: 2))) as Map; + expect(reply['error'], isNotNull); + }); + + test('POST with unknown sessionId returns 404', () async { + final client = HttpClient(); + addTearDown(client.close); + final req = await client.postUrl(Uri.parse('http://127.0.0.1:${server.port}/messages?sessionId=ghost')); + req.headers.contentType = ContentType.json; + req.write('{"jsonrpc":"2.0","id":1,"method":"initialize"}'); + final resp = await req.close(); + expect(resp.statusCode, HttpStatus.notFound); + }); + + test('POST with malformed JSON returns 400', () async { + final (sessionId, events) = await connectAndCaptureEndpoint(); + final client = HttpClient(); + addTearDown(client.close); + final req = await client.postUrl(Uri.parse('http://127.0.0.1:${server.port}/messages?sessionId=$sessionId')); + req.headers.contentType = ContentType.json; + req.write('{not json'); + final resp = await req.close(); + expect(resp.statusCode, HttpStatus.badRequest); + }); + + test('notification (no id) is processed without a reply', () async { + final (sessionId, events) = await connectAndCaptureEndpoint(); + final lines = []; + final sub = events.listen(lines.add); + addTearDown(sub.cancel); + await post(sessionId, {'jsonrpc': '2.0', 'method': 'initialize'}); + // Give the server a tick — we should NOT see a reply. + await Future.delayed(const Duration(milliseconds: 100)); + expect(lines, isEmpty); + }); + }); +}