Skip to content

Add a tool to get the active cursor location #116

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 6 commits into from
May 8, 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
1 change: 1 addition & 0 deletions pkgs/dart_tooling_mcp_server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ WIP. This package is still experimental and is likely to evolve quickly.
| `get_selected_widget` | `runtime analysis` | Retrieves the selected widget from the active Flutter application. |
| `hot_reload` | `runtime tool` | Performs a hot reload of the active Flutter application. |
| `connect_dart_tooling_daemon`* | `configuration` | Connects to the locally running Dart Tooling Daemon. |
| `get_active_location` | `editor` | Gets the active cursor position in the connected editor (if available). |

> *Experimental: may be removed.

Expand Down
54 changes: 47 additions & 7 deletions pkgs/dart_tooling_mcp_server/lib/src/mixins/dtd.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ base mixin DartToolingDaemonSupport
/// ready to be invoked.
bool _getDebugSessionsReady = false;

/// The last reported active location from the editor.
Map<String, Object?>? _activeLocation;

/// A Map of [VmService] object [Future]s by their associated
/// [DebugSession.id].
///
Expand Down Expand Up @@ -58,6 +61,7 @@ base mixin DartToolingDaemonSupport
Future<void> _resetDtd() async {
_dtd = null;
_getDebugSessionsReady = false;
_activeLocation = null;

// TODO: determine whether we need to dispose the [inspectorObjectGroup] on
// the Flutter Widget Inspector for each VM service instance.
Expand Down Expand Up @@ -135,6 +139,7 @@ base mixin DartToolingDaemonSupport
registerTool(hotReloadTool, hotReload);
registerTool(getWidgetTreeTool, widgetTree);
registerTool(getSelectedWidgetTool, selectedWidget);
registerTool(getActiveLocationTool, _getActiveLocation);

return super.initialize(request);
}
Expand Down Expand Up @@ -166,7 +171,7 @@ base mixin DartToolingDaemonSupport
);
unawaited(_dtd!.done.then((_) async => await _resetDtd()));

_listenForServices();
await _listenForServices();
return CallToolResult(
content: [TextContent(text: 'Connection succeeded')],
);
Expand All @@ -178,11 +183,12 @@ base mixin DartToolingDaemonSupport
}
}

/// Listens to the `Service` stream so we know when the
/// `Editor.getDebugSessions` extension method is registered.
/// Listens to the `Service` and `Editor` streams so we know when the
/// `Editor.getDebugSessions` extension method is registered and when debug
/// sessions are started and stopped.
///
/// The dart tooling daemon must be connected prior to calling this function.
void _listenForServices() {
Future<void> _listenForServices() async {
final dtd = _dtd!;
dtd.onEvent('Service').listen((e) async {
log(
Expand Down Expand Up @@ -210,7 +216,7 @@ base mixin DartToolingDaemonSupport
}
}
});
dtd.streamListen('Service');
await dtd.streamListen('Service');

dtd.onEvent('Editor').listen((e) async {
log(LoggingLevel.debug, e.toString());
Expand All @@ -220,12 +226,14 @@ base mixin DartToolingDaemonSupport
await updateActiveVmServices();
case 'debugSessionStopped':
await activeVmServices
.remove((e.data['debugSession'] as DebugSession).id)
.remove(e.data['debugSessionId'] as String)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is an unrelated bug fix that I snuck in here, I can separate it out if we want

?.then((service) => service.dispose());
case 'activeLocationChanged':
_activeLocation = e.data;
default:
}
});
dtd.streamListen('Editor');
await dtd.streamListen('Editor');
}

/// Takes a screenshot of the currently running app.
Expand Down Expand Up @@ -495,6 +503,24 @@ base mixin DartToolingDaemonSupport
return await callback(await vmService);
}

/// Retrieves the active location from the editor.
Future<CallToolResult> _getActiveLocation(CallToolRequest request) async {
if (_dtd == null) return _dtdNotConnected;

final activeLocation = _activeLocation;
if (activeLocation == null) {
return CallToolResult(
content: [
TextContent(text: 'No active location reported by the editor yet.'),
],
);
}

return CallToolResult(
content: [TextContent(text: jsonEncode(_activeLocation))],
);
}

@visibleForTesting
static final connectTool = Tool(
name: 'connect_dart_tooling_daemon',
Expand Down Expand Up @@ -587,6 +613,20 @@ base mixin DartToolingDaemonSupport
inputSchema: Schema.object(),
);

@visibleForTesting
static final getActiveLocationTool = Tool(
name: 'get_active_location',
description:
'Retrieves the current active location (e.g., cursor position) in the '
'connected editor. Requires "${connectTool.name}" to be successfully '
'called first.',
annotations: ToolAnnotations(
title: 'Get Active Editor Location',
readOnlyHint: true,
),
inputSchema: Schema.object(),
);

static final _dtdNotConnected = CallToolResult(
isError: true,
content: [
Expand Down
4 changes: 1 addition & 3 deletions pkgs/dart_tooling_mcp_server/test/test_harness.dart
Original file line number Diff line number Diff line change
Expand Up @@ -318,9 +318,7 @@ class FakeEditorExtension {
Future<void> removeDebugSession(AppDebugSession session) async {
if (_debugSessions.remove(session)) {
await dtd.postEvent('Editor', 'debugSessionStopped', {
'debugSession': session.asEditorDebugSession(
includeVmServiceUri: false,
),
'debugSessionId': session.id,
});
}
}
Expand Down
44 changes: 42 additions & 2 deletions pkgs/dart_tooling_mcp_server/test/tools/dtd_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,11 @@ void main() {
'lib/main.dart',
isFlutter: true,
);
await server.updateActiveVmServices();
await pumpEventQueue();
expect(server.activeVmServices.length, 1);

await testHarness.stopDebugSession(debugSession);
await server.updateActiveVmServices();
await pumpEventQueue();
expect(server.activeVmServices, isEmpty);
});
});
Expand Down Expand Up @@ -388,6 +388,46 @@ void main() {
expect(finalContents.last, overflowMatcher);
});
});

group('getActiveLocationTool', () {
test(
'returns "no location" if DTD connected but no event received',
() async {
final result = await testHarness.callToolWithRetry(
CallToolRequest(
name: DartToolingDaemonSupport.getActiveLocationTool.name,
),
);
expect(
(result.content.first as TextContent).text,
'No active location reported by the editor yet.',
);
},
);

test('returns active location after event', () async {
final fakeEditor = testHarness.fakeEditorExtension;

// Simulate activeLocationChanged event
final fakeEvent = {'someData': 'isHere'};
await fakeEditor.dtd.postEvent(
'Editor',
'activeLocationChanged',
fakeEvent,
);
await pumpEventQueue();

final result = await testHarness.callToolWithRetry(
CallToolRequest(
name: DartToolingDaemonSupport.getActiveLocationTool.name,
),
);
expect(
(result.content.first as TextContent).text,
jsonEncode(fakeEvent),
);
});
});
});
});
}
Expand Down