expose the clide command surface over MCP (T-225)
External MCP clients (Cursor, Windsurf, Copilot) can now drive clide. The MCP server's tools/list is generated from the co-registered command+schema registry (D-74) that already feeds the CLI and palette — the full mcp__clide__* namespace with no hand-maintained second surface (D-86). Each command's CommandSchema maps to a JSON-Schema inputSchema; tools/call routes mcp__clide__<cmd> to dispatcher.dispatch and renders the IpcResponse as MCP content (data as JSON, errors with isError). register() gains a mcpExpose flag (default true); pane.tail opts out as a poor request/response fit. tail/events are server-intercepted so they're naturally absent. The two /ide stubs (getDiagnostics, executeCode) are left as stubs — making them real (analyzer hook, Jupyter eval) is out of scope per the ticket. Transport unchanged (SSE, D-73). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- External MCP clients (Cursor, Windsurf, Copilot, …) can now drive clide: the
|
||||
MCP server exposes the full `mcp__clide__*` tool surface, generated from the
|
||||
command registry that already feeds the CLI + palette (D-86), with a
|
||||
per-command opt-out. The two `/ide` tools remain stubs. (T-225)
|
||||
- `clide events --since <cursor> [--filter X]` reads events after a cursor and
|
||||
returns them plus a next-cursor — the pull-based complement to the
|
||||
`tail --events` stream, made for agent poll loops. Reports `gap: true` when
|
||||
|
||||
+1
-1
@@ -151,7 +151,7 @@ Future<void> main() async {
|
||||
ipcLog.error('ipc', 'server start failed', error: e, stackTrace: st);
|
||||
return;
|
||||
}
|
||||
final mcp = McpServer(workspaceRoot: workRoot.path, log: ipcLog);
|
||||
final mcp = McpServer(workspaceRoot: workRoot.path, log: ipcLog, dispatcher: dispatcher);
|
||||
mcpServer = mcp;
|
||||
try {
|
||||
await mcp.start();
|
||||
|
||||
@@ -19,15 +19,27 @@ class DaemonDispatcher {
|
||||
/// unvalidated — schema adoption is opt-in per command.
|
||||
final Map<String, CommandSchema> _schemas = {};
|
||||
|
||||
/// Commands withheld from the generated MCP tool surface (D-86). A poor
|
||||
/// MCP fit — long-lived streams or strongly UI-side-effecting verbs —
|
||||
/// registers with `mcpExpose: false`. The CLI/palette surfaces are
|
||||
/// unaffected; only [mcpTools] skips these.
|
||||
final Set<String> _mcpHidden = {};
|
||||
|
||||
/// Register [handler] for [cmd]. Pass [schema] to have the dispatcher
|
||||
/// normalise + validate `req.args` before the handler runs (D-74).
|
||||
void register(String cmd, CommandHandler handler, {CommandSchema? schema}) {
|
||||
/// normalise + validate `req.args` before the handler runs (D-74). Pass
|
||||
/// `mcpExpose: false` to keep the command off the MCP tool surface (D-86).
|
||||
void register(String cmd, CommandHandler handler, {CommandSchema? schema, bool mcpExpose = true}) {
|
||||
_handlers[cmd] = handler;
|
||||
if (schema != null) {
|
||||
_schemas[cmd] = schema;
|
||||
} else {
|
||||
_schemas.remove(cmd);
|
||||
}
|
||||
if (mcpExpose) {
|
||||
_mcpHidden.remove(cmd);
|
||||
} else {
|
||||
_mcpHidden.add(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in commands registered in the constructor; survive [clear] and
|
||||
@@ -38,6 +50,7 @@ class DaemonDispatcher {
|
||||
void clear() {
|
||||
_handlers.removeWhere((k, _) => !_builtins.contains(k));
|
||||
_schemas.removeWhere((k, _) => !_builtins.contains(k));
|
||||
_mcpHidden.removeWhere((k) => !_builtins.contains(k));
|
||||
}
|
||||
|
||||
bool get isEmpty => _handlers.length <= _builtins.length;
|
||||
@@ -99,6 +112,69 @@ class DaemonDispatcher {
|
||||
return IpcResponse.ok(id: req.id, data: {'version': clideVersion, 'commands': commands});
|
||||
}
|
||||
|
||||
/// Build the MCP `tools/list` surface from the live command registry
|
||||
/// (D-86): one tool per registered command (minus [_mcpHidden]), named
|
||||
/// `<prefix><cmd>`, with an `inputSchema` derived from the same D-74
|
||||
/// [CommandSchema] that drives CLI/palette argument validation — so the
|
||||
/// MCP surface can't drift from what actually dispatches. Server-intercepted
|
||||
/// commands (`tail`, `events`) aren't registered here, so they're naturally
|
||||
/// absent.
|
||||
List<Map<String, Object?>> mcpTools({String prefix = 'mcp__clide__'}) {
|
||||
final names = _handlers.keys.toList()..sort();
|
||||
final tools = <Map<String, Object?>>[];
|
||||
for (final cmd in names) {
|
||||
if (_mcpHidden.contains(cmd)) continue;
|
||||
final schema = _schemas[cmd];
|
||||
final props = <String, Object?>{};
|
||||
final required = <String>[];
|
||||
if (schema != null) {
|
||||
for (final e in schema.args.entries) {
|
||||
props[e.key] = _argJsonSchema(e.value);
|
||||
if (e.value.required) required.add(e.key);
|
||||
}
|
||||
}
|
||||
final dot = cmd.indexOf('.');
|
||||
final subsystem = dot >= 0 ? cmd.substring(0, dot) : '';
|
||||
final verb = dot >= 0 ? cmd.substring(dot + 1) : cmd;
|
||||
tools.add({
|
||||
'name': '$prefix$cmd',
|
||||
'description': subsystem.isEmpty ? verb : '$subsystem: $verb',
|
||||
'inputSchema': {
|
||||
'type': 'object',
|
||||
'properties': props,
|
||||
if (required.isNotEmpty) 'required': required,
|
||||
},
|
||||
});
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
/// Map one [ArgSpec] to a JSON-Schema property (MCP `inputSchema` shape).
|
||||
static Map<String, Object?> _argJsonSchema(ArgSpec s) {
|
||||
switch (s.type) {
|
||||
case ArgType.string:
|
||||
return {
|
||||
'type': 'string',
|
||||
if (s.allowed != null) 'enum': (s.allowed!.toList()..sort()),
|
||||
if (s.pattern != null) 'pattern': s.pattern!.pattern,
|
||||
};
|
||||
case ArgType.number:
|
||||
return {
|
||||
'type': 'number',
|
||||
if (s.min != null) 'minimum': s.min,
|
||||
if (s.max != null) 'maximum': s.max,
|
||||
};
|
||||
case ArgType.boolean:
|
||||
return {'type': 'boolean'};
|
||||
case ArgType.stringList:
|
||||
return {
|
||||
'type': 'array',
|
||||
'items': const {'type': 'string'},
|
||||
if (s.maxItems != null) 'maxItems': s.maxItems,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, Object?> _argSpecJson(ArgSpec s) => {
|
||||
'type': s.type.name,
|
||||
if (s.required) 'required': true,
|
||||
|
||||
@@ -41,7 +41,9 @@ void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry, {ViewPaneSo
|
||||
schema: const CommandSchema(
|
||||
positional: ['id', 'cols', 'rows'], args: {'id': ArgSpec(), 'cols': ArgSpec(type: ArgType.number), 'rows': ArgSpec(type: ArgType.number)}));
|
||||
d.register('pane.focus', (req) => _focus(req, registry), schema: idArg);
|
||||
d.register('pane.tail', (req) => _tail(req, registry));
|
||||
// pane.tail is a streaming/no-op verb (events arrive via the tail stream),
|
||||
// a poor request/response MCP tool — keep it off the MCP surface (D-86).
|
||||
d.register('pane.tail', (req) => _tail(req, registry), mcpExpose: false);
|
||||
}
|
||||
|
||||
IpcResponse _userErr(String id, String message, {String? hint}) => IpcResponse.err(
|
||||
|
||||
@@ -25,6 +25,13 @@ 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
|
||||
@@ -59,6 +66,7 @@ class McpServer {
|
||||
McpServer({
|
||||
required this.workspaceRoot,
|
||||
required this.log,
|
||||
this.dispatcher,
|
||||
this.discoveryDirOverride,
|
||||
this.bindHost = '127.0.0.1',
|
||||
this.bindPort = 0,
|
||||
@@ -69,6 +77,11 @@ class McpServer {
|
||||
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;
|
||||
@@ -222,6 +235,9 @@ class McpServer {
|
||||
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).',
|
||||
@@ -242,10 +258,15 @@ class McpServer {
|
||||
},
|
||||
},
|
||||
},
|
||||
// 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 {
|
||||
@@ -268,6 +289,39 @@ class McpServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a `mcp__clide__<cmd>` 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<Object?> _callClideTool(String name, Map<String, Object?>? 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<String, Object?>() ?? 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<String> _writeDiscoveryFile() async {
|
||||
|
||||
@@ -89,6 +89,79 @@ void main() {
|
||||
expect(echo.containsKey('args'), isFalse);
|
||||
});
|
||||
|
||||
test('mcpTools generates the tool surface from the registry (T-225)', () async {
|
||||
final d = DaemonDispatcher();
|
||||
d.register('echo', (req) async => IpcResponse.ok(id: req.id, data: const {}));
|
||||
d.register(
|
||||
'pane.resize',
|
||||
(req) async => IpcResponse.ok(id: req.id, data: const {}),
|
||||
schema: const CommandSchema(
|
||||
positional: ['id', 'cols'],
|
||||
args: {
|
||||
'id': ArgSpec(required: true),
|
||||
'cols': ArgSpec(type: ArgType.number, min: 1),
|
||||
},
|
||||
),
|
||||
);
|
||||
d.register('pane.tail', (req) async => IpcResponse.ok(id: req.id, data: const {}), mcpExpose: false);
|
||||
d.register(
|
||||
'files.read',
|
||||
(req) async => IpcResponse.ok(id: req.id, data: const {}),
|
||||
schema: const CommandSchema(
|
||||
positional: ['path'],
|
||||
args: {
|
||||
'path': ArgSpec(pattern: null, allowed: {'a', 'b'}),
|
||||
'recursive': ArgSpec(type: ArgType.boolean),
|
||||
'globs': ArgSpec(type: ArgType.stringList, maxItems: 5),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Non-const so a RegExp pattern can be supplied (covers the string
|
||||
// `pattern` mapping).
|
||||
d.register(
|
||||
'git.checkout',
|
||||
(req) async => IpcResponse.ok(id: req.id, data: const {}),
|
||||
schema: CommandSchema(positional: const ['ref'], args: {'ref': ArgSpec(pattern: RegExp(r'^\w+$'))}),
|
||||
);
|
||||
|
||||
final tools = d.mcpTools();
|
||||
final byName = {for (final t in tools) t['name'] as String: t};
|
||||
|
||||
// Tools are prefixed; built-ins + registered commands are present.
|
||||
expect(byName.keys, containsAll(['mcp__clide__ping', 'mcp__clide__echo', 'mcp__clide__pane.resize']));
|
||||
// The opt-out command is withheld.
|
||||
expect(byName.containsKey('mcp__clide__pane.tail'), isFalse);
|
||||
|
||||
// Schema → JSON-Schema inputSchema.
|
||||
final resize = byName['mcp__clide__pane.resize']!;
|
||||
expect(resize['description'], 'pane: resize');
|
||||
final input = resize['inputSchema'] as Map<String, Object?>;
|
||||
expect(input['type'], 'object');
|
||||
final props = input['properties'] as Map<String, Object?>;
|
||||
expect((props['cols'] as Map)['type'], 'number');
|
||||
expect((props['cols'] as Map)['minimum'], 1);
|
||||
expect(input['required'], ['id']);
|
||||
|
||||
// A schema-less command gets an empty object input with no required.
|
||||
final echo = byName['mcp__clide__echo']!;
|
||||
final echoInput = echo['inputSchema'] as Map<String, Object?>;
|
||||
expect(echoInput['properties'], isEmpty);
|
||||
expect(echoInput.containsKey('required'), isFalse);
|
||||
|
||||
// Each ArgType maps to its JSON-Schema shape.
|
||||
final readProps = (byName['mcp__clide__files.read']!['inputSchema'] as Map)['properties'] as Map;
|
||||
expect((readProps['path'] as Map)['type'], 'string');
|
||||
expect((readProps['path'] as Map)['enum'], ['a', 'b']);
|
||||
expect((readProps['recursive'] as Map)['type'], 'boolean');
|
||||
expect((readProps['globs'] as Map)['type'], 'array');
|
||||
expect(((readProps['globs'] as Map)['items'] as Map)['type'], 'string');
|
||||
expect((readProps['globs'] as Map)['maxItems'], 5);
|
||||
|
||||
final refProps = (byName['mcp__clide__git.checkout']!['inputSchema'] as Map)['properties'] as Map;
|
||||
expect((refProps['ref'] as Map)['pattern'], r'^\w+$');
|
||||
});
|
||||
|
||||
test('clear removes user handlers but keeps the built-ins', () async {
|
||||
final d = DaemonDispatcher();
|
||||
d.register('extra', (req) async => IpcResponse.ok(id: req.id, data: const {}));
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/src/ipc/mcp_server.dart';
|
||||
import 'package:test/test.dart';
|
||||
@@ -202,4 +203,139 @@ void main() {
|
||||
expect(lines, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('McpServer (T-225) clide tool surface', () {
|
||||
late Directory disc;
|
||||
late McpServer srv;
|
||||
|
||||
setUp(() async {
|
||||
disc = await Directory.systemTemp.createTemp('clide-mcp-clide-');
|
||||
final dispatcher = DaemonDispatcher();
|
||||
dispatcher.register('echo', (req) async => IpcResponse.ok(id: req.id, data: {'echo': req.args['text']}));
|
||||
// A poor MCP fit — withheld from the tool surface (D-86).
|
||||
dispatcher.register('pane.tail', (req) async => IpcResponse.ok(id: req.id, data: const {}), mcpExpose: false);
|
||||
srv = McpServer(
|
||||
workspaceRoot: '/x',
|
||||
log: _silent(),
|
||||
discoveryDirOverride: disc.path,
|
||||
dispatcher: dispatcher,
|
||||
);
|
||||
await srv.start();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
try {
|
||||
await srv.stop();
|
||||
} catch (_) {}
|
||||
if (disc.existsSync()) disc.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
Future<(String, Stream<String>)> connect() async {
|
||||
final client = HttpClient();
|
||||
addTearDown(client.close);
|
||||
final resp = await (await client.getUrl(Uri.parse('http://127.0.0.1:${srv.port}/sse'))).close();
|
||||
final dataLines =
|
||||
resp.transform(utf8.decoder).transform(const LineSplitter()).where((l) => l.startsWith('data: ')).map((l) => l.substring(6)).asBroadcastStream();
|
||||
final endpoint = Completer<String>();
|
||||
final sub = dataLines.listen((d) {
|
||||
final m = RegExp(r'sessionId=([\w-]+)').firstMatch(d);
|
||||
if (m != null && !endpoint.isCompleted) endpoint.complete(m.group(1)!);
|
||||
});
|
||||
final id = await endpoint.future.timeout(const Duration(seconds: 2));
|
||||
await sub.cancel();
|
||||
return (id, dataLines);
|
||||
}
|
||||
|
||||
Future<void> post(String sid, Map<String, Object?> body) async {
|
||||
final client = HttpClient();
|
||||
addTearDown(client.close);
|
||||
final req = await client.postUrl(Uri.parse('http://127.0.0.1:${srv.port}/messages?sessionId=$sid'));
|
||||
req.headers.contentType = ContentType.json;
|
||||
req.write(jsonEncode(body));
|
||||
final resp = await req.close();
|
||||
expect(resp.statusCode, HttpStatus.accepted);
|
||||
}
|
||||
|
||||
test('tools/list adds generated mcp__clide__ tools alongside the /ide pair', () async {
|
||||
final (sid, events) = await connect();
|
||||
final replyFuture = events.firstWhere((s) => s.contains('"id":10'));
|
||||
await post(sid, {'jsonrpc': '2.0', 'id': 10, 'method': 'tools/list'});
|
||||
final reply = jsonDecode(await replyFuture.timeout(const Duration(seconds: 2))) as Map<String, Object?>;
|
||||
final names = ((reply['result'] as Map)['tools'] as List).map((t) => (t as Map)['name']).toSet();
|
||||
expect(names, containsAll(['mcp__ide__getDiagnostics', 'mcp__clide__echo', 'mcp__clide__ping']));
|
||||
// The opt-out command is withheld.
|
||||
expect(names.contains('mcp__clide__pane.tail'), isFalse);
|
||||
});
|
||||
|
||||
test('tools/call routes mcp__clide__ tools to the dispatcher', () async {
|
||||
final (sid, events) = await connect();
|
||||
final replyFuture = events.firstWhere((s) => s.contains('"id":11'));
|
||||
await post(sid, {
|
||||
'jsonrpc': '2.0',
|
||||
'id': 11,
|
||||
'method': 'tools/call',
|
||||
'params': {
|
||||
'name': 'mcp__clide__echo',
|
||||
'arguments': {'text': 'hi'},
|
||||
},
|
||||
});
|
||||
final reply = jsonDecode(await replyFuture.timeout(const Duration(seconds: 2))) as Map<String, Object?>;
|
||||
final content = ((reply['result'] as Map)['content'] as List).cast<Map<String, Object?>>();
|
||||
expect(jsonDecode(content.first['text'] as String), {'echo': 'hi'});
|
||||
});
|
||||
|
||||
test('a failing clide tool surfaces isError with the error message', () async {
|
||||
// An unknown command → dispatcher returns a not-found error.
|
||||
final (sid, events) = await connect();
|
||||
final replyFuture = events.firstWhere((s) => s.contains('"id":12'));
|
||||
await post(sid, {
|
||||
'jsonrpc': '2.0',
|
||||
'id': 12,
|
||||
'method': 'tools/call',
|
||||
'params': {'name': 'mcp__clide__nope.verb', 'arguments': const {}},
|
||||
});
|
||||
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);
|
||||
expect((result['content'] as List).first['text'], contains('unknown command'));
|
||||
});
|
||||
});
|
||||
|
||||
test('calling a clide tool with no dispatcher wired surfaces isError', () async {
|
||||
final disc = await Directory.systemTemp.createTemp('clide-mcp-nodisp-');
|
||||
final srv = McpServer(workspaceRoot: '/x', log: _silent(), discoveryDirOverride: disc.path);
|
||||
await srv.start();
|
||||
addTearDown(() async {
|
||||
await srv.stop();
|
||||
if (disc.existsSync()) disc.deleteSync(recursive: true);
|
||||
});
|
||||
final client = HttpClient();
|
||||
addTearDown(client.close);
|
||||
final resp = await (await client.getUrl(Uri.parse('http://127.0.0.1:${srv.port}/sse'))).close();
|
||||
final data = resp
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.where((l) => l.startsWith('data: '))
|
||||
.map((l) => l.substring(6))
|
||||
.asBroadcastStream();
|
||||
final ep = Completer<String>();
|
||||
final sub = data.listen((d) {
|
||||
final m = RegExp(r'sessionId=([\w-]+)').firstMatch(d);
|
||||
if (m != null && !ep.isCompleted) ep.complete(m.group(1)!);
|
||||
});
|
||||
final sid = await ep.future.timeout(const Duration(seconds: 2));
|
||||
await sub.cancel();
|
||||
final replyFuture = data.firstWhere((s) => s.contains('"id":13'));
|
||||
final post = await client.postUrl(Uri.parse('http://127.0.0.1:${srv.port}/messages?sessionId=$sid'));
|
||||
post.headers.contentType = ContentType.json;
|
||||
post.write(jsonEncode({
|
||||
'jsonrpc': '2.0',
|
||||
'id': 13,
|
||||
'method': 'tools/call',
|
||||
'params': {'name': 'mcp__clide__echo', 'arguments': const {}},
|
||||
}));
|
||||
await post.close();
|
||||
final reply = jsonDecode(await replyFuture.timeout(const Duration(seconds: 2))) as Map<String, Object?>;
|
||||
expect((reply['result'] as Map)['isError'], isTrue);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user