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
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user