Skip to content
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
79 changes: 74 additions & 5 deletions pkgs/dart_tooling_mcp_server/lib/src/mixins/dtd.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ base mixin DartToolingDaemonSupport on ToolsSupport {
// supported in profile mode).
registerTool(screenshotTool, takeScreenshot);
registerTool(hotReloadTool, hotReload);
registerTool(getWidgetTreeTool, widgetTree);

return super.initialize(request);
}
Expand Down Expand Up @@ -283,6 +284,65 @@ base mixin DartToolingDaemonSupport on ToolsSupport {
);
}

/// Retrieves the Flutter widget tree from 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> widgetTree(CallToolRequest request) async {
return _callOnVmService(
callback: (vmService) async {
final vm = await vmService.getVM();
final isolateId = vm.isolates!.first.id;
final groupId = 'dart-tooling-mcp-server';
const inspectorExtensionPrefix = 'ext.flutter.inspector';
try {
final result = await vmService.callServiceExtension(
'$inspectorExtensionPrefix.getRootWidgetTree',
isolateId: isolateId,
args: {
'groupName': groupId,
// TODO: consider making these configurable or using defaults that
// are better for the LLM.
'isSummaryTree': 'true',
'withPreviews': 'true',
'fullDetails': 'false',
},
);
final tree = result.json?['result'];
if (tree == null) {
return CallToolResult(
content: [
TextContent(
text:
'Could not get Widget tree. '
'Unexpected result: ${result.json}.',
),
],
);
}
return CallToolResult(content: [TextContent(text: tree.toString())]);
} catch (e) {
return CallToolResult(
isError: true,
content: [
TextContent(
text: 'Unknown error or bad response getting widget tree:\n$e',
),
],
);
} finally {
await vmService.callServiceExtension(
'$inspectorExtensionPrefix.disposeGroup',
isolateId: isolateId,
args: {'objectGroup': groupId},
);
Comment on lines +336 to +340
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@elliette is cleaning up this object group necessary? Or do you think we should re-use this object group for all uses of this tool and clean up when the server gets shut down? I'm not super familiar with why we use these object groups in the Inspector.

Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm I'm also not familiar with the object group lifecycle. I see this comment: https://github.com/flutter/flutter/blob/296df33be866d0cd8387524e288d9b280e725795/packages/flutter/lib/src/widgets/widget_inspector.dart#L924-L927

But I'm not sure if how we should be creating / disposing object groups is documented anywhere. I need to do more investigation

}
},
);
}

/// Calls [callback] on the first active debug session, if available.
Future<CallToolResult> _callOnVmService({
required Future<CallToolResult> Function(VmService) callback,
Expand Down Expand Up @@ -323,6 +383,16 @@ base mixin DartToolingDaemonSupport on ToolsSupport {
'command. Do not just make up a random URI to pass.',
);

@visibleForTesting
static final getRuntimeErrorsTool = Tool(
name: 'get_runtime_errors',
description:
'Retrieves the list of runtime errors that have occurred in the active '
'Dart or Flutter application. Requires "${connectTool.name}" to be '
'successfully called first.',
inputSchema: ObjectSchema(),
);

@visibleForTesting
static final screenshotTool = Tool(
name: 'take_screenshot',
Expand All @@ -344,12 +414,11 @@ base mixin DartToolingDaemonSupport on ToolsSupport {
);

@visibleForTesting
static final getRuntimeErrorsTool = Tool(
name: 'get_runtime_errors',
static final getWidgetTreeTool = Tool(
name: 'get_widget_tree',
description:
'Retrieves the list of runtime errors that have occurred in the active '
'Dart or Flutter application. Requires "${connectTool.name}" to be '
'successfully called first.',
'Retrieves the widget tree from the active Flutter application. '
'Requires "${connectTool.name}" to be successfully called first.',
inputSchema: ObjectSchema(),
);

Expand Down
16 changes: 16 additions & 0 deletions pkgs/dart_tooling_mcp_server/test/tools/dtd_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -74,5 +74,21 @@ void main() {
contains('A RenderFlex overflowed by'),
);
});

test('can get the widget tree', () async {
final tools = (await testHarness.mcpServerConnection.listTools()).tools;
final getWidgetTreeTool = tools.singleWhere(
(t) => t.name == DartToolingDaemonSupport.getWidgetTreeTool.name,
);
final getWidgetTreeResult = await testHarness.callToolWithRetry(
CallToolRequest(name: getWidgetTreeTool.name),
);

expect(getWidgetTreeResult.isError, isNot(true));
expect(
(getWidgetTreeResult.content.first as TextContent).text,
contains('MyHomePage'),
);
});
});
}
Loading