Files
clide/test/ipc/mcp_server_test.dart
T
jpmschweitzerandClaude 8b10130c87
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
T-130: MCP server over HTTP+SSE for /ide integration
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>
2026-05-19 14:34:09 +02:00

206 lines
8.3 KiB
Dart

/// 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);
});
});
}