Skip to content

[dart_tooling_mcp_server] Add a hot reload tool #42

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Apr 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pkgs/dart_tooling_mcp_server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ If you are directly editing your mcp.json file, it should look like this:
}
```

Each time you make changes to the server, you'll need to re-run
`dart compile exe bin/main.dart` and reload the Cursor window
(Developer: Reload Window from the Command Pallete) to see the changes.

## Debugging MCP Servers

For local development, use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector).
Expand Down
12 changes: 6 additions & 6 deletions pkgs/dart_tooling_mcp_server/bin/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ void main(List<String> args) async {
.transform(StreamChannelTransformer.fromCodec(utf8))
.transformStream(const LineSplitter())
.transformSink(
StreamSinkTransformer.fromHandlers(
handleData: (data, sink) {
sink.add('$data\n');
},
),
),
StreamSinkTransformer.fromHandlers(
handleData: (data, sink) {
sink.add('$data\n');
},
),
),
);
},
(e, s) {
Expand Down
9 changes: 3 additions & 6 deletions pkgs/dart_tooling_mcp_server/lib/src/mixins/analyzer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,7 @@ base mixin DartAnalyzerSupport on ToolsSupport, LoggingSupport {
return CallToolResult(
content: [
TextContent(
text:
'Analysis not yet ready, please wait a few seconds and try '
text: 'Analysis not yet ready, please wait a few seconds and try '
'again.',
),
],
Expand All @@ -132,8 +131,7 @@ base mixin DartAnalyzerSupport on ToolsSupport, LoggingSupport {
return CallToolResult(
content: [
TextContent(
text:
'Only file scheme uris are allowed for roots, but got '
text: 'Only file scheme uris are allowed for roots, but got '
'$rootUri',
),
],
Expand Down Expand Up @@ -201,8 +199,7 @@ base mixin DartAnalyzerSupport on ToolsSupport, LoggingSupport {
title: 'The URI of the project root to analyze.',
),
'paths': ListSchema(
title:
'Relative or absolute paths to analyze under the '
title: 'Relative or absolute paths to analyze under the '
'"root", must correspond to files and not directories.',
items: StringSchema(),
),
Expand Down
188 changes: 137 additions & 51 deletions pkgs/dart_tooling_mcp_server/lib/src/mixins/dtd.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:dart_mcp/server.dart';
import 'package:dtd/dtd.dart';
import 'package:json_rpc_2/json_rpc_2.dart';
import 'package:meta/meta.dart';
import 'package:vm_service/vm_service.dart';
import 'package:vm_service/vm_service_io.dart';

/// Mix this in to any MCPServer to add support for connecting to the Dart
Expand All @@ -31,7 +32,12 @@ base mixin DartToolingDaemonSupport on ToolsSupport {
@override
FutureOr<InitializeResult> initialize(InitializeRequest request) async {
registerTool(connectTool, _connect);
// TODO: these tools should only be registered for Flutter applications, or
// they should return an error when used against a pure Dart app (or a
// Flutter app that does not support the operation, e.g. hot reload is not
// supported in profile mode).
registerTool(screenshotTool, takeScreenshot);
registerTool(hotReloadTool, hotReload);
return super.initialize(request);
}

Expand Down Expand Up @@ -98,6 +104,107 @@ base mixin DartToolingDaemonSupport on ToolsSupport {
// TODO: support passing a debug session id when there is more than one debug
// session.
Future<CallToolResult> takeScreenshot(CallToolRequest request) async {
return _callOnVmService(
callback: (vmService) async {
final vm = await vmService.getVM();
final result = await vmService.callServiceExtension(
'_flutter.screenshot',
isolateId: vm.isolates!.first.id,
);
if (result.json?['type'] == 'Screenshot' &&
result.json?['screenshot'] is String) {
return CallToolResult(
content: [
ImageContent(
data: result.json!['screenshot'] as String,
mimeType: 'image/png',
),
],
);
} else {
return CallToolResult(
isError: true,
content: [
TextContent(
text: 'Unknown error or bad response taking screenshot:\n'
'${result.json}',
),
],
);
}
},
);
}

/// Performs a hot reload on the currently running app.
///
/// If more than one debug session is active, then it just uses the first one.
///
// TODO: support passing a debug session id when there is more than one debug
// session.
Future<CallToolResult> hotReload(CallToolRequest request) async {
return _callOnVmService(
callback: (vmService) async {
final vm = await vmService.getVM();

final hotReloadMethodNameCompleter = Completer<String?>();
vmService.onEvent(EventStreams.kService).listen((Event e) {
if (e.kind == EventKind.kServiceRegistered) {
final serviceName = e.service!;
if (serviceName == 'reloadSources') {
// This may look something like 's0.reloadSources'.
hotReloadMethodNameCompleter.complete(e.method);
}
}
});
await vmService.streamListen(EventStreams.kService);
final hotReloadMethodName = await hotReloadMethodNameCompleter.future
.timeout(const Duration(milliseconds: 1000), onTimeout: () async {
return null;
});
await vmService.streamCancel(EventStreams.kService);

if (hotReloadMethodName == null) {
return CallToolResult(
isError: true,
content: [
TextContent(
text: 'The hot reload service has not been registered yet, '
'please wait a few seconds and try again.',
),
],
);
}

final result = await vmService.callMethod(
hotReloadMethodName,
isolateId: vm.isolates!.first.id,
);
final resultType = result.json?['type'];
if (resultType == 'Success' ||
(resultType == 'ReloadReport' && result.json?['success'] == true)) {
return CallToolResult(
content: [TextContent(text: 'Hot reload succeeded.')],
);
} else {
return CallToolResult(
isError: true,
content: [
TextContent(
text: 'Hot reload failed:\n'
'${result.json}',
),
],
);
}
},
);
}

/// Calls [callback] on the first active debug session, if available.
Future<CallToolResult> _callOnVmService({
required Future<CallToolResult> Function(VmService) callback,
}) async {
final dtd = _dtd;
if (dtd == null) return _dtdNotConnected;
if (!_getDebugSessionsReady) {
Expand All @@ -111,38 +218,10 @@ base mixin DartToolingDaemonSupport on ToolsSupport {
if (debugSessions.isEmpty) return _noActiveDebugSession;

// TODO: Consider holding on to this connection.
final vmService = await vmServiceConnectUri(
debugSessions.first.vmServiceUri,
);

final vmService =
await vmServiceConnectUri(debugSessions.first.vmServiceUri);
try {
final vm = await vmService.getVM();
final result = await vmService.callServiceExtension(
'_flutter.screenshot',
isolateId: vm.isolates!.first.id,
);
if (result.json?['type'] == 'Screenshot' &&
result.json?['screenshot'] is String) {
return CallToolResult(
content: [
ImageContent(
data: result.json!['screenshot'] as String,
mimeType: 'image/png',
),
],
);
} else {
return CallToolResult(
isError: true,
content: [
TextContent(
text:
'Unknown error or bad response taking screenshot:\n'
'${result.json}',
),
],
);
}
return await callback(vmService);
} finally {
unawaited(vmService.dispose());
}
Expand All @@ -163,20 +242,27 @@ base mixin DartToolingDaemonSupport on ToolsSupport {

@visibleForTesting
static final screenshotTool = Tool(
name: 'take_screenshot',
description:
'Takes a screenshot of the active flutter application in its '
name: 'takeScreenshot',
description: 'Takes a screenshot of the active flutter application in its '
'current state. Requires "${connectTool.name}" to be successfully '
'called first.',
inputSchema: ObjectSchema(),
);

@visibleForTesting
static final hotReloadTool = Tool(
name: 'hotReload',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having spaces like "Hot Reload" showed up in the chat window as "Hot_Reload" (at least in cursor), so I changed these to use camel case for now as that looks better in the chat window

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh interesting ok

description: 'Performs a hot reload of the active Flutter application. '
'This is to apply the latest code changes to the running application. '
'Requires "${connectTool.name}" to be successfully called first.',
inputSchema: ObjectSchema(),
);

static final _dtdNotConnected = CallToolResult(
isError: true,
content: [
TextContent(
text:
'The dart tooling daemon is not connected, you need to call '
text: 'The dart tooling daemon is not connected, you need to call '
'"${connectTool.name}" first.',
),
],
Expand All @@ -186,8 +272,7 @@ base mixin DartToolingDaemonSupport on ToolsSupport {
isError: true,
content: [
TextContent(
text:
'The dart tooling daemon is already connected, you cannot call '
text: 'The dart tooling daemon is already connected, you cannot call '
'"${connectTool.name}" again.',
),
],
Expand All @@ -204,8 +289,7 @@ base mixin DartToolingDaemonSupport on ToolsSupport {
isError: true,
content: [
TextContent(
text:
'The dart tooling daemon is not ready yet, please wait a few '
text: 'The dart tooling daemon is not ready yet, please wait a few '
'seconds and try again.',
),
],
Expand Down Expand Up @@ -266,10 +350,11 @@ extension type GetDebugSessionsResponse.fromJson(Map<String, Object?> _value)

factory GetDebugSessionsResponse({
required List<DebugSession> debugSessions,
}) => GetDebugSessionsResponse.fromJson({
'debugSessions': debugSessions,
'type': type,
});
}) =>
GetDebugSessionsResponse.fromJson({
'debugSessions': debugSessions,
'type': type,
});
}

/// An individual debug session.
Expand All @@ -290,11 +375,12 @@ extension type DebugSession.fromJson(Map<String, Object?> _value)
required String name,
required String projectRootPath,
required String vmServiceUri,
}) => DebugSession.fromJson({
'debuggerType': debuggerType,
'id': id,
'name': name,
'projectRootPath': projectRootPath,
'vmServiceUri': vmServiceUri,
});
}) =>
DebugSession.fromJson({
'debuggerType': debuggerType,
'id': id,
'name': name,
'projectRootPath': projectRootPath,
'vmServiceUri': vmServiceUri,
});
}
18 changes: 9 additions & 9 deletions pkgs/dart_tooling_mcp_server/lib/src/server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ final class DartToolingMCPServer extends MCPServer
DartAnalyzerSupport,
DartToolingDaemonSupport {
DartToolingMCPServer({required super.channel})
: super.fromStreamChannel(
implementation: ServerImplementation(
name: 'dart and flutter tooling',
version: '0.1.0-wip',
),
instructions:
'This server helps to connect Dart and Flutter developers to '
'their development tools and running applications.',
);
: super.fromStreamChannel(
implementation: ServerImplementation(
name: 'dart and flutter tooling',
version: '0.1.0-wip',
),
instructions:
'This server helps to connect Dart and Flutter developers to '
'their development tools and running applications.',
);

static Future<DartToolingMCPServer> connect(
StreamChannel<String> mcpChannel,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,29 @@ void main() {
});
});

test('can perform a hot reload', () async {
await testHarness.connectToDtd();

await testHarness.startDebugSession(
counterAppPath,
'lib/main.dart',
isFlutter: true,
);

final tools = (await testHarness.mcpServerConnection.listTools()).tools;
final hotReloadTool = tools.singleWhere(
(t) => t.name == DartToolingDaemonSupport.hotReloadTool.name,
);
final hotReloadResult = await testHarness.callToolWithRetry(
CallToolRequest(name: hotReloadTool.name),
);

expect(hotReloadResult.isError, isNot(true));
expect(hotReloadResult.content, [
TextContent(text: 'Hot reload succeeded.'),
]);
});

group('analysis', () {
late Tool analyzeTool;

Expand Down Expand Up @@ -102,8 +125,7 @@ void main() {
expect(result.isError, isNot(true));
expect(result.content, [
TextContent(
text:
"Error: The argument type 'String' can't be assigned to the "
text: "Error: The argument type 'String' can't be assigned to the "
"parameter type 'num'. ",
),
]);
Expand Down
Loading
Loading