T-130: MCP server over HTTP+SSE for /ide integration
test / unit + widget + golden + a11y (push) Failing after 27s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 27s

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/<pid>.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 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 14:34:09 +02:00
co-authored by Claude
parent eb1eb78dfe
commit 8b10130c87
7 changed files with 542 additions and 0 deletions
@@ -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;
+13
View File
@@ -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);
+6
View File
@@ -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/<pid>.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
+8
View File
@@ -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/<pid>.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: <json>\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.
---
+19
View File
@@ -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<void> 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/<pid>.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<void> swapIpcServer(DaemonDispatcher dispatcher, Directory workRoot) async {
@@ -96,6 +102,11 @@ Future<void> 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<void> 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.
+289
View File
@@ -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/<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';
/// 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.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<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': [
{
'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<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;
}
}
+205
View File
@@ -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<HttpClientResponse> 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<String> 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<String>();
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<Map<String, Object?>> post(String sessionId, Map<String, Object?> 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<String, Object?>;
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<String, Object?>;
expect(reply['id'], 1);
final result = reply['result'] as Map<String, Object?>;
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<String, Object?>;
final tools = ((reply['result'] as Map)['tools'] as List).cast<Map<String, Object?>>();
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<String, Object?>;
final result = reply['result'] as Map<String, Object?>;
final content = (result['content'] as List).cast<Map<String, Object?>>();
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<String, Object?>;
final result = reply['result'] as Map<String, Object?>;
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<String, Object?>;
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 = <String>[];
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<void>.delayed(const Duration(milliseconds: 100));
expect(lines, isEmpty);
});
});
}