From 27cdbf8a05c4406b4c7cee7160c560d2f025eb73 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Sat, 24 Oct 2026 09:38:51 -0400 Subject: [PATCH 01/12] Refactor MCP tools and fix runtime lifecycle --- .github/workflows/ci.yaml | 6 + CHANGELOG.md | 5 + README.md | 3 + docs/architecture.md | 75 ++ .../lib/src/cli/commands/doctor_command.dart | 2 +- .../src/cli/commands/double_tap_command.dart | 24 +- .../src/cli/commands/enter_text_command.dart | 12 +- .../get_interactive_elements_command.dart | 3 +- .../src/cli/commands/get_logs_command.dart | 2 +- .../src/cli/commands/hot_reload_command.dart | 2 +- .../src/cli/commands/hot_restart_command.dart | 2 +- .../src/cli/commands/long_press_command.dart | 18 +- .../lib/src/cli/commands/mcp_command.dart | 30 +- .../src/cli/commands/pinch_zoom_command.dart | 24 +- .../commands/press_back_button_command.dart | 2 +- .../src/cli/commands/press_key_command.dart | 2 +- .../cli/commands/record_video_command.dart | 43 +- .../src/cli/commands/scroll_to_command.dart | 10 +- .../cli/commands/secondary_tap_command.dart | 18 +- .../commands/set_device_config_command.dart | 2 +- .../lib/src/cli/commands/swipe_command.dart | 33 +- .../commands/take_screenshots_command.dart | 2 +- .../lib/src/cli/commands/tap_command.dart | 18 +- .../src/cli/commands/unregister_command.dart | 2 +- .../lib/src/cli/instance_command.dart | 5 +- .../lib/src/cli/matcher_builder.dart | 89 +- .../test/record_video_command_test.dart | 127 ++- .../test/selector_commands_test.dart | 282 +++++++ .../marionette-flutter-drive-app/SKILL.md | 4 + .../marionette_mcp/bin/marionette_mcp.dart | 53 +- packages/marionette_mcp/example/pubspec.lock | 12 +- .../marionette_mcp/lib/marionette_mcp.dart | 7 + packages/marionette_mcp/lib/mcp/mcp.dart | 50 ++ .../marionette_mcp/lib/mcp/server_config.dart | 54 ++ .../server_context.dart} | 101 ++- .../lib/mcp/tools/base_tool.dart | 62 ++ .../lib/mcp/tools/device_tools.dart | 125 +++ .../lib/mcp/tools/extension_tools.dart | 169 ++++ .../lib/mcp/tools/gesture_tools.dart | 702 ++++++++++++++++ .../lib/mcp/tools/inspection_tools.dart | 305 +++++++ .../lib/mcp/tools/keyboard_tools.dart | 85 ++ .../lib/mcp/tools/system_tools.dart | 122 +++ .../lib/mcp/tools/text_tools.dart | 100 +++ .../marionette_mcp/lib/mcp/tools/tools.dart | 44 + packages/marionette_mcp/lib/runtime.dart | 17 + .../lib/src/mcp_server_runner.dart | 106 ++- .../lib/src/session/step_logger.dart | 41 - .../vm_service/dynamic_extension_tools.dart | 84 +- .../src/vm_service/tools/device_tools.dart | 107 --- .../src/vm_service/tools/extension_tools.dart | 134 --- .../src/vm_service/tools/gesture_tools.dart | 551 ------------- .../vm_service/tools/inspection_tools.dart | 256 ------ .../src/vm_service/tools/keyboard_tools.dart | 68 -- .../src/vm_service/tools/system_tools.dart | 85 -- .../lib/src/vm_service/tools/text_tools.dart | 80 -- .../src/vm_service/vm_service_connector.dart | 166 +++- packages/marionette_mcp/pubspec.yaml | 2 +- .../test/fixtures/tools_catalog.json | 765 ++++++++++++++++++ .../test/fixtures/vm_service_app.dart | 25 + .../marionette_mcp/test/server_api_test.dart | 77 ++ .../test/server_config_test.dart | 38 + .../test/server_context_test.dart | 127 +++ .../marionette_mcp/test/sse_server_test.dart | 68 ++ .../test/stdio_server_test.dart | 139 ++++ .../test/support/test_process.dart | 92 +++ .../vm_service/connector_lifecycle_test.dart | 361 +++++++++ .../dynamic_extension_tools_test.dart | 95 ++- .../vm_service/tools/device_tools_test.dart | 2 +- .../tools/extension_tools_test.dart | 2 +- .../tools/inspection_tools_test.dart | 2 +- pubspec.lock | 4 +- tool/check_frame_protocol.dart | 141 ++++ 72 files changed, 4693 insertions(+), 1780 deletions(-) create mode 100644 docs/architecture.md create mode 100644 packages/marionette_cli/test/selector_commands_test.dart create mode 100644 packages/marionette_mcp/lib/marionette_mcp.dart create mode 100644 packages/marionette_mcp/lib/mcp/mcp.dart create mode 100644 packages/marionette_mcp/lib/mcp/server_config.dart rename packages/marionette_mcp/lib/{src/vm_service/vm_service_context.dart => mcp/server_context.dart} (80%) create mode 100644 packages/marionette_mcp/lib/mcp/tools/base_tool.dart create mode 100644 packages/marionette_mcp/lib/mcp/tools/device_tools.dart create mode 100644 packages/marionette_mcp/lib/mcp/tools/extension_tools.dart create mode 100644 packages/marionette_mcp/lib/mcp/tools/gesture_tools.dart create mode 100644 packages/marionette_mcp/lib/mcp/tools/inspection_tools.dart create mode 100644 packages/marionette_mcp/lib/mcp/tools/keyboard_tools.dart create mode 100644 packages/marionette_mcp/lib/mcp/tools/system_tools.dart create mode 100644 packages/marionette_mcp/lib/mcp/tools/text_tools.dart create mode 100644 packages/marionette_mcp/lib/mcp/tools/tools.dart create mode 100644 packages/marionette_mcp/lib/runtime.dart delete mode 100644 packages/marionette_mcp/lib/src/vm_service/tools/device_tools.dart delete mode 100644 packages/marionette_mcp/lib/src/vm_service/tools/extension_tools.dart delete mode 100644 packages/marionette_mcp/lib/src/vm_service/tools/gesture_tools.dart delete mode 100644 packages/marionette_mcp/lib/src/vm_service/tools/inspection_tools.dart delete mode 100644 packages/marionette_mcp/lib/src/vm_service/tools/keyboard_tools.dart delete mode 100644 packages/marionette_mcp/lib/src/vm_service/tools/system_tools.dart delete mode 100644 packages/marionette_mcp/lib/src/vm_service/tools/text_tools.dart create mode 100644 packages/marionette_mcp/test/fixtures/tools_catalog.json create mode 100644 packages/marionette_mcp/test/fixtures/vm_service_app.dart create mode 100644 packages/marionette_mcp/test/server_api_test.dart create mode 100644 packages/marionette_mcp/test/server_config_test.dart create mode 100644 packages/marionette_mcp/test/server_context_test.dart create mode 100644 packages/marionette_mcp/test/sse_server_test.dart create mode 100644 packages/marionette_mcp/test/support/test_process.dart create mode 100644 packages/marionette_mcp/test/vm_service/connector_lifecycle_test.dart create mode 100644 tool/check_frame_protocol.dart diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0aeb6f6e..25f44a05 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -64,6 +64,9 @@ jobs: exit 1 fi + - name: Check frame protocol compatibility + run: dart tool/check_frame_protocol.dart + - name: Analyze marionette_mcp run: | cd packages/marionette_mcp @@ -177,6 +180,9 @@ jobs: cd packages/marionette_logging flutter pub get + - name: Check frame protocol compatibility + run: dart tool/check_frame_protocol.dart + - name: Analyze marionette_mcp run: | cd packages/marionette_mcp diff --git a/CHANGELOG.md b/CHANGELOG.md index 644c257b..04dfcfc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Unreleased +- Upgrade `mcp_dart` to `^2.4.3` and use its fixed dynamic-tool removal on reconnect. +- Fix server shutdown leaving app connections open and failed web recording startup leaving its WebSocket listener open. +- Reject incomplete, malformed, or nonfinite CLI coordinates and conflicting targeting modes before calling the app. +- Reject invalid SSE ports instead of falling back to stdio, and report server startup failures with a nonzero exit code. +- Expose public server and shared runtime entrypoints, with typed MCP tools separated from startup and configuration. - Fix `record-video --width/--height` recording only the top-left corner of the screen at full scale instead of the whole screen scaled down to that size. Web recordings were not affected ([#134](https://github.com/leancodepl/marionette_mcp/issues/134)) # 0.7.0 diff --git a/README.md b/README.md index f9e58e25..1ff7a7a2 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,9 @@ npx skills add leancodepl/marionette_mcp # without the Dart toolchain ## Documentation +Contributors: see [Server and runtime architecture](docs/architecture.md) for +package boundaries, tool registration, resource ownership, and verification. + | Page | What is inside | | --- | --- | | [Start](https://marionette.leancode.co/docs/) | Setup, every agent's config, the first prompt, the example app, FAQ | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..26937d49 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,75 @@ +# Server and runtime architecture + +The MCP server follows the `mcp_dart` CLI template's separation of server +composition, configuration, and typed tools: + +```text +packages/marionette_mcp/ + bin/marionette_mcp.dart parse options and start the server + lib/marionette_mcp.dart public server API + lib/runtime.dart public app/session/recording runtime API + lib/mcp/ + mcp.dart createMcpServer and server instructions + server_config.dart options shared with marionette mcp + server_context.dart app connection and session lifecycle + tools/ + base_tool.dart metadata and execute contract + tools.dart createAllTools and registration + *_tools.dart tools grouped by responsibility + lib/src/ runtime implementation details +``` + +Each static tool extends `BaseTool` and receives its connector and logger +through its constructor. Screenshot tools also receive the session step +logger. `createAllTools` composes these tools without opening sockets or +starting a process. `createMcpServer` registers them and applies step logging +once, at the composition boundary. Connection tools own their additional +session-report transitions; app-defined tools are discovered dynamically. + +## Public package boundaries + +CLI adapters import `package:marionette_mcp/runtime.dart` for app connections, +selectors, reports, and recording. They import +`package:marionette_mcp/marionette_mcp.dart` for server startup. Other packages +should use these entrypoints rather than importing `lib/src` files. Existing +MCP tools and executable options keep their names and wire schemas. + +For embedding, `createMcpServer(context: context)` accepts an explicitly owned +`VmServiceContext`. The caller must await `context.dispose()` and +`server.close()` when its transport ends. The standard `runMcpServer` entrypoint +performs both operations on EOF, signals, and transport startup failure. +Disposing the context retires dynamic tools and closes its app connection. +Disconnecting releases dynamic tool names, so reconnecting uses the current +app's schemas without retaining disabled registrations. + +Recording setup owns its WebSocket listener until a `RecordingSession` has +been created successfully. Startup failure closes the listener; after a +successful transfer the session owns cleanup. + +## CLI selection + +CLI coordinate selectors require both `--x` and `--y`, with finite numeric +values. Coordinate swipes require all four start/end coordinates. Malformed +coordinates fail before invoking the app instead of silently dropping a value. +Coordinate, element, and focused targeting are separate modes; an element +mode may retain combined key, identifier, text, and type filters. Ancestor +scope keeps its existing behavior, including being ignored by coordinate and +focused targeting. + +## Verification + +From the workspace root, resolve dependencies with `flutter pub get`. Run +`dart analyze lib bin test` and `dart test` in both `packages/marionette_mcp` +and `packages/marionette_cli`, then `flutter test --no-pub` in +`packages/marionette_flutter`. + +`server_api_test.dart` drives the real executable through public SDK client +APIs under legacy initialization and stateless discovery. Its catalog fixture +was captured from the original 0.7.0 implementation with SDK 2.4.3; update it +only for an intentional tool-contract change. Shutdown regressions use a real +Dart VM service fixture and cover connected EOF and signals. + +`dart run tool/check_frame_protocol.dart` checks Flutter-encoded frames against +the MCP consumer, including fragmented delivery. CI runs this check on both +the normal and minimum supported toolchains. This keeps the production +packages independent while detecting drift in their shared wire format. diff --git a/packages/marionette_cli/lib/src/cli/commands/doctor_command.dart b/packages/marionette_cli/lib/src/cli/commands/doctor_command.dart index 823fba2b..b4fb36a4 100644 --- a/packages/marionette_cli/lib/src/cli/commands/doctor_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/doctor_command.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:args/command_runner.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; class DoctorCommand extends Command { DoctorCommand(this._registry); diff --git a/packages/marionette_cli/lib/src/cli/commands/double_tap_command.dart b/packages/marionette_cli/lib/src/cli/commands/double_tap_command.dart index 1a6e5176..eedaf8b6 100644 --- a/packages/marionette_cli/lib/src/cli/commands/double_tap_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/double_tap_command.dart @@ -4,7 +4,7 @@ import 'package:marionette_cli/src/cli/ancestor_keys_option.dart'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/cli/matcher_builder.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; class DoubleTapCommand extends InstanceCommand { DoubleTapCommand(this._registry) { @@ -37,22 +37,11 @@ class DoubleTapCommand extends InstanceCommand { @override Future execute(VmServiceConnector connector) async { - final matcher = buildMatcherFromArgs( - key: argResults?['key'] as String?, - identifier: argResults?['identifier'] as String?, - text: argResults?['text'] as String?, - type: argResults?['type'] as String?, - x: _parseNum(argResults?['x'] as String?), - y: _parseNum(argResults?['y'] as String?), - ancestorKeys: argResults?['ancestor-keys'] as List? ?? const [], + final matcher = buildValidatedMatcherFromArgs( + argResults, + fail: usageException, ); - final xStr = argResults?['x'] as String?; - final yStr = argResults?['y'] as String?; - if ((xStr == null) != (yStr == null)) { - usageException('--x and --y must be provided together.'); - } - if (!hasSelector(matcher)) { usageException( 'At least one matcher required: --key, --identifier, --text, --type, ' @@ -75,9 +64,4 @@ class DoubleTapCommand extends InstanceCommand { stdout.writeln(message); return 0; } - - num? _parseNum(String? value) { - if (value == null) return null; - return num.tryParse(value); - } } diff --git a/packages/marionette_cli/lib/src/cli/commands/enter_text_command.dart b/packages/marionette_cli/lib/src/cli/commands/enter_text_command.dart index 8e735777..b6e6492f 100644 --- a/packages/marionette_cli/lib/src/cli/commands/enter_text_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/enter_text_command.dart @@ -4,7 +4,7 @@ import 'package:marionette_cli/src/cli/ancestor_keys_option.dart'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/cli/matcher_builder.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; class EnterTextCommand extends InstanceCommand { EnterTextCommand(this._registry) { @@ -39,13 +39,9 @@ class EnterTextCommand extends InstanceCommand { @override Future execute(VmServiceConnector connector) async { final input = argResults!['input'] as String; - final focused = argResults!['focused'] as bool; - final matcher = buildMatcherFromArgs( - key: argResults?['key'] as String?, - identifier: argResults?['identifier'] as String?, - text: argResults?['text'] as String?, - focused: focused, - ancestorKeys: argResults?['ancestor-keys'] as List? ?? const [], + final matcher = buildValidatedMatcherFromArgs( + argResults, + fail: usageException, ); if (!hasSelector(matcher)) { diff --git a/packages/marionette_cli/lib/src/cli/commands/get_interactive_elements_command.dart b/packages/marionette_cli/lib/src/cli/commands/get_interactive_elements_command.dart index 0705b0b5..12c4e6bf 100644 --- a/packages/marionette_cli/lib/src/cli/commands/get_interactive_elements_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/get_interactive_elements_command.dart @@ -2,8 +2,7 @@ import 'dart:io'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/formatting.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; class ElementsCommand extends InstanceCommand { ElementsCommand(this._registry) { diff --git a/packages/marionette_cli/lib/src/cli/commands/get_logs_command.dart b/packages/marionette_cli/lib/src/cli/commands/get_logs_command.dart index 73dd345f..446f00e0 100644 --- a/packages/marionette_cli/lib/src/cli/commands/get_logs_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/get_logs_command.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; class LogsCommand extends InstanceCommand { LogsCommand(this._registry); diff --git a/packages/marionette_cli/lib/src/cli/commands/hot_reload_command.dart b/packages/marionette_cli/lib/src/cli/commands/hot_reload_command.dart index 56e38be4..ef496577 100644 --- a/packages/marionette_cli/lib/src/cli/commands/hot_reload_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/hot_reload_command.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; class HotReloadCommand extends InstanceCommand { HotReloadCommand(this._registry); diff --git a/packages/marionette_cli/lib/src/cli/commands/hot_restart_command.dart b/packages/marionette_cli/lib/src/cli/commands/hot_restart_command.dart index b8a8b85d..1aaaa1da 100644 --- a/packages/marionette_cli/lib/src/cli/commands/hot_restart_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/hot_restart_command.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; class HotRestartCommand extends InstanceCommand { HotRestartCommand(this._registry); diff --git a/packages/marionette_cli/lib/src/cli/commands/long_press_command.dart b/packages/marionette_cli/lib/src/cli/commands/long_press_command.dart index 04bf55c6..2403cb81 100644 --- a/packages/marionette_cli/lib/src/cli/commands/long_press_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/long_press_command.dart @@ -4,7 +4,7 @@ import 'package:marionette_cli/src/cli/ancestor_keys_option.dart'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/cli/matcher_builder.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; class LongPressCommand extends InstanceCommand { LongPressCommand(this._registry) { @@ -37,14 +37,9 @@ class LongPressCommand extends InstanceCommand { @override Future execute(VmServiceConnector connector) async { - final matcher = buildMatcherFromArgs( - key: argResults?['key'] as String?, - identifier: argResults?['identifier'] as String?, - text: argResults?['text'] as String?, - type: argResults?['type'] as String?, - x: _parseNum(argResults?['x'] as String?), - y: _parseNum(argResults?['y'] as String?), - ancestorKeys: argResults?['ancestor-keys'] as List? ?? const [], + final matcher = buildValidatedMatcherFromArgs( + argResults, + fail: usageException, ); if (!hasSelector(matcher)) { @@ -66,9 +61,4 @@ class LongPressCommand extends InstanceCommand { stdout.writeln(message); return 0; } - - num? _parseNum(String? value) { - if (value == null) return null; - return num.tryParse(value); - } } diff --git a/packages/marionette_cli/lib/src/cli/commands/mcp_command.dart b/packages/marionette_cli/lib/src/cli/commands/mcp_command.dart index a4615805..ce1e3a93 100644 --- a/packages/marionette_cli/lib/src/cli/commands/mcp_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/mcp_command.dart @@ -1,23 +1,9 @@ import 'package:args/command_runner.dart'; -import 'package:marionette_mcp/src/mcp_server_runner.dart'; +import 'package:marionette_mcp/marionette_mcp.dart'; class McpCommand extends Command { McpCommand() { - argParser - ..addOption( - 'log-level', - abbr: 'l', - defaultsTo: 'INFO', - help: 'Log level (FINEST, FINER, FINE, CONFIG, INFO, WARNING, SEVERE).', - ) - ..addOption( - 'log-file', - help: 'Path to log file. If not set, logs to stderr.', - ) - ..addOption( - 'sse-port', - help: 'Port for SSE server. If not set, uses stdio transport.', - ); + ServerConfig.addOptions(argParser); } @override @@ -29,11 +15,11 @@ class McpCommand extends Command { @override Future run() async { - final logLevel = (argResults!.option('log-level') ?? 'INFO').toUpperCase(); - final logFile = argResults!.option('log-file'); - final ssePortStr = argResults!.option('sse-port'); - final ssePort = ssePortStr != null ? int.tryParse(ssePortStr) : null; - - return runMcpServer(logLevel: logLevel, logFile: logFile, ssePort: ssePort); + final config = ServerConfig.fromResults(argResults!); + return runMcpServer( + logLevel: config.logLevel, + logFile: config.logFile, + ssePort: config.ssePort, + ); } } diff --git a/packages/marionette_cli/lib/src/cli/commands/pinch_zoom_command.dart b/packages/marionette_cli/lib/src/cli/commands/pinch_zoom_command.dart index e47dd58f..3edae47e 100644 --- a/packages/marionette_cli/lib/src/cli/commands/pinch_zoom_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/pinch_zoom_command.dart @@ -4,7 +4,7 @@ import 'package:marionette_cli/src/cli/ancestor_keys_option.dart'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/cli/matcher_builder.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; class PinchZoomCommand extends InstanceCommand { PinchZoomCommand(this._registry) { @@ -43,22 +43,11 @@ class PinchZoomCommand extends InstanceCommand { @override Future execute(VmServiceConnector connector) async { - final matcher = buildMatcherFromArgs( - key: argResults?['key'] as String?, - identifier: argResults?['identifier'] as String?, - text: argResults?['text'] as String?, - type: argResults?['type'] as String?, - x: _parseNum(argResults?['x'] as String?), - y: _parseNum(argResults?['y'] as String?), - ancestorKeys: argResults?['ancestor-keys'] as List? ?? const [], + final matcher = buildValidatedMatcherFromArgs( + argResults, + fail: usageException, ); - final xStr = argResults?['x'] as String?; - final yStr = argResults?['y'] as String?; - if ((xStr == null) != (yStr == null)) { - usageException('--x and --y must be provided together.'); - } - if (!hasSelector(matcher)) { usageException( 'At least one matcher required: --key, --identifier, --text, --type, ' @@ -90,9 +79,4 @@ class PinchZoomCommand extends InstanceCommand { stdout.writeln(message); return 0; } - - num? _parseNum(String? value) { - if (value == null) return null; - return num.tryParse(value); - } } diff --git a/packages/marionette_cli/lib/src/cli/commands/press_back_button_command.dart b/packages/marionette_cli/lib/src/cli/commands/press_back_button_command.dart index df5f90fd..d2655916 100644 --- a/packages/marionette_cli/lib/src/cli/commands/press_back_button_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/press_back_button_command.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; class PressBackButtonCommand extends InstanceCommand { PressBackButtonCommand(this._registry); diff --git a/packages/marionette_cli/lib/src/cli/commands/press_key_command.dart b/packages/marionette_cli/lib/src/cli/commands/press_key_command.dart index 26a81691..f384b886 100644 --- a/packages/marionette_cli/lib/src/cli/commands/press_key_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/press_key_command.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; class PressKeyCommand extends InstanceCommand { PressKeyCommand(this._registry) { diff --git a/packages/marionette_cli/lib/src/cli/commands/record_video_command.dart b/packages/marionette_cli/lib/src/cli/commands/record_video_command.dart index 557dd996..518df6c7 100644 --- a/packages/marionette_cli/lib/src/cli/commands/record_video_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/record_video_command.dart @@ -4,13 +4,7 @@ import 'dart:io'; import 'package:marionette_cli/src/cli/adb_helper.dart'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/video/ffmpeg_process.dart'; -import 'package:marionette_mcp/src/video/recording_session.dart'; -import 'package:marionette_mcp/src/video/tcp_frame_reader.dart'; -import 'package:marionette_mcp/src/video/video_options.dart'; -import 'package:marionette_mcp/src/video/video_recorder.dart'; -import 'package:marionette_mcp/src/video/ws_frame_server.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; /// Signature for checking whether ffmpeg is available. typedef FfmpegAvailabilityChecker = Future Function({String ffmpegPath}); @@ -353,18 +347,29 @@ class RecordVideoCommand extends InstanceCommand { // returned transport info without actually starting the screencast, // so we just need one real startScreencast with wsPort. final wsServer = await _wsFrameServerFactory(); - await connector.startScreencast( - maxWidth: effectiveSize?.width, - maxHeight: effectiveSize?.height, - wsPort: wsServer.port, - ); - session = await _wsSessionFactory( - frameSource: wsServer, - outputFile: outputPath, - width: videoSize.width, - height: videoSize.height, - ffmpegPath: ffmpegPath, - ); + try { + await connector.startScreencast( + maxWidth: effectiveSize?.width, + maxHeight: effectiveSize?.height, + wsPort: wsServer.port, + ); + session = await _wsSessionFactory( + frameSource: wsServer, + outputFile: outputPath, + width: videoSize.width, + height: videoSize.height, + ffmpegPath: ffmpegPath, + ); + // The session now owns the server and closes it on stop(). + } catch (_) { + // Until the session is created, this scope owns the server. + try { + await wsServer.close(); + } catch (_) { + // Preserve the startup error if cleanup also fails. + } + rethrow; + } } else { // Auto or forced TCP: try TCP first. // diff --git a/packages/marionette_cli/lib/src/cli/commands/scroll_to_command.dart b/packages/marionette_cli/lib/src/cli/commands/scroll_to_command.dart index 2fb28275..ebd62a63 100644 --- a/packages/marionette_cli/lib/src/cli/commands/scroll_to_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/scroll_to_command.dart @@ -4,7 +4,7 @@ import 'package:marionette_cli/src/cli/ancestor_keys_option.dart'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/cli/matcher_builder.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; class ScrollToCommand extends InstanceCommand { ScrollToCommand(this._registry) { @@ -29,11 +29,9 @@ class ScrollToCommand extends InstanceCommand { @override Future execute(VmServiceConnector connector) async { - final matcher = buildMatcherFromArgs( - key: argResults?['key'] as String?, - identifier: argResults?['identifier'] as String?, - text: argResults?['text'] as String?, - ancestorKeys: argResults?['ancestor-keys'] as List? ?? const [], + final matcher = buildValidatedMatcherFromArgs( + argResults, + fail: usageException, ); if (!hasSelector(matcher)) { diff --git a/packages/marionette_cli/lib/src/cli/commands/secondary_tap_command.dart b/packages/marionette_cli/lib/src/cli/commands/secondary_tap_command.dart index 7e93f5de..24d3055e 100644 --- a/packages/marionette_cli/lib/src/cli/commands/secondary_tap_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/secondary_tap_command.dart @@ -4,7 +4,7 @@ import 'package:marionette_cli/src/cli/ancestor_keys_option.dart'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/cli/matcher_builder.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; class SecondaryTapCommand extends InstanceCommand { SecondaryTapCommand(this._registry) { @@ -33,14 +33,9 @@ class SecondaryTapCommand extends InstanceCommand { @override Future execute(VmServiceConnector connector) async { - final matcher = buildMatcherFromArgs( - key: argResults?['key'] as String?, - identifier: argResults?['identifier'] as String?, - text: argResults?['text'] as String?, - type: argResults?['type'] as String?, - x: _parseNum(argResults?['x'] as String?), - y: _parseNum(argResults?['y'] as String?), - ancestorKeys: argResults?['ancestor-keys'] as List? ?? const [], + final matcher = buildValidatedMatcherFromArgs( + argResults, + fail: usageException, ); if (!hasSelector(matcher)) { @@ -56,9 +51,4 @@ class SecondaryTapCommand extends InstanceCommand { stdout.writeln(message); return 0; } - - num? _parseNum(String? value) { - if (value == null) return null; - return num.tryParse(value); - } } diff --git a/packages/marionette_cli/lib/src/cli/commands/set_device_config_command.dart b/packages/marionette_cli/lib/src/cli/commands/set_device_config_command.dart index cbfca03b..caa1146f 100644 --- a/packages/marionette_cli/lib/src/cli/commands/set_device_config_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/set_device_config_command.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; class SetDeviceConfigCommand extends InstanceCommand { SetDeviceConfigCommand(this._registry) { diff --git a/packages/marionette_cli/lib/src/cli/commands/swipe_command.dart b/packages/marionette_cli/lib/src/cli/commands/swipe_command.dart index 65329670..385e5e8b 100644 --- a/packages/marionette_cli/lib/src/cli/commands/swipe_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/swipe_command.dart @@ -4,7 +4,7 @@ import 'package:marionette_cli/src/cli/ancestor_keys_option.dart'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/cli/matcher_builder.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; class SwipeCommand extends InstanceCommand { SwipeCommand(this._registry) { @@ -24,13 +24,19 @@ class SwipeCommand extends InstanceCommand { help: 'Swipe distance in pixels for element-based mode.', defaultsTo: '200', ) - ..addOption('start-x', - help: 'Start X coordinate for coordinate-based swipe.') - ..addOption('start-y', - help: 'Start Y coordinate for coordinate-based swipe.') + ..addOption( + 'start-x', + help: 'Start X coordinate for coordinate-based swipe.', + ) + ..addOption( + 'start-y', + help: 'Start Y coordinate for coordinate-based swipe.', + ) ..addOption('end-x', help: 'End X coordinate for coordinate-based swipe.') - ..addOption('end-y', - help: 'End Y coordinate for coordinate-based swipe.'); + ..addOption( + 'end-y', + help: 'End Y coordinate for coordinate-based swipe.', + ); } final InstanceRegistry _registry; @@ -93,12 +99,9 @@ class SwipeCommand extends InstanceCommand { swipeArgs['endX'] = endX; swipeArgs['endY'] = endY; } else { - final matcher = buildMatcherFromArgs( - key: argResults?['key'] as String?, - identifier: argResults?['identifier'] as String?, - text: argResults?['text'] as String?, - type: argResults?['type'] as String?, - ancestorKeys: argResults?['ancestor-keys'] as List? ?? const [], + final matcher = buildValidatedMatcherFromArgs( + argResults, + fail: usageException, ); if (!hasSelector(matcher)) { usageException( @@ -132,8 +135,6 @@ class SwipeCommand extends InstanceCommand { } void _ensureNumber(String flag, String value) { - if (double.tryParse(value) == null) { - usageException('$flag must be a number, got "$value".'); - } + parseFiniteNumber(flag.substring(2), value, usageException); } } diff --git a/packages/marionette_cli/lib/src/cli/commands/take_screenshots_command.dart b/packages/marionette_cli/lib/src/cli/commands/take_screenshots_command.dart index 369a9cec..93e488c3 100644 --- a/packages/marionette_cli/lib/src/cli/commands/take_screenshots_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/take_screenshots_command.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; import 'package:path/path.dart' as p; class ScreenshotCommand extends InstanceCommand { diff --git a/packages/marionette_cli/lib/src/cli/commands/tap_command.dart b/packages/marionette_cli/lib/src/cli/commands/tap_command.dart index 2f39ece1..8eaa34f1 100644 --- a/packages/marionette_cli/lib/src/cli/commands/tap_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/tap_command.dart @@ -4,7 +4,7 @@ import 'package:marionette_cli/src/cli/ancestor_keys_option.dart'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/cli/matcher_builder.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; class TapCommand extends InstanceCommand { TapCommand(this._registry) { @@ -32,14 +32,9 @@ class TapCommand extends InstanceCommand { @override Future execute(VmServiceConnector connector) async { - final matcher = buildMatcherFromArgs( - key: argResults?['key'] as String?, - identifier: argResults?['identifier'] as String?, - text: argResults?['text'] as String?, - type: argResults?['type'] as String?, - x: _parseNum(argResults?['x'] as String?), - y: _parseNum(argResults?['y'] as String?), - ancestorKeys: argResults?['ancestor-keys'] as List? ?? const [], + final matcher = buildValidatedMatcherFromArgs( + argResults, + fail: usageException, ); if (!hasSelector(matcher)) { @@ -54,9 +49,4 @@ class TapCommand extends InstanceCommand { stdout.writeln(message); return 0; } - - num? _parseNum(String? value) { - if (value == null) return null; - return num.tryParse(value); - } } diff --git a/packages/marionette_cli/lib/src/cli/commands/unregister_command.dart b/packages/marionette_cli/lib/src/cli/commands/unregister_command.dart index 02f664f7..623396d2 100644 --- a/packages/marionette_cli/lib/src/cli/commands/unregister_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/unregister_command.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:args/command_runner.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; class UnregisterCommand extends Command { UnregisterCommand(this._registry) { diff --git a/packages/marionette_cli/lib/src/cli/instance_command.dart b/packages/marionette_cli/lib/src/cli/instance_command.dart index a1f633b8..c9d9a6ea 100644 --- a/packages/marionette_cli/lib/src/cli/instance_command.dart +++ b/packages/marionette_cli/lib/src/cli/instance_command.dart @@ -3,10 +3,7 @@ import 'dart:io'; import 'package:args/command_runner.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/session/session.dart'; -import 'package:marionette_mcp/src/session/session_manager.dart'; -import 'package:marionette_mcp/src/session/step_logger.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; /// Base class for commands that operate on a connected Flutter app instance. /// diff --git a/packages/marionette_cli/lib/src/cli/matcher_builder.dart b/packages/marionette_cli/lib/src/cli/matcher_builder.dart index f60af7b6..5b4a22a8 100644 --- a/packages/marionette_cli/lib/src/cli/matcher_builder.dart +++ b/packages/marionette_cli/lib/src/cli/matcher_builder.dart @@ -1,4 +1,7 @@ -import 'dart:convert'; +import 'package:args/args.dart'; +import 'package:marionette_mcp/runtime.dart' show buildMatcher; + +export 'package:marionette_mcp/runtime.dart' show hasSelector; /// Builds a widget matcher map from CLI arguments. /// @@ -15,26 +18,74 @@ Map buildMatcherFromArgs({ bool focused = false, List ancestorKeys = const [], }) { - final matcher = {}; - if (key != null) matcher['key'] = key; - if (identifier != null) matcher['identifier'] = identifier; - if (text != null) matcher['text'] = text; - if (type != null) matcher['type'] = type; - if (x != null) matcher['x'] = x; - if (y != null) matcher['y'] = y; - if (focused) matcher['focused'] = true; - // The VM service only carries string values, so the scope chain travels - // JSON-encoded — a delimiter could appear inside a key. - if (ancestorKeys.isNotEmpty) { - matcher['ancestor_keys'] = jsonEncode(ancestorKeys); + return buildMatcher({ + if (key != null) 'key': key, + if (identifier != null) 'identifier': identifier, + if (text != null) 'text': text, + if (type != null) 'type': type, + if (x != null) 'x': x, + if (y != null) 'y': y, + if (focused) 'focused_element': true, + if (ancestorKeys.isNotEmpty) 'ancestor_keys': ancestorKeys, + }); +} + +/// Parses a finite numeric CLI option without silently discarding invalid input. +num parseFiniteNumber( + String option, + String value, + Never Function(String) fail, +) { + final number = num.tryParse(value); + if (number == null || !number.isFinite) { + fail('--$option must be a finite number, got "$value".'); } - return matcher; + return number; } -/// Whether [matcher] identifies an element to act on. +/// Builds a matcher after validating the command's targeting options. /// -/// `ancestor_keys` only narrows where the search happens, so a matcher -/// carrying nothing but a scope still selects nothing. -bool hasSelector(Map matcher) { - return matcher.keys.any((field) => field != 'ancestor_keys'); +/// Element selectors may be combined, but coordinate and focused targeting +/// cannot be combined with another targeting mode. Ancestor scope retains its +/// existing wire representation, including when the target ignores that scope. +Map buildValidatedMatcherFromArgs( + ArgResults? args, { + required Never Function(String) fail, +}) { + String? option(String name) => + args?.options.contains(name) == true ? args![name] as String? : null; + + final key = option('key'); + final identifier = option('identifier'); + final text = option('text'); + final type = option('type'); + final xText = option('x'); + final yText = option('y'); + if ((xText == null) != (yText == null)) { + fail('--x and --y must be provided together.'); + } + final x = xText == null ? null : parseFiniteNumber('x', xText, fail); + final y = yText == null ? null : parseFiniteNumber('y', yText, fail); + final focused = + args?.options.contains('focused') == true && args!['focused'] == true; + final hasElement = + key != null || identifier != null || text != null || type != null; + if (x != null && (hasElement || focused)) { + fail('Cannot mix --x/--y with element or focused selectors.'); + } + if (focused && hasElement) { + fail('Cannot mix --focused with --key, --identifier, --text, or --type.'); + } + return buildMatcherFromArgs( + key: key, + identifier: identifier, + text: text, + type: type, + x: x, + y: y, + focused: focused, + ancestorKeys: args?.options.contains('ancestor-keys') == true + ? args!['ancestor-keys'] as List + : const [], + ); } diff --git a/packages/marionette_cli/test/record_video_command_test.dart b/packages/marionette_cli/test/record_video_command_test.dart index fa740136..171a6bb3 100644 --- a/packages/marionette_cli/test/record_video_command_test.dart +++ b/packages/marionette_cli/test/record_video_command_test.dart @@ -110,10 +110,12 @@ class MockRecordingSession implements RecordingSession { bool stopCalled = false; int frameCount; Duration duration; + final Future Function()? onStop; MockRecordingSession({ this.frameCount = 10, this.duration = const Duration(seconds: 2), + this.onStop, }); @override @@ -124,6 +126,7 @@ class MockRecordingSession implements RecordingSession { @override Future stop() async { stopCalled = true; + await onStop?.call(); return RecordingResult( outputFile: '/tmp/test.webm', duration: duration, @@ -893,23 +896,118 @@ void main() { TestableRecordVideoCommand createWsCommand({ MockRecordingSession? session, + WsRecordingSessionFactory? wsSessionFactory, }) { final s = session ?? mockSession; return TestableRecordVideoCommand( ffmpegChecker: ({String ffmpegPath = 'ffmpeg'}) async => true, sessionFactory: _mockSessionFactory(s), - wsSessionFactory: ({ - required FrameSource frameSource, - required String outputFile, - required int width, - required int height, - required String ffmpegPath, - }) async => - s, + wsSessionFactory: wsSessionFactory ?? + ({ + required FrameSource frameSource, + required String outputFile, + required int width, + required int height, + required String ffmpegPath, + }) async => + s, wsFrameServerFactory: () async => fakeWsServer, ); } + test( + 'When WS screencast startup throws, Then server is closed', + () async { + final error = StateError('screencast startup failed'); + connector.wsStartError = error; + var sessionFactoryCalled = false; + final testCommand = createWsCommand( + wsSessionFactory: ({ + required FrameSource frameSource, + required String outputFile, + required int width, + required int height, + required String ffmpegPath, + }) async { + sessionFactoryCalled = true; + return mockSession; + }, + ); + + await expectLater( + testCommand.executeWithArgs(['-o', '/tmp/test.webm'], connector), + throwsA(same(error)), + ); + + expect(sessionFactoryCalled, isFalse); + expect(fakeWsServer.closeCallCount, 1); + expect(connector.stopScreencastCallCount, 1); + }, + ); + + for (final closeThrows in [false, true]) { + test( + 'When WS session creation throws and closeThrows=$closeThrows, ' + 'Then server cleanup preserves startup error', () async { + final error = StateError('session startup failed'); + if (closeThrows) { + fakeWsServer.closeError = StateError('server close failed'); + } + final testCommand = createWsCommand( + wsSessionFactory: ({ + required FrameSource frameSource, + required String outputFile, + required int width, + required int height, + required String ffmpegPath, + }) async => + throw error, + ); + + await expectLater( + testCommand.executeWithArgs(['-o', '/tmp/test.webm'], connector), + throwsA(same(error)), + ); + + expect(connector.lastWsPort, fakeWsServer.port); + expect(fakeWsServer.closeCallCount, 1); + expect(connector.stopScreencastCallCount, 1); + }); + } + + test( + 'When WS session owns the server, Then only session closes it', + () async { + final testCommand = createWsCommand( + wsSessionFactory: ({ + required FrameSource frameSource, + required String outputFile, + required int width, + required int height, + required String ffmpegPath, + }) async { + expect(frameSource, same(fakeWsServer)); + return MockRecordingSession( + onStop: () async { + expect(fakeWsServer.closeCallCount, 0); + await frameSource.close(); + }, + ); + }, + ); + + final exitCode = await testCommand.executeWithArgs([ + '-o', + '/tmp/test.webm', + '-d', + '1', + ], connector); + + expect(exitCode, 0); + expect(fakeWsServer.closeCallCount, 1); + }, + ); + test( 'When transport is ws, Then wsPort is passed to startScreencast', () async { @@ -1189,6 +1287,8 @@ class WsMockConnector implements VmServiceConnector { int get startScreencastCallCount => startScreencastCalls.length; int? get lastWsPort => startScreencastCalls.lastOrNull?.wsPort; + Object? wsStartError; + int viewportWidth = 800; int viewportHeight = 600; @@ -1205,6 +1305,9 @@ class WsMockConnector implements VmServiceConnector { wsPort: wsPort, ), ); + if (wsPort != null && wsStartError != null) { + throw wsStartError!; + } final (frameW, frameH) = _computeFrameSize( viewportWidth, viewportHeight, @@ -1238,11 +1341,17 @@ class FakeWsFrameServer implements WebSocketFrameServer { @override final int port; + int closeCallCount = 0; + Object? closeError; + @override Stream get frames => const Stream.empty(); @override - Future close() async {} + Future close() async { + closeCallCount++; + if (closeError != null) throw closeError!; + } @override Future start() async {} diff --git a/packages/marionette_cli/test/selector_commands_test.dart b/packages/marionette_cli/test/selector_commands_test.dart new file mode 100644 index 00000000..dd88c402 --- /dev/null +++ b/packages/marionette_cli/test/selector_commands_test.dart @@ -0,0 +1,282 @@ +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:marionette_cli/src/cli/commands/tap_command.dart'; +import 'package:marionette_cli/src/cli/commands/double_tap_command.dart'; +import 'package:marionette_cli/src/cli/commands/secondary_tap_command.dart'; +import 'package:marionette_cli/src/cli/commands/long_press_command.dart'; +import 'package:marionette_cli/src/cli/commands/pinch_zoom_command.dart'; +import 'package:marionette_cli/src/cli/commands/enter_text_command.dart'; +import 'package:marionette_cli/src/cli/commands/scroll_to_command.dart'; +import 'package:marionette_cli/src/cli/commands/swipe_command.dart'; +import 'package:marionette_cli/src/cli/instance_command.dart'; +import 'package:marionette_cli/src/instance_registry.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:test/test.dart'; + +mixin _ParsedCommand on InstanceCommand { + ArgResults? parsed; + + @override + ArgResults? get argResults => parsed; + + Future executeWithArgs(List args, VmServiceConnector connector) { + CommandRunner('marionette', 'Selector command tests').addCommand(this); + parsed = argParser.parse(args); + return execute(connector); + } +} + +class _Connector implements VmServiceConnector { + final calls = []; + + @override + dynamic noSuchMethod(Invocation invocation) { + calls.add(invocation); + return Future>.value({}); + } +} + +class _Tap extends TapCommand with _ParsedCommand { + _Tap() : super(InstanceRegistry()); +} + +class _DoubleTap extends DoubleTapCommand with _ParsedCommand { + _DoubleTap() : super(InstanceRegistry()); +} + +class _SecondaryTap extends SecondaryTapCommand with _ParsedCommand { + _SecondaryTap() : super(InstanceRegistry()); +} + +class _LongPress extends LongPressCommand with _ParsedCommand { + _LongPress() : super(InstanceRegistry()); +} + +class _PinchZoom extends PinchZoomCommand with _ParsedCommand { + _PinchZoom() : super(InstanceRegistry()); +} + +class _EnterText extends EnterTextCommand with _ParsedCommand { + _EnterText() : super(InstanceRegistry()); +} + +class _ScrollTo extends ScrollToCommand with _ParsedCommand { + _ScrollTo() : super(InstanceRegistry()); +} + +class _Swipe extends SwipeCommand with _ParsedCommand { + _Swipe() : super(InstanceRegistry()); +} + +void main() { + final gestures = { + 'tap': _Tap.new, + 'double-tap': _DoubleTap.new, + 'secondary-tap': _SecondaryTap.new, + 'long-press': _LongPress.new, + 'pinch-zoom': _PinchZoom.new, + }; + for (final entry in gestures.entries) { + group(entry.key, () { + final requiredArgs = + entry.key == 'pinch-zoom' ? ['--scale', '2'] : []; + final invalidArgs = >[ + ['--x', '10'], + ['--y', '20'], + ['--x', 'typo', '--y', '20'], + ['--key', 'buy', '--x', 'typo', '--y', '20'], + ['--x', '10', '--y', 'typo'], + ['--x', 'NaN', '--y', '20'], + ['--x', '10', '--y', 'Infinity'], + ['--x', '-Infinity', '--y', '20'], + ['--key', 'buy', '--x', '10', '--y', '20'], + ['--identifier', 'buy', '--x', '10', '--y', '20'], + ['--text', 'Buy', '--x', '10', '--y', '20'], + ['--type', 'Button', '--x', '10', '--y', '20'], + ['--ancestor-keys', 'card'], + ]; + for (final args in invalidArgs) { + test('rejects ${args.join(' ')} before calling connector', () async { + final connector = _Connector(); + await expectLater( + entry.value().executeWithArgs([ + ...args, + ...requiredArgs, + ], connector), + throwsA(isA()), + ); + expect(connector.calls, isEmpty); + }); + } + test('preserves scoped combined element selectors', () async { + final connector = _Connector(); + await entry.value().executeWithArgs([ + '--key', + 'buy', + '--identifier', + 'purchase', + '--text', + 'Buy', + '--type', + 'Button', + '--ancestor-keys', + 'outer', + '--ancestor-keys', + 'inner', + ...requiredArgs, + ], connector); + expect(connector.calls.single.positionalArguments.first, { + 'key': 'buy', + 'identifier': 'purchase', + 'text': 'Buy', + 'type': 'Button', + 'ancestor_keys': '["outer","inner"]', + }); + }); + test('preserves finite coordinate targeting and scope', () async { + final connector = _Connector(); + await entry.value().executeWithArgs([ + '--x', + '-10.5', + '--y', + '2e2', + '--ancestor-keys', + 'card', + ...requiredArgs, + ], connector); + expect(connector.calls.single.positionalArguments.first, { + 'x': -10.5, + 'y': 200, + 'ancestor_keys': '["card"]', + }); + }); + }); + } + test('enter-text preserves focused targeting with ancestor scope', () async { + final connector = _Connector(); + await _EnterText().executeWithArgs([ + '--focused', + '--ancestor-keys', + 'card', + '--input', + 'Hello', + ], connector); + expect(connector.calls.single.positionalArguments, [ + {'focused': true, 'ancestor_keys': '["card"]'}, + 'Hello', + ]); + }); + for (final selector in ['key', 'identifier', 'text']) { + test('enter-text rejects focused plus $selector', () async { + final connector = _Connector(); + await expectLater( + _EnterText().executeWithArgs([ + '--focused', + '--$selector', + 'field', + '--input', + 'Hello', + ], connector), + throwsA(isA()), + ); + expect(connector.calls, isEmpty); + }); + } + test('scroll-to preserves scoped text selector', () async { + final connector = _Connector(); + await _ScrollTo().executeWithArgs([ + '--text', + 'Buy', + '--ancestor-keys', + 'card', + ], connector); + expect(connector.calls.single.positionalArguments.first, { + 'text': 'Buy', + 'ancestor_keys': '["card"]', + }); + }); + for (final value in ['typo', 'NaN', 'Infinity', '-Infinity']) { + test('swipe rejects nonfinite or malformed coordinate $value', () async { + final connector = _Connector(); + await expectLater( + _Swipe().executeWithArgs([ + '--start-x', + value, + '--start-y', + '10', + '--end-x', + '20', + '--end-y', + '30', + ], connector), + throwsA(isA()), + ); + expect(connector.calls, isEmpty); + }); + } + for (final args in >[ + ['--start-x', '10'], + ['--start-x', '10', '--start-y', '20', '--end-x', '30'], + [ + '--key', + 'carousel', + '--start-x', + '10', + '--start-y', + '20', + '--end-x', + '30', + '--end-y', + '40', + ], + ['--ancestor-keys', 'card', '--direction', 'left'], + ]) { + test( + 'swipe rejects incomplete or mixed target ${args.join(' ')}', + () async { + final connector = _Connector(); + await expectLater( + _Swipe().executeWithArgs(args, connector), + throwsA(isA()), + ); + expect(connector.calls, isEmpty); + }, + ); + } + test('swipe preserves scoped element targeting', () async { + final connector = _Connector(); + await _Swipe().executeWithArgs([ + '--key', + 'carousel', + '--ancestor-keys', + 'card', + '--direction', + 'left', + ], connector); + expect(connector.calls.single.positionalArguments.first, { + 'key': 'carousel', + 'ancestor_keys': '["card"]', + 'direction': 'left', + 'distance': '200', + }); + }); + test('swipe preserves finite coordinate representation', () async { + final connector = _Connector(); + await _Swipe().executeWithArgs([ + '--start-x', + '-1.5', + '--start-y', + '10', + '--end-x', + '20', + '--end-y', + '30', + ], connector); + expect(connector.calls.single.positionalArguments.first, { + 'startX': '-1.5', + 'startY': '10', + 'endX': '20', + 'endY': '30', + }); + }); +} diff --git a/packages/marionette_flutter/skills/marionette-flutter-drive-app/SKILL.md b/packages/marionette_flutter/skills/marionette-flutter-drive-app/SKILL.md index 7260623e..e90cc03f 100644 --- a/packages/marionette_flutter/skills/marionette-flutter-drive-app/SKILL.md +++ b/packages/marionette_flutter/skills/marionette-flutter-drive-app/SKILL.md @@ -616,6 +616,10 @@ table above maps onto it one-for-one (`tap` ↔ this side. `ancestor_keys` is `--ancestor-keys`, repeated once per key, outermost first: `tap --key cell.joinButton --ancestor-keys session_2 --ancestor-keys grid.cell_3`. +Coordinate targeting requires both `--x` and `--y` as finite numbers; coordinate +swipes require all four start/end coordinates. Do not mix coordinate or focused +targeting with element selectors. Invalid combinations fail before invoking +the app. Ancestor scope still applies only to element targeting. Use `--uri ` for a one-off session and `register ` + `-i ` for repeated interaction with the same app; `marionette doctor` checks connectivity of every registered diff --git a/packages/marionette_mcp/bin/marionette_mcp.dart b/packages/marionette_mcp/bin/marionette_mcp.dart index 555234d3..296f1b73 100644 --- a/packages/marionette_mcp/bin/marionette_mcp.dart +++ b/packages/marionette_mcp/bin/marionette_mcp.dart @@ -1,33 +1,7 @@ import 'dart:io'; import 'package:args/args.dart'; -import 'package:marionette_mcp/src/mcp_server_runner.dart'; -import 'package:marionette_mcp/src/version.g.dart'; - -ArgParser buildParser() { - return ArgParser() - ..addFlag( - 'help', - abbr: 'h', - negatable: false, - help: 'Print this usage information.', - ) - ..addFlag('version', negatable: false, help: 'Print the tool version.') - ..addOption( - 'log-level', - abbr: 'l', - defaultsTo: 'INFO', - help: 'Log level (FINEST, FINER, FINE, CONFIG, INFO, WARNING, SEVERE).', - ) - ..addOption( - 'log-file', - help: 'Path to log file. If not set, logs to stderr.', - ) - ..addOption( - 'sse-port', - help: 'Port for SSE server. If not set, uses stdio transport.', - ); -} +import 'package:marionette_mcp/marionette_mcp.dart'; void printUsage(ArgParser argParser) { stderr @@ -39,38 +13,35 @@ void printUsage(ArgParser argParser) { ..writeln(argParser.usage); } -Future main(List arguments) async { - final argParser = buildParser(); +Future main(List arguments) async { + final argParser = ServerConfig.buildParser(); try { final results = argParser.parse(arguments); if (results.flag('help')) { printUsage(argParser); - return 0; + return; } if (results.flag('version')) { stderr.writeln('marionette_mcp version: $version'); - return 0; + return; } - final logLevel = (results.option('log-level') ?? 'INFO').toUpperCase(); - final logFile = results.option('log-file'); - final ssePortStr = results.option('sse-port'); - final ssePort = ssePortStr != null ? int.tryParse(ssePortStr) : null; + final config = ServerConfig.fromResults(results); - return await runMcpServer( - logLevel: logLevel, - logFile: logFile, - ssePort: ssePort, + exitCode = await runMcpServer( + logLevel: config.logLevel, + logFile: config.logFile, + ssePort: config.ssePort, ); } on FormatException catch (e) { stderr ..writeln(e.message) ..writeln(); printUsage(argParser); - return 1; + exitCode = 1; } on Exception catch (e) { stderr.writeln(e.toString()); - return 1; + exitCode = 1; } } diff --git a/packages/marionette_mcp/example/pubspec.lock b/packages/marionette_mcp/example/pubspec.lock index 7ace45b3..ae06a20e 100644 --- a/packages/marionette_mcp/example/pubspec.lock +++ b/packages/marionette_mcp/example/pubspec.lock @@ -49,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -157,10 +165,10 @@ packages: dependency: transitive description: name: mcp_dart - sha256: "30ab03147fec3fd72e98172475cd720cd4e3aec7d5f8944ef36dbfabc75a353c" + sha256: af0b2e25412f70440c881314a0b70a56ef87fd090b3eb71f31c356313096e6f5 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.4.3" meta: dependency: transitive description: diff --git a/packages/marionette_mcp/lib/marionette_mcp.dart b/packages/marionette_mcp/lib/marionette_mcp.dart new file mode 100644 index 00000000..c92f91c6 --- /dev/null +++ b/packages/marionette_mcp/lib/marionette_mcp.dart @@ -0,0 +1,7 @@ +/// Marionette MCP server composition and startup. +library; + +export 'mcp/mcp.dart'; +export 'mcp/server_config.dart'; +export 'src/mcp_server_runner.dart' show runMcpServer; +export 'src/version.g.dart' show version; diff --git a/packages/marionette_mcp/lib/mcp/mcp.dart b/packages/marionette_mcp/lib/mcp/mcp.dart new file mode 100644 index 00000000..b1375ce9 --- /dev/null +++ b/packages/marionette_mcp/lib/mcp/mcp.dart @@ -0,0 +1,50 @@ +/// Marionette MCP server composition. +library; + +import 'package:mcp_dart/mcp_dart.dart'; +import 'package:marionette_mcp/src/version.g.dart'; + +import 'server_context.dart'; + +export 'server_context.dart'; +export 'tools/tools.dart'; + +const _instructions = ''' +Marionette MCP enables AI agents to interact with Flutter apps running in debug mode. It provides tools to inspect UI elements, tap buttons, enter text, scroll, take screenshots, retrieve logs, and perform hot reloads and hot restarts. + +Usage: +1. Start the Flutter app in debug mode and note the VM service URI (e.g., ws://127.0.0.1:8181/ws). +2. Use the "connect" tool with the VM service URI to establish a connection. +3. Use "get_interactive_elements" to discover available UI elements. +4. Interact with elements using "tap", "enter_text", or "scroll_to" tools. +5. Use "take_screenshots" to see the current app state and "get_logs" to debug issues. +6. Use "hot_reload" after making code changes to reload the app without losing state. +7. Use "hot_restart" to fully restart the app from main() and reset all state — needed for changes a hot reload cannot pick up (e.g. main()/bootstrap edits, global singletons, or state shape). Requires the app to be running via `flutter run`. + +Important: Elements are matched by their key (ValueKey), Semantics identifier, or text content. Keys are the most reliable; a Semantics identifier (set via `Semantics(identifier: ...)`) is an equally stable alternative when adding a key is not practical. If you cannot locate a widget, you may need to add a ValueKey to it in the Flutter source code. For example: `ElevatedButton(key: ValueKey('submit_button'), ...)`. + +When the same key exists in several identical subtrees (grid cells, repeated cards, embedded app instances), a plain match always hits the first one. Pass "ancestor_keys" alongside the selector to scope the search to one subtree, e.g. `{"key": "cell.joinButton", "ancestor_keys": ["grid.cell_2"]}`. The keys nest, outermost first — each is looked up inside the previous one's subtree — so `["session_2", "grid.cell_3"]` reaches a cell whose own key also repeats in the other sessions. Only descendants of the last key match, never that element itself. If a key has no element the call fails naming it, instead of searching the whole tree; scroll_to keeps scrolling first, because the scope may not be built yet. ancestor_keys is ignored when targeting by coordinates or focused_element. "get_interactive_elements" accepts "ancestor_keys" as well, to list only that subtree — use it to find the right scope keys and to keep the listing small on busy screens. +'''; + +/// Creates the protocol server using an explicitly owned app context. +/// +/// The caller must dispose [context] when its transport closes. +McpServer createMcpServer({required VmServiceContext context}) { + final server = McpServer( + const Implementation(name: 'marionette-mcp', version: version), + options: const McpServerOptions( + // listChanged: true advertises that the tool set can change at + // runtime — required for clients to refetch tools/list when an app + // connects with custom extensions registered via + // registerMarionetteExtension. + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(listChanged: true), + ), + instructions: _instructions, + ), + ); + + context.registerTools(server); + + return server; +} diff --git a/packages/marionette_mcp/lib/mcp/server_config.dart b/packages/marionette_mcp/lib/mcp/server_config.dart new file mode 100644 index 00000000..6b49eaa2 --- /dev/null +++ b/packages/marionette_mcp/lib/mcp/server_config.dart @@ -0,0 +1,54 @@ +import 'package:args/args.dart'; + +/// Command-line configuration shared by both Marionette server entrypoints. +class ServerConfig { + const ServerConfig({this.logLevel = 'INFO', this.logFile, this.ssePort}); + + /// Parses server options, rejecting an invalid SSE port instead of + /// silently falling back to stdio. + factory ServerConfig.fromResults(ArgResults results) { + final rawPort = results.option('sse-port'); + final port = rawPort == null ? null : int.tryParse(rawPort); + if (rawPort != null && (port == null || port < 0 || port > 65535)) { + throw FormatException('SSE port must be an integer between 0 and 65535.'); + } + return ServerConfig( + logLevel: (results.option('log-level') ?? 'INFO').toUpperCase(), + logFile: results.option('log-file'), + ssePort: port, + ); + } + + /// Logging verbosity, using the names from package:logging. + final String logLevel; + + /// Optional log destination; otherwise records are written to stderr. + final String? logFile; + + /// Legacy SSE listener port. Null selects the default stdio transport. + final int? ssePort; + + /// Adds the common options to an executable or CLI subcommand parser. + static void addOptions(ArgParser parser) { + parser + ..addOption('log-level', + abbr: 'l', + defaultsTo: 'INFO', + help: + 'Log level (FINEST, FINER, FINE, CONFIG, INFO, WARNING, SEVERE).') + ..addOption('log-file', + help: 'Path to log file. If not set, logs to stderr.') + ..addOption('sse-port', + help: 'Port for SSE server. If not set, uses stdio transport.'); + } + + /// Builds the standalone executable parser. + static ArgParser buildParser() { + final parser = ArgParser() + ..addFlag('help', + abbr: 'h', negatable: false, help: 'Print this usage information.') + ..addFlag('version', negatable: false, help: 'Print the tool version.'); + addOptions(parser); + return parser; + } +} diff --git a/packages/marionette_mcp/lib/src/vm_service/vm_service_context.dart b/packages/marionette_mcp/lib/mcp/server_context.dart similarity index 80% rename from packages/marionette_mcp/lib/src/vm_service/vm_service_context.dart rename to packages/marionette_mcp/lib/mcp/server_context.dart index e7067d5e..0c916f4b 100644 --- a/packages/marionette_mcp/lib/src/vm_service/vm_service_context.dart +++ b/packages/marionette_mcp/lib/mcp/server_context.dart @@ -3,20 +3,15 @@ import 'package:marionette_mcp/src/session/session_manager.dart'; import 'package:marionette_mcp/src/session/step_logger.dart'; import 'package:marionette_mcp/src/version.g.dart' as v; import 'package:marionette_mcp/src/vm_service/dynamic_extension_tools.dart'; -import 'package:marionette_mcp/src/vm_service/tools/device_tools.dart'; -import 'package:marionette_mcp/src/vm_service/tools/extension_tools.dart'; -import 'package:marionette_mcp/src/vm_service/tools/gesture_tools.dart'; -import 'package:marionette_mcp/src/vm_service/tools/inspection_tools.dart'; -import 'package:marionette_mcp/src/vm_service/tools/keyboard_tools.dart'; -import 'package:marionette_mcp/src/vm_service/tools/system_tools.dart'; -import 'package:marionette_mcp/src/vm_service/tools/text_tools.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; +import 'tools/tools.dart'; + /// Context for managing VM service connection and registering MCP tools. final class VmServiceContext { - VmServiceContext() - : connector = VmServiceConnector(), + VmServiceContext({VmServiceConnector? connector}) + : connector = connector ?? VmServiceConnector(), _logger = logging.Logger('VmServiceContext'), _sessionManager = SessionManager(), _stepLogger = StepLogger(); @@ -27,23 +22,23 @@ final class VmServiceContext { final StepLogger _stepLogger; /// Owns the registry of dynamic extension tools across the lifetime of - /// this context. Reused across connect/disconnect cycles so a previously - /// disabled tool can be revived in place — see [DynamicExtensionTools]. - late final DynamicExtensionTools _dynamicTools; + /// this context. Each connection replaces its app-specific registrations. + DynamicExtensionTools? _dynamicTools; + bool _disposed = false; + int _connectionEpoch = 0; /// Registers all VM service related tools with the MCP server. /// - /// Every tool is registered through [LoggedMcpServer] so each call is + /// Every tool is wrapped with [withStepLogging] so each call is /// recorded to the active session's steps.md (see [StepLogger]) — except /// `connect`/`disconnect`, registered on the raw server: they own the /// version-compatibility handshake with the binding and the session /// lifecycle itself (`connect` clearing it before every attempt and /// setting it fresh on success; `disconnect` clearing it right after /// logging its own step, which the generic wrapper has no hook for), so - /// they log manually instead of going through [LoggedMcpServer]. - /// Everything else is delegated to themed registration functions. + /// they log manually instead of using the generic wrapper. + /// All other tools come from the central typed tool registry. void registerTools(McpServer server) { - final loggedServer = LoggedMcpServer(server, _stepLogger); _dynamicTools = DynamicExtensionTools( server: server, connector: connector, @@ -51,13 +46,16 @@ final class VmServiceContext { stepLogger: _stepLogger, ); _registerConnectionTools(server); - registerInspectionTools(loggedServer, connector, _logger); - registerGestureTools(loggedServer, connector, _logger); - registerTextTools(loggedServer, connector, _logger); - registerKeyboardTools(loggedServer, connector, _logger); - registerDeviceTools(loggedServer, connector, _logger); - registerExtensionTools(loggedServer, connector, _logger); - registerSystemTools(loggedServer, connector, _logger); + for (final tool in createAllTools( + connector: connector, + logger: _logger, + stepLogger: _stepLogger, + )) { + server.registerBaseTool( + tool, + callback: withStepLogging(_stepLogger, tool.name, tool.execute), + ); + } } void _registerConnectionTools(McpServer server) { @@ -95,6 +93,7 @@ final class VmServiceContext { required: ['uri'], ), callback: withStepLogging(_stepLogger, 'connect', (args, extra) async { + final epoch = ++_connectionEpoch; final uri = args['uri'] as String; _logger.info('Connecting to app at $uri'); // A new connect attempt immediately supersedes whatever session @@ -103,14 +102,18 @@ final class VmServiceContext { // still in flight (or after it fails) would append to a session // that's no longer the one actually in use. _stepLogger.session = null; + _disableDynamicTools(); try { + _ensureCurrentConnection(epoch); await connector.connect(uri); + _ensureCurrentConnection(epoch); // Version compatibility check — unwind the connection on mismatch // so the next call to connect can start fresh. try { final bindingVersion = await connector.getVersion(); + _ensureCurrentConnection(epoch); if (bindingVersion != v.version) { await connector.disconnect(); return CallToolResult( @@ -126,7 +129,9 @@ final class VmServiceContext { } } catch (err) { _logger.warning('Failed to check binding version', err); - await connector.disconnect(); + if (!_disposed && epoch == _connectionEpoch) { + await connector.disconnect(); + } return CallToolResult( isError: true, content: [ @@ -144,6 +149,7 @@ final class VmServiceContext { // MCP tool. Failures here are logged but don't fail the connect — // the generic call_custom_extension fallback keeps working. await _registerDynamicTools(); + _ensureCurrentConnection(epoch); // Session reports are opt-in app-side // (MarionetteConfiguration.enableSessionReports): with them off, @@ -163,6 +169,7 @@ final class VmServiceContext { err, ); } + _ensureCurrentConnection(epoch); if (!sessionReportsEnabled) { _stepLogger.session = null; return CallToolResult( @@ -226,6 +233,8 @@ final class VmServiceContext { annotations: const ToolAnnotations(title: 'Disconnect from App'), inputSchema: const ToolInputSchema(properties: {}), callback: (args, extra) async { + final epoch = ++_connectionEpoch; + final disconnectLogger = StepLogger()..session = _stepLogger.session; _logger.info('Disconnecting from app'); try { @@ -234,7 +243,7 @@ final class VmServiceContext { _disableDynamicTools(); await connector.disconnect(); - final session = _stepLogger.session; + final session = disconnectLogger.session; final nudge = session == null ? '' : '\nSession: ${session.directory.path}\nWrite report.md now.'; @@ -245,15 +254,11 @@ final class VmServiceContext { ), ], ); - // Registered on the raw server (not LoggedMcpServer), so this - // step is logged manually here — and only now, right after, is - // the session cleared. That order matters: clearing it first - // would mean disconnect's own line never lands anywhere, and a - // stray call made after disconnecting — or a later failed - // reconnect — would otherwise silently append to a session - // whose report may already be written. - _stepLogger.logStep('disconnect', args, result); - _stepLogger.session = null; + // Attribute the step to the connection being closed. A newer + // connect may already own a different session while cleanup waits. + if (!_disposed) + disconnectLogger.logStep('disconnect', args, result); + if (epoch == _connectionEpoch) _stepLogger.session = null; return result; } catch (err) { _logger.severe('Error during disconnect', err); @@ -261,7 +266,8 @@ final class VmServiceContext { isError: true, content: [TextContent(text: 'Error during disconnect: $err')], ); - _stepLogger.logStep('disconnect', args, result); + if (!_disposed) + disconnectLogger.logStep('disconnect', args, result); return result; } }, @@ -270,10 +276,27 @@ final class VmServiceContext { Future _registerDynamicTools() async { // A reconnect without a clean disconnect would leak the previous - // batch — disable any leftovers before registering fresh ones. - _dynamicTools.disableAll(); - await _dynamicTools.registerAll(); + // batch — remove any leftovers before registering fresh ones. + _dynamicTools?.removeAll(); + await _dynamicTools?.registerAll(); + } + + void _disableDynamicTools() => _dynamicTools?.removeAll(); + + void _ensureCurrentConnection(int epoch) { + if (_disposed || epoch != _connectionEpoch) { + throw StateError('The app connection was closed or superseded.'); + } } - void _disableDynamicTools() => _dynamicTools.disableAll(); + /// Releases dynamic registrations, reports, and the VM-service connection. + /// + /// Called by the server owner on transport close, startup failure, or signal. + Future dispose() async { + _disposed = true; + ++_connectionEpoch; + _disableDynamicTools(); + _stepLogger.session = null; + await connector.disconnect(); + } } diff --git a/packages/marionette_mcp/lib/mcp/tools/base_tool.dart b/packages/marionette_mcp/lib/mcp/tools/base_tool.dart new file mode 100644 index 00000000..fd0c2cad --- /dev/null +++ b/packages/marionette_mcp/lib/mcp/tools/base_tool.dart @@ -0,0 +1,62 @@ +/// Base class for MCP tools with dependency injection. +/// +/// Each tool implementation should extend this class and provide: +/// - [name]: Unique tool identifier +/// - [description]: Human-readable description of what the tool does +/// - [inputSchema]: JSON schema for tool arguments +/// - [outputSchema]: Optional JSON schema for tool output +/// - [annotations]: Optional tool behavior hints (readOnly, destructive, etc.) +/// - [meta]: Optional metadata for the tool +/// - [execute]: Implementation of the tool logic +library; + +import 'package:mcp_dart/mcp_dart.dart'; + +/// Base class for all MCP tools. +abstract class BaseTool { + /// Unique name for this tool. + String get name; + + /// Human-readable description of what this tool does. + String get description; + + /// JSON schema defining the input arguments. + /// Use [JsonSchema.object()] to create an object schema. + ToolInputSchema get inputSchema; + + /// Optional JSON schema defining the output format. + ToolOutputSchema? get outputSchema => null; + + /// Optional tool annotations with hints about tool behavior. + /// Includes readOnlyHint, destructiveHint, idempotentHint, etc. + ToolAnnotations? get annotations => null; + + /// Optional metadata for the tool. + Map? get meta => null; + + /// Execute the tool with the given arguments. + /// + /// Returns a [CallToolResult] with either success content or an error. + Future execute( + Map args, + RequestHandlerExtra extra, + ); +} + +/// Extension to register tools with an MCP server. +extension ToolRegistration on McpServer { + /// Register a [BaseTool] with this server. + /// + /// Use [callback] to wrap execution with shared concerns such as logging. + void registerBaseTool(BaseTool tool, {ToolFunction? callback}) { + registerTool( + tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + annotations: tool.annotations, + meta: tool.meta, + callback: callback ?? tool.execute, + ); + } +} diff --git a/packages/marionette_mcp/lib/mcp/tools/device_tools.dart b/packages/marionette_mcp/lib/mcp/tools/device_tools.dart new file mode 100644 index 00000000..c8c2def5 --- /dev/null +++ b/packages/marionette_mcp/lib/mcp/tools/device_tools.dart @@ -0,0 +1,125 @@ +import 'package:logging/logging.dart' as logging; +import 'package:marionette_mcp/src/vm_service/tools/tool_runner.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:mcp_dart/mcp_dart.dart'; + +import 'base_tool.dart'; + +/// Creates the device tools with their shared dependencies. +List createDeviceTools({ + required VmServiceConnector connector, + required logging.Logger logger, +}) => + [SetDeviceConfigTool(connector, logger)]; + +/// MCP tool `set_device_config`. +class SetDeviceConfigTool extends BaseTool { + SetDeviceConfigTool(this.connector, this.logger); + + final VmServiceConnector connector; + final logging.Logger logger; + + @override + String get name => 'set_device_config'; + + @override + String get description => + 'Overrides the device configuration the running app sees — text scale, bold text, and light/dark appearance — without touching the real device settings. Use it to sweep a screen under accessibility or appearance conditions (large text, bold text, dark mode). Omitted parameters keep their current override; reset clears every override, and combining reset with values leaves exactly those values set (that is how a single override is reverted). Requires the app to wrap its root widget in MarionetteDeviceConfig — if it does not, this returns setup instructions. Requires an active connection established via connect.'; + + @override + ToolAnnotations get annotations => + const ToolAnnotations(title: 'Set Device Config'); + + @override + ToolInputSchema get inputSchema => ToolInputSchema( + properties: { + 'text_scale': JsonSchema.number( + description: + 'Linear text scale, as a multiplier of the platform default. ' + 'Must be greater than 0. Typical sweep values are 1.0 ' + '(default), 1.5 (large) and 2.0 (very large); real devices top ' + 'out around 3.0, and much larger values mostly produce overflow ' + 'errors rather than useful signal. Note that some Material ' + 'widgets clamp their own text scaling.', + ), + 'bold_text': JsonSchema.boolean( + description: 'The system bold-text accessibility setting.', + ), + 'platform_brightness': JsonSchema.string( + description: + 'The system light/dark appearance. Drives MaterialApp theme ' + 'resolution when themeMode is system.', + enumValues: supportedBrightnessValues.toList(), + ), + 'reset': JsonSchema.boolean( + description: + 'Clear every override and revert to the platform defaults. ' + 'Applied before any other parameter in the same call.', + ), + }, + ); + + @override + Future execute( + Map args, + RequestHandlerExtra extra, + ) => + setDeviceConfig(connector, logger, args); +} + +/// Handles a `set_device_config` invocation: validates the args and forwards +/// them to [connector]. +/// +/// Extracted from the tool callback so the validation branches can be +/// exercised in isolation. +Future setDeviceConfig( + VmServiceConnector connector, + logging.Logger logger, + Map args, +) { + final rawTextScale = args['text_scale'] as num?; + final boldText = args['bold_text'] as bool?; + final brightness = args['platform_brightness'] as String?; + final reset = args['reset'] == true; + + final setsAnyField = + rawTextScale != null || boldText != null || brightness != null; + + // A bare `reset: false` would otherwise report success having changed + // nothing. + if (!setsAnyField && !reset) { + return _invalid( + 'At least one parameter is required: text_scale, bold_text, ' + 'platform_brightness, or reset=true.', + ); + } + + if (rawTextScale != null && (!rawTextScale.isFinite || rawTextScale <= 0)) { + return _invalid('text_scale must be greater than 0.'); + } + + if (invalidBrightnessError(brightness) case final error?) { + return _invalid(error); + } + + logger.info( + 'Setting device config: textScale=$rawTextScale, boldText=$boldText, ' + 'platformBrightness=$brightness, reset=$reset', + ); + + return runTool(logger, 'set device config', () async { + final response = await connector.setDeviceConfig( + textScale: rawTextScale?.toDouble(), + boldText: boldText, + platformBrightness: brightness, + reset: reset, + ); + final message = response['message'] as String?; + return CallToolResult( + content: [TextContent(text: message ?? 'Device config updated')], + ); + }); +} + +Future _invalid(String message) async => + CallToolResult(isError: true, content: [TextContent(text: message)]); diff --git a/packages/marionette_mcp/lib/mcp/tools/extension_tools.dart b/packages/marionette_mcp/lib/mcp/tools/extension_tools.dart new file mode 100644 index 00000000..03e94336 --- /dev/null +++ b/packages/marionette_mcp/lib/mcp/tools/extension_tools.dart @@ -0,0 +1,169 @@ +import 'dart:convert'; + +import 'package:logging/logging.dart' as logging; +import 'package:marionette_mcp/src/vm_service/tools/arg_coercion.dart'; +import 'package:marionette_mcp/src/vm_service/tools/tool_runner.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:mcp_dart/mcp_dart.dart'; + +import 'base_tool.dart'; + +/// Creates the extension tools with their shared dependencies. +List createExtensionTools({ + required VmServiceConnector connector, + required logging.Logger logger, +}) => + [ + ListCustomExtensionsTool(connector, logger), + CallCustomExtensionTool(connector, logger), + ]; + +/// MCP tool `list_custom_extensions`. +class ListCustomExtensionsTool extends BaseTool { + ListCustomExtensionsTool(this.connector, this.logger); + + final VmServiceConnector connector; + final logging.Logger logger; + + @override + String get name => 'list_custom_extensions'; + + @override + String get description => + 'Lists all custom VM service extensions registered by the Flutter ' + 'app (outside of the built-in Marionette extensions). Each ' + 'extension includes its name and an optional description. Use this ' + 'to discover what app-specific extensions are available before ' + 'calling them with call_custom_extension. ' + 'Requires an active connection established via connect.'; + + @override + ToolAnnotations get annotations => const ToolAnnotations( + title: 'List Custom Extensions', + readOnlyHint: true, + idempotentHint: true, + ); + + @override + ToolInputSchema get inputSchema => const ToolInputSchema(properties: {}); + + @override + Future execute( + Map args, + RequestHandlerExtra extra, + ) async { + logger.info('Listing custom extensions'); + return runTool(logger, 'list custom extensions', () async { + final response = await connector.listExtensions(); + final extensions = (response['extensions'] as List) + .cast>(); + + if (extensions.isEmpty) { + return CallToolResult( + content: [ + const TextContent(text: 'No custom extensions registered.'), + ], + ); + } + + final buffer = StringBuffer() + ..writeln('Found ${extensions.length} custom extension(s):\n'); + + for (final ext in extensions) { + final name = ext['name'] as String; + final description = ext['description'] as String?; + buffer.write('- $name'); + if (description != null) { + buffer.write(': $description'); + } + buffer.writeln(); + } + + return CallToolResult(content: [TextContent(text: buffer.toString())]); + }); + } +} + +/// MCP tool `call_custom_extension`. +class CallCustomExtensionTool extends BaseTool { + CallCustomExtensionTool(this.connector, this.logger); + + final VmServiceConnector connector; + final logging.Logger logger; + + @override + String get name => 'call_custom_extension'; + + @override + String get description => + 'Calls a custom VM service extension registered by the Flutter app. ' + 'This is an escape hatch for interacting with app-specific ' + 'extensions that are not part of marionette\'s built-in tools. ' + 'For marionette features, use the dedicated tools instead. ' + 'The extension name should not include the "ext.flutter." prefix as ' + 'it is added automatically. For example, use ' + '"deckNavigation.goToSlide" instead of ' + '"ext.flutter.deckNavigation.goToSlide". ' + 'Arguments are passed as string key-value pairs. ' + 'The available extensions depend on what the connected Flutter app ' + 'has registered. Check the app\'s source code for available extensions. ' + 'Requires an active connection established via connect.'; + + // Read-only and idempotency hints depend on the target extension. + @override + ToolAnnotations get annotations => + const ToolAnnotations(title: 'Call Custom Extension'); + + @override + ToolInputSchema get inputSchema => ToolInputSchema( + properties: { + 'extension': JsonSchema.string( + description: 'The extension name without the "ext.flutter." prefix ' + '(e.g., "deckNavigation.goToSlide").', + ), + 'args': JsonSchema.object( + description: 'Optional key-value pairs to pass as arguments. ' + 'Scalars are stringified and nested object/array values are ' + 'JSON-encoded before being sent to the VM service extension.', + properties: {}, + ), + }, + required: ['extension'], + ); + + @override + Future execute( + Map args, + RequestHandlerExtra extra, + ) => + callCustomExtension(connector, logger, args); +} + +/// Handles a `call_custom_extension` invocation: coerces the supplied `args` +/// into the wire shape, forwards them to [connector], and wraps the response. +/// +/// Extracted from the tool callback so the coercion contract can be exercised +/// in isolation. Args go through [coerceToStringMap] — the same path the +/// dynamically-promoted extension tools use — so both produce identical wire +/// args: scalars are stringified and nested object/array values become valid +/// JSON (not Dart's un-parseable `toString()` form). +Future callCustomExtension( + VmServiceConnector connector, + logging.Logger logger, + Map args, +) { + final extensionName = args['extension'] as String; + final extensionArgs = coerceToStringMap( + (args['args'] as Map?) ?? const {}, + ); + logger.info( + 'Calling custom extension: $extensionName with args: $extensionArgs', + ); + return runTool(logger, 'call custom extension', () async { + final response = await connector.callCustomExtension( + extensionName, + extensionArgs, + ); + return CallToolResult(content: [TextContent(text: jsonEncode(response))]); + }); +} diff --git a/packages/marionette_mcp/lib/mcp/tools/gesture_tools.dart b/packages/marionette_mcp/lib/mcp/tools/gesture_tools.dart new file mode 100644 index 00000000..aea0ca66 --- /dev/null +++ b/packages/marionette_mcp/lib/mcp/tools/gesture_tools.dart @@ -0,0 +1,702 @@ +import 'package:logging/logging.dart' as logging; +import 'package:marionette_mcp/src/formatting.dart'; +import 'package:marionette_mcp/src/vm_service/tools/ancestor_keys_description.dart'; +import 'package:marionette_mcp/src/vm_service/tools/tool_runner.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:mcp_dart/mcp_dart.dart'; + +import 'base_tool.dart'; + +/// Creates the gesture tools with their shared dependencies. +List createGestureTools({ + required VmServiceConnector connector, + required logging.Logger logger, +}) => + [ + TapTool(connector, logger), + SecondaryTapTool(connector, logger), + DoubleTapTool(connector, logger), + LongPressTool(connector, logger), + SwipeTool(connector, logger), + PinchZoomTool(connector, logger), + PressBackButtonTool(connector, logger), + ScrollToTool(connector, logger), + ]; + +/// MCP tool `tap`. +class TapTool extends BaseTool { + TapTool(this.connector, this.logger); + + final VmServiceConnector connector; + final logging.Logger logger; + + @override + String get name => 'tap'; + + @override + String get description => + 'Simulates a tap gesture on an element in the Flutter app that matches the given criteria. You can match elements by their key (a ValueKey), by their Semantics identifier, by their text content (but not accessibility label!), by their widget type, or by screen coordinates. Only one matching method should be used: either key, identifier, text, type, or coordinates. Prefer using the key if available, as it is more reliable; the Semantics identifier is the next-best stable selector. Limit yourself to elements from get_interactive_elements only if you can. Tapping a text field gives it focus, after which you can use enter_text with focused_element to type into it. Requires an active connection established via connect.'; + + @override + ToolAnnotations get annotations => + const ToolAnnotations(title: 'Tap Element'); + + @override + ToolInputSchema get inputSchema => ToolInputSchema( + properties: { + 'key': JsonSchema.string( + description: + 'The key of the element to tap. You can get the key of an element by calling get_interactive_elements.', + ), + 'identifier': JsonSchema.string( + description: + 'The Semantics identifier of the element to tap. A stable, ' + 'unique, accessibility identifier set via Semantics(identifier: ...). ' + 'Useful when an element has no ValueKey but does set an identifier. ' + 'You can discover identifiers by calling get_interactive_elements.', + ), + 'text': JsonSchema.string( + description: + 'The visible text content of the element to tap. Use this for elements that display text like buttons or labels.', + ), + 'type': JsonSchema.string( + description: + 'The widget type name of the element to tap (e.g., "ElevatedButton", "IconButton"). Use this to match elements by their Flutter widget type.', + ), + 'coordinates': JsonSchema.object( + description: + 'Screen coordinates to tap at. Use this to tap at a specific position on the screen.', + properties: { + 'x': JsonSchema.number( + description: + 'The x coordinate (horizontal position from left).', + ), + 'y': JsonSchema.number( + description: 'The y coordinate (vertical position from top).', + ), + }, + required: ['x', 'y'], + ), + 'ancestor_keys': JsonSchema.array( + items: JsonSchema.string(), + description: ancestorKeysDescription, + ), + }, + ); + + @override + Future execute( + Map args, + RequestHandlerExtra extra, + ) async { + final matcher = buildMatcher(args); + logger.info('Tapping with matcher: $matcher'); + return runTool(logger, 'tap', () async { + final response = await connector.tap(matcher); + final message = response['message'] as String?; + return CallToolResult( + content: [TextContent(text: message ?? 'Successfully tapped')], + ); + }); + } +} + +/// MCP tool `secondary_tap`. +class SecondaryTapTool extends BaseTool { + SecondaryTapTool(this.connector, this.logger); + + final VmServiceConnector connector; + final logging.Logger logger; + + @override + String get name => 'secondary_tap'; + + @override + String get description => + 'Simulates a secondary tap (right mouse button click) on an element in the Flutter app. Desktop only — dispatches a mouse pointer with the secondary button pressed, which is what Flutter recognises as onSecondaryTap (e.g. for opening context menus). You can match elements by their key (a ValueKey), by their Semantics identifier, by their text content, by their widget type, or by screen coordinates. Only one matching method should be used. Prefer using the key if available, as it is more reliable. Requires an active connection established via connect.'; + + @override + ToolAnnotations get annotations => + const ToolAnnotations(title: 'Secondary Tap Element'); + + @override + ToolInputSchema get inputSchema => ToolInputSchema( + properties: { + 'key': JsonSchema.string( + description: + 'The key of the element to secondary tap. You can get the key of an element by calling get_interactive_elements.', + ), + 'identifier': JsonSchema.string( + description: + 'The Semantics identifier of the element to secondary tap. A ' + 'stable, unique accessibility identifier set via ' + 'Semantics(identifier: ...).', + ), + 'text': JsonSchema.string( + description: + 'The visible text content of the element to secondary tap.', + ), + 'type': JsonSchema.string( + description: + 'The widget type name of the element to secondary tap (e.g., "ElevatedButton", "ListTile").', + ), + 'coordinates': JsonSchema.object( + description: 'Screen coordinates to secondary tap at.', + properties: { + 'x': JsonSchema.number( + description: + 'The x coordinate (horizontal position from left).', + ), + 'y': JsonSchema.number( + description: 'The y coordinate (vertical position from top).', + ), + }, + required: ['x', 'y'], + ), + 'ancestor_keys': JsonSchema.array( + items: JsonSchema.string(), + description: ancestorKeysDescription, + ), + }, + ); + + @override + Future execute( + Map args, + RequestHandlerExtra extra, + ) async { + final matcher = buildMatcher(args); + logger.info('Secondary tapping with matcher: $matcher'); + return runTool(logger, 'secondary tap', () async { + final response = await connector.secondaryTap(matcher); + final message = response['message'] as String?; + return CallToolResult( + content: [ + TextContent(text: message ?? 'Successfully secondary tapped'), + ], + ); + }); + } +} + +/// MCP tool `double_tap`. +class DoubleTapTool extends BaseTool { + DoubleTapTool(this.connector, this.logger); + + final VmServiceConnector connector; + final logging.Logger logger; + + @override + String get name => 'double_tap'; + + @override + String get description => + 'Simulates a double tap gesture on an element in the Flutter app. This is useful for triggering text selection, zoom, or any widget that responds to double tap. You can match elements by their key, identifier, text, type, or coordinates. An optional delay parameter controls the time between the two taps (default: 100ms). Requires an active connection established via connect.'; + + @override + ToolAnnotations get annotations => + const ToolAnnotations(title: 'Double Tap Element'); + + @override + ToolInputSchema get inputSchema => ToolInputSchema( + properties: { + 'key': JsonSchema.string( + description: + 'The key of the element to double tap. You can get the key of an element by calling get_interactive_elements.', + ), + 'identifier': JsonSchema.string( + description: + 'The Semantics identifier of the element to double tap. A ' + 'stable, unique accessibility identifier set via ' + 'Semantics(identifier: ...).', + ), + 'text': JsonSchema.string( + description: + 'The visible text content of the element to double tap.', + ), + 'type': JsonSchema.string( + description: + 'The widget type name of the element to double tap (e.g., "ListTile", "Card").', + ), + 'coordinates': JsonSchema.object( + description: 'Screen coordinates to double tap at.', + properties: { + 'x': JsonSchema.number( + description: + 'The x coordinate (horizontal position from left).', + ), + 'y': JsonSchema.number( + description: 'The y coordinate (vertical position from top).', + ), + }, + required: ['x', 'y'], + ), + 'delay': JsonSchema.number( + description: + 'Time between the two taps in milliseconds. Defaults to 100ms which is within Flutter\'s double-tap recognition window (40ms-300ms).', + ), + 'ancestor_keys': JsonSchema.array( + items: JsonSchema.string(), + description: ancestorKeysDescription, + ), + }, + ); + + @override + Future execute( + Map args, + RequestHandlerExtra extra, + ) async { + final delay = (args['delay'] as num?)?.toInt(); + if (delay != null && delay <= 0) { + return CallToolResult( + isError: true, + content: [ + const TextContent( + text: 'Parameter "delay" must be a positive integer.', + ), + ], + ); + } + final matcher = buildMatcher(args); + logger.info('Double tapping with matcher: $matcher'); + return runTool(logger, 'double tap', () async { + final response = await connector.doubleTap(matcher, delayMs: delay); + final message = response['message'] as String?; + return CallToolResult( + content: [TextContent(text: message ?? 'Successfully double tapped')], + ); + }); + } +} + +/// MCP tool `long_press`. +class LongPressTool extends BaseTool { + LongPressTool(this.connector, this.logger); + + final VmServiceConnector connector; + final logging.Logger logger; + + @override + String get name => 'long_press'; + + @override + String get description => + 'Simulates a long press gesture on an element in the Flutter app. This is useful for triggering context menus, reorderable lists, or any widget that responds to long press. You can match elements by their key, identifier, text, type, or coordinates. An optional duration parameter controls how long the press is held (default: 600ms). Requires an active connection established via connect.'; + + @override + ToolAnnotations get annotations => + const ToolAnnotations(title: 'Long Press Element'); + + @override + ToolInputSchema get inputSchema => ToolInputSchema( + properties: { + 'key': JsonSchema.string( + description: + 'The key of the element to long press. You can get the key of an element by calling get_interactive_elements.', + ), + 'identifier': JsonSchema.string( + description: + 'The Semantics identifier of the element to long press. A ' + 'stable, unique accessibility identifier set via ' + 'Semantics(identifier: ...).', + ), + 'text': JsonSchema.string( + description: + 'The visible text content of the element to long press.', + ), + 'type': JsonSchema.string( + description: + 'The widget type name of the element to long press (e.g., "ListTile", "Card").', + ), + 'coordinates': JsonSchema.object( + description: 'Screen coordinates to long press at.', + properties: { + 'x': JsonSchema.number( + description: + 'The x coordinate (horizontal position from left).', + ), + 'y': JsonSchema.number( + description: 'The y coordinate (vertical position from top).', + ), + }, + required: ['x', 'y'], + ), + 'duration': JsonSchema.number( + description: + 'How long to hold the press in milliseconds. Defaults to 600ms which matches Flutter\'s long press behavior.', + ), + 'ancestor_keys': JsonSchema.array( + items: JsonSchema.string(), + description: ancestorKeysDescription, + ), + }, + ); + + @override + Future execute( + Map args, + RequestHandlerExtra extra, + ) async { + final duration = (args['duration'] as num?)?.toInt(); + final matcher = buildMatcher(args); + logger.info('Long pressing with matcher: $matcher'); + return runTool(logger, 'long press', () async { + final response = await connector.longPress(matcher, durationMs: duration); + final message = response['message'] as String?; + return CallToolResult( + content: [TextContent(text: message ?? 'Successfully long pressed')], + ); + }); + } +} + +/// MCP tool `swipe`. +class SwipeTool extends BaseTool { + SwipeTool(this.connector, this.logger); + + final VmServiceConnector connector; + final logging.Logger logger; + + @override + String get name => 'swipe'; + + @override + String get description => + 'Simulates a swipe/drag gesture on the Flutter app. Supports two modes: ' + '1. Element-based: provide key, identifier, or text to identify the element, plus a direction (left, right, up, down) and optional distance in pixels (default 200). ' + '2. Coordinate-based: provide startX, startY, endX, endY for precise control. ' + 'Useful for interacting with PageView, Dismissible, Drawer, Slider, and other swipe-based widgets. ' + 'Requires an active connection established via connect.'; + + @override + ToolAnnotations get annotations => const ToolAnnotations(title: 'Swipe'); + + @override + ToolInputSchema get inputSchema => ToolInputSchema( + properties: { + 'key': JsonSchema.string( + description: + 'The key of the element to swipe on. Use with direction.', + ), + 'identifier': JsonSchema.string( + description: + 'The Semantics identifier of the element to swipe on. Use with ' + 'direction. A stable, unique accessibility identifier set via ' + 'Semantics(identifier: ...).', + ), + 'text': JsonSchema.string( + description: + 'The visible text content of the element to swipe on. Use with direction.', + ), + 'direction': JsonSchema.string( + description: + 'Swipe direction when using element-based mode: left, right, up, or down.', + ), + 'distance': JsonSchema.number( + description: + 'Swipe distance in pixels for element-based mode (default: 200).', + ), + 'startX': JsonSchema.number( + description: 'Start X coordinate for coordinate-based swipe.', + ), + 'startY': JsonSchema.number( + description: 'Start Y coordinate for coordinate-based swipe.', + ), + 'endX': JsonSchema.number( + description: 'End X coordinate for coordinate-based swipe.', + ), + 'endY': JsonSchema.number( + description: 'End Y coordinate for coordinate-based swipe.', + ), + 'ancestor_keys': JsonSchema.array( + items: JsonSchema.string(), + description: ancestorKeysDescription, + ), + }, + ); + + @override + Future execute( + Map args, + RequestHandlerExtra extra, + ) async { + logger.info('Swiping with args: $args'); + + final swipeArgs = {}; + + if (args.containsKey('startX')) { + if (!args.containsKey('startY') || + !args.containsKey('endX') || + !args.containsKey('endY')) { + return CallToolResult( + isError: true, + content: [ + const TextContent( + text: 'Coordinate-based swipe requires all of: ' + 'startX, startY, endX, endY', + ), + ], + ); + } + swipeArgs['startX'] = args['startX'].toString(); + swipeArgs['startY'] = args['startY'].toString(); + swipeArgs['endX'] = args['endX'].toString(); + swipeArgs['endY'] = args['endY'].toString(); + } else { + if (!args.containsKey('direction')) { + return CallToolResult( + isError: true, + content: [ + const TextContent( + text: 'Element-based swipe requires a direction ' + '(left, right, up, or down).', + ), + ], + ); + } + swipeArgs.addAll(buildMatcher(args)); + swipeArgs['direction'] = args['direction'] as String; + if (args.containsKey('distance')) { + swipeArgs['distance'] = args['distance'].toString(); + } + } + + return runTool(logger, 'swipe', () async { + final response = await connector.swipe(swipeArgs); + final message = response['message'] as String?; + return CallToolResult( + content: [TextContent(text: message ?? 'Successfully swiped')], + ); + }); + } +} + +/// MCP tool `pinch_zoom`. +class PinchZoomTool extends BaseTool { + PinchZoomTool(this.connector, this.logger); + + final VmServiceConnector connector; + final logging.Logger logger; + + @override + String get name => 'pinch_zoom'; + + @override + String get description => + 'Simulates a pinch zoom gesture on an element in the Flutter app. ' + 'Use scale > 1.0 to zoom in (fingers move apart) and scale < 1.0 ' + 'to zoom out (fingers move together). You can target the element by ' + 'key, identifier, text, type, or coordinates. Useful for maps, ' + 'images, PDFs, and other zoomable content. ' + 'Requires an active connection established via connect.'; + + @override + ToolAnnotations get annotations => const ToolAnnotations(title: 'Pinch Zoom'); + + @override + ToolInputSchema get inputSchema => ToolInputSchema( + properties: { + 'key': JsonSchema.string( + description: 'The key of the element to pinch zoom on.', + ), + 'identifier': JsonSchema.string( + description: + 'The Semantics identifier of the element to pinch zoom on. A ' + 'stable, unique accessibility identifier set via ' + 'Semantics(identifier: ...).', + ), + 'text': JsonSchema.string( + description: + 'The visible text content of the element to pinch zoom on.', + ), + 'type': JsonSchema.string( + description: + 'The widget type name of the element to pinch zoom on.', + ), + 'coordinates': JsonSchema.object( + description: 'Screen coordinates to pinch zoom at.', + properties: { + 'x': JsonSchema.number(description: 'The x coordinate.'), + 'y': JsonSchema.number(description: 'The y coordinate.'), + }, + required: ['x', 'y'], + ), + 'scale': JsonSchema.number( + description: + 'Zoom scale factor. Values > 1.0 zoom in, values < 1.0 ' + 'zoom out. For example, 2.0 doubles the zoom level.', + ), + 'start_distance': JsonSchema.number( + description: 'Initial distance between the two fingers in pixels ' + '(default: 200).', + ), + 'ancestor_keys': JsonSchema.array( + items: JsonSchema.string(), + description: ancestorKeysDescription, + ), + }, + required: ['scale'], + ); + + @override + Future execute( + Map args, + RequestHandlerExtra extra, + ) async { + final matcher = buildMatcher(args); + if (!hasSelector(matcher)) { + return CallToolResult( + isError: true, + content: [ + const TextContent( + text: 'Missing required selector: provide "key", ' + '"identifier", "text", "type", or "coordinates".', + ), + ], + ); + } + final scale = (args['scale'] as num).toDouble(); + if (scale <= 0) { + return CallToolResult( + isError: true, + content: [const TextContent(text: 'scale must be a positive number.')], + ); + } + final startDistance = (args['start_distance'] as num?)?.toDouble(); + if (startDistance != null && startDistance <= 0) { + return CallToolResult( + isError: true, + content: [ + const TextContent(text: 'start_distance must be a positive number.'), + ], + ); + } + + logger.info('Pinch zooming with matcher: $matcher, scale: $scale'); + return runTool(logger, 'pinch zoom', () async { + final response = await connector.pinchZoom( + matcher, + scale: scale, + startDistance: startDistance, + ); + final message = response['message'] as String?; + return CallToolResult( + content: [TextContent(text: message ?? 'Successfully pinch zoomed')], + ); + }); + } +} + +/// MCP tool `press_back_button`. +class PressBackButtonTool extends BaseTool { + PressBackButtonTool(this.connector, this.logger); + + final VmServiceConnector connector; + final logging.Logger logger; + + @override + String get name => 'press_back_button'; + + @override + String get description => + 'Simulates a system back button press in the Flutter app. ' + 'This triggers the same mechanism as the Android back button or ' + 'iOS swipe-back gesture. If the app has a route to pop, it will ' + 'be popped. If the app is on the root route, the system may ' + 'minimize or close the app (same as real back button behavior). ' + 'Works with Navigator, GoRouter, and other routing solutions. ' + 'Requires an active connection established via connect.'; + + @override + ToolAnnotations get annotations => + const ToolAnnotations(title: 'Press Back Button'); + + @override + ToolInputSchema get inputSchema => const ToolInputSchema(properties: {}); + + @override + Future execute( + Map args, + RequestHandlerExtra extra, + ) async { + logger.info('Pressing back button'); + return runTool(logger, 'press back button', () async { + final response = await connector.pressBackButton(); + final message = response['message'] as String?; + final didPop = response['didPop']; + + return CallToolResult( + content: [ + TextContent( + text: message ?? + (didPop == true + ? 'Back button pressed, route was popped' + : 'Back button pressed, no route to pop'), + ), + ], + ); + }); + } +} + +/// MCP tool `scroll_to`. +class ScrollToTool extends BaseTool { + ScrollToTool(this.connector, this.logger); + + final VmServiceConnector connector; + final logging.Logger logger; + + @override + String get name => 'scroll_to'; + + @override + String get description => + 'Scrolls the view until an element matching the given criteria becomes visible. You can match elements by their key (a ValueKey), by their Semantics identifier, or by their visible text content. This is useful when you need to interact with elements that are not currently visible on screen. Requires an active connection established via connect.'; + + @override + ToolAnnotations get annotations => + const ToolAnnotations(title: 'Scroll to Element'); + + @override + ToolInputSchema get inputSchema => ToolInputSchema( + properties: { + 'key': JsonSchema.string( + description: + 'The key of the element to scroll to. You can get the key of an element by calling get_interactive_elements.', + ), + 'identifier': JsonSchema.string( + description: + 'The Semantics identifier of the element to scroll to. A ' + 'stable, unique accessibility identifier set via ' + 'Semantics(identifier: ...).', + ), + 'text': JsonSchema.string( + description: + 'The visible text content of the element to scroll to.', + ), + 'ancestor_keys': JsonSchema.array( + items: JsonSchema.string(), + description: ancestorKeysDescription, + ), + }, + ); + + @override + Future execute( + Map args, + RequestHandlerExtra extra, + ) async { + final matcher = buildMatcher(args); + logger.info('Scrolling to element with matcher: $matcher'); + return runTool(logger, 'scroll to element', () async { + final response = await connector.scrollToElement(matcher); + final message = response['message'] as String?; + return CallToolResult( + content: [ + TextContent(text: message ?? 'Successfully scrolled to element'), + ], + ); + }); + } +} diff --git a/packages/marionette_mcp/lib/mcp/tools/inspection_tools.dart b/packages/marionette_mcp/lib/mcp/tools/inspection_tools.dart new file mode 100644 index 00000000..035534b2 --- /dev/null +++ b/packages/marionette_mcp/lib/mcp/tools/inspection_tools.dart @@ -0,0 +1,305 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:logging/logging.dart' as logging; +import 'package:marionette_mcp/src/formatting.dart'; +import 'package:marionette_mcp/src/session/session.dart'; +import 'package:marionette_mcp/src/session/step_logger.dart'; +import 'package:marionette_mcp/src/vm_service/tools/tool_runner.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:mcp_dart/mcp_dart.dart'; +import 'package:path/path.dart' as p; + +import 'base_tool.dart'; + +/// Creates the inspection tools with their shared dependencies. +List createInspectionTools({ + required VmServiceConnector connector, + required logging.Logger logger, + required StepLogger stepLogger, +}) => + [ + GetInteractiveElementsTool(connector, logger), + GetLogsTool(connector, logger), + TakeScreenshotsTool(connector, logger, stepLogger), + ]; + +/// MCP tool `get_interactive_elements`. +class GetInteractiveElementsTool extends BaseTool { + GetInteractiveElementsTool(this.connector, this.logger); + + final VmServiceConnector connector; + final logging.Logger logger; + + @override + String get name => 'get_interactive_elements'; + + @override + String get description => + 'Returns a list of all interactive elements currently visible in the Flutter app UI tree. Each element includes its type, text content (if any), key (if any), and other identifying properties. This is useful for understanding what can be interacted with in the app. Style, decoration and colour blobs are never reported. Set compaction to "compact" (the default for most apps) to also drop rendering details no interaction tool reads (textAlign, softWrap, overflow, font metrics, …), report visible only when an element is not visible, round bounds to whole logical pixels, and drop a Text element\'s duplicated data field — much more token-efficient for agent loops. Set compaction to "none" to force the full payload. Omit compaction to use the app\'s MarionetteConfiguration.compaction default. Requires an active connection established via connect.'; + + @override + ToolAnnotations get annotations => const ToolAnnotations( + title: 'Get Interactive Elements', + readOnlyHint: true, + idempotentHint: true, + ); + + @override + ToolInputSchema get inputSchema => ToolInputSchema( + properties: { + 'ancestor_keys': JsonSchema.array( + items: JsonSchema.string(), + description: _ancestorKeysDescription, + ), + 'compaction': JsonSchema.string( + description: + 'How much to reduce the payload. "compact" drops rendering details, font metrics, a Text element\'s duplicated data, rounds bounds to whole logical pixels, and reports visible only when false. "none" forces the full payload. Omit to use the app\'s MarionetteConfiguration.compaction default.', + enumValues: supportedCompactionModes.toList(), + ), + }, + ); + + @override + Future execute( + Map args, + RequestHandlerExtra extra, + ) async { + final compaction = args['compaction'] as String?; + if (invalidCompactionError(compaction) case final error?) { + return CallToolResult(isError: true, content: [TextContent(text: error)]); + } + + logger.info('Getting interactive elements (compaction: $compaction)'); + return runTool(logger, 'get interactive elements', () async { + final response = await connector.getInteractiveElements( + ancestorKeys: + (args['ancestor_keys'] as List?)?.cast() ?? + const [], + compaction: compaction, + ); + final elements = response['elements'] as List; + + final buffer = StringBuffer() + ..writeln('Found ${elements.length} interactive element(s):\n'); + + for (final element in elements) { + buffer.writeln(formatElement(element as Map)); + } + + return CallToolResult(content: [TextContent(text: buffer.toString())]); + }); + } +} + +/// MCP tool `get_logs`. +class GetLogsTool extends BaseTool { + GetLogsTool(this.connector, this.logger); + + final VmServiceConnector connector; + final logging.Logger logger; + + @override + String get name => 'get_logs'; + + @override + String get description => + 'Retrieves all application logs collected from the Flutter app since app start or since the last hot reload. This includes debug messages, errors, and other log output from the running app. Requires an active connection established via connect.'; + + @override + ToolAnnotations get annotations => + const ToolAnnotations(title: 'Get Application Logs', readOnlyHint: true); + + @override + ToolInputSchema get inputSchema => const ToolInputSchema(properties: {}); + + @override + Future execute( + Map args, + RequestHandlerExtra extra, + ) async { + logger.info('Getting application logs'); + + try { + final response = await connector.getLogs(); + final logs = response['logs'] as List; + final count = response['count'] as int; + + if (count == 0) { + return CallToolResult( + content: [const TextContent(text: 'No logs collected')], + ); + } + + final buffer = StringBuffer() + ..writeln('Collected $count log entr${count == 1 ? 'y' : 'ies'}:\n'); + + for (final log in logs) { + buffer.writeln(log); + } + + return CallToolResult(content: [TextContent(text: buffer.toString())]); + } on VmServiceExtensionException catch (err) { + // Surface the VM service's own error message verbatim — it carries + // setup instructions for enabling log collection. + logger.warning('Failed to get logs', err); + return CallToolResult( + isError: true, + content: [TextContent(text: err.error ?? err.message)], + ); + } catch (err) { + logger.warning('Failed to get logs', err); + return CallToolResult( + isError: true, + content: [TextContent(text: err.toString())], + ); + } + } +} + +/// MCP tool `take_screenshots`. +class TakeScreenshotsTool extends BaseTool { + TakeScreenshotsTool(this.connector, this.logger, this.stepLogger); + + final VmServiceConnector connector; + final logging.Logger logger; + final StepLogger stepLogger; + + @override + String get name => 'take_screenshots'; + + @override + String get description => + 'Takes screenshots of all views in the Flutter app. By default returns base64-encoded PNG images inline, which can be decoded and saved. Set inline: false to instead save the screenshots straight into the active session directory (only available when the app enables session reports) and get back their paths — evidence you plan to cite in a report but don\'t need to look at right now, since each inline screenshot costs real visual tokens. Requires an active connection established via connect.'; + + @override + ToolAnnotations get annotations => + const ToolAnnotations(title: 'Take Screenshots', readOnlyHint: true); + + @override + ToolInputSchema get inputSchema => ToolInputSchema( + properties: { + 'inline': JsonSchema.boolean( + description: + 'If false, saves the screenshots into the session directory ' + 'and returns their paths instead of the image data. ' + 'Defaults to true.', + ), + }, + ); + + @override + Future execute( + Map args, + RequestHandlerExtra extra, + ) async { + logger.info('Taking screenshots'); + return runTool( + logger, + 'take screenshots', + () => takeScreenshots(connector, stepLogger.session, args), + ); + } +} + +/// Handles a `take_screenshots` invocation: captures via [connector], then +/// either returns the images inline or — when `args['inline'] == false` — +/// saves them into [session]'s screenshots directory and returns their paths. +/// +/// Extracted from the tool callback so the inline/save-only branching can be +/// exercised in isolation. +Future takeScreenshots( + VmServiceConnector connector, + Session? session, + Map args, +) async { + final inline = args['inline'] != false; + final response = await connector.takeScreenshots(); + final screenshots = (response['screenshots'] as List).cast(); + + if (screenshots.isEmpty) { + return CallToolResult( + content: [const TextContent(text: 'No screenshots captured')], + ); + } + + if (inline) { + return CallToolResult( + content: screenshots + .map( + (screenshot) => + ImageContent(data: screenshot, mimeType: 'image/png'), + ) + .toList(), + ); + } + + if (session == null) { + return CallToolResult( + isError: true, + content: [ + const TextContent( + text: 'inline: false requires an active session, which is ' + 'only created once connect succeeds and only when the app ' + 'enables session reports ' + '(MarionetteConfiguration.enableSessionReports).', + ), + ], + ); + } + + final paths = _saveScreenshots(session.screenshotsDir, screenshots); + return CallToolResult( + content: [ + TextContent( + text: 'Saved ${paths.length} screenshot(s):\n${paths.join('\n')}', + ), + ], + ); +} + +/// Saves each base64-encoded PNG in [screenshots] into [screenshotsDir], +/// numbered sequentially after whatever is already there, and returns the +/// paths written to. +List _saveScreenshots( + Directory screenshotsDir, + List screenshots, +) { + screenshotsDir.createSync(recursive: true); + // The next index is one past the highest existing numeric name, not a + // count of PNGs present — a gap (e.g. 01.png and 03.png after 02.png was + // deleted) would otherwise make a count-based index collide with, and + // overwrite, the existing 03.png. + final highestExisting = screenshotsDir + .listSync() + .whereType() + .where((f) => p.extension(f.path) == '.png') + .map((f) => int.tryParse(p.basenameWithoutExtension(f.path))) + .whereType() + .fold(0, (highest, n) => n > highest ? n : highest); + var nextIndex = highestExisting + 1; + + final paths = []; + for (final screenshot in screenshots) { + final path = p.join( + screenshotsDir.path, + '${nextIndex.toString().padLeft(2, '0')}.png', + ); + File(path).writeAsBytesSync(base64Decode(screenshot)); + paths.add(path); + nextIndex++; + } + return paths; +} + +/// `ancestor_keys` as worded for `get_interactive_elements`, which lists a +/// subtree rather than matching one element inside it. +/// +/// Kept separate from the matcher tools' `ancestorKeysDescription` because +/// the field means something else here: the scope alone is the whole request, +/// and it lists that subtree instead of limiting where one element is +/// searched for. +const _ancestorKeysDescription = + 'Optional wrapper keys (ValueKey), outermost first; each is ' + 'looked up inside the previous one. Lists only that subtree. Fails if a ' + 'key has no element.'; diff --git a/packages/marionette_mcp/lib/mcp/tools/keyboard_tools.dart b/packages/marionette_mcp/lib/mcp/tools/keyboard_tools.dart new file mode 100644 index 00000000..d04e6d71 --- /dev/null +++ b/packages/marionette_mcp/lib/mcp/tools/keyboard_tools.dart @@ -0,0 +1,85 @@ +import 'package:logging/logging.dart' as logging; +import 'package:marionette_mcp/src/vm_service/tools/tool_runner.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:mcp_dart/mcp_dart.dart'; + +import 'base_tool.dart'; + +/// Creates the keyboard tools with their shared dependencies. +List createKeyboardTools({ + required VmServiceConnector connector, + required logging.Logger logger, +}) => + [PressKeyTool(connector, logger)]; + +/// MCP tool `press_key`. +class PressKeyTool extends BaseTool { + PressKeyTool(this.connector, this.logger); + + final VmServiceConnector connector; + final logging.Logger logger; + + @override + String get name => 'press_key'; + + @override + String get description => + 'Presses a keyboard key in the Flutter app, producing a real key event ' + 'that flows through the focus system — unlike enter_text, which only ' + 'replaces a field\'s value. Use it to submit a form (enter), move focus ' + '(tab), dismiss a dialog (escape), edit within a field (backspace, ' + 'delete, arrowLeft/arrowRight/arrowUp/arrowDown, home, end), or trigger ' + 'app shortcuts via modifiers (for example control+a to select all). ' + 'The key is sent to whatever currently has focus, exactly like a real ' + 'keyboard, so focus a target first if needed (for example with tap). ' + 'Requires an active connection established via connect.'; + + @override + ToolAnnotations get annotations => const ToolAnnotations(title: 'Press Key'); + + @override + ToolInputSchema get inputSchema => ToolInputSchema( + properties: { + 'key': JsonSchema.string( + description: + 'The key to press. Named keys: enter, tab, escape, backspace, ' + 'delete, space, arrowUp, arrowDown, arrowLeft, arrowRight, home, ' + 'end, pageUp, pageDown. Also accepts a single character a-z or ' + '0-9 (case-insensitive).', + ), + 'modifiers': JsonSchema.string( + description: + 'Optional comma-separated modifier keys to hold during the ' + 'press: any of control, shift, alt, meta (e.g. "control" or ' + '"control,shift"). On macOS use meta for the Command key.', + ), + }, + required: ['key'], + ); + + @override + Future execute( + Map args, + RequestHandlerExtra extra, + ) async { + final key = args['key'] as String; + final modifiers = args['modifiers'] as String?; + + final modifierError = invalidModifiersError(modifiers); + if (modifierError != null) { + return CallToolResult( + isError: true, + content: [TextContent(text: modifierError)], + ); + } + + logger.info('Pressing key: $key (modifiers: ${modifiers ?? 'none'})'); + return runTool(logger, 'press key', () async { + final response = await connector.pressKey(key, modifiers: modifiers); + final message = response['message'] as String?; + return CallToolResult( + content: [TextContent(text: message ?? 'Successfully pressed key')], + ); + }); + } +} diff --git a/packages/marionette_mcp/lib/mcp/tools/system_tools.dart b/packages/marionette_mcp/lib/mcp/tools/system_tools.dart new file mode 100644 index 00000000..6d98dfe4 --- /dev/null +++ b/packages/marionette_mcp/lib/mcp/tools/system_tools.dart @@ -0,0 +1,122 @@ +import 'package:logging/logging.dart' as logging; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:mcp_dart/mcp_dart.dart'; + +import 'base_tool.dart'; + +/// Creates the system tools with their shared dependencies. +List createSystemTools({ + required VmServiceConnector connector, + required logging.Logger logger, +}) => + [HotReloadTool(connector, logger), HotRestartTool(connector, logger)]; + +/// MCP tool `hot_reload`. +class HotReloadTool extends BaseTool { + HotReloadTool(this.connector, this.logger); + + final VmServiceConnector connector; + final logging.Logger logger; + + @override + String get name => 'hot_reload'; + + @override + String get description => + 'Performs a hot reload of the Flutter app. This reloads the Dart code without restarting the app, preserving the current state. Useful after making code changes to see them reflected in the running app. Requires an active connection established via connect.'; + + @override + ToolAnnotations get annotations => const ToolAnnotations(title: 'Hot Reload'); + + @override + ToolInputSchema get inputSchema => const ToolInputSchema(properties: {}); + + @override + Future execute( + Map args, + RequestHandlerExtra extra, + ) async { + logger.info('Performing hot reload'); + + try { + final reloaded = await connector.hotReload(); + if (reloaded) { + return CallToolResult( + content: [ + const TextContent(text: 'Hot reload completed successfully'), + ], + ); + } + return CallToolResult( + isError: true, + content: [ + TextContent( + text: 'Hot reload failed. The app may need a full restart.', + ), + ], + ); + } catch (err) { + logger.warning('Failed to perform hot reload', err); + return CallToolResult( + isError: true, + content: [TextContent(text: 'Hot reload failed: $err')], + ); + } + } +} + +/// MCP tool `hot_restart`. +class HotRestartTool extends BaseTool { + HotRestartTool(this.connector, this.logger); + + final VmServiceConnector connector; + final logging.Logger logger; + + @override + String get name => 'hot_restart'; + + @override + String get description => + 'Performs a hot restart of the Flutter app. This fully restarts the app from main() and resets all state. Use it instead of hot_reload after changes a reload cannot pick up (e.g. changes to main()/bootstrap, global singletons, or state shape). Requires the app to be running via `flutter run` and an active connection established via connect.'; + + @override + ToolAnnotations get annotations => + const ToolAnnotations(title: 'Hot Restart'); + + @override + ToolInputSchema get inputSchema => const ToolInputSchema(properties: {}); + + @override + Future execute( + Map args, + RequestHandlerExtra extra, + ) async { + logger.info('Performing hot restart'); + + try { + final restarted = await connector.hotRestart(); + if (restarted) { + return CallToolResult( + content: [ + const TextContent(text: 'Hot restart completed successfully'), + ], + ); + } + return CallToolResult( + isError: true, + content: [ + TextContent( + text: 'Hot restart failed or is unavailable. Make sure the ' + 'app is running via `flutter run`.', + ), + ], + ); + } catch (err) { + logger.warning('Failed to perform hot restart', err); + return CallToolResult( + isError: true, + content: [TextContent(text: 'Hot restart failed: $err')], + ); + } + } +} diff --git a/packages/marionette_mcp/lib/mcp/tools/text_tools.dart b/packages/marionette_mcp/lib/mcp/tools/text_tools.dart new file mode 100644 index 00000000..935018e4 --- /dev/null +++ b/packages/marionette_mcp/lib/mcp/tools/text_tools.dart @@ -0,0 +1,100 @@ +import 'package:logging/logging.dart' as logging; +import 'package:marionette_mcp/src/formatting.dart'; +import 'package:marionette_mcp/src/vm_service/tools/ancestor_keys_description.dart'; +import 'package:marionette_mcp/src/vm_service/tools/tool_runner.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:mcp_dart/mcp_dart.dart'; + +import 'base_tool.dart'; + +/// Creates the text tools with their shared dependencies. +List createTextTools({ + required VmServiceConnector connector, + required logging.Logger logger, +}) => + [EnterTextTool(connector, logger)]; + +/// MCP tool `enter_text`. +class EnterTextTool extends BaseTool { + EnterTextTool(this.connector, this.logger); + + final VmServiceConnector connector; + final logging.Logger logger; + + @override + String get name => 'enter_text'; + + @override + String get description => + 'Enters text into a text field in the Flutter app. You can target the field in three ways: 1. By key: provide the key parameter with the ValueKey of the field. You can discover available keys by calling get_interactive_elements. 2. By Semantics identifier: provide the identifier parameter with the accessibility identifier of the field. Useful when the field has no ValueKey but does set a Semantics identifier. 3. By focused element: first tap a text field to give it focus, then call enter_text with focused_element set to true. Important: when targeting by focused element, a text field must be focused before calling this (for example by using the tap tool), otherwise it will fail with an error. Exactly one of key, identifier, or focused_element must be provided. Requires an active connection established via connect.'; + + @override + ToolAnnotations get annotations => const ToolAnnotations(title: 'Enter Text'); + + @override + ToolInputSchema get inputSchema => ToolInputSchema( + properties: { + 'input': JsonSchema.string( + description: 'The text to enter into the text field.', + ), + 'key': JsonSchema.string( + description: + 'The key of the text field. You can get the key of an element by calling get_interactive_elements.', + ), + 'identifier': JsonSchema.string( + description: + 'The Semantics identifier of the text field. A stable, unique ' + 'accessibility identifier set via Semantics(identifier: ...). ' + 'You can discover identifiers by calling get_interactive_elements.', + ), + 'focused_element': JsonSchema.boolean( + description: + 'If true, enters text into the currently focused text field. ' + 'A text field must be focused first (for example by using tap), otherwise this will fail.', + ), + 'ancestor_keys': JsonSchema.array( + items: JsonSchema.string(), + description: ancestorKeysDescription, + ), + }, + required: ['input'], + ); + + @override + Future execute( + Map args, + RequestHandlerExtra extra, + ) async { + final input = args['input'] as String; + final hasKey = args['key'] != null; + final hasIdentifier = args['identifier'] != null; + final hasFocusedElement = args['focused_element'] == true; + + final selectorCount = [ + hasKey, + hasIdentifier, + hasFocusedElement, + ].where((e) => e).length; + if (selectorCount != 1) { + return CallToolResult( + isError: true, + content: [ + const TextContent( + text: + 'enter_text requires exactly one selector: provide key, identifier, or focused_element=true.', + ), + ], + ); + } + + final matcher = buildMatcher(args); + logger.info('Entering text into element with matcher: $matcher'); + return runTool(logger, 'enter text', () async { + final response = await connector.enterText(matcher, input); + final message = response['message'] as String?; + return CallToolResult( + content: [TextContent(text: message ?? 'Successfully entered text')], + ); + }); + } +} diff --git a/packages/marionette_mcp/lib/mcp/tools/tools.dart b/packages/marionette_mcp/lib/mcp/tools/tools.dart new file mode 100644 index 00000000..4b2d2954 --- /dev/null +++ b/packages/marionette_mcp/lib/mcp/tools/tools.dart @@ -0,0 +1,44 @@ +/// Static MCP tools for inspecting and interacting with a Flutter app. +library; + +import 'package:logging/logging.dart' as logging; +import 'package:marionette_mcp/src/session/step_logger.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; + +import 'base_tool.dart'; +import 'device_tools.dart'; +import 'extension_tools.dart'; +import 'gesture_tools.dart'; +import 'inspection_tools.dart'; +import 'keyboard_tools.dart'; +import 'system_tools.dart'; +import 'text_tools.dart'; + +export 'base_tool.dart'; +export 'device_tools.dart'; +export 'extension_tools.dart'; +export 'gesture_tools.dart'; +export 'inspection_tools.dart'; +export 'keyboard_tools.dart'; +export 'system_tools.dart'; +export 'text_tools.dart'; + +/// Creates all static tools with their shared dependencies. +List createAllTools({ + required VmServiceConnector connector, + required logging.Logger logger, + required StepLogger stepLogger, +}) => + [ + ...createInspectionTools( + connector: connector, + logger: logger, + stepLogger: stepLogger, + ), + ...createGestureTools(connector: connector, logger: logger), + ...createTextTools(connector: connector, logger: logger), + ...createKeyboardTools(connector: connector, logger: logger), + ...createDeviceTools(connector: connector, logger: logger), + ...createExtensionTools(connector: connector, logger: logger), + ...createSystemTools(connector: connector, logger: logger), + ]; diff --git a/packages/marionette_mcp/lib/runtime.dart b/packages/marionette_mcp/lib/runtime.dart new file mode 100644 index 00000000..5ff84747 --- /dev/null +++ b/packages/marionette_mcp/lib/runtime.dart @@ -0,0 +1,17 @@ +/// Shared app connection, session reporting, and recording runtime. +/// +/// Both CLI adapters and MCP tools use this supported package boundary. +library; + +export 'src/formatting.dart'; +export 'src/session/session.dart'; +export 'src/session/session_manager.dart'; +export 'src/session/step_logger.dart'; +export 'src/video/ffmpeg_process.dart'; +export 'src/video/frame_protocol.dart'; +export 'src/video/recording_session.dart'; +export 'src/video/tcp_frame_reader.dart'; +export 'src/video/video_options.dart'; +export 'src/video/video_recorder.dart'; +export 'src/video/ws_frame_server.dart'; +export 'src/vm_service/vm_service_connector.dart'; diff --git a/packages/marionette_mcp/lib/src/mcp_server_runner.dart b/packages/marionette_mcp/lib/src/mcp_server_runner.dart index 1d32b5d6..c3a6529a 100644 --- a/packages/marionette_mcp/lib/src/mcp_server_runner.dart +++ b/packages/marionette_mcp/lib/src/mcp_server_runner.dart @@ -2,27 +2,9 @@ import 'dart:async'; import 'dart:io'; import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/src/version.g.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_context.dart'; +import 'package:marionette_mcp/mcp/mcp.dart'; import 'package:mcp_dart/mcp_dart.dart'; -const _instructions = ''' -Marionette MCP enables AI agents to interact with Flutter apps running in debug mode. It provides tools to inspect UI elements, tap buttons, enter text, scroll, take screenshots, retrieve logs, and perform hot reloads and hot restarts. - -Usage: -1. Start the Flutter app in debug mode and note the VM service URI (e.g., ws://127.0.0.1:8181/ws). -2. Use the "connect" tool with the VM service URI to establish a connection. -3. Use "get_interactive_elements" to discover available UI elements. -4. Interact with elements using "tap", "enter_text", or "scroll_to" tools. -5. Use "take_screenshots" to see the current app state and "get_logs" to debug issues. -6. Use "hot_reload" after making code changes to reload the app without losing state. -7. Use "hot_restart" to fully restart the app from main() and reset all state — needed for changes a hot reload cannot pick up (e.g. main()/bootstrap edits, global singletons, or state shape). Requires the app to be running via `flutter run`. - -Important: Elements are matched by their key (ValueKey), Semantics identifier, or text content. Keys are the most reliable; a Semantics identifier (set via `Semantics(identifier: ...)`) is an equally stable alternative when adding a key is not practical. If you cannot locate a widget, you may need to add a ValueKey to it in the Flutter source code. For example: `ElevatedButton(key: ValueKey('submit_button'), ...)`. - -When the same key exists in several identical subtrees (grid cells, repeated cards, embedded app instances), a plain match always hits the first one. Pass "ancestor_keys" alongside the selector to scope the search to one subtree, e.g. `{"key": "cell.joinButton", "ancestor_keys": ["grid.cell_2"]}`. The keys nest, outermost first — each is looked up inside the previous one's subtree — so `["session_2", "grid.cell_3"]` reaches a cell whose own key also repeats in the other sessions. Only descendants of the last key match, never that element itself. If a key has no element the call fails naming it, instead of searching the whole tree; scroll_to keeps scrolling first, because the scope may not be built yet. ancestor_keys is ignored when targeting by coordinates or focused_element. "get_interactive_elements" accepts "ancestor_keys" as well, to list only that subtree — use it to find the right scope keys and to keep the listing small on busy screens. -'''; - /// Runs the Marionette MCP server with the given configuration. /// /// Sets up logging, creates the MCP server with tools, and runs it on either @@ -32,34 +14,34 @@ Future runMcpServer({ String? logFile, int? ssePort, }) async { - setupLogging(logLevel, logFile); + final logSubscription = setupLogging(logLevel, logFile); final vmService = VmServiceContext(); - final server = McpServer( - const Implementation(name: 'marionette-mcp', version: version), - options: const McpServerOptions( - // listChanged: true advertises that the tool set can change at - // runtime — required for clients to refetch tools/list when an app - // connects with custom extensions registered via - // registerMarionetteExtension. - capabilities: ServerCapabilities( - tools: ServerCapabilitiesTools(listChanged: true), - ), - instructions: _instructions, - ), - ); - - vmService.registerTools(server); - - if (ssePort != null) { - return _runSseServer(server, ssePort); - } else { - return _runStdioServer(server); + final server = createMcpServer(context: vmService); + try { + return ssePort == null + ? await _runStdioServer(server) + : await _runSseServer(server, ssePort); + } finally { + // The app connection outlives individual tool calls, so the server owns it. + // Closing only the MCP transport leaves its VM-service socket alive. + try { + await vmService.dispose(); + } finally { + try { + await server.close(); + } finally { + await logSubscription.cancel(); + } + } } } -void setupLogging(String logLevelName, String? logFile) { +StreamSubscription setupLogging( + String logLevelName, + String? logFile, +) { final logLevel = logging.Level.LEVELS.firstWhere( (e) => e.name == logLevelName, orElse: () => logging.Level.INFO, @@ -69,14 +51,14 @@ void setupLogging(String logLevelName, String? logFile) { if (logFile != null) { final file = File(logFile)..createSync(recursive: true); - logging.Logger.root.onRecord.listen((record) { + return logging.Logger.root.onRecord.listen((record) { file.writeAsStringSync( '[${record.level.name}][${record.loggerName}][${_formatTime(record.time)}] ${record.message}\n', mode: FileMode.append, ); }); } else { - logging.Logger.root.onRecord.listen((record) { + return logging.Logger.root.onRecord.listen((record) { stderr.writeln( '[${record.level.name}][${record.loggerName}][${_formatTime(record.time)}] ${record.message}', ); @@ -135,7 +117,6 @@ Future _runStdioServer(McpServer server) async { // Release the signal subscriptions so the event loop can drain and the // process actually exits on the stdin-EOF path. exitSignal.dispose(); - await server.close(); await transport.close(); logger.info('Stopped'); return 0; @@ -143,33 +124,36 @@ Future _runStdioServer(McpServer server) async { Future _runSseServer(McpServer server, int ssePort) async { final logger = logging.Logger('main'); - final sseServerManager = SseServerManager(server); + final manager = SseServerManager(server); + final exitSignal = ExitSignal(); + HttpServer? httpServer; try { - final httpServer = await HttpServer.bind( + final listener = await HttpServer.bind( InternetAddress.loopbackIPv4, ssePort, ); + httpServer = listener; logger.fine('Running MCP server on SSE port $ssePort'); - unawaited( - ExitSignal().wait.then((signal) { - logger.info('Received ${signal.name}, stopping'); - unawaited(httpServer.close()); - }), - ); - - await for (final request in httpServer) { - unawaited(sseServerManager.handleRequest(request)); + unawaited(exitSignal.wait.then((signal) async { + logger.info('Received ${signal.name}, stopping'); + await listener.close(force: true); + })); + await for (final request in listener) { + unawaited(manager.handleRequest(request)); } - - logger.info('Stopping'); - await server.close(); + logger.info('Stopped'); + return 0; } catch (e, st) { logger.severe('Error when waiting for MCP client connection', e, st); return 1; + } finally { + exitSignal.dispose(); + await httpServer?.close(force: true); + await Future.wait([ + for (final transport in manager.activeSseTransports.values.toList()) + transport.close(), + ]); } - - logger.info('Stopped'); - return 0; } /// Waits for SIGINT or SIGTERM to signal graceful shutdown. diff --git a/packages/marionette_mcp/lib/src/session/step_logger.dart b/packages/marionette_mcp/lib/src/session/step_logger.dart index 76cd4784..99ef528c 100644 --- a/packages/marionette_mcp/lib/src/session/step_logger.dart +++ b/packages/marionette_mcp/lib/src/session/step_logger.dart @@ -344,44 +344,3 @@ ToolFunction withStepLogging( } }; } - -/// A drop-in [McpServer] substitute whose `registerTool` logs every call to -/// the active session's steps.md (see [withStepLogging]) before delegating -/// to the real server. -/// -/// Passed to every `register*Tools` function in place of the raw server so -/// step logging happens uniformly, without each tool file having to call -/// back into a logger itself. `connect`/`disconnect` are the exception — -/// registered on the raw server so `disconnect` can clear [StepLogger.session] -/// immediately after its own step is logged (see [vm_service_context.dart]). -class LoggedMcpServer { - LoggedMcpServer(this._server, this.stepLogger); - - final McpServer _server; - - /// Exposed so a tool that needs the active session directly (currently - /// only `take_screenshots`, to save files into it) can reach it. - final StepLogger stepLogger; - - RegisteredTool registerTool( - String name, { - String? title, - String? description, - ToolInputSchema? inputSchema, - ToolOutputSchema? outputSchema, - ToolAnnotations? annotations, - Map? meta, - required ToolFunction callback, - }) { - return _server.registerTool( - name, - title: title, - description: description, - inputSchema: inputSchema, - outputSchema: outputSchema, - annotations: annotations, - meta: meta, - callback: withStepLogging(stepLogger, name, callback), - ); - } -} diff --git a/packages/marionette_mcp/lib/src/vm_service/dynamic_extension_tools.dart b/packages/marionette_mcp/lib/src/vm_service/dynamic_extension_tools.dart index 76c38b4d..3f961d9e 100644 --- a/packages/marionette_mcp/lib/src/vm_service/dynamic_extension_tools.dart +++ b/packages/marionette_mcp/lib/src/vm_service/dynamic_extension_tools.dart @@ -10,14 +10,8 @@ import 'package:mcp_dart/mcp_dart.dart'; /// Registers MCP tools at runtime for each custom extension exposed by the /// connected Flutter app via `registerMarionetteExtension`. /// -/// A single instance lives for the lifetime of the MCP server and is reused -/// across connect/disconnect cycles. Callers invoke [registerAll] after -/// `connect` succeeds, then [disableAll] on `disconnect` to retire the -/// registered tools. On a subsequent [registerAll] previously-disabled tools -/// are revived in place via [RegisteredTool.update] rather than re-registered -/// — re-registering by name throws in mcp_dart 2.1.0 because [disable] -/// leaves the entry in the server's tool map (and [RegisteredTool.remove] -/// is broken — see [disableAll]). +/// Registrations belong to the current app connection and are removed on +/// disconnect. Reconnecting registers the current schemas from scratch. /// /// Only extensions that ship an `inputSchema` get promoted — schema-less /// extensions remain reachable through the generic `call_custom_extension` @@ -49,30 +43,16 @@ class DynamicExtensionTools { final logging.Logger _logger; final StepLogger? _stepLogger; - /// Every tool we have ever registered with the server, keyed by the - /// (sanitized) MCP tool name. Persists across connect/disconnect cycles so - /// we can revive entries via [RegisteredTool.update] instead of hitting the - /// duplicate-name guard in [McpServer.registerTool]. - final Map _pool = {}; - - /// Maps a sanitized tool name back to the real extension name that owns it. - /// Used to tell a same-extension revival (across reconnects) apart from two - /// distinct extensions whose names sanitize to the same tool name (a - /// collision we must skip rather than silently clobber). Persists for the - /// lifetime of the instance, mirroring [_pool]. + final Map _tools = {}; final Map _extensionByTool = {}; + int _generation = 0; - /// Tool names that are currently enabled (i.e. promoted in this connect - /// cycle). - final Set _active = {}; - - /// The tools enabled by the most recent [registerAll] call that have not - /// yet been disabled. Exposed for tests. - List get registeredTools => - List.unmodifiable(_active.map((name) => _pool[name]!)); + /// The tools registered by the most recent [registerAll] call that have not + /// yet been removed. Exposed for tests. + List get registeredTools => List.unmodifiable(_tools.values); /// The real extension name backing the promoted MCP tool [toolName], or - /// null if no such tool has been pooled. The promoted tool name is + /// null if no such tool is registered. The promoted tool name is /// sanitized (see [sanitizeToolName]); this maps it back to the name the /// VM service extension is actually invoked with. Exposed for introspection /// and tests. @@ -86,6 +66,8 @@ class DynamicExtensionTools { /// and renames their extension. Tools whose schema fails to parse are /// likewise skipped with a warning. Future registerAll() async { + removeAll(); + final generation = _generation; final Map response; try { response = await _connector.listExtensions(); @@ -98,6 +80,9 @@ class DynamicExtensionTools { return; } + // A disconnect may finish while the app's listExtensions call is pending. + if (generation != _generation) return; + final extensionsRaw = response['extensions']; if (extensionsRaw is! List) { _logger.warning( @@ -140,7 +125,6 @@ class DynamicExtensionTools { schemaJson: schemaJson, ); if (tool != null) { - _active.add(toolName); registered++; } } @@ -152,24 +136,17 @@ class DynamicExtensionTools { ); } - /// Disables every tool currently enabled by [registerAll] but keeps the - /// references in the pool so the next [registerAll] can revive them via - /// [RegisteredTool.update]. + /// Removes the tools owned by the current app connection. /// - /// We can't drop them from the server because [RegisteredTool.remove] is - /// broken in mcp_dart 2.1.0 (`update(name: null)` never deletes the entry - /// from the registry). Disabled tools are filtered out of `tools/list` - /// and rejected on call, which is the behavior we want; the registry - /// entry just stays squatting on its name and we re-enable it next time. - void disableAll() { - if (_active.isEmpty) return; - for (final name in _active) { - _pool[name]!.disable(); + /// mcp_dart 2.4.3 releases their registry names, so reconnects need no pool + /// of disabled handles and a different app can reuse a sanitized name. + void removeAll() { + ++_generation; + for (final tool in _tools.values) { + tool.remove(); } - _logger.info( - 'Disabled ${_active.length} dynamic extension tool(s) on disconnect.', - ); - _active.clear(); + _tools.clear(); + _extensionByTool.clear(); } RegisteredTool? _promote({ @@ -203,8 +180,8 @@ class DynamicExtensionTools { final effectiveDescription = _describe(extensionName, toolName, description); - final pooled = _pool[toolName]; - if (pooled != null) { + final existing = _tools[toolName]; + if (existing != null) { if (_extensionByTool[toolName] != extensionName) { // Two distinct extensions sanitize to the same tool name. Reviving in // place would silently hijack the first one's tool, so skip instead. @@ -215,16 +192,14 @@ class DynamicExtensionTools { ); return null; } - // Previously seen — revive in place. Re-registering by name would - // throw because mcp_dart's `disable()` leaves the entry in the - // server's tool map. - pooled.update( + // Repeated entries for the same extension update its current schema. + existing.update( description: effectiveDescription, inputSchema: schema, callback: FunctionToolCallback(callback), enabled: true, ); - return pooled; + return existing; } try { @@ -234,14 +209,13 @@ class DynamicExtensionTools { inputSchema: schema, callback: callback, ); - _pool[toolName] = tool; + _tools[toolName] = tool; _extensionByTool[toolName] = extensionName; return tool; } on ArgumentError catch (err) { // mcp_dart throws ArgumentError on duplicate name — most likely a // collision with a built-in marionette tool (tap, scroll_to, etc.). - // We don't pool colliding names so subsequent reconnects re-emit the - // warning. + // A conflicting built-in remains untouched across reconnects. _logger.warning( 'Skipping extension "$extensionName" (tool name "$toolName"): MCP ' 'tool name collision. Rename the extension on the Flutter side. ' diff --git a/packages/marionette_mcp/lib/src/vm_service/tools/device_tools.dart b/packages/marionette_mcp/lib/src/vm_service/tools/device_tools.dart deleted file mode 100644 index 235ed19f..00000000 --- a/packages/marionette_mcp/lib/src/vm_service/tools/device_tools.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/src/session/step_logger.dart'; -import 'package:marionette_mcp/src/vm_service/tools/tool_runner.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; -import 'package:mcp_dart/mcp_dart.dart'; - -/// Registers MCP tools that change the device configuration the app runs -/// under: `set_device_config`. -void registerDeviceTools( - LoggedMcpServer server, - VmServiceConnector connector, - logging.Logger logger, -) { - server.registerTool( - 'set_device_config', - description: - 'Overrides the device configuration the running app sees — text scale, bold text, and light/dark appearance — without touching the real device settings. Use it to sweep a screen under accessibility or appearance conditions (large text, bold text, dark mode). Omitted parameters keep their current override; reset clears every override, and combining reset with values leaves exactly those values set (that is how a single override is reverted). Requires the app to wrap its root widget in MarionetteDeviceConfig — if it does not, this returns setup instructions. Requires an active connection established via connect.', - annotations: const ToolAnnotations(title: 'Set Device Config'), - inputSchema: ToolInputSchema( - properties: { - 'text_scale': JsonSchema.number( - description: - 'Linear text scale, as a multiplier of the platform default. ' - 'Must be greater than 0. Typical sweep values are 1.0 ' - '(default), 1.5 (large) and 2.0 (very large); real devices top ' - 'out around 3.0, and much larger values mostly produce overflow ' - 'errors rather than useful signal. Note that some Material ' - 'widgets clamp their own text scaling.', - ), - 'bold_text': JsonSchema.boolean( - description: 'The system bold-text accessibility setting.', - ), - 'platform_brightness': JsonSchema.string( - description: - 'The system light/dark appearance. Drives MaterialApp theme ' - 'resolution when themeMode is system.', - enumValues: supportedBrightnessValues.toList(), - ), - 'reset': JsonSchema.boolean( - description: - 'Clear every override and revert to the platform defaults. ' - 'Applied before any other parameter in the same call.', - ), - }, - ), - callback: (args, extra) => setDeviceConfig(connector, logger, args), - ); -} - -/// Handles a `set_device_config` invocation: validates the args and forwards -/// them to [connector]. -/// -/// Extracted from the tool callback so the validation branches can be -/// exercised in isolation. -Future setDeviceConfig( - VmServiceConnector connector, - logging.Logger logger, - Map args, -) { - final rawTextScale = args['text_scale'] as num?; - final boldText = args['bold_text'] as bool?; - final brightness = args['platform_brightness'] as String?; - final reset = args['reset'] == true; - - final setsAnyField = - rawTextScale != null || boldText != null || brightness != null; - - // A bare `reset: false` would otherwise report success having changed - // nothing. - if (!setsAnyField && !reset) { - return _invalid( - 'At least one parameter is required: text_scale, bold_text, ' - 'platform_brightness, or reset=true.', - ); - } - - if (rawTextScale != null && (!rawTextScale.isFinite || rawTextScale <= 0)) { - return _invalid('text_scale must be greater than 0.'); - } - - if (invalidBrightnessError(brightness) case final error?) { - return _invalid(error); - } - - logger.info( - 'Setting device config: textScale=$rawTextScale, boldText=$boldText, ' - 'platformBrightness=$brightness, reset=$reset', - ); - - return runTool(logger, 'set device config', () async { - final response = await connector.setDeviceConfig( - textScale: rawTextScale?.toDouble(), - boldText: boldText, - platformBrightness: brightness, - reset: reset, - ); - final message = response['message'] as String?; - return CallToolResult( - content: [TextContent(text: message ?? 'Device config updated')], - ); - }); -} - -Future _invalid(String message) async => CallToolResult( - isError: true, - content: [TextContent(text: message)], - ); diff --git a/packages/marionette_mcp/lib/src/vm_service/tools/extension_tools.dart b/packages/marionette_mcp/lib/src/vm_service/tools/extension_tools.dart deleted file mode 100644 index d5bbe60f..00000000 --- a/packages/marionette_mcp/lib/src/vm_service/tools/extension_tools.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'dart:convert'; - -import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/src/session/step_logger.dart'; -import 'package:marionette_mcp/src/vm_service/tools/arg_coercion.dart'; -import 'package:marionette_mcp/src/vm_service/tools/tool_runner.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; -import 'package:mcp_dart/mcp_dart.dart'; - -/// Registers MCP tools for discovering and invoking custom VM service -/// extensions registered by the Flutter app: -/// `list_custom_extensions`, `call_custom_extension`. -void registerExtensionTools( - LoggedMcpServer server, - VmServiceConnector connector, - logging.Logger logger, -) { - server - ..registerTool( - 'list_custom_extensions', - description: - 'Lists all custom VM service extensions registered by the Flutter ' - 'app (outside of the built-in Marionette extensions). Each ' - 'extension includes its name and an optional description. Use this ' - 'to discover what app-specific extensions are available before ' - 'calling them with call_custom_extension. ' - 'Requires an active connection established via connect.', - annotations: const ToolAnnotations( - title: 'List Custom Extensions', - readOnlyHint: true, - idempotentHint: true, - ), - inputSchema: const ToolInputSchema(properties: {}), - callback: (args, extra) async { - logger.info('Listing custom extensions'); - return runTool(logger, 'list custom extensions', () async { - final response = await connector.listExtensions(); - final extensions = (response['extensions'] as List) - .cast>(); - - if (extensions.isEmpty) { - return CallToolResult( - content: [ - const TextContent(text: 'No custom extensions registered.'), - ], - ); - } - - final buffer = StringBuffer() - ..writeln('Found ${extensions.length} custom extension(s):\n'); - - for (final ext in extensions) { - final name = ext['name'] as String; - final description = ext['description'] as String?; - buffer.write('- $name'); - if (description != null) { - buffer.write(': $description'); - } - buffer.writeln(); - } - - return CallToolResult( - content: [TextContent(text: buffer.toString())], - ); - }); - }, - ) - // Intentionally no readOnlyHint or idempotentHint on call_custom_extension - // since the behavior depends entirely on the target extension. - ..registerTool( - 'call_custom_extension', - description: - 'Calls a custom VM service extension registered by the Flutter app. ' - 'This is an escape hatch for interacting with app-specific ' - 'extensions that are not part of marionette\'s built-in tools. ' - 'For marionette features, use the dedicated tools instead. ' - 'The extension name should not include the "ext.flutter." prefix as ' - 'it is added automatically. For example, use ' - '"deckNavigation.goToSlide" instead of ' - '"ext.flutter.deckNavigation.goToSlide". ' - 'Arguments are passed as string key-value pairs. ' - 'The available extensions depend on what the connected Flutter app ' - 'has registered. Check the app\'s source code for available extensions. ' - 'Requires an active connection established via connect.', - annotations: const ToolAnnotations(title: 'Call Custom Extension'), - inputSchema: ToolInputSchema( - properties: { - 'extension': JsonSchema.string( - description: 'The extension name without the "ext.flutter." prefix ' - '(e.g., "deckNavigation.goToSlide").', - ), - 'args': JsonSchema.object( - description: 'Optional key-value pairs to pass as arguments. ' - 'Scalars are stringified and nested object/array values are ' - 'JSON-encoded before being sent to the VM service extension.', - properties: {}, - ), - }, - required: ['extension'], - ), - callback: (args, extra) => callCustomExtension(connector, logger, args), - ); -} - -/// Handles a `call_custom_extension` invocation: coerces the supplied `args` -/// into the wire shape, forwards them to [connector], and wraps the response. -/// -/// Extracted from the tool callback so the coercion contract can be exercised -/// in isolation. Args go through [coerceToStringMap] — the same path the -/// dynamically-promoted extension tools use — so both produce identical wire -/// args: scalars are stringified and nested object/array values become valid -/// JSON (not Dart's un-parseable `toString()` form). -Future callCustomExtension( - VmServiceConnector connector, - logging.Logger logger, - Map args, -) { - final extensionName = args['extension'] as String; - final extensionArgs = coerceToStringMap( - (args['args'] as Map?) ?? const {}, - ); - logger.info( - 'Calling custom extension: $extensionName with args: $extensionArgs', - ); - return runTool(logger, 'call custom extension', () async { - final response = await connector.callCustomExtension( - extensionName, - extensionArgs, - ); - return CallToolResult( - content: [TextContent(text: jsonEncode(response))], - ); - }); -} diff --git a/packages/marionette_mcp/lib/src/vm_service/tools/gesture_tools.dart b/packages/marionette_mcp/lib/src/vm_service/tools/gesture_tools.dart deleted file mode 100644 index 6c4aea44..00000000 --- a/packages/marionette_mcp/lib/src/vm_service/tools/gesture_tools.dart +++ /dev/null @@ -1,551 +0,0 @@ -import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/src/formatting.dart'; -import 'package:marionette_mcp/src/session/step_logger.dart'; -import 'package:marionette_mcp/src/vm_service/tools/ancestor_keys_description.dart'; -import 'package:marionette_mcp/src/vm_service/tools/tool_runner.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; -import 'package:mcp_dart/mcp_dart.dart'; - -/// Registers gesture-based MCP tools: tap, secondary_tap, double_tap, -/// long_press, swipe, pinch_zoom, scroll_to, press_back_button. -void registerGestureTools( - LoggedMcpServer server, - VmServiceConnector connector, - logging.Logger logger, -) { - server - ..registerTool( - 'tap', - description: - 'Simulates a tap gesture on an element in the Flutter app that matches the given criteria. You can match elements by their key (a ValueKey), by their Semantics identifier, by their text content (but not accessibility label!), by their widget type, or by screen coordinates. Only one matching method should be used: either key, identifier, text, type, or coordinates. Prefer using the key if available, as it is more reliable; the Semantics identifier is the next-best stable selector. Limit yourself to elements from get_interactive_elements only if you can. Tapping a text field gives it focus, after which you can use enter_text with focused_element to type into it. Requires an active connection established via connect.', - annotations: const ToolAnnotations(title: 'Tap Element'), - inputSchema: ToolInputSchema( - properties: { - 'key': JsonSchema.string( - description: - 'The key of the element to tap. You can get the key of an element by calling get_interactive_elements.', - ), - 'identifier': JsonSchema.string( - description: - 'The Semantics identifier of the element to tap. A stable, ' - 'unique, accessibility identifier set via Semantics(identifier: ...). ' - 'Useful when an element has no ValueKey but does set an identifier. ' - 'You can discover identifiers by calling get_interactive_elements.', - ), - 'text': JsonSchema.string( - description: - 'The visible text content of the element to tap. Use this for elements that display text like buttons or labels.', - ), - 'type': JsonSchema.string( - description: - 'The widget type name of the element to tap (e.g., "ElevatedButton", "IconButton"). Use this to match elements by their Flutter widget type.', - ), - 'coordinates': JsonSchema.object( - description: - 'Screen coordinates to tap at. Use this to tap at a specific position on the screen.', - properties: { - 'x': JsonSchema.number( - description: - 'The x coordinate (horizontal position from left).', - ), - 'y': JsonSchema.number( - description: 'The y coordinate (vertical position from top).', - ), - }, - required: ['x', 'y'], - ), - 'ancestor_keys': JsonSchema.array( - items: JsonSchema.string(), - description: ancestorKeysDescription, - ), - }, - ), - callback: (args, extra) async { - final matcher = buildMatcher(args); - logger.info('Tapping with matcher: $matcher'); - return runTool(logger, 'tap', () async { - final response = await connector.tap(matcher); - final message = response['message'] as String?; - return CallToolResult( - content: [TextContent(text: message ?? 'Successfully tapped')], - ); - }); - }, - ) - ..registerTool( - 'secondary_tap', - description: - 'Simulates a secondary tap (right mouse button click) on an element in the Flutter app. Desktop only — dispatches a mouse pointer with the secondary button pressed, which is what Flutter recognises as onSecondaryTap (e.g. for opening context menus). You can match elements by their key (a ValueKey), by their Semantics identifier, by their text content, by their widget type, or by screen coordinates. Only one matching method should be used. Prefer using the key if available, as it is more reliable. Requires an active connection established via connect.', - annotations: const ToolAnnotations(title: 'Secondary Tap Element'), - inputSchema: ToolInputSchema( - properties: { - 'key': JsonSchema.string( - description: - 'The key of the element to secondary tap. You can get the key of an element by calling get_interactive_elements.', - ), - 'identifier': JsonSchema.string( - description: - 'The Semantics identifier of the element to secondary tap. A ' - 'stable, unique accessibility identifier set via ' - 'Semantics(identifier: ...).', - ), - 'text': JsonSchema.string( - description: - 'The visible text content of the element to secondary tap.', - ), - 'type': JsonSchema.string( - description: - 'The widget type name of the element to secondary tap (e.g., "ElevatedButton", "ListTile").', - ), - 'coordinates': JsonSchema.object( - description: 'Screen coordinates to secondary tap at.', - properties: { - 'x': JsonSchema.number( - description: - 'The x coordinate (horizontal position from left).', - ), - 'y': JsonSchema.number( - description: 'The y coordinate (vertical position from top).', - ), - }, - required: ['x', 'y'], - ), - 'ancestor_keys': JsonSchema.array( - items: JsonSchema.string(), - description: ancestorKeysDescription, - ), - }, - ), - callback: (args, extra) async { - final matcher = buildMatcher(args); - logger.info('Secondary tapping with matcher: $matcher'); - return runTool(logger, 'secondary tap', () async { - final response = await connector.secondaryTap(matcher); - final message = response['message'] as String?; - return CallToolResult( - content: [ - TextContent(text: message ?? 'Successfully secondary tapped'), - ], - ); - }); - }, - ) - ..registerTool( - 'double_tap', - description: - 'Simulates a double tap gesture on an element in the Flutter app. This is useful for triggering text selection, zoom, or any widget that responds to double tap. You can match elements by their key, identifier, text, type, or coordinates. An optional delay parameter controls the time between the two taps (default: 100ms). Requires an active connection established via connect.', - annotations: const ToolAnnotations(title: 'Double Tap Element'), - inputSchema: ToolInputSchema( - properties: { - 'key': JsonSchema.string( - description: - 'The key of the element to double tap. You can get the key of an element by calling get_interactive_elements.', - ), - 'identifier': JsonSchema.string( - description: - 'The Semantics identifier of the element to double tap. A ' - 'stable, unique accessibility identifier set via ' - 'Semantics(identifier: ...).', - ), - 'text': JsonSchema.string( - description: - 'The visible text content of the element to double tap.', - ), - 'type': JsonSchema.string( - description: - 'The widget type name of the element to double tap (e.g., "ListTile", "Card").', - ), - 'coordinates': JsonSchema.object( - description: 'Screen coordinates to double tap at.', - properties: { - 'x': JsonSchema.number( - description: - 'The x coordinate (horizontal position from left).', - ), - 'y': JsonSchema.number( - description: 'The y coordinate (vertical position from top).', - ), - }, - required: ['x', 'y'], - ), - 'delay': JsonSchema.number( - description: - 'Time between the two taps in milliseconds. Defaults to 100ms which is within Flutter\'s double-tap recognition window (40ms-300ms).', - ), - 'ancestor_keys': JsonSchema.array( - items: JsonSchema.string(), - description: ancestorKeysDescription, - ), - }, - ), - callback: (args, extra) async { - final delay = (args['delay'] as num?)?.toInt(); - if (delay != null && delay <= 0) { - return CallToolResult( - isError: true, - content: [ - const TextContent( - text: 'Parameter "delay" must be a positive integer.', - ), - ], - ); - } - final matcher = buildMatcher(args); - logger.info('Double tapping with matcher: $matcher'); - return runTool(logger, 'double tap', () async { - final response = await connector.doubleTap(matcher, delayMs: delay); - final message = response['message'] as String?; - return CallToolResult( - content: [ - TextContent(text: message ?? 'Successfully double tapped'), - ], - ); - }); - }, - ) - ..registerTool( - 'long_press', - description: - 'Simulates a long press gesture on an element in the Flutter app. This is useful for triggering context menus, reorderable lists, or any widget that responds to long press. You can match elements by their key, identifier, text, type, or coordinates. An optional duration parameter controls how long the press is held (default: 600ms). Requires an active connection established via connect.', - annotations: const ToolAnnotations(title: 'Long Press Element'), - inputSchema: ToolInputSchema( - properties: { - 'key': JsonSchema.string( - description: - 'The key of the element to long press. You can get the key of an element by calling get_interactive_elements.', - ), - 'identifier': JsonSchema.string( - description: - 'The Semantics identifier of the element to long press. A ' - 'stable, unique accessibility identifier set via ' - 'Semantics(identifier: ...).', - ), - 'text': JsonSchema.string( - description: - 'The visible text content of the element to long press.', - ), - 'type': JsonSchema.string( - description: - 'The widget type name of the element to long press (e.g., "ListTile", "Card").', - ), - 'coordinates': JsonSchema.object( - description: 'Screen coordinates to long press at.', - properties: { - 'x': JsonSchema.number( - description: - 'The x coordinate (horizontal position from left).', - ), - 'y': JsonSchema.number( - description: 'The y coordinate (vertical position from top).', - ), - }, - required: ['x', 'y'], - ), - 'duration': JsonSchema.number( - description: - 'How long to hold the press in milliseconds. Defaults to 600ms which matches Flutter\'s long press behavior.', - ), - 'ancestor_keys': JsonSchema.array( - items: JsonSchema.string(), - description: ancestorKeysDescription, - ), - }, - ), - callback: (args, extra) async { - final duration = (args['duration'] as num?)?.toInt(); - final matcher = buildMatcher(args); - logger.info('Long pressing with matcher: $matcher'); - return runTool(logger, 'long press', () async { - final response = await connector.longPress( - matcher, - durationMs: duration, - ); - final message = response['message'] as String?; - return CallToolResult( - content: [ - TextContent(text: message ?? 'Successfully long pressed'), - ], - ); - }); - }, - ) - ..registerTool( - 'swipe', - description: - 'Simulates a swipe/drag gesture on the Flutter app. Supports two modes: ' - '1. Element-based: provide key, identifier, or text to identify the element, plus a direction (left, right, up, down) and optional distance in pixels (default 200). ' - '2. Coordinate-based: provide startX, startY, endX, endY for precise control. ' - 'Useful for interacting with PageView, Dismissible, Drawer, Slider, and other swipe-based widgets. ' - 'Requires an active connection established via connect.', - annotations: const ToolAnnotations(title: 'Swipe'), - inputSchema: ToolInputSchema( - properties: { - 'key': JsonSchema.string( - description: - 'The key of the element to swipe on. Use with direction.', - ), - 'identifier': JsonSchema.string( - description: - 'The Semantics identifier of the element to swipe on. Use with ' - 'direction. A stable, unique accessibility identifier set via ' - 'Semantics(identifier: ...).', - ), - 'text': JsonSchema.string( - description: - 'The visible text content of the element to swipe on. Use with direction.', - ), - 'direction': JsonSchema.string( - description: - 'Swipe direction when using element-based mode: left, right, up, or down.', - ), - 'distance': JsonSchema.number( - description: - 'Swipe distance in pixels for element-based mode (default: 200).', - ), - 'startX': JsonSchema.number( - description: 'Start X coordinate for coordinate-based swipe.', - ), - 'startY': JsonSchema.number( - description: 'Start Y coordinate for coordinate-based swipe.', - ), - 'endX': JsonSchema.number( - description: 'End X coordinate for coordinate-based swipe.', - ), - 'endY': JsonSchema.number( - description: 'End Y coordinate for coordinate-based swipe.', - ), - 'ancestor_keys': JsonSchema.array( - items: JsonSchema.string(), - description: ancestorKeysDescription, - ), - }, - ), - callback: (args, extra) async { - logger.info('Swiping with args: $args'); - - final swipeArgs = {}; - - if (args.containsKey('startX')) { - if (!args.containsKey('startY') || - !args.containsKey('endX') || - !args.containsKey('endY')) { - return CallToolResult( - isError: true, - content: [ - const TextContent( - text: 'Coordinate-based swipe requires all of: ' - 'startX, startY, endX, endY', - ), - ], - ); - } - swipeArgs['startX'] = args['startX'].toString(); - swipeArgs['startY'] = args['startY'].toString(); - swipeArgs['endX'] = args['endX'].toString(); - swipeArgs['endY'] = args['endY'].toString(); - } else { - if (!args.containsKey('direction')) { - return CallToolResult( - isError: true, - content: [ - const TextContent( - text: 'Element-based swipe requires a direction ' - '(left, right, up, or down).', - ), - ], - ); - } - swipeArgs.addAll(buildMatcher(args)); - swipeArgs['direction'] = args['direction'] as String; - if (args.containsKey('distance')) { - swipeArgs['distance'] = args['distance'].toString(); - } - } - - return runTool(logger, 'swipe', () async { - final response = await connector.swipe(swipeArgs); - final message = response['message'] as String?; - return CallToolResult( - content: [TextContent(text: message ?? 'Successfully swiped')], - ); - }); - }, - ) - ..registerTool( - 'pinch_zoom', - description: - 'Simulates a pinch zoom gesture on an element in the Flutter app. ' - 'Use scale > 1.0 to zoom in (fingers move apart) and scale < 1.0 ' - 'to zoom out (fingers move together). You can target the element by ' - 'key, identifier, text, type, or coordinates. Useful for maps, ' - 'images, PDFs, and other zoomable content. ' - 'Requires an active connection established via connect.', - annotations: const ToolAnnotations(title: 'Pinch Zoom'), - inputSchema: ToolInputSchema( - properties: { - 'key': JsonSchema.string( - description: 'The key of the element to pinch zoom on.', - ), - 'identifier': JsonSchema.string( - description: - 'The Semantics identifier of the element to pinch zoom on. A ' - 'stable, unique accessibility identifier set via ' - 'Semantics(identifier: ...).', - ), - 'text': JsonSchema.string( - description: - 'The visible text content of the element to pinch zoom on.', - ), - 'type': JsonSchema.string( - description: - 'The widget type name of the element to pinch zoom on.', - ), - 'coordinates': JsonSchema.object( - description: 'Screen coordinates to pinch zoom at.', - properties: { - 'x': JsonSchema.number(description: 'The x coordinate.'), - 'y': JsonSchema.number(description: 'The y coordinate.'), - }, - required: ['x', 'y'], - ), - 'scale': JsonSchema.number( - description: - 'Zoom scale factor. Values > 1.0 zoom in, values < 1.0 ' - 'zoom out. For example, 2.0 doubles the zoom level.', - ), - 'start_distance': JsonSchema.number( - description: 'Initial distance between the two fingers in pixels ' - '(default: 200).', - ), - 'ancestor_keys': JsonSchema.array( - items: JsonSchema.string(), - description: ancestorKeysDescription, - ), - }, - required: ['scale'], - ), - callback: (args, extra) async { - final matcher = buildMatcher(args); - if (!hasSelector(matcher)) { - return CallToolResult( - isError: true, - content: [ - const TextContent( - text: 'Missing required selector: provide "key", ' - '"identifier", "text", "type", or "coordinates".', - ), - ], - ); - } - final scale = (args['scale'] as num).toDouble(); - if (scale <= 0) { - return CallToolResult( - isError: true, - content: [ - const TextContent(text: 'scale must be a positive number.'), - ], - ); - } - final startDistance = (args['start_distance'] as num?)?.toDouble(); - if (startDistance != null && startDistance <= 0) { - return CallToolResult( - isError: true, - content: [ - const TextContent( - text: 'start_distance must be a positive number.', - ), - ], - ); - } - - logger.info('Pinch zooming with matcher: $matcher, scale: $scale'); - return runTool(logger, 'pinch zoom', () async { - final response = await connector.pinchZoom( - matcher, - scale: scale, - startDistance: startDistance, - ); - final message = response['message'] as String?; - return CallToolResult( - content: [ - TextContent(text: message ?? 'Successfully pinch zoomed'), - ], - ); - }); - }, - ) - ..registerTool( - 'press_back_button', - description: 'Simulates a system back button press in the Flutter app. ' - 'This triggers the same mechanism as the Android back button or ' - 'iOS swipe-back gesture. If the app has a route to pop, it will ' - 'be popped. If the app is on the root route, the system may ' - 'minimize or close the app (same as real back button behavior). ' - 'Works with Navigator, GoRouter, and other routing solutions. ' - 'Requires an active connection established via connect.', - annotations: const ToolAnnotations(title: 'Press Back Button'), - inputSchema: const ToolInputSchema(properties: {}), - callback: (args, extra) async { - logger.info('Pressing back button'); - return runTool(logger, 'press back button', () async { - final response = await connector.pressBackButton(); - final message = response['message'] as String?; - final didPop = response['didPop']; - - return CallToolResult( - content: [ - TextContent( - text: message ?? - (didPop == true - ? 'Back button pressed, route was popped' - : 'Back button pressed, no route to pop'), - ), - ], - ); - }); - }, - ) - ..registerTool( - 'scroll_to', - description: - 'Scrolls the view until an element matching the given criteria becomes visible. You can match elements by their key (a ValueKey), by their Semantics identifier, or by their visible text content. This is useful when you need to interact with elements that are not currently visible on screen. Requires an active connection established via connect.', - annotations: const ToolAnnotations(title: 'Scroll to Element'), - inputSchema: ToolInputSchema( - properties: { - 'key': JsonSchema.string( - description: - 'The key of the element to scroll to. You can get the key of an element by calling get_interactive_elements.', - ), - 'identifier': JsonSchema.string( - description: - 'The Semantics identifier of the element to scroll to. A ' - 'stable, unique accessibility identifier set via ' - 'Semantics(identifier: ...).', - ), - 'text': JsonSchema.string( - description: - 'The visible text content of the element to scroll to.', - ), - 'ancestor_keys': JsonSchema.array( - items: JsonSchema.string(), - description: ancestorKeysDescription, - ), - }, - ), - callback: (args, extra) async { - final matcher = buildMatcher(args); - logger.info('Scrolling to element with matcher: $matcher'); - return runTool(logger, 'scroll to element', () async { - final response = await connector.scrollToElement(matcher); - final message = response['message'] as String?; - return CallToolResult( - content: [ - TextContent( - text: message ?? 'Successfully scrolled to element', - ), - ], - ); - }); - }, - ); -} diff --git a/packages/marionette_mcp/lib/src/vm_service/tools/inspection_tools.dart b/packages/marionette_mcp/lib/src/vm_service/tools/inspection_tools.dart deleted file mode 100644 index 1e360fe1..00000000 --- a/packages/marionette_mcp/lib/src/vm_service/tools/inspection_tools.dart +++ /dev/null @@ -1,256 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/src/formatting.dart'; -import 'package:marionette_mcp/src/session/session.dart'; -import 'package:marionette_mcp/src/session/step_logger.dart'; -import 'package:marionette_mcp/src/vm_service/tools/tool_runner.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; -import 'package:mcp_dart/mcp_dart.dart'; -import 'package:path/path.dart' as p; - -/// Registers read-only MCP tools that inspect the running app: -/// `get_interactive_elements`, `get_logs`, `take_screenshots`. -void registerInspectionTools( - LoggedMcpServer server, - VmServiceConnector connector, - logging.Logger logger, -) { - server - ..registerTool( - 'get_interactive_elements', - description: - 'Returns a list of all interactive elements currently visible in the Flutter app UI tree. Each element includes its type, text content (if any), key (if any), and other identifying properties. This is useful for understanding what can be interacted with in the app. Style, decoration and colour blobs are never reported. Set compaction to "compact" (the default for most apps) to also drop rendering details no interaction tool reads (textAlign, softWrap, overflow, font metrics, …), report visible only when an element is not visible, round bounds to whole logical pixels, and drop a Text element\'s duplicated data field — much more token-efficient for agent loops. Set compaction to "none" to force the full payload. Omit compaction to use the app\'s MarionetteConfiguration.compaction default. Requires an active connection established via connect.', - annotations: const ToolAnnotations( - title: 'Get Interactive Elements', - readOnlyHint: true, - idempotentHint: true, - ), - inputSchema: ToolInputSchema( - properties: { - 'ancestor_keys': JsonSchema.array( - items: JsonSchema.string(), - description: _ancestorKeysDescription, - ), - 'compaction': JsonSchema.string( - description: - 'How much to reduce the payload. "compact" drops rendering details, font metrics, a Text element\'s duplicated data, rounds bounds to whole logical pixels, and reports visible only when false. "none" forces the full payload. Omit to use the app\'s MarionetteConfiguration.compaction default.', - enumValues: supportedCompactionModes.toList(), - ), - }, - ), - callback: (args, extra) async { - final compaction = args['compaction'] as String?; - if (invalidCompactionError(compaction) case final error?) { - return CallToolResult( - isError: true, - content: [TextContent(text: error)], - ); - } - - logger.info('Getting interactive elements (compaction: $compaction)'); - return runTool(logger, 'get interactive elements', () async { - final response = await connector.getInteractiveElements( - ancestorKeys: - (args['ancestor_keys'] as List?)?.cast() ?? - const [], - compaction: compaction, - ); - final elements = response['elements'] as List; - - final buffer = StringBuffer() - ..writeln('Found ${elements.length} interactive element(s):\n'); - - for (final element in elements) { - buffer.writeln(formatElement(element as Map)); - } - - return CallToolResult( - content: [TextContent(text: buffer.toString())], - ); - }); - }, - ) - ..registerTool( - 'get_logs', - description: - 'Retrieves all application logs collected from the Flutter app since app start or since the last hot reload. This includes debug messages, errors, and other log output from the running app. Requires an active connection established via connect.', - annotations: const ToolAnnotations( - title: 'Get Application Logs', - readOnlyHint: true, - ), - inputSchema: const ToolInputSchema(properties: {}), - callback: (args, extra) async { - logger.info('Getting application logs'); - - try { - final response = await connector.getLogs(); - final logs = response['logs'] as List; - final count = response['count'] as int; - - if (count == 0) { - return CallToolResult( - content: [const TextContent(text: 'No logs collected')], - ); - } - - final buffer = StringBuffer() - ..writeln( - 'Collected $count log entr${count == 1 ? 'y' : 'ies'}:\n', - ); - - for (final log in logs) { - buffer.writeln(log); - } - - return CallToolResult( - content: [TextContent(text: buffer.toString())], - ); - } on VmServiceExtensionException catch (err) { - // Surface the VM service's own error message verbatim — it carries - // setup instructions for enabling log collection. - logger.warning('Failed to get logs', err); - return CallToolResult( - isError: true, - content: [TextContent(text: err.error ?? err.message)], - ); - } catch (err) { - logger.warning('Failed to get logs', err); - return CallToolResult( - isError: true, - content: [TextContent(text: err.toString())], - ); - } - }, - ) - ..registerTool( - 'take_screenshots', - description: - 'Takes screenshots of all views in the Flutter app. By default returns base64-encoded PNG images inline, which can be decoded and saved. Set inline: false to instead save the screenshots straight into the active session directory (only available when the app enables session reports) and get back their paths — evidence you plan to cite in a report but don\'t need to look at right now, since each inline screenshot costs real visual tokens. Requires an active connection established via connect.', - annotations: const ToolAnnotations( - title: 'Take Screenshots', - readOnlyHint: true, - ), - inputSchema: ToolInputSchema( - properties: { - 'inline': JsonSchema.boolean( - description: - 'If false, saves the screenshots into the session directory ' - 'and returns their paths instead of the image data. ' - 'Defaults to true.', - ), - }, - ), - callback: (args, extra) async { - logger.info('Taking screenshots'); - return runTool( - logger, - 'take screenshots', - () => takeScreenshots(connector, server.stepLogger.session, args), - ); - }, - ); -} - -/// Handles a `take_screenshots` invocation: captures via [connector], then -/// either returns the images inline or — when `args['inline'] == false` — -/// saves them into [session]'s screenshots directory and returns their paths. -/// -/// Extracted from the tool callback so the inline/save-only branching can be -/// exercised in isolation. -Future takeScreenshots( - VmServiceConnector connector, - Session? session, - Map args, -) async { - final inline = args['inline'] != false; - final response = await connector.takeScreenshots(); - final screenshots = (response['screenshots'] as List).cast(); - - if (screenshots.isEmpty) { - return CallToolResult( - content: [const TextContent(text: 'No screenshots captured')], - ); - } - - if (inline) { - return CallToolResult( - content: screenshots - .map( - (screenshot) => - ImageContent(data: screenshot, mimeType: 'image/png'), - ) - .toList(), - ); - } - - if (session == null) { - return CallToolResult( - isError: true, - content: [ - const TextContent( - text: 'inline: false requires an active session, which is ' - 'only created once connect succeeds and only when the app ' - 'enables session reports ' - '(MarionetteConfiguration.enableSessionReports).', - ), - ], - ); - } - - final paths = _saveScreenshots(session.screenshotsDir, screenshots); - return CallToolResult( - content: [ - TextContent( - text: 'Saved ${paths.length} screenshot(s):\n${paths.join('\n')}', - ), - ], - ); -} - -/// Saves each base64-encoded PNG in [screenshots] into [screenshotsDir], -/// numbered sequentially after whatever is already there, and returns the -/// paths written to. -List _saveScreenshots( - Directory screenshotsDir, - List screenshots, -) { - screenshotsDir.createSync(recursive: true); - // The next index is one past the highest existing numeric name, not a - // count of PNGs present — a gap (e.g. 01.png and 03.png after 02.png was - // deleted) would otherwise make a count-based index collide with, and - // overwrite, the existing 03.png. - final highestExisting = screenshotsDir - .listSync() - .whereType() - .where((f) => p.extension(f.path) == '.png') - .map((f) => int.tryParse(p.basenameWithoutExtension(f.path))) - .whereType() - .fold(0, (highest, n) => n > highest ? n : highest); - var nextIndex = highestExisting + 1; - - final paths = []; - for (final screenshot in screenshots) { - final path = p.join( - screenshotsDir.path, - '${nextIndex.toString().padLeft(2, '0')}.png', - ); - File(path).writeAsBytesSync(base64Decode(screenshot)); - paths.add(path); - nextIndex++; - } - return paths; -} - -/// `ancestor_keys` as worded for `get_interactive_elements`, which lists a -/// subtree rather than matching one element inside it. -/// -/// Kept separate from the matcher tools' `ancestorKeysDescription` because -/// the field means something else here: the scope alone is the whole request, -/// and it lists that subtree instead of limiting where one element is -/// searched for. -const _ancestorKeysDescription = - 'Optional wrapper keys (ValueKey), outermost first; each is ' - 'looked up inside the previous one. Lists only that subtree. Fails if a ' - 'key has no element.'; diff --git a/packages/marionette_mcp/lib/src/vm_service/tools/keyboard_tools.dart b/packages/marionette_mcp/lib/src/vm_service/tools/keyboard_tools.dart deleted file mode 100644 index afab1de6..00000000 --- a/packages/marionette_mcp/lib/src/vm_service/tools/keyboard_tools.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/src/session/step_logger.dart'; -import 'package:marionette_mcp/src/vm_service/tools/tool_runner.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; -import 'package:mcp_dart/mcp_dart.dart'; - -/// Registers keyboard MCP tools: `press_key`. -void registerKeyboardTools( - LoggedMcpServer server, - VmServiceConnector connector, - logging.Logger logger, -) { - server.registerTool( - 'press_key', - description: - 'Presses a keyboard key in the Flutter app, producing a real key event ' - 'that flows through the focus system — unlike enter_text, which only ' - 'replaces a field\'s value. Use it to submit a form (enter), move focus ' - '(tab), dismiss a dialog (escape), edit within a field (backspace, ' - 'delete, arrowLeft/arrowRight/arrowUp/arrowDown, home, end), or trigger ' - 'app shortcuts via modifiers (for example control+a to select all). ' - 'The key is sent to whatever currently has focus, exactly like a real ' - 'keyboard, so focus a target first if needed (for example with tap). ' - 'Requires an active connection established via connect.', - annotations: const ToolAnnotations(title: 'Press Key'), - inputSchema: ToolInputSchema( - properties: { - 'key': JsonSchema.string( - description: - 'The key to press. Named keys: enter, tab, escape, backspace, ' - 'delete, space, arrowUp, arrowDown, arrowLeft, arrowRight, home, ' - 'end, pageUp, pageDown. Also accepts a single character a-z or ' - '0-9 (case-insensitive).', - ), - 'modifiers': JsonSchema.string( - description: - 'Optional comma-separated modifier keys to hold during the ' - 'press: any of control, shift, alt, meta (e.g. "control" or ' - '"control,shift"). On macOS use meta for the Command key.', - ), - }, - required: ['key'], - ), - callback: (args, extra) async { - final key = args['key'] as String; - final modifiers = args['modifiers'] as String?; - - final modifierError = invalidModifiersError(modifiers); - if (modifierError != null) { - return CallToolResult( - isError: true, - content: [TextContent(text: modifierError)], - ); - } - - logger.info('Pressing key: $key (modifiers: ${modifiers ?? 'none'})'); - return runTool(logger, 'press key', () async { - final response = await connector.pressKey(key, modifiers: modifiers); - final message = response['message'] as String?; - return CallToolResult( - content: [ - TextContent(text: message ?? 'Successfully pressed key'), - ], - ); - }); - }, - ); -} diff --git a/packages/marionette_mcp/lib/src/vm_service/tools/system_tools.dart b/packages/marionette_mcp/lib/src/vm_service/tools/system_tools.dart deleted file mode 100644 index 8138c018..00000000 --- a/packages/marionette_mcp/lib/src/vm_service/tools/system_tools.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/src/session/step_logger.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; -import 'package:mcp_dart/mcp_dart.dart'; - -/// Registers MCP tools that control the Flutter app's dev workflow rather -/// than its UI: `hot_reload` and `hot_restart`. -void registerSystemTools( - LoggedMcpServer server, - VmServiceConnector connector, - logging.Logger logger, -) { - server - ..registerTool( - 'hot_reload', - description: - 'Performs a hot reload of the Flutter app. This reloads the Dart code without restarting the app, preserving the current state. Useful after making code changes to see them reflected in the running app. Requires an active connection established via connect.', - annotations: const ToolAnnotations(title: 'Hot Reload'), - inputSchema: const ToolInputSchema(properties: {}), - callback: (args, extra) async { - logger.info('Performing hot reload'); - - try { - final reloaded = await connector.hotReload(); - if (reloaded) { - return CallToolResult( - content: [ - const TextContent(text: 'Hot reload completed successfully'), - ], - ); - } - return CallToolResult( - isError: true, - content: [ - TextContent( - text: 'Hot reload failed. The app may need a full restart.', - ), - ], - ); - } catch (err) { - logger.warning('Failed to perform hot reload', err); - return CallToolResult( - isError: true, - content: [TextContent(text: 'Hot reload failed: $err')], - ); - } - }, - ) - ..registerTool( - 'hot_restart', - description: - 'Performs a hot restart of the Flutter app. This fully restarts the app from main() and resets all state. Use it instead of hot_reload after changes a reload cannot pick up (e.g. changes to main()/bootstrap, global singletons, or state shape). Requires the app to be running via `flutter run` and an active connection established via connect.', - annotations: const ToolAnnotations(title: 'Hot Restart'), - inputSchema: const ToolInputSchema(properties: {}), - callback: (args, extra) async { - logger.info('Performing hot restart'); - - try { - final restarted = await connector.hotRestart(); - if (restarted) { - return CallToolResult( - content: [ - const TextContent(text: 'Hot restart completed successfully'), - ], - ); - } - return CallToolResult( - isError: true, - content: [ - TextContent( - text: 'Hot restart failed or is unavailable. Make sure the ' - 'app is running via `flutter run`.', - ), - ], - ); - } catch (err) { - logger.warning('Failed to perform hot restart', err); - return CallToolResult( - isError: true, - content: [TextContent(text: 'Hot restart failed: $err')], - ); - } - }, - ); -} diff --git a/packages/marionette_mcp/lib/src/vm_service/tools/text_tools.dart b/packages/marionette_mcp/lib/src/vm_service/tools/text_tools.dart deleted file mode 100644 index f663cf79..00000000 --- a/packages/marionette_mcp/lib/src/vm_service/tools/text_tools.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/src/formatting.dart'; -import 'package:marionette_mcp/src/session/step_logger.dart'; -import 'package:marionette_mcp/src/vm_service/tools/ancestor_keys_description.dart'; -import 'package:marionette_mcp/src/vm_service/tools/tool_runner.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; -import 'package:mcp_dart/mcp_dart.dart'; - -/// Registers text-input MCP tools: `enter_text`. -void registerTextTools( - LoggedMcpServer server, - VmServiceConnector connector, - logging.Logger logger, -) { - server.registerTool( - 'enter_text', - description: - 'Enters text into a text field in the Flutter app. You can target the field in three ways: 1. By key: provide the key parameter with the ValueKey of the field. You can discover available keys by calling get_interactive_elements. 2. By Semantics identifier: provide the identifier parameter with the accessibility identifier of the field. Useful when the field has no ValueKey but does set a Semantics identifier. 3. By focused element: first tap a text field to give it focus, then call enter_text with focused_element set to true. Important: when targeting by focused element, a text field must be focused before calling this (for example by using the tap tool), otherwise it will fail with an error. Exactly one of key, identifier, or focused_element must be provided. Requires an active connection established via connect.', - annotations: const ToolAnnotations(title: 'Enter Text'), - inputSchema: ToolInputSchema( - properties: { - 'input': JsonSchema.string( - description: 'The text to enter into the text field.', - ), - 'key': JsonSchema.string( - description: - 'The key of the text field. You can get the key of an element by calling get_interactive_elements.', - ), - 'identifier': JsonSchema.string( - description: - 'The Semantics identifier of the text field. A stable, unique ' - 'accessibility identifier set via Semantics(identifier: ...). ' - 'You can discover identifiers by calling get_interactive_elements.', - ), - 'focused_element': JsonSchema.boolean( - description: - 'If true, enters text into the currently focused text field. ' - 'A text field must be focused first (for example by using tap), otherwise this will fail.', - ), - 'ancestor_keys': JsonSchema.array( - items: JsonSchema.string(), - description: ancestorKeysDescription, - ), - }, - required: ['input'], - ), - callback: (args, extra) async { - final input = args['input'] as String; - final hasKey = args['key'] != null; - final hasIdentifier = args['identifier'] != null; - final hasFocusedElement = args['focused_element'] == true; - - final selectorCount = - [hasKey, hasIdentifier, hasFocusedElement].where((e) => e).length; - if (selectorCount != 1) { - return CallToolResult( - isError: true, - content: [ - const TextContent( - text: - 'enter_text requires exactly one selector: provide key, identifier, or focused_element=true.', - ), - ], - ); - } - - final matcher = buildMatcher(args); - logger.info('Entering text into element with matcher: $matcher'); - return runTool(logger, 'enter text', () async { - final response = await connector.enterText(matcher, input); - final message = response['message'] as String?; - return CallToolResult( - content: [ - TextContent(text: message ?? 'Successfully entered text'), - ], - ); - }); - }, - ); -} diff --git a/packages/marionette_mcp/lib/src/vm_service/vm_service_connector.dart b/packages/marionette_mcp/lib/src/vm_service/vm_service_connector.dart index 5357c4cf..91431413 100644 --- a/packages/marionette_mcp/lib/src/vm_service/vm_service_connector.dart +++ b/packages/marionette_mcp/lib/src/vm_service/vm_service_connector.dart @@ -1,9 +1,9 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:logging/logging.dart' as logging; import 'package:vm_service/vm_service.dart'; -import 'package:vm_service/vm_service_io.dart'; /// Exception thrown when an operation is attempted without an active connection. class NotConnectedException implements Exception { @@ -147,7 +147,7 @@ class VmServiceConnector { final logging.Logger _logger; VmService? _service; String? _isolateId; - StreamSubscription? _serviceEventSubscription; + _VmServiceConnection? _connection; final Map _registeredServices = {}; final Map>> _pendingServiceRequests = {}; @@ -159,16 +159,60 @@ class VmServiceConnector { /// /// Throws an exception if connection fails. Future connect(String uri) async { - if (isConnected) { - _logger.warning('Already connected, disconnecting first'); - await disconnect(); - } + final previous = _connection; + final connection = _VmServiceConnection(); + // Claim ownership before the first await. A newer connect or disconnect + // supersedes this attempt, including while its HTTP upgrade is pending. + _connection = connection; + _service = null; + _isolateId = null; + _clearServiceRegistrations(); _logger.info('Connecting to VM service at $uri'); try { - _service = await vmServiceConnectUri(uri); - _serviceEventSubscription = _service!.onServiceEvent.listen((e) { + await previous?.dispose(); + _ensureCurrentConnection(connection); + final socket = await WebSocket.connect( + uri, + customClient: connection.client, + ); + if (!identical(_connection, connection)) { + await socket.close(); + throw StateError('VM service connection attempt was superseded.'); + } + // Keep the socket reader independent from VmService's input stream, + // as vm_service_io does. VmService.dispose cancels its input before + // closing the socket; canceling the socket reader at that point would + // prevent the peer's close frame from canceling its keep-alive timer. + socket.pingInterval = const Duration(seconds: 15); + final input = StreamController(); + final streamClosed = Completer(); + void closeInput() { + if (!streamClosed.isCompleted) streamClosed.complete(); + } + + socket.listen( + input.add, + onDone: closeInput, + onError: (Object error, StackTrace stackTrace) { + _logger.warning('VM service WebSocket error', error, stackTrace); + // VmService turns stream closure into disposal and rejects pending + // RPCs. Its input listener has no onError handler for raw IO errors. + closeInput(); + }, + ); + final service = VmService( + input.stream, + socket.add, + disposeHandler: socket.close, + streamClosed: streamClosed.future, + wsUri: uri, + ); + connection.service = service; + _service = service; + connection.subscription = service.onServiceEvent.listen((e) { + if (!identical(_connection, connection)) return; switch (e.kind) { case EventKind.kServiceRegistered: final serviceName = e.service!; @@ -188,37 +232,65 @@ class VmServiceConnector { } }); try { - await _service! + await service .streamListen(EventStreams.kService) .timeout(const Duration(milliseconds: 500)); } catch (e) { _logger.info('streamListen(EventStreams.kService) skipped: $e'); } + _ensureCurrentConnection(connection); - _isolateId = await _findIsolateWithMarionetteExtensions(); + final isolateId = await _findIsolateWithMarionetteExtensions( + service, + connection, + ); + _ensureCurrentConnection(connection); + _isolateId = isolateId; _logger.info('Connected to isolate: $_isolateId'); } catch (err) { _logger.severe('Failed to connect to VM service', err); - await disconnect(); + // A superseded attempt may fail after its replacement has connected. + // It must only release its own resources, never the new connection. + if (identical(_connection, connection)) { + await disconnect(); + } else { + await connection.dispose(); + } rethrow; } } - /// Disconnects from the current VM service. + /// Disconnects from the current VM service and cancels any pending connect. Future disconnect() async { - if (_service != null) { + final connection = _connection; + // Retire ownership synchronously, before disposing asynchronous resources. + _connection = null; + _service = null; + _isolateId = null; + _clearServiceRegistrations(); + if (connection != null) { _logger.info('Disconnecting from VM service'); - await _serviceEventSubscription?.cancel(); - _serviceEventSubscription = null; - await _service!.dispose(); - _service = null; - _isolateId = null; - _registeredServices.clear(); - _pendingServiceRequests.clear(); + await connection.dispose(); _logger.fine('Disconnected'); } } + void _ensureCurrentConnection(_VmServiceConnection connection) { + if (!identical(_connection, connection)) { + throw StateError('VM service connection was disconnected or superseded.'); + } + } + + void _clearServiceRegistrations() { + _registeredServices.clear(); + for (final requests in _pendingServiceRequests.values) { + for (final completer in requests) { + completer.complete(null); + } + } + _pendingServiceRequests.clear(); + } + /// Returns a future that completes with the registered method name for the /// given [serviceName]. /// @@ -604,18 +676,22 @@ class VmServiceConnector { Future hotReload() async { _ensureConnected(); + final connection = _connection!; + final service = _service!; + final isolateId = _isolateId!; _logger.info('Performing hot reload'); try { final method = await waitForServiceRegistration('reloadSources'); + _ensureCurrentConnection(connection); if (method == null) { - final report = await _service!.reloadSources(_isolateId!); + final report = await service.reloadSources(isolateId); _logger.fine('Hot reload completed: success=${report.success}'); return report.success ?? false; } else { - final result = await _service!.callMethod( + final result = await service.callMethod( method, - isolateId: _isolateId!, + isolateId: isolateId, ); _logger.fine('Hot reload completed: result=${result.json}'); return result.json?['type'] == 'Success'; @@ -641,10 +717,14 @@ class VmServiceConnector { Future hotRestart() async { _ensureConnected(); + final connection = _connection!; + final service = _service!; + final isolateId = _isolateId!; _logger.info('Performing hot restart'); try { final method = await waitForServiceRegistration('hotRestart'); + _ensureCurrentConnection(connection); if (method == null) { _logger.warning( 'hotRestart service not registered; app must be run via flutter run', @@ -652,17 +732,22 @@ class VmServiceConnector { return false; } - final result = await _service!.callMethod(method, isolateId: _isolateId!); + final result = await service.callMethod(method, isolateId: isolateId); + _ensureCurrentConnection(connection); _logger.fine('Hot restart completed: result=${result.json}'); final success = result.json?['type'] == 'Success'; if (success) { // The previous isolate was torn down and replaced. Re-resolve the new // isolate, retrying while it boots and re-registers its extensions. - _isolateId = await _findIsolateWithMarionetteExtensions( + final replacementIsolateId = await _findIsolateWithMarionetteExtensions( + service, + connection, attempts: 10, delay: const Duration(milliseconds: 500), ); + _ensureCurrentConnection(connection); + _isolateId = replacementIsolateId; _logger.info('Reconnected to isolate after hot restart: $_isolateId'); } @@ -680,7 +765,9 @@ class VmServiceConnector { /// [delay] between tries before giving up. /// /// Throws an exception if no suitable isolate is found within the retries. - Future _findIsolateWithMarionetteExtensions({ + Future _findIsolateWithMarionetteExtensions( + VmService service, + _VmServiceConnection connection, { int attempts = 1, Duration delay = const Duration(milliseconds: 500), }) async { @@ -689,7 +776,9 @@ class VmServiceConnector { await Future.delayed(delay); } - final vm = await _service!.getVM(); + _ensureCurrentConnection(connection); + final vm = await service.getVM(); + _ensureCurrentConnection(connection); if (vm.isolates == null || vm.isolates!.isEmpty) { continue; } @@ -701,7 +790,8 @@ class VmServiceConnector { } try { - final isolate = await _service!.getIsolate(isolateRef.id!); + final isolate = await service.getIsolate(isolateRef.id!); + _ensureCurrentConnection(connection); final hasExtension = isolate.extensionRPCs?.any( (ext) => ext == 'ext.flutter.marionette.getLogs', ) ?? @@ -711,6 +801,7 @@ class VmServiceConnector { return isolateRef.id!; } } catch (err) { + _ensureCurrentConnection(connection); _logger.warning( 'Failed to check extensions for isolate ${isolateRef.id}', err, @@ -726,3 +817,22 @@ class VmServiceConnector { ); } } + +/// Resources owned by one connection attempt, including its pending upgrade. +class _VmServiceConnection { + final HttpClient client = HttpClient(); + VmService? service; + StreamSubscription? subscription; + Future? _disposeFuture; + + Future dispose() { + // Force-close the HTTP request even if no WebSocket/VmService exists yet. + client.close(force: true); + return _disposeFuture ??= _dispose(); + } + + Future _dispose() async { + await subscription?.cancel(); + await service?.dispose(); + } +} diff --git a/packages/marionette_mcp/pubspec.yaml b/packages/marionette_mcp/pubspec.yaml index 43cc9f22..e5459053 100644 --- a/packages/marionette_mcp/pubspec.yaml +++ b/packages/marionette_mcp/pubspec.yaml @@ -22,7 +22,7 @@ environment: dependencies: args: ^2.7.0 logging: ^1.3.0 - mcp_dart: ^2.1.0 + mcp_dart: ^2.4.3 path: ^1.9.0 stack_trace: ^1.12.0 vm_service: ">=14.0.0" diff --git a/packages/marionette_mcp/test/fixtures/tools_catalog.json b/packages/marionette_mcp/test/fixtures/tools_catalog.json new file mode 100644 index 00000000..a578ce06 --- /dev/null +++ b/packages/marionette_mcp/test/fixtures/tools_catalog.json @@ -0,0 +1,765 @@ +[ + { + "name": "connect", + "description": "Connects to a Flutter app via its VM service URI. This must be called before using any other tools. The VM service URI is typically in the format ws://127.0.0.1:PORT/ws and can be found in the Flutter app output when running in debug mode. If the app enabled session reports (MarionetteConfiguration.enableSessionReports), this also opens a fresh session directory under .marionette/sessions/ where the step log and screenshots for this run are kept.", + "inputSchema": { + "type": "object", + "properties": { + "uri": { + "description": "VM service URI (e.g., ws://127.0.0.1:8181/ws). This is printed in the Flutter app console when running in debug mode.", + "type": "string" + }, + "session_title": { + "description": "A short title for this run, e.g. \"profile validation\". Keep it under ~60 characters \u2014 longer is truncated. Ignored unless the app enabled session reports. Slugified and timestamped into the session directory name. Purely a human-readable label \u2014 every connect opens its own fresh session directory, even if this matches an earlier title. Falls back to \"run-\" when omitted.", + "type": "string" + }, + "session_dir": { + "description": "Base directory .marionette/sessions/ is created under. Overrides the MARIONETTE_SESSION_DIR environment variable. Only needed when the MCP client hasn't set that variable and the process's working directory isn't the project root.", + "type": "string" + } + }, + "required": [ + "uri" + ] + }, + "annotations": { + "title": "Connect to App", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "disconnect", + "description": "Disconnects from the currently connected Flutter app. After disconnecting, you must call connect again to use any other tools.", + "inputSchema": { + "type": "object", + "properties": {} + }, + "annotations": { + "title": "Disconnect from App", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "get_interactive_elements", + "description": "Returns a list of all interactive elements currently visible in the Flutter app UI tree. Each element includes its type, text content (if any), key (if any), and other identifying properties. This is useful for understanding what can be interacted with in the app. Style, decoration and colour blobs are never reported. Set compaction to \"compact\" (the default for most apps) to also drop rendering details no interaction tool reads (textAlign, softWrap, overflow, font metrics, \u2026), report visible only when an element is not visible, round bounds to whole logical pixels, and drop a Text element's duplicated data field \u2014 much more token-efficient for agent loops. Set compaction to \"none\" to force the full payload. Omit compaction to use the app's MarionetteConfiguration.compaction default. Requires an active connection established via connect.", + "inputSchema": { + "type": "object", + "properties": { + "ancestor_keys": { + "description": "Optional wrapper keys (ValueKey), outermost first; each is looked up inside the previous one. Lists only that subtree. Fails if a key has no element.", + "type": "array", + "items": { + "type": "string" + } + }, + "compaction": { + "description": "How much to reduce the payload. \"compact\" drops rendering details, font metrics, a Text element's duplicated data, rounds bounds to whole logical pixels, and reports visible only when false. \"none\" forces the full payload. Omit to use the app's MarionetteConfiguration.compaction default.", + "type": "string", + "enum": [ + "none", + "compact" + ] + } + } + }, + "annotations": { + "title": "Get Interactive Elements", + "readOnlyHint": true, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "get_logs", + "description": "Retrieves all application logs collected from the Flutter app since app start or since the last hot reload. This includes debug messages, errors, and other log output from the running app. Requires an active connection established via connect.", + "inputSchema": { + "type": "object", + "properties": {} + }, + "annotations": { + "title": "Get Application Logs", + "readOnlyHint": true, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "take_screenshots", + "description": "Takes screenshots of all views in the Flutter app. By default returns base64-encoded PNG images inline, which can be decoded and saved. Set inline: false to instead save the screenshots straight into the active session directory (only available when the app enables session reports) and get back their paths \u2014 evidence you plan to cite in a report but don't need to look at right now, since each inline screenshot costs real visual tokens. Requires an active connection established via connect.", + "inputSchema": { + "type": "object", + "properties": { + "inline": { + "description": "If false, saves the screenshots into the session directory and returns their paths instead of the image data. Defaults to true.", + "type": "boolean" + } + } + }, + "annotations": { + "title": "Take Screenshots", + "readOnlyHint": true, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "tap", + "description": "Simulates a tap gesture on an element in the Flutter app that matches the given criteria. You can match elements by their key (a ValueKey), by their Semantics identifier, by their text content (but not accessibility label!), by their widget type, or by screen coordinates. Only one matching method should be used: either key, identifier, text, type, or coordinates. Prefer using the key if available, as it is more reliable; the Semantics identifier is the next-best stable selector. Limit yourself to elements from get_interactive_elements only if you can. Tapping a text field gives it focus, after which you can use enter_text with focused_element to type into it. Requires an active connection established via connect.", + "inputSchema": { + "type": "object", + "properties": { + "key": { + "description": "The key of the element to tap. You can get the key of an element by calling get_interactive_elements.", + "type": "string" + }, + "identifier": { + "description": "The Semantics identifier of the element to tap. A stable, unique, accessibility identifier set via Semantics(identifier: ...). Useful when an element has no ValueKey but does set an identifier. You can discover identifiers by calling get_interactive_elements.", + "type": "string" + }, + "text": { + "description": "The visible text content of the element to tap. Use this for elements that display text like buttons or labels.", + "type": "string" + }, + "type": { + "description": "The widget type name of the element to tap (e.g., \"ElevatedButton\", \"IconButton\"). Use this to match elements by their Flutter widget type.", + "type": "string" + }, + "coordinates": { + "description": "Screen coordinates to tap at. Use this to tap at a specific position on the screen.", + "type": "object", + "properties": { + "x": { + "description": "The x coordinate (horizontal position from left).", + "type": "number" + }, + "y": { + "description": "The y coordinate (vertical position from top).", + "type": "number" + } + }, + "required": [ + "x", + "y" + ] + }, + "ancestor_keys": { + "description": "Optional wrapper keys (ValueKey), outermost first; each is looked up inside the previous one. Limits the search to that subtree. Fails if a key has no element.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "annotations": { + "title": "Tap Element", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "secondary_tap", + "description": "Simulates a secondary tap (right mouse button click) on an element in the Flutter app. Desktop only \u2014 dispatches a mouse pointer with the secondary button pressed, which is what Flutter recognises as onSecondaryTap (e.g. for opening context menus). You can match elements by their key (a ValueKey), by their Semantics identifier, by their text content, by their widget type, or by screen coordinates. Only one matching method should be used. Prefer using the key if available, as it is more reliable. Requires an active connection established via connect.", + "inputSchema": { + "type": "object", + "properties": { + "key": { + "description": "The key of the element to secondary tap. You can get the key of an element by calling get_interactive_elements.", + "type": "string" + }, + "identifier": { + "description": "The Semantics identifier of the element to secondary tap. A stable, unique accessibility identifier set via Semantics(identifier: ...).", + "type": "string" + }, + "text": { + "description": "The visible text content of the element to secondary tap.", + "type": "string" + }, + "type": { + "description": "The widget type name of the element to secondary tap (e.g., \"ElevatedButton\", \"ListTile\").", + "type": "string" + }, + "coordinates": { + "description": "Screen coordinates to secondary tap at.", + "type": "object", + "properties": { + "x": { + "description": "The x coordinate (horizontal position from left).", + "type": "number" + }, + "y": { + "description": "The y coordinate (vertical position from top).", + "type": "number" + } + }, + "required": [ + "x", + "y" + ] + }, + "ancestor_keys": { + "description": "Optional wrapper keys (ValueKey), outermost first; each is looked up inside the previous one. Limits the search to that subtree. Fails if a key has no element.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "annotations": { + "title": "Secondary Tap Element", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "double_tap", + "description": "Simulates a double tap gesture on an element in the Flutter app. This is useful for triggering text selection, zoom, or any widget that responds to double tap. You can match elements by their key, identifier, text, type, or coordinates. An optional delay parameter controls the time between the two taps (default: 100ms). Requires an active connection established via connect.", + "inputSchema": { + "type": "object", + "properties": { + "key": { + "description": "The key of the element to double tap. You can get the key of an element by calling get_interactive_elements.", + "type": "string" + }, + "identifier": { + "description": "The Semantics identifier of the element to double tap. A stable, unique accessibility identifier set via Semantics(identifier: ...).", + "type": "string" + }, + "text": { + "description": "The visible text content of the element to double tap.", + "type": "string" + }, + "type": { + "description": "The widget type name of the element to double tap (e.g., \"ListTile\", \"Card\").", + "type": "string" + }, + "coordinates": { + "description": "Screen coordinates to double tap at.", + "type": "object", + "properties": { + "x": { + "description": "The x coordinate (horizontal position from left).", + "type": "number" + }, + "y": { + "description": "The y coordinate (vertical position from top).", + "type": "number" + } + }, + "required": [ + "x", + "y" + ] + }, + "delay": { + "description": "Time between the two taps in milliseconds. Defaults to 100ms which is within Flutter's double-tap recognition window (40ms-300ms).", + "type": "number" + }, + "ancestor_keys": { + "description": "Optional wrapper keys (ValueKey), outermost first; each is looked up inside the previous one. Limits the search to that subtree. Fails if a key has no element.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "annotations": { + "title": "Double Tap Element", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "long_press", + "description": "Simulates a long press gesture on an element in the Flutter app. This is useful for triggering context menus, reorderable lists, or any widget that responds to long press. You can match elements by their key, identifier, text, type, or coordinates. An optional duration parameter controls how long the press is held (default: 600ms). Requires an active connection established via connect.", + "inputSchema": { + "type": "object", + "properties": { + "key": { + "description": "The key of the element to long press. You can get the key of an element by calling get_interactive_elements.", + "type": "string" + }, + "identifier": { + "description": "The Semantics identifier of the element to long press. A stable, unique accessibility identifier set via Semantics(identifier: ...).", + "type": "string" + }, + "text": { + "description": "The visible text content of the element to long press.", + "type": "string" + }, + "type": { + "description": "The widget type name of the element to long press (e.g., \"ListTile\", \"Card\").", + "type": "string" + }, + "coordinates": { + "description": "Screen coordinates to long press at.", + "type": "object", + "properties": { + "x": { + "description": "The x coordinate (horizontal position from left).", + "type": "number" + }, + "y": { + "description": "The y coordinate (vertical position from top).", + "type": "number" + } + }, + "required": [ + "x", + "y" + ] + }, + "duration": { + "description": "How long to hold the press in milliseconds. Defaults to 600ms which matches Flutter's long press behavior.", + "type": "number" + }, + "ancestor_keys": { + "description": "Optional wrapper keys (ValueKey), outermost first; each is looked up inside the previous one. Limits the search to that subtree. Fails if a key has no element.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "annotations": { + "title": "Long Press Element", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "swipe", + "description": "Simulates a swipe/drag gesture on the Flutter app. Supports two modes: 1. Element-based: provide key, identifier, or text to identify the element, plus a direction (left, right, up, down) and optional distance in pixels (default 200). 2. Coordinate-based: provide startX, startY, endX, endY for precise control. Useful for interacting with PageView, Dismissible, Drawer, Slider, and other swipe-based widgets. Requires an active connection established via connect.", + "inputSchema": { + "type": "object", + "properties": { + "key": { + "description": "The key of the element to swipe on. Use with direction.", + "type": "string" + }, + "identifier": { + "description": "The Semantics identifier of the element to swipe on. Use with direction. A stable, unique accessibility identifier set via Semantics(identifier: ...).", + "type": "string" + }, + "text": { + "description": "The visible text content of the element to swipe on. Use with direction.", + "type": "string" + }, + "direction": { + "description": "Swipe direction when using element-based mode: left, right, up, or down.", + "type": "string" + }, + "distance": { + "description": "Swipe distance in pixels for element-based mode (default: 200).", + "type": "number" + }, + "startX": { + "description": "Start X coordinate for coordinate-based swipe.", + "type": "number" + }, + "startY": { + "description": "Start Y coordinate for coordinate-based swipe.", + "type": "number" + }, + "endX": { + "description": "End X coordinate for coordinate-based swipe.", + "type": "number" + }, + "endY": { + "description": "End Y coordinate for coordinate-based swipe.", + "type": "number" + }, + "ancestor_keys": { + "description": "Optional wrapper keys (ValueKey), outermost first; each is looked up inside the previous one. Limits the search to that subtree. Fails if a key has no element.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "annotations": { + "title": "Swipe", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "pinch_zoom", + "description": "Simulates a pinch zoom gesture on an element in the Flutter app. Use scale > 1.0 to zoom in (fingers move apart) and scale < 1.0 to zoom out (fingers move together). You can target the element by key, identifier, text, type, or coordinates. Useful for maps, images, PDFs, and other zoomable content. Requires an active connection established via connect.", + "inputSchema": { + "type": "object", + "properties": { + "key": { + "description": "The key of the element to pinch zoom on.", + "type": "string" + }, + "identifier": { + "description": "The Semantics identifier of the element to pinch zoom on. A stable, unique accessibility identifier set via Semantics(identifier: ...).", + "type": "string" + }, + "text": { + "description": "The visible text content of the element to pinch zoom on.", + "type": "string" + }, + "type": { + "description": "The widget type name of the element to pinch zoom on.", + "type": "string" + }, + "coordinates": { + "description": "Screen coordinates to pinch zoom at.", + "type": "object", + "properties": { + "x": { + "description": "The x coordinate.", + "type": "number" + }, + "y": { + "description": "The y coordinate.", + "type": "number" + } + }, + "required": [ + "x", + "y" + ] + }, + "scale": { + "description": "Zoom scale factor. Values > 1.0 zoom in, values < 1.0 zoom out. For example, 2.0 doubles the zoom level.", + "type": "number" + }, + "start_distance": { + "description": "Initial distance between the two fingers in pixels (default: 200).", + "type": "number" + }, + "ancestor_keys": { + "description": "Optional wrapper keys (ValueKey), outermost first; each is looked up inside the previous one. Limits the search to that subtree. Fails if a key has no element.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "scale" + ] + }, + "annotations": { + "title": "Pinch Zoom", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "press_back_button", + "description": "Simulates a system back button press in the Flutter app. This triggers the same mechanism as the Android back button or iOS swipe-back gesture. If the app has a route to pop, it will be popped. If the app is on the root route, the system may minimize or close the app (same as real back button behavior). Works with Navigator, GoRouter, and other routing solutions. Requires an active connection established via connect.", + "inputSchema": { + "type": "object", + "properties": {} + }, + "annotations": { + "title": "Press Back Button", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "scroll_to", + "description": "Scrolls the view until an element matching the given criteria becomes visible. You can match elements by their key (a ValueKey), by their Semantics identifier, or by their visible text content. This is useful when you need to interact with elements that are not currently visible on screen. Requires an active connection established via connect.", + "inputSchema": { + "type": "object", + "properties": { + "key": { + "description": "The key of the element to scroll to. You can get the key of an element by calling get_interactive_elements.", + "type": "string" + }, + "identifier": { + "description": "The Semantics identifier of the element to scroll to. A stable, unique accessibility identifier set via Semantics(identifier: ...).", + "type": "string" + }, + "text": { + "description": "The visible text content of the element to scroll to.", + "type": "string" + }, + "ancestor_keys": { + "description": "Optional wrapper keys (ValueKey), outermost first; each is looked up inside the previous one. Limits the search to that subtree. Fails if a key has no element.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "annotations": { + "title": "Scroll to Element", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "enter_text", + "description": "Enters text into a text field in the Flutter app. You can target the field in three ways: 1. By key: provide the key parameter with the ValueKey of the field. You can discover available keys by calling get_interactive_elements. 2. By Semantics identifier: provide the identifier parameter with the accessibility identifier of the field. Useful when the field has no ValueKey but does set a Semantics identifier. 3. By focused element: first tap a text field to give it focus, then call enter_text with focused_element set to true. Important: when targeting by focused element, a text field must be focused before calling this (for example by using the tap tool), otherwise it will fail with an error. Exactly one of key, identifier, or focused_element must be provided. Requires an active connection established via connect.", + "inputSchema": { + "type": "object", + "properties": { + "input": { + "description": "The text to enter into the text field.", + "type": "string" + }, + "key": { + "description": "The key of the text field. You can get the key of an element by calling get_interactive_elements.", + "type": "string" + }, + "identifier": { + "description": "The Semantics identifier of the text field. A stable, unique accessibility identifier set via Semantics(identifier: ...). You can discover identifiers by calling get_interactive_elements.", + "type": "string" + }, + "focused_element": { + "description": "If true, enters text into the currently focused text field. A text field must be focused first (for example by using tap), otherwise this will fail.", + "type": "boolean" + }, + "ancestor_keys": { + "description": "Optional wrapper keys (ValueKey), outermost first; each is looked up inside the previous one. Limits the search to that subtree. Fails if a key has no element.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "input" + ] + }, + "annotations": { + "title": "Enter Text", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "press_key", + "description": "Presses a keyboard key in the Flutter app, producing a real key event that flows through the focus system \u2014 unlike enter_text, which only replaces a field's value. Use it to submit a form (enter), move focus (tab), dismiss a dialog (escape), edit within a field (backspace, delete, arrowLeft/arrowRight/arrowUp/arrowDown, home, end), or trigger app shortcuts via modifiers (for example control+a to select all). The key is sent to whatever currently has focus, exactly like a real keyboard, so focus a target first if needed (for example with tap). Requires an active connection established via connect.", + "inputSchema": { + "type": "object", + "properties": { + "key": { + "description": "The key to press. Named keys: enter, tab, escape, backspace, delete, space, arrowUp, arrowDown, arrowLeft, arrowRight, home, end, pageUp, pageDown. Also accepts a single character a-z or 0-9 (case-insensitive).", + "type": "string" + }, + "modifiers": { + "description": "Optional comma-separated modifier keys to hold during the press: any of control, shift, alt, meta (e.g. \"control\" or \"control,shift\"). On macOS use meta for the Command key.", + "type": "string" + } + }, + "required": [ + "key" + ] + }, + "annotations": { + "title": "Press Key", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "set_device_config", + "description": "Overrides the device configuration the running app sees \u2014 text scale, bold text, and light/dark appearance \u2014 without touching the real device settings. Use it to sweep a screen under accessibility or appearance conditions (large text, bold text, dark mode). Omitted parameters keep their current override; reset clears every override, and combining reset with values leaves exactly those values set (that is how a single override is reverted). Requires the app to wrap its root widget in MarionetteDeviceConfig \u2014 if it does not, this returns setup instructions. Requires an active connection established via connect.", + "inputSchema": { + "type": "object", + "properties": { + "text_scale": { + "description": "Linear text scale, as a multiplier of the platform default. Must be greater than 0. Typical sweep values are 1.0 (default), 1.5 (large) and 2.0 (very large); real devices top out around 3.0, and much larger values mostly produce overflow errors rather than useful signal. Note that some Material widgets clamp their own text scaling.", + "type": "number" + }, + "bold_text": { + "description": "The system bold-text accessibility setting.", + "type": "boolean" + }, + "platform_brightness": { + "description": "The system light/dark appearance. Drives MaterialApp theme resolution when themeMode is system.", + "type": "string", + "enum": [ + "light", + "dark" + ] + }, + "reset": { + "description": "Clear every override and revert to the platform defaults. Applied before any other parameter in the same call.", + "type": "boolean" + } + } + }, + "annotations": { + "title": "Set Device Config", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "list_custom_extensions", + "description": "Lists all custom VM service extensions registered by the Flutter app (outside of the built-in Marionette extensions). Each extension includes its name and an optional description. Use this to discover what app-specific extensions are available before calling them with call_custom_extension. Requires an active connection established via connect.", + "inputSchema": { + "type": "object", + "properties": {} + }, + "annotations": { + "title": "List Custom Extensions", + "readOnlyHint": true, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "call_custom_extension", + "description": "Calls a custom VM service extension registered by the Flutter app. This is an escape hatch for interacting with app-specific extensions that are not part of marionette's built-in tools. For marionette features, use the dedicated tools instead. The extension name should not include the \"ext.flutter.\" prefix as it is added automatically. For example, use \"deckNavigation.goToSlide\" instead of \"ext.flutter.deckNavigation.goToSlide\". Arguments are passed as string key-value pairs. The available extensions depend on what the connected Flutter app has registered. Check the app's source code for available extensions. Requires an active connection established via connect.", + "inputSchema": { + "type": "object", + "properties": { + "extension": { + "description": "The extension name without the \"ext.flutter.\" prefix (e.g., \"deckNavigation.goToSlide\").", + "type": "string" + }, + "args": { + "description": "Optional key-value pairs to pass as arguments. Scalars are stringified and nested object/array values are JSON-encoded before being sent to the VM service extension.", + "type": "object", + "properties": {} + } + }, + "required": [ + "extension" + ] + }, + "annotations": { + "title": "Call Custom Extension", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "hot_reload", + "description": "Performs a hot reload of the Flutter app. This reloads the Dart code without restarting the app, preserving the current state. Useful after making code changes to see them reflected in the running app. Requires an active connection established via connect.", + "inputSchema": { + "type": "object", + "properties": {} + }, + "annotations": { + "title": "Hot Reload", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "hot_restart", + "description": "Performs a hot restart of the Flutter app. This fully restarts the app from main() and resets all state. Use it instead of hot_reload after changes a reload cannot pick up (e.g. changes to main()/bootstrap, global singletons, or state shape). Requires the app to be running via `flutter run` and an active connection established via connect.", + "inputSchema": { + "type": "object", + "properties": {} + }, + "annotations": { + "title": "Hot Restart", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + } +] diff --git a/packages/marionette_mcp/test/fixtures/vm_service_app.dart b/packages/marionette_mcp/test/fixtures/vm_service_app.dart new file mode 100644 index 00000000..3acdeb8c --- /dev/null +++ b/packages/marionette_mcp/test/fixtures/vm_service_app.dart @@ -0,0 +1,25 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer' as developer; + +/// Minimal Marionette-compatible VM service without a Flutter dependency. +Future main() async { + for (final entry in >{ + 'getLogs': {'logs': [], 'count': 0}, + 'getVersion': {'version': '0.7.0'}, + 'getConfiguration': {'enableSessionReports': false}, + 'listExtensions': {'extensions': []}, + }.entries) { + developer.registerExtension( + 'ext.flutter.marionette.${entry.key}', + (_, __) async => + developer.ServiceExtensionResponse.result(jsonEncode(entry.value)), + ); + } + final info = await developer.Service.getInfo(); + final serverUri = info.serverUri; + if (serverUri == null) throw StateError('VM service is not enabled'); + final websocketUri = serverUri.replace(scheme: 'ws').resolve('ws'); + print('READY:$websocketUri'); + Timer.periodic(const Duration(seconds: 10), (_) {}); +} diff --git a/packages/marionette_mcp/test/server_api_test.dart b/packages/marionette_mcp/test/server_api_test.dart new file mode 100644 index 00000000..e0cf78fd --- /dev/null +++ b/packages/marionette_mcp/test/server_api_test.dart @@ -0,0 +1,77 @@ +@Timeout(Duration(seconds: 60)) +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:marionette_mcp/marionette_mcp.dart'; +import 'package:marionette_mcp/runtime.dart'; +import 'package:mcp_dart/mcp_dart.dart'; +import 'package:test/test.dart'; + +void main() { + for (final protocol in [McpProtocol.legacy, McpProtocol.stable]) { + test('public client preserves the original tool catalog on $protocol', + () async { + final client = McpClient( + const Implementation(name: 'marionette-api-test', version: '1'), + options: McpClientOptions(protocol: protocol), + ); + final transport = StdioClientTransport(StdioServerParameters( + command: Platform.resolvedExecutable, + args: ['run', 'bin/marionette_mcp.dart', '--log-level', 'SEVERE'], + restartOnUnexpectedExit: false, + )); + addTearDown(client.close); + await client.connect(transport); + + // Captured from the original 0.7.0 server with mcp_dart 2.4.3, before + // changing any registrations. This is the client-visible contract. + final expected = (jsonDecode( + File('test/fixtures/tools_catalog.json').readAsStringSync(), + ) as List) + .cast>(); + if (protocol == McpProtocol.stable) { + // SDK stateless discovery sorts the catalog and omits legacy Tasks + // execution metadata. Tool-owned metadata remains identical. + expected.sort( + (a, b) => (a['name'] as String).compareTo(b['name'] as String)); + for (final tool in expected) { + tool.remove('execution'); + } + } + final catalog = await client.listTools(); + expect(catalog.tools.map((tool) => tool.toJson()).toList(), expected); + + final result = await client.callTool( + const CallToolRequest(name: 'get_logs', arguments: {}), + ); + expect(result.isError, isTrue); + expect(result.content.whereType().single.text, + contains('Not connected')); + }); + } + + test('public server composition accepts an owned runtime dependency', + () async { + final connector = _TrackingConnector(); + final context = VmServiceContext(connector: connector); + final server = createMcpServer(context: context); + try { + await context.dispose(); + expect(connector.disconnections, 1); + } finally { + await server.close(); + } + }); +} + +class _TrackingConnector extends VmServiceConnector { + int disconnections = 0; + + @override + Future disconnect() async { + disconnections++; + await super.disconnect(); + } +} diff --git a/packages/marionette_mcp/test/server_config_test.dart b/packages/marionette_mcp/test/server_config_test.dart new file mode 100644 index 00000000..b4237260 --- /dev/null +++ b/packages/marionette_mcp/test/server_config_test.dart @@ -0,0 +1,38 @@ +import 'dart:io'; + +import 'package:marionette_mcp/marionette_mcp.dart'; +import 'package:test/test.dart'; + +void main() { + ServerConfig parse(List args) => + ServerConfig.fromResults(ServerConfig.buildParser().parse(args)); + + test('retains stdio and logging defaults', () { + final config = parse([]); + expect(config.ssePort, isNull); + expect(config.logLevel, 'INFO'); + expect(config.logFile, isNull); + }); + + test('parses the shared legacy SSE and logging options', () { + final config = + parse(['--sse-port', '8080', '-l', 'fine', '--log-file', 'log']); + expect(config.ssePort, 8080); + expect(config.logLevel, 'FINE'); + expect(config.logFile, 'log'); + }); + + for (final port in ['typo', '-1', '65536', '1.5', '']) { + test('rejects invalid SSE port "$port" instead of using stdio', () { + expect(() => parse(['--sse-port=$port']), throwsFormatException); + }); + } + + test('standalone executable reports an invalid port with a failing exit code', + () async { + final result = await Process.run(Platform.resolvedExecutable, + ['run', 'bin/marionette_mcp.dart', '--sse-port=typo']); + expect(result.exitCode, 1); + expect(result.stderr, contains('SSE port must be an integer')); + }); +} diff --git a/packages/marionette_mcp/test/server_context_test.dart b/packages/marionette_mcp/test/server_context_test.dart new file mode 100644 index 00000000..0ff25dc9 --- /dev/null +++ b/packages/marionette_mcp/test/server_context_test.dart @@ -0,0 +1,127 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:marionette_mcp/marionette_mcp.dart'; +import 'package:marionette_mcp/runtime.dart'; +import 'package:mcp_dart/mcp_dart.dart'; +import 'package:test/test.dart'; + +void main() { + test('an older disconnect preserves a replacement connection session', + () async { + final directory = + Directory.systemTemp.createTempSync('marionette-overlap-'); + final connector = _DelayedDisconnectConnector(); + final context = VmServiceContext(connector: connector); + final server = _CapturingServer(); + context.registerTools(server); + addTearDown(() async { + if (!connector.release.isCompleted) connector.release.complete(); + await context.dispose(); + await server.close(); + directory.deleteSync(recursive: true); + }); + final extra = _UnusedExtra(); + Future call(String name, Map args) async => + server.callbacks[name]!(args, extra); + Map connectArgs(String title) => { + 'uri': 'ws://fixture/ws', + 'session_title': title, + 'session_dir': directory.path, + }; + expect((await call('connect', connectArgs('first'))).isError, isFalse); + + connector.holdOnce = true; + final disconnect = call('disconnect', {}); + await connector.started.future; + expect( + (await call('connect', connectArgs('replacement'))).isError, isFalse); + expect( + (await call('take_screenshots', {'inline': false})).isError, isFalse); + + connector.release.complete(); + expect((await disconnect).isError, isFalse); + expect( + (await call('take_screenshots', {'inline': false})).isError, isFalse); + + final sessions = Directory('${directory.path}/.marionette/sessions') + .listSync() + .whereType() + .toList(); + final first = sessions.singleWhere((entry) => entry.path.contains('first')); + final replacement = + sessions.singleWhere((entry) => entry.path.contains('replacement')); + expect(File('${first.path}/steps.md').readAsStringSync(), + contains('disconnect')); + expect(File('${replacement.path}/steps.md').readAsStringSync(), + isNot(contains('disconnect'))); + }); +} + +class _CapturingServer extends McpServer { + _CapturingServer() + : super(const Implementation(name: 'context-test', version: '1')); + + final callbacks = {}; + + @override + RegisteredTool registerTool( + String name, { + String? title, + String? description, + ToolInputSchema? inputSchema, + ToolOutputSchema? outputSchema, + ToolAnnotations? annotations, + Map? meta, + required ToolFunction callback, + }) { + callbacks[name] = callback; + return super.registerTool( + name, + title: title, + description: description, + inputSchema: inputSchema, + outputSchema: outputSchema, + annotations: annotations, + meta: meta, + callback: callback, + ); + } +} + +class _UnusedExtra implements RequestHandlerExtra { + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} + +class _DelayedDisconnectConnector extends VmServiceConnector { + bool holdOnce = false; + final started = Completer(); + final release = Completer(); + + @override + Future connect(String uri) async {} + + @override + Future disconnect() async { + if (holdOnce) { + holdOnce = false; + started.complete(); + await release.future; + } + } + + @override + Future getVersion() async => version; + + @override + Future getSessionReportsEnabled() async => true; + + @override + Future> listExtensions() async => {'extensions': []}; + + @override + Future> takeScreenshots() async => { + 'screenshots': ['aGVsbG8='], + }; +} diff --git a/packages/marionette_mcp/test/sse_server_test.dart b/packages/marionette_mcp/test/sse_server_test.dart new file mode 100644 index 00000000..000c3b61 --- /dev/null +++ b/packages/marionette_mcp/test/sse_server_test.dart @@ -0,0 +1,68 @@ +@Timeout(Duration(seconds: 60)) +library; + +import 'dart:io'; + +import 'package:mcp_dart/mcp_dart.dart'; +import 'package:test/test.dart'; + +import 'support/test_process.dart'; + +void main() { + test( + 'legacy SSE initializes, lists tools and exits on SIGTERM while connected', + () async { + final app = await TestProcess.start([ + '--enable-vm-service=0', + '--disable-service-auth-codes', + 'test/fixtures/vm_service_app.dart', + ]); + addTearDown(app.dispose); + final vmUri = await app.vmServiceUri(); + + // Pick an available loopback port before launching the actual executable. + final reservation = + await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final port = reservation.port; + await reservation.close(); + final server = await TestProcess.start([ + 'run', + 'bin/marionette_mcp.dart', + '--sse-port', + '$port', + '--log-level', + 'FINE', + ]); + addTearDown(server.dispose); + await server.waitForStderr('Running MCP server on SSE port $port'); + + final client = McpClient( + const Implementation(name: 'sse-shutdown-test', version: '1'), + options: McpClientOptions(protocol: McpProtocol.legacy), + ); + addTearDown(() => client.close().timeout(const Duration(seconds: 5))); + // The executable still exposes --sse-port for legacy clients. + final transport = + // ignore: deprecated_member_use + SseClientTransport(Uri.parse('http://127.0.0.1:$port/sse')); + await client.connect(transport).timeout(const Duration(seconds: 15)); + final catalog = await client.listTools().timeout( + const Duration(seconds: 15), + ); + expect(catalog.tools.map((tool) => tool.name), contains('connect')); + final connected = await client + .callTool(CallToolRequest(name: 'connect', arguments: {'uri': vmUri})) + .timeout(const Duration(seconds: 15)); + expect(connected.isError, isFalse, reason: '${connected.content}'); + + // Both the SSE session and VM connection remain active until shutdown. + expect(server.process.kill(ProcessSignal.sigterm), isTrue); + expect( + await server.exitCodeWithin(const Duration(seconds: 10)), + 0, + reason: server.errors.toString(), + ); + }, + skip: Platform.isWindows ? 'SIGTERM requires POSIX signals' : false, + ); +} diff --git a/packages/marionette_mcp/test/stdio_server_test.dart b/packages/marionette_mcp/test/stdio_server_test.dart index ad34b4c1..5efacac2 100644 --- a/packages/marionette_mcp/test/stdio_server_test.dart +++ b/packages/marionette_mcp/test/stdio_server_test.dart @@ -7,6 +7,8 @@ import 'dart:io'; import 'package:test/test.dart'; +import 'support/test_process.dart'; + /// End-to-end tests for the stdio MCP server. /// /// These spawn the real entrypoint ([bin/marionette_mcp.dart]) and drive it @@ -76,6 +78,143 @@ void main() { }, ); + for (final signal in [false, if (!Platform.isWindows) true]) { + test( + 'exits with code 0 while connected on ' + '${signal ? 'SIGTERM' : 'stdin EOF'}', + () async { + final app = await TestProcess.start([ + '--enable-vm-service=0', + '--disable-service-auth-codes', + 'test/fixtures/vm_service_app.dart', + ]); + addTearDown(app.dispose); + final uri = await app.vmServiceUri(); + + final server = await TestProcess.start([ + 'run', + entrypoint, + '--log-level', + 'SEVERE', + ]); + addTearDown(server.dispose); + server.send({ + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'initialize', + 'params': { + 'protocolVersion': '2025-06-18', + 'capabilities': {}, + 'clientInfo': {'name': 'shutdown-test', 'version': '1.0.0'}, + }, + }); + final initialized = await server.response(1); + expect(initialized['error'], isNull); + expect(initialized['result'], isA()); + server.send({ + 'jsonrpc': '2.0', + 'method': 'notifications/initialized', + }); + server.send({ + 'jsonrpc': '2.0', + 'id': 2, + 'method': 'tools/call', + 'params': { + 'name': 'connect', + 'arguments': {'uri': uri}, + }, + }); + final connected = await server.response(2); + expect(connected['error'], isNull); + expect(connected['result'], isA()); + final result = connected['result'] as Map; + expect(result['isError'], isNot(true), reason: '$connected'); + + // Keep the VM connection alive: shutdown must dispose it without + // requiring an explicit disconnect tool call from the client. + if (signal) { + expect(server.process.kill(ProcessSignal.sigterm), isTrue); + } else { + await server.process.stdin.close(); + } + expect( + await server.exitCodeWithin(const Duration(seconds: 10)), + 0, + reason: server.errors.toString(), + ); + }, + ); + } + + for (final signal in [false, if (!Platform.isWindows) true]) { + test( + 'exits with code 0 during a pending VM WebSocket upgrade on ' + '${signal ? 'SIGTERM' : 'stdin EOF'}', + () async { + final listener = + await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final accepted = Completer(); + final requests = listener.listen((request) { + if (!accepted.isCompleted) accepted.complete(request); + }); + addTearDown(() async { + await listener.close(force: true); + await requests.cancel(); + }); + final server = await TestProcess.start([ + 'run', + entrypoint, + '--log-level', + 'SEVERE', + ]); + addTearDown(server.dispose); + server.send({ + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'initialize', + 'params': { + 'protocolVersion': '2025-06-18', + 'capabilities': {}, + 'clientInfo': {'name': 'pending-shutdown-test', 'version': '1'}, + }, + }); + final initialized = await server.response(1); + expect(initialized['error'], isNull); + server.send({ + 'jsonrpc': '2.0', + 'method': 'notifications/initialized', + }); + server.send({ + 'jsonrpc': '2.0', + 'id': 2, + 'method': 'tools/call', + 'params': { + 'name': 'connect', + 'arguments': {'uri': 'ws://127.0.0.1:${listener.port}/ws'}, + }, + }); + final request = await accepted.future.timeout( + const Duration(seconds: 15), + ); + expect(WebSocketTransformer.isUpgradeRequest(request), isTrue); + + // Leave the HTTP upgrade unanswered until after process exit. Merely + // closing a late successful WebSocket does not release a still-open + // HttpClient request, which would otherwise keep the process alive. + if (signal) { + expect(server.process.kill(ProcessSignal.sigterm), isTrue); + } else { + await server.process.stdin.close(); + } + expect( + await server.exitCodeWithin(const Duration(seconds: 10)), + 0, + reason: server.errors.toString(), + ); + }, + ); + } + test('exits with code 0 when stdin reaches EOF', () async { final process = await startServer(); addTearDown(process.kill); diff --git a/packages/marionette_mcp/test/support/test_process.dart b/packages/marionette_mcp/test/support/test_process.dart new file mode 100644 index 00000000..556eee8b --- /dev/null +++ b/packages/marionette_mcp/test/support/test_process.dart @@ -0,0 +1,92 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +/// Owns subprocess streams and guarantees cleanup even when an assertion fails. +class TestProcess { + TestProcess(this.process) + : lines = StreamIterator( + process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()), + ) { + _stderr = process.stderr.transform(utf8.decoder).listen((message) { + errors.write(message); + _errorEvents.add(message); + }); + } + + final Process process; + final StreamIterator lines; + final errors = StringBuffer(); + final _errorEvents = StreamController.broadcast(); + late final StreamSubscription _stderr; + + static Future start(List arguments) async => + TestProcess(await Process.start(Platform.resolvedExecutable, arguments)); + + Future waitForStderr(String text) async { + if (errors.toString().contains(text)) return; + await _errorEvents.stream + .firstWhere((_) => errors.toString().contains(text)) + .timeout(const Duration(seconds: 15)); + } + + Future exitCodeWithin(Duration timeout) => process.exitCode.timeout( + timeout, + onTimeout: () => throw TimeoutException( + 'Child did not exit. Stderr: $errors', + timeout, + ), + ); + + void send(Map message) { + process.stdin.writeln(jsonEncode(message)); + } + + Future vmServiceUri() => _vmServiceUri().timeout( + const Duration(seconds: 15), + onTimeout: () => throw TimeoutException( + 'VM fixture did not become ready: $errors', + ), + ); + + Future _vmServiceUri() async { + while (await lines.moveNext()) { + final line = lines.current; + if (line.startsWith('READY:')) return line.substring('READY:'.length); + } + throw StateError('VM fixture exited before becoming ready: $errors'); + } + + Future> response(int id) => _response(id).timeout( + const Duration(seconds: 15), + onTimeout: () => throw TimeoutException( + 'MCP server did not respond to request $id: $errors', + ), + ); + + Future> _response(int id) async { + while (await lines.moveNext()) { + final message = jsonDecode(lines.current) as Map; + if (message['id'] == id) return message; + } + throw StateError('MCP server exited before responding to $id: $errors'); + } + + Future dispose() async { + process.kill(ProcessSignal.sigkill); + try { + await process.exitCode.timeout(const Duration(seconds: 5)); + } finally { + await lines.cancel(); + await _stderr.cancel(); + await _errorEvents.close(); + try { + await process.stdin.close(); + } on IOException { + // The process may already have closed its input pipe. + } + } + } +} diff --git a/packages/marionette_mcp/test/vm_service/connector_lifecycle_test.dart b/packages/marionette_mcp/test/vm_service/connector_lifecycle_test.dart new file mode 100644 index 00000000..50d5edc9 --- /dev/null +++ b/packages/marionette_mcp/test/vm_service/connector_lifecycle_test.dart @@ -0,0 +1,361 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:test/test.dart'; + +/// A loopback VM service whose HTTP upgrade or RPC responses can be withheld. +class _VmServer { + _VmServer(this.server, this.name); + + final HttpServer server; + final String name; + final accepted = Completer(); + final requests = >[]; + final sockets = []; + final _requests = + StreamController>.broadcast(sync: true); + bool holdUpgrade = false; + bool holdGetVm = false; + bool holdGetIsolate = false; + bool holdGetVersion = false; + bool respondToStreamListen = true; + bool registerHotRestart = false; + String isolateId = 'isolates/1'; + int emptyVmResponses = 0; + + String get uri => 'ws://127.0.0.1:${server.port}/ws'; + + static Future<_VmServer> start(String name) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final vm = _VmServer(server, name); + server.listen(vm._accept); + return vm; + } + + Future _accept(HttpRequest request) async { + if (!accepted.isCompleted) accepted.complete(request); + if (holdUpgrade) return; + final socket = await WebSocketTransformer.upgrade(request); + sockets.add(socket); + socket.listen((data) { + final message = jsonDecode(data as String) as Map; + requests.add(message); + _requests.add(message); + final method = message['method']; + if ((method == 'streamListen' && !respondToStreamListen) || + (method == 'getVM' && holdGetVm) || + (method == 'getIsolate' && holdGetIsolate) || + (method == 'ext.flutter.marionette.getVersion' && holdGetVersion)) { + return; + } + Map result; + switch (method) { + case 'getVM': + final empty = emptyVmResponses > 0; + if (empty) emptyVmResponses--; + result = { + 'type': 'VM', + 'isolates': [ + if (!empty) + { + 'type': '@Isolate', + 'id': isolateId, + 'name': 'main', + 'number': '1', + }, + ], + }; + case 'getIsolate': + result = { + 'type': 'Isolate', + 'id': isolateId, + 'name': 'main', + 'number': '1', + 'extensionRPCs': ['ext.flutter.marionette.getLogs'], + }; + case 'ext.flutter.marionette.getVersion': + result = {'version': name}; + case 's0.hotRestart': + isolateId = 'isolates/2'; + result = {'type': 'Success'}; + default: + result = {'type': 'Success'}; + } + socket.add(jsonEncode({ + 'jsonrpc': '2.0', + 'id': message['id'], + 'result': result, + })); + if (method == 'streamListen' && registerHotRestart) { + socket.add(jsonEncode({ + 'jsonrpc': '2.0', + 'method': 'streamNotify', + 'params': { + 'streamId': 'Service', + 'event': { + 'type': 'Event', + 'kind': 'ServiceRegistered', + 'service': 'hotRestart', + 'method': 's0.hotRestart', + }, + }, + })); + } + }); + } + + Future seen(String method) async { + if (requests.any((request) => request['method'] == method)) return; + await _requests.stream + .firstWhere((request) => request['method'] == method) + .timeout(const Duration(seconds: 3)); + } + + Future close() async { + for (final socket in sockets) { + await socket.close(); + } + await server.close(force: true); + await _requests.close(); + } +} + +/// Observe cancellation immediately so a rejected connect cannot go unhandled. +Future _failureOf(Future future) => future.then( + (_) => null, + onError: (Object error) => error, + ); + +void main() { + group('VM service connection ownership', () { + late VmServiceConnector connector; + late List<_VmServer> servers; + + setUp(() { + connector = VmServiceConnector(); + servers = []; + }); + + tearDown(() async { + await connector.disconnect(); + for (final server in servers) { + await server.close(); + } + }); + + Future<_VmServer> server(String name) async { + final value = await _VmServer.start(name); + servers.add(value); + return value; + } + + test('disconnect aborts an HTTP upgrade that never responds', () async { + final vm = await server('pending'); + vm.holdUpgrade = true; + final failure = _failureOf(connector.connect(vm.uri)); + await vm.accepted.future.timeout(const Duration(seconds: 3)); + + await connector.disconnect().timeout(const Duration(seconds: 2)); + + // The server deliberately never releases the upgrade. Closing only an + // already-created VmService cannot make this future complete. + expect(await failure.timeout(const Duration(seconds: 2)), isNotNull); + expect(connector.isConnected, isFalse); + expect(vm.sockets, isEmpty); + await expectLater( + connector.getVersion(), throwsA(isA())); + }); + + test('same-turn disconnect supersedes connect before socket acquisition', + () async { + final vm = await server('cancelled'); + final failure = _failureOf(connector.connect(vm.uri)); + + await connector.disconnect(); + + expect( + await failure.timeout(const Duration(seconds: 2)), isA()); + expect(connector.isConnected, isFalse); + expect(vm.accepted.isCompleted, isFalse); + }); + + test('the last of same-turn connect calls owns the connection', () async { + final old = await server('old'); + final replacement = await server('replacement'); + final oldFailure = _failureOf(connector.connect(old.uri)); + + await connector + .connect(replacement.uri) + .timeout(const Duration(seconds: 3)); + + expect(await oldFailure.timeout(const Duration(seconds: 2)), + isA()); + expect(old.accepted.isCompleted, isFalse); + expect(await connector.getVersion(), 'replacement'); + }); + + test('a replacement connect cancels the previous pending upgrade', + () async { + final old = await server('old'); + old.holdUpgrade = true; + final oldFailure = _failureOf(connector.connect(old.uri)); + await old.accepted.future.timeout(const Duration(seconds: 3)); + final replacement = await server('replacement'); + + await connector + .connect(replacement.uri) + .timeout(const Duration(seconds: 3)); + + expect(await oldFailure.timeout(const Duration(seconds: 2)), isNotNull); + expect(connector.isConnected, isTrue); + expect(await connector.getVersion(), 'replacement'); + expect(old.sockets, isEmpty); + }); + + test('stale discovery failure does not disconnect a newer connection', + () async { + final old = await server('old'); + old.holdGetVm = true; + final oldFailure = _failureOf(connector.connect(old.uri)); + await old.seen('getVM'); + final replacement = await server('replacement'); + + await connector + .connect(replacement.uri) + .timeout(const Duration(seconds: 3)); + + expect(await oldFailure.timeout(const Duration(seconds: 2)), isNotNull); + expect(connector.isConnected, isTrue); + expect(await connector.getVersion(), 'replacement'); + expect(old.requests.where((r) => r['method'] == 'getIsolate'), isEmpty); + }); + + test('disconnect also cancels pending isolate discovery', () async { + final vm = await server('discovery'); + vm.holdGetIsolate = true; + final failure = _failureOf(connector.connect(vm.uri)); + await vm.seen('getIsolate'); + + await connector.disconnect().timeout(const Duration(seconds: 2)); + + expect(await failure.timeout(const Duration(seconds: 2)), isNotNull); + expect(connector.isConnected, isFalse); + }); + + test('peer close rejects pending discovery and permits reconnect', + () async { + final old = await server('old'); + old.holdGetIsolate = true; + final failure = _failureOf(connector.connect(old.uri)); + await old.seen('getIsolate'); + + await old.sockets.single.close(); + + expect(await failure.timeout(const Duration(seconds: 2)), isNotNull); + expect(connector.isConnected, isFalse); + final replacement = await server('replacement'); + await connector.connect(replacement.uri); + expect(await connector.getVersion(), 'replacement'); + }); + + test('peer close rejects an established connection RPC without hanging', + () async { + final vm = await server('closed-peer'); + await connector.connect(vm.uri); + vm.holdGetVersion = true; + final failure = _failureOf(connector.getVersion().then((_) {})); + await vm.seen('ext.flutter.marionette.getVersion'); + + await vm.sockets.single.close(); + + expect(await failure.timeout(const Duration(seconds: 2)), + isA()); + await connector.disconnect().timeout(const Duration(seconds: 2)); + expect(connector.isConnected, isFalse); + }); + + test('old disconnect cleanup cannot clear an immediate new connect', + () async { + final old = await server('old'); + await connector.connect(old.uri); + final replacement = await server('replacement'); + + final disconnect = connector.disconnect(); + final connect = connector.connect(replacement.uri); + await Future.wait([disconnect, connect]) + .timeout(const Duration(seconds: 3)); + + expect(connector.isConnected, isTrue); + expect(await connector.getVersion(), 'replacement'); + }); + + test('service discovery still proceeds without a streamListen response', + () async { + final vm = await server('no-stream-ack'); + vm.respondToStreamListen = false; + + await connector.connect(vm.uri).timeout(const Duration(seconds: 2)); + + expect(connector.isConnected, isTrue); + expect(await connector.getVersion(), 'no-stream-ack'); + }); + + test('hot restart still discovers the replacement isolate after retries', + () async { + final vm = await server('restart'); + vm.registerHotRestart = true; + await connector.connect(vm.uri); + vm.emptyVmResponses = 1; + + expect(await connector.hotRestart().timeout(const Duration(seconds: 3)), + isTrue); + expect(await connector.getVersion(), 'restart'); + final version = vm.requests + .lastWhere((r) => r['method'] == 'ext.flutter.marionette.getVersion'); + expect((version['params'] as Map)['isolateId'], 'isolates/2'); + }); + + test('superseded hot restart cannot overwrite a new connection isolate', + () async { + final old = await server('old'); + old.registerHotRestart = true; + await connector.connect(old.uri); + old.holdGetVm = true; + final restartFailure = _failureOf(connector.hotRestart().then((_) {})); + await old.seen('s0.hotRestart'); + final replacement = await server('replacement'); + replacement.isolateId = 'isolates/new'; + + await connector + .connect(replacement.uri) + .timeout(const Duration(seconds: 3)); + + expect( + await restartFailure.timeout(const Duration(seconds: 2)), isNotNull); + expect(await connector.getVersion(), 'replacement'); + final version = replacement.requests + .lastWhere((r) => r['method'] == 'ext.flutter.marionette.getVersion'); + expect((version['params'] as Map)['isolateId'], 'isolates/new'); + }); + + test('superseded hot reload does not send an RPC to the replacement', + () async { + final old = await server('old'); + await connector.connect(old.uri); + final reloadFailure = _failureOf(connector.hotReload().then((_) {})); + final replacement = await server('replacement'); + + await connector + .connect(replacement.uri) + .timeout(const Duration(seconds: 3)); + + expect( + await reloadFailure.timeout(const Duration(seconds: 2)), isNotNull); + expect(await connector.getVersion(), 'replacement'); + expect(replacement.requests.where((r) => r['method'] == 'reloadSources'), + isEmpty); + }); + }); +} diff --git a/packages/marionette_mcp/test/vm_service/dynamic_extension_tools_test.dart b/packages/marionette_mcp/test/vm_service/dynamic_extension_tools_test.dart index 8eca7dee..733f7e58 100644 --- a/packages/marionette_mcp/test/vm_service/dynamic_extension_tools_test.dart +++ b/packages/marionette_mcp/test/vm_service/dynamic_extension_tools_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:logging/logging.dart' as logging; import 'package:marionette_mcp/src/vm_service/dynamic_extension_tools.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; @@ -305,8 +307,9 @@ void main() { }); }); - group('DynamicExtensionTools.disableAll', () { - test('disables every registered tool and clears the list', () async { + group('DynamicExtensionTools.removeAll', () { + test('removes every registered tool and releases its registry name', + () async { final connector = _FakeConnector( listExtensionsResponse: const { 'extensions': [ @@ -328,8 +331,9 @@ void main() { }, ); + final server = _server(); final dynamicTools = DynamicExtensionTools( - server: _server(), + server: server, connector: connector, logger: logging.Logger.detached('test'), ); @@ -337,10 +341,17 @@ void main() { final captured = dynamicTools.registeredTools.toList(); expect(captured.every((t) => t.enabled), isTrue); - dynamicTools.disableAll(); + dynamicTools.removeAll(); expect(dynamicTools.registeredTools, isEmpty); - expect(captured.every((t) => !t.enabled), isTrue); + for (final tool in captured) { + expect(dynamicTools.extensionNameForTool(tool.name), isNull); + // Reusing the exact names would throw if removal left registry entries. + server.registerTool( + tool.name, + callback: (_, __) async => CallToolResult(content: []), + ); + } }); test('is a no-op when nothing is registered', () { @@ -351,15 +362,14 @@ void main() { ); // Must not throw. - dynamicTools.disableAll(); + dynamicTools.removeAll(); expect(dynamicTools.registeredTools, isEmpty); }); }); group('DynamicExtensionTools reconnect', () { - test( - 're-registers same extension after disable without name collision ' - 'and revives the same RegisteredTool instance', () async { + test('re-registers the same extension with a fresh handle after removal', + () async { final connector = _FakeConnector( listExtensionsResponse: const { 'extensions': [ @@ -387,26 +397,25 @@ void main() { final firstInstance = dynamicTools.registeredTools.single; expect(firstInstance.enabled, isTrue); - // Disconnect — emulates the disable-on-disconnect path. - dynamicTools.disableAll(); + // Disconnect releases the old registrations. + dynamicTools.removeAll(); expect(dynamicTools.registeredTools, isEmpty); - expect(firstInstance.enabled, isFalse); + expect(dynamicTools.extensionNameForTool(firstInstance.name), isNull); - // Cycle 2 with the same payload — must not throw, must revive in - // place. + // Cycle 2 with the same payload must register without a collision. await dynamicTools.registerAll(); expect(dynamicTools.registeredTools, hasLength(1)); final secondInstance = dynamicTools.registeredTools.single; expect( identical(firstInstance, secondInstance), - isTrue, - reason: 'reconnect should reuse the pooled RegisteredTool', + isFalse, + reason: 'reconnect registers a fresh tool after removing the old one', ); expect(secondInstance.enabled, isTrue); }); - test('reflects changed description and inputSchema on revival', () async { + test('reflects changed description and inputSchema on reconnect', () async { final connector = _FakeConnector( listExtensionsResponse: const { 'extensions': [ @@ -433,7 +442,7 @@ void main() { expect(tool.description, 'v1'); expect(tool.inputSchema?.properties, isEmpty); - dynamicTools.disableAll(); + dynamicTools.removeAll(); connector.listExtensionsResponse = const { 'extensions': [ @@ -452,13 +461,13 @@ void main() { }; await dynamicTools.registerAll(); - expect(tool.description, 'v2'); - expect(tool.inputSchema?.properties?.keys, ['count']); + final reconnected = dynamicTools.registeredTools.single; + expect(reconnected.description, 'v2'); + expect(reconnected.inputSchema?.properties?.keys, ['count']); }); - test( - 'extensions that disappear on reconnect stay disabled and leave the ' - 'present ones enabled', () async { + test('extensions that disappear on reconnect release their names', + () async { final connector = _FakeConnector( listExtensionsResponse: const { 'extensions': [ @@ -492,7 +501,7 @@ void main() { }; expect(byName.keys, containsAll(['foo', 'bar'])); - dynamicTools.disableAll(); + dynamicTools.removeAll(); // App now exposes only `foo`. connector.listExtensionsResponse = const { @@ -512,7 +521,7 @@ void main() { dynamicTools.registeredTools.map((t) => t.name).toSet(); expect(activeNames, {'foo'}); expect(byName['foo']!.enabled, isTrue); - expect(byName['bar']!.enabled, isFalse); + expect(dynamicTools.extensionNameForTool('bar'), isNull); }); test('built-in name collisions are skipped on every cycle', () async { @@ -548,17 +557,36 @@ void main() { await dynamicTools.registerAll(); expect(dynamicTools.registeredTools, isEmpty); - dynamicTools.disableAll(); + dynamicTools.removeAll(); - // Second cycle with the same colliding extension — still skipped, - // and we still don't fall over because the collision name is not - // pooled (so we re-attempt registerTool, which throws and is - // caught again). + // The built-in still owns this name across connection cycles. await dynamicTools.registerAll(); expect(dynamicTools.registeredTools, isEmpty); }); }); + test('disconnect retires an in-flight dynamic tool discovery', () async { + final connector = _DeferredConnector(); + final tools = DynamicExtensionTools( + server: _server(), + connector: connector, + logger: logging.Logger.detached('test'), + ); + final pending = tools.registerAll(); + tools.removeAll(); + connector.response.complete({ + 'extensions': [ + { + 'name': 'late', + 'inputSchema': {'type': 'object', 'properties': {}} + }, + ], + }); + await pending; + expect(tools.registeredTools, isEmpty); + expect(tools.extensionNameForTool('late'), isNull); + }); + group('sanitizeToolName', () { test('rewrites camelCase + dotted names', () { expect(sanitizeToolName('appNavigation.goToPage'), @@ -585,3 +613,10 @@ void main() { }); }); } + +class _DeferredConnector extends _FakeConnector { + final response = Completer>(); + + @override + Future> listExtensions() => response.future; +} diff --git a/packages/marionette_mcp/test/vm_service/tools/device_tools_test.dart b/packages/marionette_mcp/test/vm_service/tools/device_tools_test.dart index 8ceec161..e875e049 100644 --- a/packages/marionette_mcp/test/vm_service/tools/device_tools_test.dart +++ b/packages/marionette_mcp/test/vm_service/tools/device_tools_test.dart @@ -1,5 +1,5 @@ import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/src/vm_service/tools/device_tools.dart'; +import 'package:marionette_mcp/mcp/tools/device_tools.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; import 'package:test/test.dart'; diff --git a/packages/marionette_mcp/test/vm_service/tools/extension_tools_test.dart b/packages/marionette_mcp/test/vm_service/tools/extension_tools_test.dart index 45c90873..ac5b85bd 100644 --- a/packages/marionette_mcp/test/vm_service/tools/extension_tools_test.dart +++ b/packages/marionette_mcp/test/vm_service/tools/extension_tools_test.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/src/vm_service/tools/extension_tools.dart'; +import 'package:marionette_mcp/mcp/tools/extension_tools.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; import 'package:test/test.dart'; diff --git a/packages/marionette_mcp/test/vm_service/tools/inspection_tools_test.dart b/packages/marionette_mcp/test/vm_service/tools/inspection_tools_test.dart index 2b10b195..58290944 100644 --- a/packages/marionette_mcp/test/vm_service/tools/inspection_tools_test.dart +++ b/packages/marionette_mcp/test/vm_service/tools/inspection_tools_test.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:marionette_mcp/src/session/session.dart'; -import 'package:marionette_mcp/src/vm_service/tools/inspection_tools.dart'; +import 'package:marionette_mcp/mcp/tools/inspection_tools.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; import 'package:path/path.dart' as p; diff --git a/pubspec.lock b/pubspec.lock index de6d86d7..4ae86954 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -260,10 +260,10 @@ packages: dependency: transitive description: name: mcp_dart - sha256: "30ab03147fec3fd72e98172475cd720cd4e3aec7d5f8944ef36dbfabc75a353c" + sha256: af0b2e25412f70440c881314a0b70a56ef87fd090b3eb71f31c356313096e6f5 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.4.3" meta: dependency: transitive description: diff --git a/tool/check_frame_protocol.dart b/tool/check_frame_protocol.dart new file mode 100644 index 00000000..bf5cbf10 --- /dev/null +++ b/tool/check_frame_protocol.dart @@ -0,0 +1,141 @@ +import 'dart:typed_data'; + +import '../packages/marionette_flutter/lib/src/services/frame_protocol.dart' + as producer; +import '../packages/marionette_mcp/lib/src/video/frame_protocol.dart' + as consumer; + +// Exercise both production implementations without coupling their packages. +void main() { + _check(producer.FrameHeader.byteLength == 20, 'Producer header length'); + _check(consumer.FrameHeader.byteLength == 20, 'Consumer header length'); + _check(producer.FrameHeader.magic == 0x4D524E54, 'Producer magic'); + _check(consumer.FrameHeader.magic == 0x4D524E54, 'Consumer magic'); + + const endianHeader = producer.FrameHeader( + frameLength: 0x01020304, + width: 0x05060708, + height: 0x090A0B0C, + timestampMs: 0x0D0E0F10, + ); + _checkBytes( + endianHeader.encode(), + [ + 0x54, + 0x4E, + 0x52, + 0x4D, + 0x04, + 0x03, + 0x02, + 0x01, + 0x08, + 0x07, + 0x06, + 0x05, + 0x0C, + 0x0B, + 0x0A, + 0x09, + 0x10, + 0x0F, + 0x0E, + 0x0D, + ], + 'Little-endian wire bytes'); + + for (final value in [0, 1, 0x7FFFFFFF, 0x80000000, 0xFFFFFFFF]) { + final header = producer.FrameHeader( + frameLength: value, + width: value, + height: value, + timestampMs: value, + ); + _checkHeader(consumer.FrameHeader.decode(header.encode()), header); + } + + final fixtures = [ + _Fixture(0, 0, 0), + _Fixture(1, 1, 1), + _Fixture(2, 3, 0x80000000), + _Fixture(256, 1, 0xFFFFFFFF), + ]; + final stream = BytesBuilder(); + for (final fixture in fixtures) { + final encoded = fixture.header.encodeWithPayload(fixture.payload); + _check( + encoded.length == 20 + fixture.payload.length, + 'Encoded frame length', + ); + _checkBytes( + encoded.sublist(0, 20), + fixture.header.encode(), + 'Combined frame header', + ); + _checkBytes(encoded.sublist(20), fixture.payload, 'Combined frame payload'); + stream.add(encoded); + } + final bytes = stream.takeBytes(); + // Cover single-byte delivery, header/payload boundaries, and coalesced frames. + for (final chunkSizes in [ + [1], + [19, 1, 3, 20, 7], + [20, 4, 21, 23], + [bytes.length], + ]) { + final assembler = consumer.FrameAssembler(); + var offset = 0; + var chunkIndex = 0; + var frameIndex = 0; + while (offset < bytes.length) { + final requestedEnd = offset + chunkSizes[chunkIndex % chunkSizes.length]; + final end = requestedEnd < bytes.length ? requestedEnd : bytes.length; + assembler.addData(Uint8List.sublistView(bytes, offset, end)); + offset = end; + chunkIndex++; + for (final frame in assembler.drain()) { + _check(frameIndex < fixtures.length, 'Unexpected extra frame'); + final fixture = fixtures[frameIndex++]; + _checkHeader(frame.header, fixture.header); + _checkBytes(frame.rgbaBytes, fixture.payload, 'Assembled RGBA payload'); + } + } + _check(frameIndex == fixtures.length, 'Missing assembled frames'); + _check(assembler.drain().isEmpty, 'Unexpected trailing frame'); + } + print('Frame protocol verified: header boundaries and 4 fragmented streams.'); +} + +class _Fixture { + _Fixture(int width, int height, int timestampMs) + : payload = Uint8List.fromList( + List.generate(width * height * 4, (index) => (index * 31 + 7) & 0xFF), + ), + header = producer.FrameHeader( + frameLength: width * height * 4, + width: width, + height: height, + timestampMs: timestampMs, + ); + + final producer.FrameHeader header; + final Uint8List payload; +} + +void _checkHeader(consumer.FrameHeader actual, producer.FrameHeader expected) { + _check(actual.frameLength == expected.frameLength, 'Header frameLength'); + _check(actual.width == expected.width, 'Header width'); + _check(actual.height == expected.height, 'Header height'); + _check(actual.timestampMs == expected.timestampMs, 'Header timestampMs'); +} + +void _checkBytes(List actual, List expected, String label) { + _check(actual.length == expected.length, '$label length'); + for (var index = 0; index < expected.length; index++) { + _check(actual[index] == expected[index], '$label at byte $index'); + } +} + +void _check(bool condition, String label) { + if (!condition) throw StateError('Frame protocol mismatch: $label'); +} From 1bf652e3987d17b073273d08ad686a37377c0f9c Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Sat, 24 Oct 2026 09:54:03 -0400 Subject: [PATCH 02/12] Polish session ownership and workspace validation --- .github/workflows/ci.yaml | 65 +--- CHANGELOG.md | 3 +- docs/architecture.md | 2 + .../src/cli/commands/pinch_zoom_command.dart | 11 +- .../test/record_video_command_test.dart | 5 +- .../test/selector_commands_test.dart | 38 ++- .../marionette-flutter-drive-app/SKILL.md | 10 +- .../lib/mcp/server_context.dart | 284 +++++++++--------- .../lib/src/session/step_logger.dart | 18 +- .../src/vm_service/vm_service_connector.dart | 14 +- .../test/server_context_test.dart | 56 ++++ .../test/session/step_logger_test.dart | 42 ++- .../vm_service/connector_lifecycle_test.dart | 48 +++ 13 files changed, 381 insertions(+), 215 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 25f44a05..f0d9dd6b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -26,35 +26,8 @@ jobs: flutter-version-file: .fvmrc cache: true - - name: Install dependencies for tool - run: | - cd tool - dart pub get - - - name: Install dependencies for marionette_mcp - run: | - cd packages/marionette_mcp - dart pub get - - - name: Install dependencies for marionette_cli - run: | - cd packages/marionette_cli - dart pub get - - - name: Install dependencies for marionette_flutter - run: | - cd packages/marionette_flutter - flutter pub get - - - name: Install dependencies for marionette_logger - run: | - cd packages/marionette_logger - flutter pub get - - - name: Install dependencies for marionette_logging - run: | - cd packages/marionette_logging - flutter pub get + - name: Install workspace dependencies + run: flutter pub get - name: Check version.g.dart is in sync run: | @@ -70,12 +43,12 @@ jobs: - name: Analyze marionette_mcp run: | cd packages/marionette_mcp - dart analyze --fatal-infos lib bin + dart analyze --fatal-infos lib bin test - name: Analyze marionette_cli run: | cd packages/marionette_cli - dart analyze --fatal-infos lib bin + dart analyze --fatal-infos lib bin test - name: Analyze marionette_flutter run: | @@ -155,30 +128,8 @@ jobs: flutter-version: '3.27.0' cache: true - - name: Install dependencies for marionette_mcp - run: | - cd packages/marionette_mcp - dart pub get - - - name: Install dependencies for marionette_cli - run: | - cd packages/marionette_cli - dart pub get - - - name: Install dependencies for marionette_flutter - run: | - cd packages/marionette_flutter - flutter pub get - - - name: Install dependencies for marionette_logger - run: | - cd packages/marionette_logger - flutter pub get - - - name: Install dependencies for marionette_logging - run: | - cd packages/marionette_logging - flutter pub get + - name: Install workspace dependencies + run: flutter pub get - name: Check frame protocol compatibility run: dart tool/check_frame_protocol.dart @@ -186,12 +137,12 @@ jobs: - name: Analyze marionette_mcp run: | cd packages/marionette_mcp - dart analyze --fatal-infos lib bin + dart analyze --fatal-infos lib bin test - name: Analyze marionette_cli run: | cd packages/marionette_cli - dart analyze --fatal-infos lib bin + dart analyze --fatal-infos lib bin test - name: Analyze marionette_flutter run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 04dfcfc3..019743a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ - Upgrade `mcp_dart` to `^2.4.3` and use its fixed dynamic-tool removal on reconnect. - Fix server shutdown leaving app connections open and failed web recording startup leaving its WebSocket listener open. -- Reject incomplete, malformed, or nonfinite CLI coordinates and conflicting targeting modes before calling the app. +- Keep in-flight tool steps in their original report across reconnects, and clear connection status when the app disconnects. +- Reject incomplete, malformed, or nonfinite CLI coordinates and pinch-zoom values, and conflicting targeting modes, before calling the app. - Reject invalid SSE ports instead of falling back to stdio, and report server startup failures with a nonzero exit code. - Expose public server and shared runtime entrypoints, with typed MCP tools separated from startup and configuration. - Fix `record-video --width/--height` recording only the top-left corner of the screen at full scale instead of the whole screen scaled down to that size. Web recordings were not affected ([#134](https://github.com/leancodepl/marionette_mcp/issues/134)) diff --git a/docs/architecture.md b/docs/architecture.md index 26937d49..85efddbe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,6 +25,8 @@ logger. `createAllTools` composes these tools without opening sockets or starting a process. `createMcpServer` registers them and applies step logging once, at the composition boundary. Connection tools own their additional session-report transitions; app-defined tools are discovered dynamically. +In-flight tool calls retain their original report session; a superseded +connection attempt cannot add a step to its replacement's report. ## Public package boundaries diff --git a/packages/marionette_cli/lib/src/cli/commands/pinch_zoom_command.dart b/packages/marionette_cli/lib/src/cli/commands/pinch_zoom_command.dart index 3edae47e..b91813ee 100644 --- a/packages/marionette_cli/lib/src/cli/commands/pinch_zoom_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/pinch_zoom_command.dart @@ -56,14 +56,17 @@ class PinchZoomCommand extends InstanceCommand { } final scaleStr = argResults?['scale'] as String; - final scale = double.tryParse(scaleStr); - if (scale == null || scale <= 0) { + final scale = + parseFiniteNumber('scale', scaleStr, usageException).toDouble(); + if (scale <= 0) { usageException('--scale must be a positive number, got "$scaleStr".'); } final distanceStr = argResults?['start-distance'] as String; - final startDistance = double.tryParse(distanceStr); - if (startDistance == null || startDistance <= 0) { + final startDistance = + parseFiniteNumber('start-distance', distanceStr, usageException) + .toDouble(); + if (startDistance <= 0) { usageException( '--start-distance must be a positive number, got "$distanceStr".', ); diff --git a/packages/marionette_cli/test/record_video_command_test.dart b/packages/marionette_cli/test/record_video_command_test.dart index 171a6bb3..00559830 100644 --- a/packages/marionette_cli/test/record_video_command_test.dart +++ b/packages/marionette_cli/test/record_video_command_test.dart @@ -6,10 +6,7 @@ import 'package:args/args.dart'; import 'package:marionette_cli/src/cli/adb_helper.dart'; import 'package:marionette_cli/src/cli/commands/record_video_command.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/video/ffmpeg_process.dart'; -import 'package:marionette_mcp/src/video/recording_session.dart'; -import 'package:marionette_mcp/src/video/ws_frame_server.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; import 'package:test/test.dart'; /// Mirrors ScreencastService.computeFrameSize for mock connectors. diff --git a/packages/marionette_cli/test/selector_commands_test.dart b/packages/marionette_cli/test/selector_commands_test.dart index dd88c402..6078bfd9 100644 --- a/packages/marionette_cli/test/selector_commands_test.dart +++ b/packages/marionette_cli/test/selector_commands_test.dart @@ -10,7 +10,7 @@ import 'package:marionette_cli/src/cli/commands/scroll_to_command.dart'; import 'package:marionette_cli/src/cli/commands/swipe_command.dart'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:marionette_mcp/runtime.dart'; import 'package:test/test.dart'; mixin _ParsedCommand on InstanceCommand { @@ -152,6 +152,42 @@ void main() { }); }); } + for (final option in ['scale', 'start-distance']) { + for (final value in ['NaN', 'Infinity', '-Infinity']) { + test('pinch-zoom rejects --$option $value before calling connector', + () async { + final connector = _Connector(); + await expectLater( + _PinchZoom().executeWithArgs([ + '--key', + 'image', + '--scale', + '2', + '--$option', + value, + ], connector), + throwsA(isA()), + ); + expect(connector.calls, isEmpty); + }); + } + } + test('pinch-zoom preserves positive finite scale and start distance', + () async { + final connector = _Connector(); + await _PinchZoom().executeWithArgs([ + '--key', + 'image', + '--scale', + '0.5', + '--start-distance', + '150.5', + ], connector); + expect(connector.calls.single.namedArguments, { + #scale: 0.5, + #startDistance: 150.5, + }); + }); test('enter-text preserves focused targeting with ancestor scope', () async { final connector = _Connector(); await _EnterText().executeWithArgs([ diff --git a/packages/marionette_flutter/skills/marionette-flutter-drive-app/SKILL.md b/packages/marionette_flutter/skills/marionette-flutter-drive-app/SKILL.md index e90cc03f..0445404b 100644 --- a/packages/marionette_flutter/skills/marionette-flutter-drive-app/SKILL.md +++ b/packages/marionette_flutter/skills/marionette-flutter-drive-app/SKILL.md @@ -620,14 +620,16 @@ Coordinate targeting requires both `--x` and `--y` as finite numbers; coordinate swipes require all four start/end coordinates. Do not mix coordinate or focused targeting with element selectors. Invalid combinations fail before invoking the app. Ancestor scope still applies only to element targeting. +Pinch-zoom `--scale` and `--start-distance` must be positive, finite numbers. Use `--uri ` for a one-off session and `register ` + `-i ` for repeated interaction with the same app; `marionette doctor` checks connectivity of every registered instance and `unregister` cleans up stale ones. Session reports work the same -way here: pass `--session ` (the CLI's equivalent of `session_title`) -consistently across a script's invocations to log a multi-step run into one -session directory instead of a fresh, untitled one per command — see -*Reporting what you found*, above. +way here: each invocation creates a fresh session directory. Pass +`--session <title>` (the CLI's equivalent of `session_title`) to label it; +reusing a title does not resume an earlier directory. For a multi-command run, +keep the session paths printed by each command and cite the relevant directories +in the report — see *Reporting what you found*, above. ## Keeping this skill in sync diff --git a/packages/marionette_mcp/lib/mcp/server_context.dart b/packages/marionette_mcp/lib/mcp/server_context.dart index 0c916f4b..373e1693 100644 --- a/packages/marionette_mcp/lib/mcp/server_context.dart +++ b/packages/marionette_mcp/lib/mcp/server_context.dart @@ -33,10 +33,9 @@ final class VmServiceContext { /// recorded to the active session's steps.md (see [StepLogger]) — except /// `connect`/`disconnect`, registered on the raw server: they own the /// version-compatibility handshake with the binding and the session - /// lifecycle itself (`connect` clearing it before every attempt and - /// setting it fresh on success; `disconnect` clearing it right after - /// logging its own step, which the generic wrapper has no hook for), so - /// they log manually instead of using the generic wrapper. + /// lifecycle itself. Connect logs only for the epoch it still owns; + /// disconnect retains the closing session while cleanup waits. Both log + /// manually so overlapping requests cannot write into a replacement report. /// All other tools come from the central typed tool registry. void registerTools(McpServer server) { _dynamicTools = DynamicExtensionTools( @@ -92,139 +91,14 @@ final class VmServiceContext { }, required: ['uri'], ), - callback: withStepLogging(_stepLogger, 'connect', (args, extra) async { + callback: (args, extra) async { final epoch = ++_connectionEpoch; - final uri = args['uri'] as String; - _logger.info('Connecting to app at $uri'); - // A new connect attempt immediately supersedes whatever session - // was active before, regardless of whether this attempt itself - // succeeds — otherwise a call that slips in while this attempt is - // still in flight (or after it fails) would append to a session - // that's no longer the one actually in use. - _stepLogger.session = null; - _disableDynamicTools(); - - try { - _ensureCurrentConnection(epoch); - await connector.connect(uri); - _ensureCurrentConnection(epoch); - - // Version compatibility check — unwind the connection on mismatch - // so the next call to connect can start fresh. - try { - final bindingVersion = await connector.getVersion(); - _ensureCurrentConnection(epoch); - if (bindingVersion != v.version) { - await connector.disconnect(); - return CallToolResult( - isError: true, - content: [ - TextContent( - text: 'Version mismatch: marionette_mcp is ${v.version}, ' - 'but marionette_flutter binding is $bindingVersion. ' - 'Please ensure both packages are the same version.', - ), - ], - ); - } - } catch (err) { - _logger.warning('Failed to check binding version', err); - if (!_disposed && epoch == _connectionEpoch) { - await connector.disconnect(); - } - return CallToolResult( - isError: true, - content: [ - TextContent( - text: - 'Failed to verify marionette_flutter binding version. ' - 'Please ensure marionette_flutter is up to date. ' - 'Error: $err', - ), - ], - ); - } - - // Promote each schema-bearing custom extension into a first-class - // MCP tool. Failures here are logged but don't fail the connect — - // the generic call_custom_extension fallback keeps working. - await _registerDynamicTools(); - _ensureCurrentConnection(epoch); - - // Session reports are opt-in app-side - // (MarionetteConfiguration.enableSessionReports): with them off, - // connect writes nothing to disk and steps go unlogged. Failing - // to even determine that counts as off too, same as a session - // directory that fails to open below — the app connection and - // every other tool already work fine either way, so neither is - // worth dropping an otherwise-working connection over. - var sessionReportsEnabled = false; - try { - sessionReportsEnabled = - await connector.getSessionReportsEnabled(); - } catch (err) { - _logger.warning( - 'Failed to read binding configuration; continuing without ' - 'session reports', - err, - ); - } - _ensureCurrentConnection(epoch); - if (!sessionReportsEnabled) { - _stepLogger.session = null; - return CallToolResult( - content: [ - TextContent(text: 'Successfully connected to app at $uri'), - ], - ); - } - - try { - final session = _sessionManager.create( - title: args['session_title'] as String?, - baseDirOverride: args['session_dir'] as String?, - ); - _stepLogger.session = session; - - return CallToolResult( - content: [ - TextContent( - text: 'Successfully connected to app at $uri\n' - 'Opened session: ${session.directory.path}', - ), - ], - ); - } catch (err) { - // Session setup is best-effort: the app connection and every - // other tool already work fine without it, so a directory - // creation failure (e.g. an unwritable session_dir) shouldn't - // drop an otherwise-working connection. Degrade the same way - // an app that opted out of session reports already does — - // connect succeeds, just without step logging. - _logger.warning( - 'Failed to open session directory; continuing without ' - 'session reports', - err, - ); - _stepLogger.session = null; - return CallToolResult( - content: [ - TextContent( - text: 'Successfully connected to app at $uri\n' - 'Could not open a session directory, so this run ' - 'will not be logged: $err', - ), - ], - ); - } - } catch (err) { - _logger.severe('Failed to connect to app', err); - return CallToolResult( - isError: true, - content: [TextContent(text: 'Failed to connect to app: $err')], - ); + final result = await _connect(args, epoch); + if (!_disposed && epoch == _connectionEpoch) { + _stepLogger.logStep('connect', args, result); } - }), + return result; + }, ) ..registerTool( 'disconnect', @@ -256,8 +130,9 @@ final class VmServiceContext { ); // Attribute the step to the connection being closed. A newer // connect may already own a different session while cleanup waits. - if (!_disposed) + if (!_disposed) { disconnectLogger.logStep('disconnect', args, result); + } if (epoch == _connectionEpoch) _stepLogger.session = null; return result; } catch (err) { @@ -266,14 +141,149 @@ final class VmServiceContext { isError: true, content: [TextContent(text: 'Error during disconnect: $err')], ); - if (!_disposed) + if (!_disposed) { disconnectLogger.logStep('disconnect', args, result); + } return result; } }, ); } + Future<CallToolResult> _connect( + Map<String, dynamic> args, + int epoch, + ) async { + final uri = args['uri'] as String; + _logger.info('Connecting to app at $uri'); + // A new connect attempt immediately supersedes whatever session + // was active before, regardless of whether this attempt itself + // succeeds — otherwise a call that slips in while this attempt is + // still in flight (or after it fails) would append to a session + // that's no longer the one actually in use. + _stepLogger.session = null; + _disableDynamicTools(); + + try { + _ensureCurrentConnection(epoch); + await connector.connect(uri); + _ensureCurrentConnection(epoch); + + // Version compatibility check — unwind the connection on mismatch + // so the next call to connect can start fresh. + try { + final bindingVersion = await connector.getVersion(); + _ensureCurrentConnection(epoch); + if (bindingVersion != v.version) { + await connector.disconnect(); + return CallToolResult( + isError: true, + content: [ + TextContent( + text: 'Version mismatch: marionette_mcp is ${v.version}, ' + 'but marionette_flutter binding is $bindingVersion. ' + 'Please ensure both packages are the same version.', + ), + ], + ); + } + } catch (err) { + _logger.warning('Failed to check binding version', err); + if (!_disposed && epoch == _connectionEpoch) { + await connector.disconnect(); + } + return CallToolResult( + isError: true, + content: [ + TextContent( + text: 'Failed to verify marionette_flutter binding version. ' + 'Please ensure marionette_flutter is up to date. ' + 'Error: $err', + ), + ], + ); + } + + // Promote each schema-bearing custom extension into a first-class + // MCP tool. Failures here are logged but don't fail the connect — + // the generic call_custom_extension fallback keeps working. + await _registerDynamicTools(); + _ensureCurrentConnection(epoch); + + // Session reports are opt-in app-side + // (MarionetteConfiguration.enableSessionReports): with them off, + // connect writes nothing to disk and steps go unlogged. Failing + // to even determine that counts as off too, same as a session + // directory that fails to open below — the app connection and + // every other tool already work fine either way, so neither is + // worth dropping an otherwise-working connection over. + var sessionReportsEnabled = false; + try { + sessionReportsEnabled = await connector.getSessionReportsEnabled(); + } catch (err) { + _logger.warning( + 'Failed to read binding configuration; continuing without ' + 'session reports', + err, + ); + } + _ensureCurrentConnection(epoch); + if (!sessionReportsEnabled) { + _stepLogger.session = null; + return CallToolResult( + content: [ + TextContent(text: 'Successfully connected to app at $uri'), + ], + ); + } + + try { + final session = _sessionManager.create( + title: args['session_title'] as String?, + baseDirOverride: args['session_dir'] as String?, + ); + _stepLogger.session = session; + + return CallToolResult( + content: [ + TextContent( + text: 'Successfully connected to app at $uri\n' + 'Opened session: ${session.directory.path}', + ), + ], + ); + } catch (err) { + // Session setup is best-effort: the app connection and every + // other tool already work fine without it, so a directory + // creation failure (e.g. an unwritable session_dir) shouldn't + // drop an otherwise-working connection. Degrade the same way + // an app that opted out of session reports already does — + // connect succeeds, just without step logging. + _logger.warning( + 'Failed to open session directory; continuing without ' + 'session reports', + err, + ); + _stepLogger.session = null; + return CallToolResult( + content: [ + TextContent( + text: 'Successfully connected to app at $uri\n' + 'Could not open a session directory, so this run ' + 'will not be logged: $err', + ), + ], + ); + } + } catch (err) { + _logger.severe('Failed to connect to app', err); + return CallToolResult( + isError: true, + content: [TextContent(text: 'Failed to connect to app: $err')], + ); + } + } + Future<void> _registerDynamicTools() async { // A reconnect without a clean disconnect would leak the previous // batch — remove any leftovers before registering fresh ones. diff --git a/packages/marionette_mcp/lib/src/session/step_logger.dart b/packages/marionette_mcp/lib/src/session/step_logger.dart index 99ef528c..76243f15 100644 --- a/packages/marionette_mcp/lib/src/session/step_logger.dart +++ b/packages/marionette_mcp/lib/src/session/step_logger.dart @@ -85,8 +85,15 @@ class StepLogger { String toolName, Map<String, dynamic> args, CallToolResult result, + ) => + _logStep(session, toolName, args, result); + + void _logStep( + Session? activeSession, + String toolName, + Map<String, dynamic> args, + CallToolResult result, ) { - final activeSession = session; if (activeSession == null) return; final selector = describeStepSelector(toolName, args); @@ -320,19 +327,22 @@ String _formatTime(DateTime time) { /// unexpected, un-caught error rather than the usual `isError: true` /// result), so the report contract's `Tested:` line — rendered from /// steps.md, not the agent's own claim — never silently undercounts an -/// attempt just because it failed in an unanticipated way. +/// attempt just because it failed in an unanticipated way. The session is +/// captured when the call starts, so reconnects cannot reattribute its result. ToolFunction withStepLogging( StepLogger stepLogger, String name, ToolFunction callback, ) { return (args, extra) async { + final session = stepLogger.session; try { final result = await callback(args, extra); - stepLogger.logStep(name, args, result); + stepLogger._logStep(session, name, args, result); return result; } catch (err) { - stepLogger.logStep( + stepLogger._logStep( + session, name, args, CallToolResult( diff --git a/packages/marionette_mcp/lib/src/vm_service/vm_service_connector.dart b/packages/marionette_mcp/lib/src/vm_service/vm_service_connector.dart index 91431413..7297a1fb 100644 --- a/packages/marionette_mcp/lib/src/vm_service/vm_service_connector.dart +++ b/packages/marionette_mcp/lib/src/vm_service/vm_service_connector.dart @@ -189,6 +189,15 @@ class VmServiceConnector { final input = StreamController<dynamic>(); final streamClosed = Completer<void>(); void closeInput() { + if (connection.closed) return; + connection.closed = true; + // Retain the resource owner for deterministic disconnect/reconnect + // cleanup, but retire state only if this is still its connection. + if (identical(_connection, connection)) { + _service = null; + _isolateId = null; + _clearServiceRegistrations(); + } if (!streamClosed.isCompleted) streamClosed.complete(); } @@ -212,7 +221,7 @@ class VmServiceConnector { connection.service = service; _service = service; connection.subscription = service.onServiceEvent.listen((e) { - if (!identical(_connection, connection)) return; + if (!identical(_connection, connection) || connection.closed) return; switch (e.kind) { case EventKind.kServiceRegistered: final serviceName = e.service!; @@ -276,7 +285,7 @@ class VmServiceConnector { } void _ensureCurrentConnection(_VmServiceConnection connection) { - if (!identical(_connection, connection)) { + if (!identical(_connection, connection) || connection.closed) { throw StateError('VM service connection was disconnected or superseded.'); } } @@ -823,6 +832,7 @@ class _VmServiceConnection { final HttpClient client = HttpClient(); VmService? service; StreamSubscription<Event>? subscription; + bool closed = false; Future<void>? _disposeFuture; Future<void> dispose() { diff --git a/packages/marionette_mcp/test/server_context_test.dart b/packages/marionette_mcp/test/server_context_test.dart index 0ff25dc9..a3f374f0 100644 --- a/packages/marionette_mcp/test/server_context_test.dart +++ b/packages/marionette_mcp/test/server_context_test.dart @@ -7,6 +7,46 @@ import 'package:mcp_dart/mcp_dart.dart'; import 'package:test/test.dart'; void main() { + test('a superseded connect cannot log into a replacement session', () async { + final directory = Directory.systemTemp.createTempSync('connect-overlap-'); + final connector = _DelayedVersionConnector(); + final context = VmServiceContext(connector: connector); + final server = _CapturingServer(); + context.registerTools(server); + Future<CallToolResult> connect(String title) async => + server.callbacks['connect']!({ + 'uri': 'ws://$title/ws', + 'session_title': title, + 'session_dir': directory.path, + }, _UnusedExtra()); + final stale = connect('old'); + addTearDown(() async { + if (!connector.versionRelease.isCompleted) + connector.versionRelease.complete(); + await stale; + await context.dispose(); + await server.close(); + directory.deleteSync(recursive: true); + }); + await connector.versionStarted.future; + expect((await connect('replacement')).isError, isFalse); + connector.versionRelease.complete(); + expect((await stale).isError, isTrue); + + final reports = directory + .listSync(recursive: true) + .whereType<File>() + .where((entry) => entry.path.endsWith('steps.md')) + .toList(); + expect(reports, hasLength(1)); + final lines = reports.single + .readAsLinesSync() + .where((line) => line.contains('connect ')) + .toList(); + expect(lines, hasLength(1)); + expect(lines.single, isNot(contains('error:'))); + }); + test('an older disconnect preserves a replacement connection session', () async { final directory = @@ -125,3 +165,19 @@ class _DelayedDisconnectConnector extends VmServiceConnector { 'screenshots': ['aGVsbG8='], }; } + +class _DelayedVersionConnector extends _DelayedDisconnectConnector { + final versionStarted = Completer<void>(); + final versionRelease = Completer<void>(); + bool firstVersion = true; + + @override + Future<String> getVersion() async { + if (firstVersion) { + firstVersion = false; + versionStarted.complete(); + await versionRelease.future; + } + return version; + } +} diff --git a/packages/marionette_mcp/test/session/step_logger_test.dart b/packages/marionette_mcp/test/session/step_logger_test.dart index cc3ea1d1..debfa981 100644 --- a/packages/marionette_mcp/test/session/step_logger_test.dart +++ b/packages/marionette_mcp/test/session/step_logger_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -633,6 +634,44 @@ void main() { }); group('withStepLogging', () { + for (final initiallyConnected in [true, false]) { + for (final fails in [true, false]) { + test( + 'keeps invocation session when initiallyConnected=$initiallyConnected ' + 'and fails=$fails across reconnect', () async { + final tempDir = Directory.systemTemp.createTempSync('step_overlap_'); + addTearDown(() => tempDir.deleteSync(recursive: true)); + final original = Session.open(Directory(p.join(tempDir.path, 'old'))); + final replacement = + Session.open(Directory(p.join(tempDir.path, 'replacement'))); + final logger = StepLogger() + ..session = initiallyConnected ? original : null; + final release = Completer<void>(); + final wrapped = withStepLogging(logger, 'hot_reload', (_, __) async { + await release.future; + if (fails) throw StateError('old call failed'); + return const CallToolResult( + content: [TextContent(text: 'reloaded')]); + }); + final pending = Future<CallToolResult>.sync( + () => wrapped(const {}, _fakeExtra()), + ); + final expectation = fails + ? expectLater(pending, throwsStateError) + : expectLater(pending, completion(isA<CallToolResult>())); + logger.session = replacement; + release.complete(); + await expectation; + + String contents(Session session) => session.stepsFile.existsSync() + ? session.stepsFile.readAsStringSync() + : ''; + expect(contents(original).contains('hot_reload'), initiallyConnected); + expect(contents(replacement), isNot(contains('hot_reload'))); + }); + } + } + test('logs after the wrapped callback resolves', () async { final tempDir = Directory.systemTemp.createTempSync('with_step_logging_'); addTearDown(() { @@ -654,7 +693,8 @@ void main() { expect(session.stepsFile.readAsStringSync(), contains('hot_reload')); }); - test('still logs a step when the wrapped callback throws, then ' + test( + 'still logs a step when the wrapped callback throws, then ' 'rethrows', () async { // Regression test: an unexpected, uncaught throw (not the usual // isError: true result) must still be recorded — otherwise a report's diff --git a/packages/marionette_mcp/test/vm_service/connector_lifecycle_test.dart b/packages/marionette_mcp/test/vm_service/connector_lifecycle_test.dart index 50d5edc9..4eb53ed5 100644 --- a/packages/marionette_mcp/test/vm_service/connector_lifecycle_test.dart +++ b/packages/marionette_mcp/test/vm_service/connector_lifecycle_test.dart @@ -272,8 +272,56 @@ void main() { expect(await failure.timeout(const Duration(seconds: 2)), isA<VmServiceExtensionException>()); + expect(connector.isConnected, isFalse); + await expectLater( + connector.getVersion(), throwsA(isA<NotConnectedException>())); await connector.disconnect().timeout(const Duration(seconds: 2)); + }); + + test('peer close retires service registrations and pending waiters', + () async { + final vm = await server('closed-peer'); + vm.registerHotRestart = true; + await connector.connect(vm.uri); + expect(await connector.waitForServiceRegistration('hotRestart'), + 's0.hotRestart'); + final waiting = connector.waitForServiceRegistration( + 'pending', + timeout: const Duration(minutes: 1), + ); + + await vm.sockets.single.close(); + + expect(await waiting.timeout(const Duration(seconds: 2)), isNull); expect(connector.isConnected, isFalse); + expect( + await connector.waitForServiceRegistration( + 'hotRestart', + timeout: Duration.zero, + ), + isNull, + ); + await expectLater( + connector.getVersion(), throwsA(isA<NotConnectedException>())); + }); + + test('old peer closure preserves replacement registration waiters', + () async { + final old = await server('old'); + await connector.connect(old.uri); + final replacement = await server('replacement'); + replacement.registerHotRestart = true; + + final connecting = connector.connect(replacement.uri); + final registration = connector.waitForServiceRegistration( + 'hotRestart', + timeout: const Duration(seconds: 3), + ); + await connecting.timeout(const Duration(seconds: 3)); + + expect(await registration, 's0.hotRestart'); + expect(connector.isConnected, isTrue); + expect(await connector.getVersion(), 'replacement'); }); test('old disconnect cleanup cannot clear an immediate new connect', From b49083209f3b776d70fc9f085aca2428904214d6 Mon Sep 17 00:00:00 2001 From: Jhin Lee <jhin.lee@unity3d.com> Date: Sat, 24 Oct 2026 10:10:17 -0400 Subject: [PATCH 03/12] Validate minimum Dart and Flutter packages independently --- .github/workflows/ci.yaml | 75 ++++++++++++++++++++++++--------------- docs/architecture.md | 13 ++++++- 2 files changed, 58 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f0d9dd6b..ba026829 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -128,51 +128,68 @@ jobs: flutter-version: '3.27.0' cache: true - - name: Install workspace dependencies - run: flutter pub get + # The standalone server and Flutter binding have independent consumers. + # Flutter 3.27's flutter_test pins meta 1.15, while mcp_dart needs 1.16. + # Select workspace members without overriding dependency constraints. + - name: Install minimum-Dart workspace dependencies + run: | + cat > pubspec_overrides.yaml <<'YAML' + workspace: + - packages/marionette_mcp + - packages/marionette_cli + - tool + YAML + dart pub get --no-example - name: Check frame protocol compatibility run: dart tool/check_frame_protocol.dart - name: Analyze marionette_mcp - run: | - cd packages/marionette_mcp - dart analyze --fatal-infos lib bin test + working-directory: packages/marionette_mcp + run: dart analyze --fatal-infos lib bin test - name: Analyze marionette_cli + working-directory: packages/marionette_cli + run: dart analyze --fatal-infos lib bin test + + - name: Run tests for marionette_mcp + working-directory: packages/marionette_mcp + run: dart test + + - name: Run tests for marionette_cli + working-directory: packages/marionette_cli + run: dart test + + - name: Install minimum-Flutter workspace dependencies run: | - cd packages/marionette_cli - dart analyze --fatal-infos lib bin test + cat > pubspec_overrides.yaml <<'YAML' + workspace: + - packages/marionette_flutter + - packages/marionette_logger + - packages/marionette_logging + - example + YAML + flutter pub get - name: Analyze marionette_flutter - run: | - cd packages/marionette_flutter - flutter analyze --fatal-infos lib + working-directory: packages/marionette_flutter + run: flutter analyze --fatal-infos lib - name: Analyze marionette_logger - run: | - cd packages/marionette_logger - flutter analyze --fatal-infos lib + working-directory: packages/marionette_logger + run: flutter analyze --fatal-infos lib - name: Analyze marionette_logging - run: | - cd packages/marionette_logging - flutter analyze --fatal-infos lib - - - name: Run tests for marionette_mcp - run: | - cd packages/marionette_mcp - dart test - - - name: Run tests for marionette_cli - run: | - cd packages/marionette_cli - dart test + working-directory: packages/marionette_logging + run: flutter analyze --fatal-infos lib - name: Run tests for marionette_flutter - run: | - cd packages/marionette_flutter - flutter test --no-pub + working-directory: packages/marionette_flutter + run: flutter test --no-pub + + - name: Remove workspace selection + if: always() + run: rm -f pubspec_overrides.yaml publish: name: Publish packages diff --git a/docs/architecture.md b/docs/architecture.md index 85efddbe..1258ebd1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,7 +71,18 @@ was captured from the original 0.7.0 implementation with SDK 2.4.3; update it only for an intentional tool-contract change. Shutdown regressions use a real Dart VM service fixture and cover connected EOF and signals. -`dart run tool/check_frame_protocol.dart` checks Flutter-encoded frames against +`dart tool/check_frame_protocol.dart` checks Flutter-encoded frames against the MCP consumer, including fragmented delivery. CI runs this check on both the normal and minimum supported toolchains. This keeps the production packages independent while detecting drift in their shared wire format. + +The minimum-toolchain CI job selects separate workspace groups for the +standalone Dart server/CLI and the Flutter binding/adapters. Flutter 3.27's +`flutter_test` pins `meta` to 1.15, while `mcp_dart` 2.4.3 requires 1.16 or +newer. Temporary `pubspec_overrides.yaml` files select workspace members only; +they never override dependency constraints. Dart checks use `--no-example` to +avoid resolving Flutter examples in the server-only workspace. The normal +CI job continues to validate the complete workspace on the pinned Flutter SDK. +The complete workspace and the MCP package's Flutter example combine both +dependency graphs and therefore require a newer Flutter SDK. Flutter 3.27 is +supported for apps using the binding with the server running separately. From 767e0d2e54ee1fa8cfc67d52676f4fcf6e4bc2df Mon Sep 17 00:00:00 2001 From: Jhin Lee <jhin.lee@unity3d.com> Date: Sat, 24 Oct 2026 10:29:45 -0400 Subject: [PATCH 04/12] Complete typed tool architecture and structured MCP results --- CHANGELOG.md | 1 + docs/architecture.md | 48 ++- .../marionette-flutter-drive-app/SKILL.md | 6 + .../marionette_mcp/lib/marionette_mcp.dart | 2 +- packages/marionette_mcp/lib/mcp/mcp.dart | 27 +- .../lib/mcp/server_context.dart | 313 ++------------- .../marionette_mcp/lib/mcp/server_runner.dart | 195 ++++++++++ .../marionette_mcp/lib/mcp/step_logging.dart | 137 +++++++ .../mcp/tools/ancestor_keys_description.dart | 11 + .../lib/mcp/tools/base_tool.dart | 6 +- .../lib/mcp/tools/connection_tools.dart | 307 +++++++++++++++ .../lib/mcp/tools/device_tools.dart | 10 +- .../mcp/tools/dynamic_extension_tools.dart | 322 ++++++++++++++++ .../lib/mcp/tools/extension_tools.dart | 31 +- .../lib/mcp/tools/gesture_tools.dart | 82 ++-- .../lib/mcp/tools/inspection_tools.dart | 51 ++- .../lib/mcp/tools/keyboard_tools.dart | 10 +- .../lib/mcp/tools/system_tools.dart | 19 +- .../lib/mcp/tools/text_tools.dart | 12 +- .../lib/mcp/tools/tool_results.dart | 34 ++ .../lib/mcp/tools/tool_runner.dart | 25 ++ .../marionette_mcp/lib/mcp/tools/tools.dart | 41 +- packages/marionette_mcp/lib/runtime.dart | 2 +- .../lib/src/mcp_server_runner.dart | 197 +--------- .../lib/src/session/report_steps.dart | 241 ++++++++++++ .../lib/src/session/step_logger.dart | 360 +----------------- .../vm_service/dynamic_extension_tools.dart | 289 +------------- .../tools/ancestor_keys_description.dart | 13 +- .../lib/src/vm_service/tools/tool_runner.dart | 27 +- .../marionette_mcp/test/server_api_test.dart | 129 ++++++- .../test/server_context_test.dart | 4 +- .../test/structured_tool_outputs_test.dart | 299 +++++++++++++++ 32 files changed, 1991 insertions(+), 1260 deletions(-) create mode 100644 packages/marionette_mcp/lib/mcp/server_runner.dart create mode 100644 packages/marionette_mcp/lib/mcp/step_logging.dart create mode 100644 packages/marionette_mcp/lib/mcp/tools/ancestor_keys_description.dart create mode 100644 packages/marionette_mcp/lib/mcp/tools/connection_tools.dart create mode 100644 packages/marionette_mcp/lib/mcp/tools/dynamic_extension_tools.dart create mode 100644 packages/marionette_mcp/lib/mcp/tools/tool_results.dart create mode 100644 packages/marionette_mcp/lib/mcp/tools/tool_runner.dart create mode 100644 packages/marionette_mcp/lib/src/session/report_steps.dart create mode 100644 packages/marionette_mcp/test/structured_tool_outputs_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 019743a7..c2d4b46e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Unreleased +- Add output schemas and structured success results to MCP tools while preserving existing text and images. - Upgrade `mcp_dart` to `^2.4.3` and use its fixed dynamic-tool removal on reconnect. - Fix server shutdown leaving app connections open and failed web recording startup leaving its WebSocket listener open. - Keep in-flight tool steps in their original report across reconnects, and clear connection status when the app disconnects. diff --git a/docs/architecture.md b/docs/architecture.md index 1258ebd1..073d8337 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -11,19 +11,25 @@ packages/marionette_mcp/ lib/mcp/ mcp.dart createMcpServer and server instructions server_config.dart options shared with marionette mcp - server_context.dart app connection and session lifecycle + server_context.dart shared connection/session state and ownership + server_runner.dart logging, transport startup, and shutdown + step_logging.dart MCP result/session logging adapter tools/ base_tool.dart metadata and execute contract tools.dart createAllTools and registration + connection_tools.dart connect/disconnect contracts and lifecycle calls + dynamic_extension_tools.dart app-defined typed tools and registrations *_tools.dart tools grouped by responsibility lib/src/ runtime implementation details ``` Each static tool extends `BaseTool` and receives its connector and logger through its constructor. Screenshot tools also receive the session step -logger. `createAllTools` composes these tools without opening sockets or -starting a process. `createMcpServer` registers them and applies step logging -once, at the composition boundary. Connection tools own their additional +logger. `createAllTools` composes all 20 built-in tools, including connection +tools, without opening sockets or starting a process. `createMcpServer` uses +`registerMcpTools` to register them and apply step logging once, at the +composition boundary. The context contains no tool definitions or registration +loop. Connection tools own their additional session-report transitions; app-defined tools are discovered dynamically. In-flight tool calls retain their original report session; a superseded connection attempt cannot add a step to its replacement's report. @@ -34,7 +40,31 @@ CLI adapters import `package:marionette_mcp/runtime.dart` for app connections, selectors, reports, and recording. They import `package:marionette_mcp/marionette_mcp.dart` for server startup. Other packages should use these entrypoints rather than importing `lib/src` files. Existing -MCP tools and executable options keep their names and wire schemas. +MCP tools and executable options keep their names and input schemas. +Shared report formatting and persistence live in `src/session/report_steps.dart` +without an MCP dependency; MCP result interpretation lives in `mcp/step_logging.dart`. +Former internal MCP paths are compatibility exports of the new implementations. + +## Structured tool results + +Every built-in tool advertises an `outputSchema` and returns matching +`structuredContent` on success. The SDK validates these results. Existing +human-readable text and inline images remain available in `content`; error +results retain `isError: true` and need not match a success schema. + +| Tool family | Structured success data | +| --- | --- | +| Actions, keyboard, device config, reload/restart | `message`; back navigation may also include `didPop` | +| UI inspection | `elements`, `count` | +| Logs | `logs`, `count` | +| Screenshots | `count`, `inline`, and saved `paths` when applicable; image bytes stay in `content` | +| Extension discovery | `extensions`, `count` | +| Generic and dynamically promoted extensions | `response`, an object containing the app's result | +| Connect | `uri`, `sessionReportsEnabled`, optional `sessionPath` or nonfatal `warning` | +| Disconnect | `disconnected`, optional closing `sessionPath` | + +Extension output fields are app-defined, so their response envelope permits +arbitrary properties rather than claiming a schema the app has not supplied. For embedding, `createMcpServer(context: context)` accepts an explicitly owned `VmServiceContext`. The caller must await `context.dispose()` and @@ -66,9 +96,13 @@ and `packages/marionette_cli`, then `flutter test --no-pub` in `packages/marionette_flutter`. `server_api_test.dart` drives the real executable through public SDK client -APIs under legacy initialization and stateless discovery. Its catalog fixture +APIs under legacy initialization and stateless discovery, including successful +structured results, dynamic extension removal, and ordinary error results. +Its catalog fixture was captured from the original 0.7.0 implementation with SDK 2.4.3; update it -only for an intentional tool-contract change. Shutdown regressions use a real +only for an intentional tool-contract change. The comparison permits the new +output schemas while preserving every original input contract and annotation. +Shutdown regressions use a real Dart VM service fixture and cover connected EOF and signals. `dart tool/check_frame_protocol.dart` checks Flutter-encoded frames against diff --git a/packages/marionette_flutter/skills/marionette-flutter-drive-app/SKILL.md b/packages/marionette_flutter/skills/marionette-flutter-drive-app/SKILL.md index 0445404b..a68343c3 100644 --- a/packages/marionette_flutter/skills/marionette-flutter-drive-app/SKILL.md +++ b/packages/marionette_flutter/skills/marionette-flutter-drive-app/SKILL.md @@ -307,6 +307,12 @@ disconnecting. | Session | `connect`, `disconnect` | `connect` must be called before any other tool; a second `connect` implicitly disconnects the first. `connect` also opens a fresh session directory (`session_title`) that owns the step log and screenshots — see *Reporting what you found*. | | Video (CLI only) | `record-video` | Records a WebM video of the session (`-o/--output`, `-d/--duration`, `--width`/`--height`; needs `ffmpeg` on `PATH`). No MCP equivalent — use `take_screenshots` there instead. | +MCP tools advertise output schemas. When your client exposes +`structuredContent`, read the returned `elements`, `logs`, screenshot `paths`, +or action `message` directly instead of parsing the display text. Custom +extension results are under `response`; inline screenshot images remain in +`content`. Check `isError` first: error results can omit structured data. + ## Good practices - **Look before you act.** Call `get_interactive_elements` (or diff --git a/packages/marionette_mcp/lib/marionette_mcp.dart b/packages/marionette_mcp/lib/marionette_mcp.dart index c92f91c6..4f598a17 100644 --- a/packages/marionette_mcp/lib/marionette_mcp.dart +++ b/packages/marionette_mcp/lib/marionette_mcp.dart @@ -3,5 +3,5 @@ library; export 'mcp/mcp.dart'; export 'mcp/server_config.dart'; -export 'src/mcp_server_runner.dart' show runMcpServer; +export 'mcp/server_runner.dart' show runMcpServer; export 'src/version.g.dart' show version; diff --git a/packages/marionette_mcp/lib/mcp/mcp.dart b/packages/marionette_mcp/lib/mcp/mcp.dart index b1375ce9..a3cc4657 100644 --- a/packages/marionette_mcp/lib/mcp/mcp.dart +++ b/packages/marionette_mcp/lib/mcp/mcp.dart @@ -1,10 +1,13 @@ /// Marionette MCP server composition. library; -import 'package:mcp_dart/mcp_dart.dart'; import 'package:marionette_mcp/src/version.g.dart'; +import 'package:mcp_dart/mcp_dart.dart'; import 'server_context.dart'; +import 'step_logging.dart'; +import 'tools/dynamic_extension_tools.dart'; +import 'tools/tools.dart'; export 'server_context.dart'; export 'tools/tools.dart'; @@ -44,7 +47,27 @@ McpServer createMcpServer({required VmServiceContext context}) { ), ); - context.registerTools(server); + registerMcpTools(server, context: context); return server; } + +/// Registers the complete catalog on a server owned by the caller. +/// +/// A context belongs to one server and must be disposed when its transport ends. +void registerMcpTools(McpServer server, {required VmServiceContext context}) { + context.attachDynamicTools(DynamicExtensionTools( + server: server, + connector: context.connector, + logger: context.logger, + stepLogger: context.stepLogger, + )); + for (final tool in createAllTools(context: context)) { + server.registerBaseTool( + tool, + callback: tool is ConnectionTool + ? tool.execute + : withStepLogging(context.stepLogger, tool.name, tool.execute), + ); + } +} diff --git a/packages/marionette_mcp/lib/mcp/server_context.dart b/packages/marionette_mcp/lib/mcp/server_context.dart index 373e1693..2bb4efd0 100644 --- a/packages/marionette_mcp/lib/mcp/server_context.dart +++ b/packages/marionette_mcp/lib/mcp/server_context.dart @@ -1,312 +1,63 @@ import 'package:logging/logging.dart' as logging; +import 'package:marionette_mcp/mcp/step_logging.dart'; import 'package:marionette_mcp/src/session/session_manager.dart'; -import 'package:marionette_mcp/src/session/step_logger.dart'; -import 'package:marionette_mcp/src/version.g.dart' as v; -import 'package:marionette_mcp/src/vm_service/dynamic_extension_tools.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; -import 'package:mcp_dart/mcp_dart.dart'; -import 'tools/tools.dart'; +import 'tools/dynamic_extension_tools.dart'; -/// Context for managing VM service connection and registering MCP tools. +/// Shared app connection and session state, owned by the server lifecycle. +/// +/// Tool metadata and registration live in the MCP composition and tool classes. final class VmServiceContext { VmServiceContext({VmServiceConnector? connector}) - : connector = connector ?? VmServiceConnector(), - _logger = logging.Logger('VmServiceContext'), - _sessionManager = SessionManager(), - _stepLogger = StepLogger(); + : connector = connector ?? VmServiceConnector(); final VmServiceConnector connector; - final logging.Logger _logger; - final SessionManager _sessionManager; - final StepLogger _stepLogger; + final logging.Logger logger = logging.Logger('VmServiceContext'); + final SessionManager sessionManager = SessionManager(); + final StepLogger stepLogger = StepLogger(); - /// Owns the registry of dynamic extension tools across the lifetime of - /// this context. Each connection replaces its app-specific registrations. DynamicExtensionTools? _dynamicTools; bool _disposed = false; int _connectionEpoch = 0; - /// Registers all VM service related tools with the MCP server. - /// - /// Every tool is wrapped with [withStepLogging] so each call is - /// recorded to the active session's steps.md (see [StepLogger]) — except - /// `connect`/`disconnect`, registered on the raw server: they own the - /// version-compatibility handshake with the binding and the session - /// lifecycle itself. Connect logs only for the epoch it still owns; - /// disconnect retains the closing session while cleanup waits. Both log - /// manually so overlapping requests cannot write into a replacement report. - /// All other tools come from the central typed tool registry. - void registerTools(McpServer server) { - _dynamicTools = DynamicExtensionTools( - server: server, - connector: connector, - logger: _logger, - stepLogger: _stepLogger, - ); - _registerConnectionTools(server); - for (final tool in createAllTools( - connector: connector, - logger: _logger, - stepLogger: _stepLogger, - )) { - server.registerBaseTool( - tool, - callback: withStepLogging(_stepLogger, tool.name, tool.execute), - ); - } - } - - void _registerConnectionTools(McpServer server) { - server - ..registerTool( - 'connect', - description: - 'Connects to a Flutter app via its VM service URI. This must be called before using any other tools. The VM service URI is typically in the format ws://127.0.0.1:PORT/ws and can be found in the Flutter app output when running in debug mode. If the app enabled session reports (MarionetteConfiguration.enableSessionReports), this also opens a fresh session directory under .marionette/sessions/ where the step log and screenshots for this run are kept.', - annotations: const ToolAnnotations(title: 'Connect to App'), - inputSchema: ToolInputSchema( - properties: { - 'uri': JsonSchema.string( - description: - 'VM service URI (e.g., ws://127.0.0.1:8181/ws). This is printed in the Flutter app console when running in debug mode.', - ), - 'session_title': JsonSchema.string( - description: - 'A short title for this run, e.g. "profile validation". ' - 'Keep it under ~60 characters — longer is truncated. ' - 'Ignored unless the app enabled session reports. ' - 'Slugified and timestamped into the session directory ' - 'name. Purely a human-readable label — every connect ' - 'opens its own fresh session directory, even if this ' - 'matches an earlier title. Falls back to "run-<timestamp>" ' - 'when omitted.', - ), - 'session_dir': JsonSchema.string( - description: - 'Base directory .marionette/sessions/ is created under. ' - 'Overrides the MARIONETTE_SESSION_DIR environment variable. ' - 'Only needed when the MCP client hasn\'t set that variable ' - 'and the process\'s working directory isn\'t the project root.', - ), - }, - required: ['uri'], - ), - callback: (args, extra) async { - final epoch = ++_connectionEpoch; - final result = await _connect(args, epoch); - if (!_disposed && epoch == _connectionEpoch) { - _stepLogger.logStep('connect', args, result); - } - return result; - }, - ) - ..registerTool( - 'disconnect', - description: - 'Disconnects from the currently connected Flutter app. After disconnecting, you must call connect again to use any other tools.', - annotations: const ToolAnnotations(title: 'Disconnect from App'), - inputSchema: const ToolInputSchema(properties: {}), - callback: (args, extra) async { - final epoch = ++_connectionEpoch; - final disconnectLogger = StepLogger()..session = _stepLogger.session; - _logger.info('Disconnecting from app'); - - try { - // Retire dynamic tools before tearing down the connection so a - // mid-disconnect tools/list reflects only what's still callable. - _disableDynamicTools(); - await connector.disconnect(); - - final session = disconnectLogger.session; - final nudge = session == null - ? '' - : '\nSession: ${session.directory.path}\nWrite report.md now.'; - final result = CallToolResult( - content: [ - TextContent( - text: 'Successfully disconnected from app$nudge', - ), - ], - ); - // Attribute the step to the connection being closed. A newer - // connect may already own a different session while cleanup waits. - if (!_disposed) { - disconnectLogger.logStep('disconnect', args, result); - } - if (epoch == _connectionEpoch) _stepLogger.session = null; - return result; - } catch (err) { - _logger.severe('Error during disconnect', err); - final result = CallToolResult( - isError: true, - content: [TextContent(text: 'Error during disconnect: $err')], - ); - if (!_disposed) { - disconnectLogger.logStep('disconnect', args, result); - } - return result; - } - }, - ); - } - - Future<CallToolResult> _connect( - Map<String, dynamic> args, - int epoch, - ) async { - final uri = args['uri'] as String; - _logger.info('Connecting to app at $uri'); - // A new connect attempt immediately supersedes whatever session - // was active before, regardless of whether this attempt itself - // succeeds — otherwise a call that slips in while this attempt is - // still in flight (or after it fails) would append to a session - // that's no longer the one actually in use. - _stepLogger.session = null; - _disableDynamicTools(); - - try { - _ensureCurrentConnection(epoch); - await connector.connect(uri); - _ensureCurrentConnection(epoch); + /// Whether the server owner has retired this context. + bool get isDisposed => _disposed; - // Version compatibility check — unwind the connection on mismatch - // so the next call to connect can start fresh. - try { - final bindingVersion = await connector.getVersion(); - _ensureCurrentConnection(epoch); - if (bindingVersion != v.version) { - await connector.disconnect(); - return CallToolResult( - isError: true, - content: [ - TextContent( - text: 'Version mismatch: marionette_mcp is ${v.version}, ' - 'but marionette_flutter binding is $bindingVersion. ' - 'Please ensure both packages are the same version.', - ), - ], - ); - } - } catch (err) { - _logger.warning('Failed to check binding version', err); - if (!_disposed && epoch == _connectionEpoch) { - await connector.disconnect(); - } - return CallToolResult( - isError: true, - content: [ - TextContent( - text: 'Failed to verify marionette_flutter binding version. ' - 'Please ensure marionette_flutter is up to date. ' - 'Error: $err', - ), - ], - ); - } + /// Starts a transition that supersedes older connection/session work. + int beginTransition() => ++_connectionEpoch; - // Promote each schema-bearing custom extension into a first-class - // MCP tool. Failures here are logged but don't fail the connect — - // the generic call_custom_extension fallback keeps working. - await _registerDynamicTools(); - _ensureCurrentConnection(epoch); + /// Whether asynchronous work still belongs to the active connection. + bool ownsConnection(int epoch) => !_disposed && epoch == _connectionEpoch; - // Session reports are opt-in app-side - // (MarionetteConfiguration.enableSessionReports): with them off, - // connect writes nothing to disk and steps go unlogged. Failing - // to even determine that counts as off too, same as a session - // directory that fails to open below — the app connection and - // every other tool already work fine either way, so neither is - // worth dropping an otherwise-working connection over. - var sessionReportsEnabled = false; - try { - sessionReportsEnabled = await connector.getSessionReportsEnabled(); - } catch (err) { - _logger.warning( - 'Failed to read binding configuration; continuing without ' - 'session reports', - err, - ); - } - _ensureCurrentConnection(epoch); - if (!sessionReportsEnabled) { - _stepLogger.session = null; - return CallToolResult( - content: [ - TextContent(text: 'Successfully connected to app at $uri'), - ], - ); - } - - try { - final session = _sessionManager.create( - title: args['session_title'] as String?, - baseDirOverride: args['session_dir'] as String?, - ); - _stepLogger.session = session; - - return CallToolResult( - content: [ - TextContent( - text: 'Successfully connected to app at $uri\n' - 'Opened session: ${session.directory.path}', - ), - ], - ); - } catch (err) { - // Session setup is best-effort: the app connection and every - // other tool already work fine without it, so a directory - // creation failure (e.g. an unwritable session_dir) shouldn't - // drop an otherwise-working connection. Degrade the same way - // an app that opted out of session reports already does — - // connect succeeds, just without step logging. - _logger.warning( - 'Failed to open session directory; continuing without ' - 'session reports', - err, - ); - _stepLogger.session = null; - return CallToolResult( - content: [ - TextContent( - text: 'Successfully connected to app at $uri\n' - 'Could not open a session directory, so this run ' - 'will not be logged: $err', - ), - ], - ); - } - } catch (err) { - _logger.severe('Failed to connect to app', err); - return CallToolResult( - isError: true, - content: [TextContent(text: 'Failed to connect to app: $err')], - ); + /// Rejects asynchronous work superseded by another transition or disposal. + void ensureCurrentConnection(int epoch) { + if (!ownsConnection(epoch)) { + throw StateError('The app connection was closed or superseded.'); } } - Future<void> _registerDynamicTools() async { - // A reconnect without a clean disconnect would leak the previous - // batch — remove any leftovers before registering fresh ones. - _dynamicTools?.removeAll(); - await _dynamicTools?.registerAll(); + /// Gives this context ownership of its app-specific MCP registrations. + void attachDynamicTools(DynamicExtensionTools tools) { + if (_disposed || _dynamicTools != null) { + throw StateError( + 'The context is disposed or already attached to a server.'); + } + _dynamicTools = tools; } - void _disableDynamicTools() => _dynamicTools?.removeAll(); + /// Discovers the connected app's schema-bearing extension tools. + Future<void> registerDynamicTools() async => _dynamicTools?.registerAll(); - void _ensureCurrentConnection(int epoch) { - if (_disposed || epoch != _connectionEpoch) { - throw StateError('The app connection was closed or superseded.'); - } - } + /// Retires all app-specific tools before replacing or closing a connection. + void removeDynamicTools() => _dynamicTools?.removeAll(); /// Releases dynamic registrations, reports, and the VM-service connection. - /// - /// Called by the server owner on transport close, startup failure, or signal. Future<void> dispose() async { _disposed = true; ++_connectionEpoch; - _disableDynamicTools(); - _stepLogger.session = null; + removeDynamicTools(); + stepLogger.session = null; await connector.disconnect(); } } diff --git a/packages/marionette_mcp/lib/mcp/server_runner.dart b/packages/marionette_mcp/lib/mcp/server_runner.dart new file mode 100644 index 00000000..c3a6529a --- /dev/null +++ b/packages/marionette_mcp/lib/mcp/server_runner.dart @@ -0,0 +1,195 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:logging/logging.dart' as logging; +import 'package:marionette_mcp/mcp/mcp.dart'; +import 'package:mcp_dart/mcp_dart.dart'; + +/// Runs the Marionette MCP server with the given configuration. +/// +/// Sets up logging, creates the MCP server with tools, and runs it on either +/// stdio or SSE transport depending on whether [ssePort] is provided. +Future<int> runMcpServer({ + required String logLevel, + String? logFile, + int? ssePort, +}) async { + final logSubscription = setupLogging(logLevel, logFile); + + final vmService = VmServiceContext(); + + final server = createMcpServer(context: vmService); + try { + return ssePort == null + ? await _runStdioServer(server) + : await _runSseServer(server, ssePort); + } finally { + // The app connection outlives individual tool calls, so the server owns it. + // Closing only the MCP transport leaves its VM-service socket alive. + try { + await vmService.dispose(); + } finally { + try { + await server.close(); + } finally { + await logSubscription.cancel(); + } + } + } +} + +StreamSubscription<logging.LogRecord> setupLogging( + String logLevelName, + String? logFile, +) { + final logLevel = logging.Level.LEVELS.firstWhere( + (e) => e.name == logLevelName, + orElse: () => logging.Level.INFO, + ); + + logging.Logger.root.level = logLevel; + + if (logFile != null) { + final file = File(logFile)..createSync(recursive: true); + return logging.Logger.root.onRecord.listen((record) { + file.writeAsStringSync( + '[${record.level.name}][${record.loggerName}][${_formatTime(record.time)}] ${record.message}\n', + mode: FileMode.append, + ); + }); + } else { + return logging.Logger.root.onRecord.listen((record) { + stderr.writeln( + '[${record.level.name}][${record.loggerName}][${_formatTime(record.time)}] ${record.message}', + ); + }); + } +} + +String _formatTime(DateTime time) { + return '${time.hour.toString().padLeft(2, '0')}:' + '${time.minute.toString().padLeft(2, '0')}:' + '${time.second.toString().padLeft(2, '0')}'; +} + +Future<int> _runStdioServer(McpServer server) async { + final logger = logging.Logger('main'); + + final transport = StdioServerTransport(); + final exitSignal = ExitSignal(); + final stdinClosed = Completer<void>(); + + // Install the close handler before connecting. An immediate stdin EOF + // (e.g. `</dev/null`) can close the transport during or right after + // connect(); registering afterwards risks missing that close and hanging + // until a signal. Fires when the transport closes — including stdin EOF, + // i.e. the MCP host went away without sending a signal. Per the stdio + // lifecycle the server should shut down then, not wait for SIGINT/SIGTERM. + server.server.onclose = () { + if (!stdinClosed.isCompleted) stdinClosed.complete(); + }; + + try { + logger.fine('Running MCP server on stdio'); + await server.connect(transport); + logger.info('Server started'); + } catch (e, st) { + logger.severe('Error when starting the Stdio transport', e, st); + exitSignal.dispose(); + return 1; + } + + // Stop on whichever happens first: an OS signal or the transport closing. + // Only the first cause is logged — closing the server below re-triggers + // `onclose`, which would otherwise log a misleading second reason. + var stopping = false; + void logStop(String reason) { + if (stopping) return; + stopping = true; + logger.info('$reason, stopping'); + } + + await Future.any([ + exitSignal.wait.then((signal) => logStop('Received ${signal.name}')), + stdinClosed.future.then((_) => logStop('stdin closed')), + ]); + + // Release the signal subscriptions so the event loop can drain and the + // process actually exits on the stdin-EOF path. + exitSignal.dispose(); + await transport.close(); + logger.info('Stopped'); + return 0; +} + +Future<int> _runSseServer(McpServer server, int ssePort) async { + final logger = logging.Logger('main'); + final manager = SseServerManager(server); + final exitSignal = ExitSignal(); + HttpServer? httpServer; + try { + final listener = await HttpServer.bind( + InternetAddress.loopbackIPv4, + ssePort, + ); + httpServer = listener; + logger.fine('Running MCP server on SSE port $ssePort'); + unawaited(exitSignal.wait.then((signal) async { + logger.info('Received ${signal.name}, stopping'); + await listener.close(force: true); + })); + await for (final request in listener) { + unawaited(manager.handleRequest(request)); + } + logger.info('Stopped'); + return 0; + } catch (e, st) { + logger.severe('Error when waiting for MCP client connection', e, st); + return 1; + } finally { + exitSignal.dispose(); + await httpServer?.close(force: true); + await Future.wait([ + for (final transport in manager.activeSseTransports.values.toList()) + transport.close(), + ]); + } +} + +/// Waits for SIGINT or SIGTERM to signal graceful shutdown. +class ExitSignal { + ExitSignal() { + if (!Platform.isWindows) { + _sigtermSubscription = ProcessSignal.sigterm.watch().listen( + _handleSignal, + ); + } + _sigintSubscription = ProcessSignal.sigint.watch().listen(_handleSignal); + } + + final _completer = Completer<ProcessSignal>(); + StreamSubscription<ProcessSignal>? _sigtermSubscription; + late final StreamSubscription<ProcessSignal> _sigintSubscription; + bool _disposed = false; + + Future<ProcessSignal> get wait => _completer.future; + + /// Cancels the signal subscriptions so they no longer keep the event loop + /// alive. Safe to call multiple times — in normal operation it runs once + /// from [_handleSignal] and again from the shutdown path. + void dispose() => _cleanup(); + + void _handleSignal(ProcessSignal signal) { + if (!_completer.isCompleted) { + _completer.complete(signal); + _cleanup(); + } + } + + void _cleanup() { + if (_disposed) return; + _disposed = true; + _sigtermSubscription?.cancel(); + _sigintSubscription.cancel(); + } +} diff --git a/packages/marionette_mcp/lib/mcp/step_logging.dart b/packages/marionette_mcp/lib/mcp/step_logging.dart new file mode 100644 index 00000000..41883426 --- /dev/null +++ b/packages/marionette_mcp/lib/mcp/step_logging.dart @@ -0,0 +1,137 @@ +import 'package:mcp_dart/mcp_dart.dart'; + +import '../src/session/report_steps.dart'; +import '../src/session/session.dart'; + +/// Tools whose successful result is a short, synthesized confirmation +/// message (e.g. "Successfully tapped"), never a dump of application data — +/// safe to echo verbatim (truncated) in steps.md. Everything else — +/// data-listing tools (`get_interactive_elements`, `get_logs`, +/// `call_custom_extension`, `list_custom_extensions`) and any +/// dynamically-promoted custom extension tool, whose result may carry +/// arbitrary app data — gets a generic outcome on success instead, so +/// steps.md never ends up persisting a payload. Error messages are shown +/// regardless of the tool (see [_describeOutcome]): they're diagnostic +/// context the report contract explicitly wants, not a payload. +const _shortConfirmationTools = { + 'connect', + 'disconnect', + 'tap', + 'secondary_tap', + 'double_tap', + 'long_press', + 'swipe', + 'pinch_zoom', + 'scroll_to', + 'press_back_button', + 'enter_text', + 'press_key', + 'set_device_config', + 'hot_reload', + 'hot_restart', + 'take_screenshots', +}; + +/// Tools whose success message is one status line followed by session +/// bookkeeping aimed at the agent — `connect`'s "Opened session: <path>", +/// `disconnect`'s "Session: <path>" and its report nudge. That tail is +/// redundant inside the very session it points at (and, being an absolute +/// path, mostly lost to truncation), so only the first line is logged. +const _firstLineOnlyTools = {'connect', 'disconnect'}; + +/// Appends one line per tool call to the active session's steps.md: tool, +/// selector, outcome. No payloads. No-ops when there is no active session +/// (nothing connected yet). +class StepLogger { + /// The session tool calls are currently logged into. Set by `connect` on + /// success; cleared both at the very start of every `connect` attempt and + /// once `disconnect`'s own step has been logged (see `VmServiceContext`), + /// so neither a stray call made while disconnected nor one that slips in + /// during a reconnect attempt — successful or not — can silently append + /// to a session whose report may already be written. + Session? session; + + void logStep( + String toolName, + Map<String, dynamic> args, + CallToolResult result, + ) => + _logStep(session, toolName, args, result); + + void _logStep( + Session? activeSession, + String toolName, + Map<String, dynamic> args, + CallToolResult result, + ) { + if (activeSession == null) return; + + appendReportStep( + activeSession, + toolName, + args, + _describeOutcome(toolName, result, activeSession), + ); + } + + String _describeOutcome( + String toolName, + CallToolResult result, + Session session, + ) { + if (result.isError) { + return describeStepError(_firstNonEmptyText(result)); + } + + if (_shortConfirmationTools.contains(toolName)) { + var text = _firstNonEmptyText(result); + if (_firstLineOnlyTools.contains(toolName)) { + text = text.split('\n').first; + } + final confirmation = describeStepConfirmation(text, session); + if (confirmation != null) return confirmation; + } + + final imageCount = result.content.whereType<ImageContent>().length; + return imageCount > 0 ? '$imageCount screenshot(s) captured' : 'ok'; + } + + String _firstNonEmptyText(CallToolResult result) => result.content + .whereType<TextContent>() + .map((c) => c.text) + .firstWhere((t) => t.isNotEmpty, orElse: () => ''); +} + +/// Wraps [callback] so every call is recorded as one line in the active +/// session's steps.md via [stepLogger] before the result is returned to the +/// client — including a call [callback] itself throws out of (an +/// unexpected, un-caught error rather than the usual `isError: true` +/// result), so the report contract's `Tested:` line — rendered from +/// steps.md, not the agent's own claim — never silently undercounts an +/// attempt just because it failed in an unanticipated way. The session is +/// captured when the call starts, so reconnects cannot reattribute its result. +ToolFunction withStepLogging( + StepLogger stepLogger, + String name, + ToolFunction callback, +) { + return (args, extra) async { + final session = stepLogger.session; + try { + final result = await callback(args, extra); + stepLogger._logStep(session, name, args, result); + return result; + } catch (err) { + stepLogger._logStep( + session, + name, + args, + CallToolResult( + isError: true, + content: [TextContent(text: 'error: $err')], + ), + ); + rethrow; + } + }; +} diff --git a/packages/marionette_mcp/lib/mcp/tools/ancestor_keys_description.dart b/packages/marionette_mcp/lib/mcp/tools/ancestor_keys_description.dart new file mode 100644 index 00000000..93395e2b --- /dev/null +++ b/packages/marionette_mcp/lib/mcp/tools/ancestor_keys_description.dart @@ -0,0 +1,11 @@ +/// Shared description of the `ancestor_keys` field across every matcher-based +/// tool. +/// +/// Kept short on purpose: it is repeated in every schema, so its length is +/// paid on every connection. The full contract — strict nesting, the failure +/// rule, the `scroll_to` timing, what ignores the field — is stated once in +/// the server instructions. +const ancestorKeysDescription = + 'Optional wrapper keys (ValueKey<String>), outermost first; each is ' + 'looked up inside the previous one. Limits the search to that subtree. ' + 'Fails if a key has no element.'; diff --git a/packages/marionette_mcp/lib/mcp/tools/base_tool.dart b/packages/marionette_mcp/lib/mcp/tools/base_tool.dart index fd0c2cad..7e32399f 100644 --- a/packages/marionette_mcp/lib/mcp/tools/base_tool.dart +++ b/packages/marionette_mcp/lib/mcp/tools/base_tool.dart @@ -18,7 +18,7 @@ abstract class BaseTool { String get name; /// Human-readable description of what this tool does. - String get description; + String? get description; /// JSON schema defining the input arguments. /// Use [JsonSchema.object()] to create an object schema. @@ -48,8 +48,8 @@ extension ToolRegistration on McpServer { /// Register a [BaseTool] with this server. /// /// Use [callback] to wrap execution with shared concerns such as logging. - void registerBaseTool(BaseTool tool, {ToolFunction? callback}) { - registerTool( + RegisteredTool registerBaseTool(BaseTool tool, {ToolFunction? callback}) { + return registerTool( tool.name, description: tool.description, inputSchema: tool.inputSchema, diff --git a/packages/marionette_mcp/lib/mcp/tools/connection_tools.dart b/packages/marionette_mcp/lib/mcp/tools/connection_tools.dart new file mode 100644 index 00000000..146f4759 --- /dev/null +++ b/packages/marionette_mcp/lib/mcp/tools/connection_tools.dart @@ -0,0 +1,307 @@ +import 'package:marionette_mcp/mcp/step_logging.dart'; +import 'package:marionette_mcp/src/version.g.dart' as v; +import 'package:mcp_dart/mcp_dart.dart'; + +import '../server_context.dart'; +import 'base_tool.dart'; + +/// Connection tools log their own session transitions instead of using the +/// ordinary invocation wrapper, which captures the session before execution. +abstract class ConnectionTool extends BaseTool { + ConnectionTool(this.context); + + final VmServiceContext context; +} + +/// Connects an app and opens its optional report session. +class ConnectTool extends ConnectionTool { + ConnectTool(super.context); + + @override + String get name => 'connect'; + + @override + String get description => + 'Connects to a Flutter app via its VM service URI. This must be called before using any other tools. The VM service URI is typically in the format ws://127.0.0.1:PORT/ws and can be found in the Flutter app output when running in debug mode. If the app enabled session reports (MarionetteConfiguration.enableSessionReports), this also opens a fresh session directory under .marionette/sessions/ where the step log and screenshots for this run are kept.'; + + @override + ToolAnnotations get annotations => + const ToolAnnotations(title: 'Connect to App'); + + @override + ToolInputSchema get inputSchema => ToolInputSchema( + properties: { + 'uri': JsonSchema.string( + description: + 'VM service URI (e.g., ws://127.0.0.1:8181/ws). This is printed in the Flutter app console when running in debug mode.', + ), + 'session_title': JsonSchema.string( + description: + 'A short title for this run, e.g. "profile validation". ' + 'Keep it under ~60 characters — longer is truncated. ' + 'Ignored unless the app enabled session reports. ' + 'Slugified and timestamped into the session directory ' + 'name. Purely a human-readable label — every connect ' + 'opens its own fresh session directory, even if this ' + 'matches an earlier title. Falls back to "run-<timestamp>" ' + 'when omitted.', + ), + 'session_dir': JsonSchema.string( + description: + 'Base directory .marionette/sessions/ is created under. ' + 'Overrides the MARIONETTE_SESSION_DIR environment variable. ' + 'Only needed when the MCP client hasn\'t set that variable ' + 'and the process\'s working directory isn\'t the project root.', + ), + }, + required: ['uri'], + ); + + @override + ToolOutputSchema get outputSchema => ToolOutputSchema( + properties: { + 'uri': + JsonSchema.string(description: 'The connected VM service URI.'), + 'sessionReportsEnabled': JsonSchema.boolean(), + 'sessionPath': JsonSchema.string( + description: 'The new report directory, when enabled.'), + 'warning': + JsonSchema.string(description: 'Nonfatal report setup failure.'), + }, + required: ['uri', 'sessionReportsEnabled'], + ); + + @override + Future<CallToolResult> execute( + Map<String, dynamic> args, RequestHandlerExtra extra) async { + final epoch = context.beginTransition(); + final result = await _connect(args, epoch); + if (context.ownsConnection(epoch)) { + context.stepLogger.logStep(name, args, result); + } + return result; + } + + Future<CallToolResult> _connect( + Map<String, dynamic> args, + int epoch, + ) async { + final uri = args['uri'] as String; + context.logger.info('Connecting to app at $uri'); + // A new connect attempt immediately supersedes whatever session + // was active before, regardless of whether this attempt itself + // succeeds — otherwise a call that slips in while this attempt is + // still in flight (or after it fails) would append to a session + // that's no longer the one actually in use. + context.stepLogger.session = null; + context.removeDynamicTools(); + + try { + context.ensureCurrentConnection(epoch); + await context.connector.connect(uri); + context.ensureCurrentConnection(epoch); + + // Version compatibility check — unwind the connection on mismatch + // so the next call to connect can start fresh. + try { + final bindingVersion = await context.connector.getVersion(); + context.ensureCurrentConnection(epoch); + if (bindingVersion != v.version) { + await context.connector.disconnect(); + return CallToolResult( + isError: true, + content: [ + TextContent( + text: 'Version mismatch: marionette_mcp is ${v.version}, ' + 'but marionette_flutter binding is $bindingVersion. ' + 'Please ensure both packages are the same version.', + ), + ], + ); + } + } catch (err) { + context.logger.warning('Failed to check binding version', err); + if (context.ownsConnection(epoch)) { + await context.connector.disconnect(); + } + return CallToolResult( + isError: true, + content: [ + TextContent( + text: 'Failed to verify marionette_flutter binding version. ' + 'Please ensure marionette_flutter is up to date. ' + 'Error: $err', + ), + ], + ); + } + + // Promote each schema-bearing custom extension into a first-class + // MCP tool. Failures here are logged but don't fail the connect — + // the generic call_custom_extension fallback keeps working. + await context.registerDynamicTools(); + context.ensureCurrentConnection(epoch); + + // Session reports are opt-in app-side + // (MarionetteConfiguration.enableSessionReports): with them off, + // connect writes nothing to disk and steps go unlogged. Failing + // to even determine that counts as off too, same as a session + // directory that fails to open below — the app connection and + // every other tool already work fine either way, so neither is + // worth dropping an otherwise-working connection over. + var sessionReportsEnabled = false; + try { + sessionReportsEnabled = + await context.connector.getSessionReportsEnabled(); + } catch (err) { + context.logger.warning( + 'Failed to read binding configuration; continuing without ' + 'session reports', + err, + ); + } + context.ensureCurrentConnection(epoch); + if (!sessionReportsEnabled) { + context.stepLogger.session = null; + return CallToolResult( + structuredContent: {'uri': uri, 'sessionReportsEnabled': false}, + content: [ + TextContent(text: 'Successfully connected to app at $uri'), + ], + ); + } + + try { + final session = context.sessionManager.create( + title: args['session_title'] as String?, + baseDirOverride: args['session_dir'] as String?, + ); + context.stepLogger.session = session; + + return CallToolResult( + structuredContent: { + 'uri': uri, + 'sessionReportsEnabled': true, + 'sessionPath': session.directory.path, + }, + content: [ + TextContent( + text: 'Successfully connected to app at $uri\n' + 'Opened session: ${session.directory.path}', + ), + ], + ); + } catch (err) { + // Session setup is best-effort: the app connection and every + // other tool already work fine without it, so a directory + // creation failure (e.g. an unwritable session_dir) shouldn't + // drop an otherwise-working connection. Degrade the same way + // an app that opted out of session reports already does — + // connect succeeds, just without step logging. + context.logger.warning( + 'Failed to open session directory; continuing without ' + 'session reports', + err, + ); + context.stepLogger.session = null; + return CallToolResult( + structuredContent: { + 'uri': uri, + 'sessionReportsEnabled': false, + 'warning': err.toString(), + }, + content: [ + TextContent( + text: 'Successfully connected to app at $uri\n' + 'Could not open a session directory, so this run ' + 'will not be logged: $err', + ), + ], + ); + } + } catch (err) { + context.logger.severe('Failed to connect to app', err); + return CallToolResult( + isError: true, + content: [TextContent(text: 'Failed to connect to app: $err')], + ); + } + } +} + +/// Disconnects an app while retaining its report until the final step is logged. +class DisconnectTool extends ConnectionTool { + DisconnectTool(super.context); + + @override + String get name => 'disconnect'; + + @override + String get description => + 'Disconnects from the currently connected Flutter app. After disconnecting, you must call connect again to use any other tools.'; + + @override + ToolAnnotations get annotations => + const ToolAnnotations(title: 'Disconnect from App'); + + @override + ToolInputSchema get inputSchema => const ToolInputSchema(properties: {}); + + @override + ToolOutputSchema get outputSchema => ToolOutputSchema( + properties: { + 'disconnected': JsonSchema.boolean(), + 'sessionPath': JsonSchema.string( + description: 'The report directory of the closed session.'), + }, + required: ['disconnected'], + ); + + @override + Future<CallToolResult> execute( + Map<String, dynamic> args, RequestHandlerExtra extra) async { + final epoch = context.beginTransition(); + final disconnectLogger = StepLogger()..session = context.stepLogger.session; + context.logger.info('Disconnecting from app'); + + try { + // Retire dynamic tools before tearing down the connection so a + // mid-disconnect tools/list reflects only what's still callable. + context.removeDynamicTools(); + await context.connector.disconnect(); + + final session = disconnectLogger.session; + final nudge = session == null + ? '' + : '\nSession: ${session.directory.path}\nWrite report.md now.'; + final result = CallToolResult( + structuredContent: { + 'disconnected': true, + if (session != null) 'sessionPath': session.directory.path, + }, + content: [ + TextContent( + text: 'Successfully disconnected from app$nudge', + ), + ], + ); + // Attribute the step to the connection being closed. A newer + // connect may already own a different session while cleanup waits. + if (!context.isDisposed) { + disconnectLogger.logStep('disconnect', args, result); + } + if (context.ownsConnection(epoch)) context.stepLogger.session = null; + return result; + } catch (err) { + context.logger.severe('Error during disconnect', err); + final result = CallToolResult( + isError: true, + content: [TextContent(text: 'Error during disconnect: $err')], + ); + if (!context.isDisposed) { + disconnectLogger.logStep('disconnect', args, result); + } + return result; + } + } +} diff --git a/packages/marionette_mcp/lib/mcp/tools/device_tools.dart b/packages/marionette_mcp/lib/mcp/tools/device_tools.dart index c8c2def5..9453a8a6 100644 --- a/packages/marionette_mcp/lib/mcp/tools/device_tools.dart +++ b/packages/marionette_mcp/lib/mcp/tools/device_tools.dart @@ -1,9 +1,10 @@ import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/src/vm_service/tools/tool_runner.dart'; +import 'package:marionette_mcp/mcp/tools/tool_runner.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; import 'base_tool.dart'; +import 'tool_results.dart'; /// Creates the device tools with their shared dependencies. List<BaseTool> createDeviceTools({ @@ -30,6 +31,9 @@ class SetDeviceConfigTool extends BaseTool { ToolAnnotations get annotations => const ToolAnnotations(title: 'Set Device Config'); + @override + ToolOutputSchema get outputSchema => messageOutputSchema; + @override ToolInputSchema get inputSchema => ToolInputSchema( properties: { @@ -115,9 +119,7 @@ Future<CallToolResult> setDeviceConfig( reset: reset, ); final message = response['message'] as String?; - return CallToolResult( - content: [TextContent(text: message ?? 'Device config updated')], - ); + return messageResult(message ?? 'Device config updated'); }); } diff --git a/packages/marionette_mcp/lib/mcp/tools/dynamic_extension_tools.dart b/packages/marionette_mcp/lib/mcp/tools/dynamic_extension_tools.dart new file mode 100644 index 00000000..27100cb0 --- /dev/null +++ b/packages/marionette_mcp/lib/mcp/tools/dynamic_extension_tools.dart @@ -0,0 +1,322 @@ +import 'package:logging/logging.dart' as logging; +import 'package:marionette_mcp/mcp/step_logging.dart'; +import 'package:marionette_mcp/mcp/tools/tool_runner.dart'; +import 'package:marionette_mcp/src/vm_service/tools/arg_coercion.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:mcp_dart/mcp_dart.dart'; + +import 'base_tool.dart'; +import 'tool_results.dart'; + +/// Registers MCP tools at runtime for each custom extension exposed by the +/// connected Flutter app via `registerMarionetteExtension`. +/// +/// Registrations belong to the current app connection and are removed on +/// disconnect. Reconnecting registers the current schemas from scratch. +/// +/// Only extensions that ship an `inputSchema` get promoted — schema-less +/// extensions remain reachable through the generic `call_custom_extension` +/// tool, preserving backward compatibility with apps that haven't migrated. +/// +/// Extension names are sanitized to a client-safe MCP tool name (see +/// [sanitizeToolName]). The `namespace.method` convention is out of spec for +/// the underlying tool-calling APIs — Anthropic allows `[a-zA-Z0-9_]`, OpenAI +/// `[a-zA-Z0-9_-]`, both rejecting the `.` separator — and clients diverge on +/// how they cope (Claude Code rewrites the name transparently; VS Code Copilot +/// rejects anything outside `[a-z0-9_-]` and drops the tool). Sanitizing to +/// the strictest set keeps the tool usable everywhere. The underlying +/// extension is still invoked by its real name, so `appNavigation.goToPage` is +/// exposed as the tool `app_navigation_go_to_page` but dispatched to +/// `appNavigation.goToPage` on the VM service. +class DynamicExtensionTools { + DynamicExtensionTools({ + required McpServer server, + required VmServiceConnector connector, + required logging.Logger logger, + StepLogger? stepLogger, + }) : _server = server, + _connector = connector, + _logger = logger, + _stepLogger = stepLogger; + + final McpServer _server; + final VmServiceConnector _connector; + final logging.Logger _logger; + final StepLogger? _stepLogger; + + final Map<String, RegisteredTool> _tools = {}; + final Map<String, String> _extensionByTool = {}; + int _generation = 0; + + /// The tools registered by the most recent [registerAll] call that have not + /// yet been removed. Exposed for tests. + List<RegisteredTool> get registeredTools => List.unmodifiable(_tools.values); + + /// The real extension name backing the promoted MCP tool [toolName], or + /// null if no such tool is registered. The promoted tool name is + /// sanitized (see [sanitizeToolName]); this maps it back to the name the + /// VM service extension is actually invoked with. Exposed for introspection + /// and tests. + String? extensionNameForTool(String toolName) => _extensionByTool[toolName]; + + /// Reads the connected app's custom extensions and promotes each one + /// with an `inputSchema` to a first-class MCP tool. + /// + /// Tools whose name collides with a built-in (or another custom) tool + /// are skipped with a warning — the operator sees the conflict in logs + /// and renames their extension. Tools whose schema fails to parse are + /// likewise skipped with a warning. + Future<void> registerAll() async { + removeAll(); + final generation = _generation; + final Map<String, dynamic> response; + try { + response = await _connector.listExtensions(); + } catch (err) { + _logger.warning( + 'Failed to fetch custom extensions for dynamic registration; ' + 'skipping. Generic call_custom_extension still works.', + err, + ); + return; + } + + // A disconnect may finish while the app's listExtensions call is pending. + if (generation != _generation) return; + + final extensionsRaw = response['extensions']; + if (extensionsRaw is! List) { + _logger.warning( + 'marionette.listExtensions response missing "extensions" list; ' + 'got: $extensionsRaw', + ); + return; + } + + var registered = 0; + var skippedNoSchema = 0; + for (final ext in extensionsRaw) { + if (ext is! Map) { + _logger.warning('Skipping malformed extension entry: $ext'); + continue; + } + final name = ext['name']; + if (name is! String || name.isEmpty) { + _logger.warning('Skipping extension with missing/invalid name: $ext'); + continue; + } + final description = ext['description'] as String?; + final schemaJson = ext['inputSchema']; + if (schemaJson == null) { + skippedNoSchema++; + continue; + } + if (schemaJson is! Map<String, dynamic>) { + _logger.warning( + 'Skipping extension "$name": inputSchema is not a JSON object', + ); + continue; + } + + final toolName = sanitizeToolName(name); + final tool = _promote( + extensionName: name, + toolName: toolName, + description: description, + schemaJson: schemaJson, + ); + if (tool != null) { + registered++; + } + } + + _logger.info( + 'Promoted $registered custom extension(s) to MCP tools ' + '(${skippedNoSchema} schema-less extension(s) remain reachable via ' + 'call_custom_extension).', + ); + } + + /// Removes the tools owned by the current app connection. + /// + /// mcp_dart 2.4.3 releases their registry names, so reconnects need no pool + /// of disabled handles and a different app can reuse a sanitized name. + void removeAll() { + ++_generation; + for (final tool in _tools.values) { + tool.remove(); + } + _tools.clear(); + _extensionByTool.clear(); + } + + RegisteredTool? _promote({ + required String extensionName, + required String toolName, + required String? description, + required Map<String, dynamic> schemaJson, + }) { + final ToolInputSchema schema; + try { + final parsed = JsonSchema.fromJson(schemaJson); + if (parsed is! ToolInputSchema) { + _logger.warning( + 'Skipping extension "$extensionName": inputSchema parsed to ' + '${parsed.runtimeType} but MCP requires a JSON object schema.', + ); + return null; + } + schema = parsed; + } catch (err) { + _logger.warning( + 'Skipping extension "$extensionName": failed to parse inputSchema', + err, + ); + return null; + } + + // The extension is always invoked by its real name; only the MCP tool + // name is sanitized for clients that restrict the character set. + final effectiveDescription = + _describe(extensionName, toolName, description); + final definition = CustomExtensionTool( + name: toolName, + extensionName: extensionName, + description: effectiveDescription, + inputSchema: schema, + connector: _connector, + logger: _logger, + ); + final stepLogger = _stepLogger; + final callback = stepLogger == null + ? definition.execute + : withStepLogging(stepLogger, toolName, definition.execute); + + final existing = _tools[toolName]; + if (existing != null) { + if (_extensionByTool[toolName] != extensionName) { + // Two distinct extensions sanitize to the same tool name. Reviving in + // place would silently hijack the first one's tool, so skip instead. + _logger.warning( + 'Skipping extension "$extensionName": its sanitized MCP tool name ' + '"$toolName" collides with extension "${_extensionByTool[toolName]}". ' + 'Rename one of them on the Flutter side.', + ); + return null; + } + // Repeated entries for the same extension update its current schema. + existing.update( + description: definition.description, + inputSchema: schema, + outputSchema: definition.outputSchema, + callback: FunctionToolCallback(callback), + enabled: true, + ); + return existing; + } + + try { + final tool = _server.registerTool( + toolName, + description: definition.description, + inputSchema: schema, + outputSchema: definition.outputSchema, + callback: callback, + ); + _tools[toolName] = tool; + _extensionByTool[toolName] = extensionName; + return tool; + } on ArgumentError catch (err) { + // mcp_dart throws ArgumentError on duplicate name — most likely a + // collision with a built-in marionette tool (tap, scroll_to, etc.). + // A conflicting built-in remains untouched across reconnects. + _logger.warning( + 'Skipping extension "$extensionName" (tool name "$toolName"): MCP ' + 'tool name collision. Rename the extension on the Flutter side. ' + 'Details: ${err.message}', + ); + return null; + } + } + + /// Builds the tool description, appending the real extension name when it + /// differs from the sanitized tool name so agents can still reach it via + /// `call_custom_extension` and understand what the tool maps to. + String? _describe(String extensionName, String toolName, String? base) { + if (toolName == extensionName) return base; + final note = 'Custom extension: $extensionName'; + return base == null || base.isEmpty ? note : '$base\n\n$note'; + } +} + +/// Maps a custom-extension name to an MCP tool name accepted across clients. +/// The target set `[a-z0-9_-]` is the strictest in common use (VS Code Copilot +/// enforces it); it also stays within the `.`-rejecting character sets the +/// Anthropic and OpenAI tool-calling APIs require, which the `namespace.method` +/// convention would otherwise violate. +/// +/// A name already within the allowed set is returned verbatim — including +/// any leading, trailing, or repeated `_`/`-` the author chose — so valid +/// names are never silently rewritten. Otherwise the transform lower-cases +/// the name, inserts `_` at camelCase boundaries so `goToPage` reads as +/// `go_to_page`, replaces every other disallowed character (e.g. the `.` +/// namespace separator) with `_`, then collapses and trims runs of `_`. For +/// example `appNavigation.goToPage` becomes `app_navigation_go_to_page`. +String sanitizeToolName(String name) { + // Already client-safe: leave it exactly as the author wrote it. Collapsing + // or trimming here would change valid names (e.g. `__foo__` -> `foo`) and + // could create surprising collisions. + if (RegExp(r'^[a-z0-9_-]+$').hasMatch(name)) return name; + + final withBoundaries = name.replaceAllMapped( + RegExp('[a-z0-9][A-Z]'), + (m) => '${m[0]![0]}_${m[0]![1]}', + ); + final sanitized = withBoundaries + .toLowerCase() + .replaceAll(RegExp('[^a-z0-9_-]'), '_') + .replaceAll(RegExp('_+'), '_') + .replaceAll(RegExp(r'^_+|_+$'), ''); + return sanitized.isEmpty ? '_' : sanitized; +} + +/// A schema-bearing app extension promoted into the common typed tool catalog. +class CustomExtensionTool extends BaseTool { + CustomExtensionTool({ + required this.name, + required this.extensionName, + required String? description, + required this.inputSchema, + required this.connector, + required this.logger, + }) : _description = description; + + @override + final String name; + final String extensionName; + final String? _description; + final VmServiceConnector connector; + final logging.Logger logger; + + @override + String? get description => _description; + + @override + final ToolInputSchema inputSchema; + + @override + ToolOutputSchema get outputSchema => extensionResponseOutputSchema; + + @override + Future<CallToolResult> execute( + Map<String, dynamic> args, + RequestHandlerExtra extra, + ) => + runTool(logger, 'call extension "$extensionName"', () async { + final response = await connector.callCustomExtension( + extensionName, + coerceToStringMap(args), + ); + return extensionResponseResult(response); + }); +} diff --git a/packages/marionette_mcp/lib/mcp/tools/extension_tools.dart b/packages/marionette_mcp/lib/mcp/tools/extension_tools.dart index 03e94336..033d3c8a 100644 --- a/packages/marionette_mcp/lib/mcp/tools/extension_tools.dart +++ b/packages/marionette_mcp/lib/mcp/tools/extension_tools.dart @@ -1,12 +1,11 @@ -import 'dart:convert'; - import 'package:logging/logging.dart' as logging; +import 'package:marionette_mcp/mcp/tools/tool_runner.dart'; import 'package:marionette_mcp/src/vm_service/tools/arg_coercion.dart'; -import 'package:marionette_mcp/src/vm_service/tools/tool_runner.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; import 'base_tool.dart'; +import 'tool_results.dart'; /// Creates the extension tools with their shared dependencies. List<BaseTool> createExtensionTools({ @@ -44,6 +43,18 @@ class ListCustomExtensionsTool extends BaseTool { idempotentHint: true, ); + @override + ToolOutputSchema get outputSchema => ToolOutputSchema( + properties: { + 'extensions': JsonSchema.array( + items: + JsonSchema.object(properties: {}, additionalProperties: true), + ), + 'count': JsonSchema.integer(), + }, + required: ['extensions', 'count'], + ); + @override ToolInputSchema get inputSchema => const ToolInputSchema(properties: {}); @@ -60,6 +71,7 @@ class ListCustomExtensionsTool extends BaseTool { if (extensions.isEmpty) { return CallToolResult( + structuredContent: {'extensions': extensions, 'count': 0}, content: [ const TextContent(text: 'No custom extensions registered.'), ], @@ -79,7 +91,13 @@ class ListCustomExtensionsTool extends BaseTool { buffer.writeln(); } - return CallToolResult(content: [TextContent(text: buffer.toString())]); + return CallToolResult( + content: [TextContent(text: buffer.toString())], + structuredContent: { + 'extensions': extensions, + 'count': extensions.length, + }, + ); }); } } @@ -114,6 +132,9 @@ class CallCustomExtensionTool extends BaseTool { ToolAnnotations get annotations => const ToolAnnotations(title: 'Call Custom Extension'); + @override + ToolOutputSchema get outputSchema => extensionResponseOutputSchema; + @override ToolInputSchema get inputSchema => ToolInputSchema( properties: { @@ -164,6 +185,6 @@ Future<CallToolResult> callCustomExtension( extensionName, extensionArgs, ); - return CallToolResult(content: [TextContent(text: jsonEncode(response))]); + return extensionResponseResult(response); }); } diff --git a/packages/marionette_mcp/lib/mcp/tools/gesture_tools.dart b/packages/marionette_mcp/lib/mcp/tools/gesture_tools.dart index aea0ca66..60fc826b 100644 --- a/packages/marionette_mcp/lib/mcp/tools/gesture_tools.dart +++ b/packages/marionette_mcp/lib/mcp/tools/gesture_tools.dart @@ -1,11 +1,12 @@ import 'package:logging/logging.dart' as logging; +import 'package:marionette_mcp/mcp/tools/ancestor_keys_description.dart'; +import 'package:marionette_mcp/mcp/tools/tool_runner.dart'; import 'package:marionette_mcp/src/formatting.dart'; -import 'package:marionette_mcp/src/vm_service/tools/ancestor_keys_description.dart'; -import 'package:marionette_mcp/src/vm_service/tools/tool_runner.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; import 'base_tool.dart'; +import 'tool_results.dart'; /// Creates the gesture tools with their shared dependencies. List<BaseTool> createGestureTools({ @@ -41,6 +42,9 @@ class TapTool extends BaseTool { ToolAnnotations get annotations => const ToolAnnotations(title: 'Tap Element'); + @override + ToolOutputSchema get outputSchema => messageOutputSchema; + @override ToolInputSchema get inputSchema => ToolInputSchema( properties: { @@ -94,9 +98,7 @@ class TapTool extends BaseTool { return runTool(logger, 'tap', () async { final response = await connector.tap(matcher); final message = response['message'] as String?; - return CallToolResult( - content: [TextContent(text: message ?? 'Successfully tapped')], - ); + return messageResult(message ?? 'Successfully tapped'); }); } } @@ -119,6 +121,9 @@ class SecondaryTapTool extends BaseTool { ToolAnnotations get annotations => const ToolAnnotations(title: 'Secondary Tap Element'); + @override + ToolOutputSchema get outputSchema => messageOutputSchema; + @override ToolInputSchema get inputSchema => ToolInputSchema( properties: { @@ -170,11 +175,7 @@ class SecondaryTapTool extends BaseTool { return runTool(logger, 'secondary tap', () async { final response = await connector.secondaryTap(matcher); final message = response['message'] as String?; - return CallToolResult( - content: [ - TextContent(text: message ?? 'Successfully secondary tapped'), - ], - ); + return messageResult(message ?? 'Successfully secondary tapped'); }); } } @@ -197,6 +198,9 @@ class DoubleTapTool extends BaseTool { ToolAnnotations get annotations => const ToolAnnotations(title: 'Double Tap Element'); + @override + ToolOutputSchema get outputSchema => messageOutputSchema; + @override ToolInputSchema get inputSchema => ToolInputSchema( properties: { @@ -263,9 +267,7 @@ class DoubleTapTool extends BaseTool { return runTool(logger, 'double tap', () async { final response = await connector.doubleTap(matcher, delayMs: delay); final message = response['message'] as String?; - return CallToolResult( - content: [TextContent(text: message ?? 'Successfully double tapped')], - ); + return messageResult(message ?? 'Successfully double tapped'); }); } } @@ -288,6 +290,9 @@ class LongPressTool extends BaseTool { ToolAnnotations get annotations => const ToolAnnotations(title: 'Long Press Element'); + @override + ToolOutputSchema get outputSchema => messageOutputSchema; + @override ToolInputSchema get inputSchema => ToolInputSchema( properties: { @@ -344,9 +349,7 @@ class LongPressTool extends BaseTool { return runTool(logger, 'long press', () async { final response = await connector.longPress(matcher, durationMs: duration); final message = response['message'] as String?; - return CallToolResult( - content: [TextContent(text: message ?? 'Successfully long pressed')], - ); + return messageResult(message ?? 'Successfully long pressed'); }); } } @@ -372,6 +375,9 @@ class SwipeTool extends BaseTool { @override ToolAnnotations get annotations => const ToolAnnotations(title: 'Swipe'); + @override + ToolOutputSchema get outputSchema => messageOutputSchema; + @override ToolInputSchema get inputSchema => ToolInputSchema( properties: { @@ -465,9 +471,7 @@ class SwipeTool extends BaseTool { return runTool(logger, 'swipe', () async { final response = await connector.swipe(swipeArgs); final message = response['message'] as String?; - return CallToolResult( - content: [TextContent(text: message ?? 'Successfully swiped')], - ); + return messageResult(message ?? 'Successfully swiped'); }); } } @@ -494,6 +498,9 @@ class PinchZoomTool extends BaseTool { @override ToolAnnotations get annotations => const ToolAnnotations(title: 'Pinch Zoom'); + @override + ToolOutputSchema get outputSchema => messageOutputSchema; + @override ToolInputSchema get inputSchema => ToolInputSchema( properties: { @@ -581,9 +588,7 @@ class PinchZoomTool extends BaseTool { startDistance: startDistance, ); final message = response['message'] as String?; - return CallToolResult( - content: [TextContent(text: message ?? 'Successfully pinch zoomed')], - ); + return messageResult(message ?? 'Successfully pinch zoomed'); }); } } @@ -612,6 +617,15 @@ class PressBackButtonTool extends BaseTool { ToolAnnotations get annotations => const ToolAnnotations(title: 'Press Back Button'); + @override + ToolOutputSchema get outputSchema => ToolOutputSchema( + properties: { + 'message': JsonSchema.string(), + 'didPop': JsonSchema.boolean(), + }, + required: ['message'], + ); + @override ToolInputSchema get inputSchema => const ToolInputSchema(properties: {}); @@ -626,15 +640,12 @@ class PressBackButtonTool extends BaseTool { final message = response['message'] as String?; final didPop = response['didPop']; - return CallToolResult( - content: [ - TextContent( - text: message ?? - (didPop == true - ? 'Back button pressed, route was popped' - : 'Back button pressed, no route to pop'), - ), - ], + return messageResult( + message ?? + (didPop == true + ? 'Back button pressed, route was popped' + : 'Back button pressed, no route to pop'), + fields: {if (didPop is bool) 'didPop': didPop}, ); }); } @@ -658,6 +669,9 @@ class ScrollToTool extends BaseTool { ToolAnnotations get annotations => const ToolAnnotations(title: 'Scroll to Element'); + @override + ToolOutputSchema get outputSchema => messageOutputSchema; + @override ToolInputSchema get inputSchema => ToolInputSchema( properties: { @@ -692,11 +706,7 @@ class ScrollToTool extends BaseTool { return runTool(logger, 'scroll to element', () async { final response = await connector.scrollToElement(matcher); final message = response['message'] as String?; - return CallToolResult( - content: [ - TextContent(text: message ?? 'Successfully scrolled to element'), - ], - ); + return messageResult(message ?? 'Successfully scrolled to element'); }); } } diff --git a/packages/marionette_mcp/lib/mcp/tools/inspection_tools.dart b/packages/marionette_mcp/lib/mcp/tools/inspection_tools.dart index 035534b2..17a5a87b 100644 --- a/packages/marionette_mcp/lib/mcp/tools/inspection_tools.dart +++ b/packages/marionette_mcp/lib/mcp/tools/inspection_tools.dart @@ -4,8 +4,8 @@ import 'dart:io'; import 'package:logging/logging.dart' as logging; import 'package:marionette_mcp/src/formatting.dart'; import 'package:marionette_mcp/src/session/session.dart'; -import 'package:marionette_mcp/src/session/step_logger.dart'; -import 'package:marionette_mcp/src/vm_service/tools/tool_runner.dart'; +import 'package:marionette_mcp/mcp/step_logging.dart'; +import 'package:marionette_mcp/mcp/tools/tool_runner.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; import 'package:path/path.dart' as p; @@ -45,6 +45,20 @@ class GetInteractiveElementsTool extends BaseTool { idempotentHint: true, ); + @override + ToolOutputSchema get outputSchema => ToolOutputSchema( + properties: { + 'elements': JsonSchema.array( + items: JsonSchema.object( + properties: {}, + additionalProperties: true, + ), + ), + 'count': JsonSchema.integer(), + }, + required: ['elements', 'count'], + ); + @override ToolInputSchema get inputSchema => ToolInputSchema( properties: { @@ -87,7 +101,10 @@ class GetInteractiveElementsTool extends BaseTool { buffer.writeln(formatElement(element as Map<String, dynamic>)); } - return CallToolResult(content: [TextContent(text: buffer.toString())]); + return CallToolResult( + content: [TextContent(text: buffer.toString())], + structuredContent: {'elements': elements, 'count': elements.length}, + ); }); } } @@ -110,6 +127,15 @@ class GetLogsTool extends BaseTool { ToolAnnotations get annotations => const ToolAnnotations(title: 'Get Application Logs', readOnlyHint: true); + @override + ToolOutputSchema get outputSchema => ToolOutputSchema( + properties: { + 'logs': JsonSchema.array(items: JsonSchema.string()), + 'count': JsonSchema.integer(), + }, + required: ['logs', 'count'], + ); + @override ToolInputSchema get inputSchema => const ToolInputSchema(properties: {}); @@ -128,6 +154,7 @@ class GetLogsTool extends BaseTool { if (count == 0) { return CallToolResult( content: [const TextContent(text: 'No logs collected')], + structuredContent: {'logs': logs, 'count': count}, ); } @@ -138,7 +165,10 @@ class GetLogsTool extends BaseTool { buffer.writeln(log); } - return CallToolResult(content: [TextContent(text: buffer.toString())]); + return CallToolResult( + content: [TextContent(text: buffer.toString())], + structuredContent: {'logs': logs, 'count': count}, + ); } on VmServiceExtensionException catch (err) { // Surface the VM service's own error message verbatim — it carries // setup instructions for enabling log collection. @@ -176,6 +206,16 @@ class TakeScreenshotsTool extends BaseTool { ToolAnnotations get annotations => const ToolAnnotations(title: 'Take Screenshots', readOnlyHint: true); + @override + ToolOutputSchema get outputSchema => ToolOutputSchema( + properties: { + 'count': JsonSchema.integer(), + 'inline': JsonSchema.boolean(), + 'paths': JsonSchema.array(items: JsonSchema.string()), + }, + required: ['count', 'inline'], + ); + @override ToolInputSchema get inputSchema => ToolInputSchema( properties: { @@ -220,11 +260,13 @@ Future<CallToolResult> takeScreenshots( if (screenshots.isEmpty) { return CallToolResult( content: [const TextContent(text: 'No screenshots captured')], + structuredContent: {'count': 0, 'inline': inline}, ); } if (inline) { return CallToolResult( + structuredContent: {'count': screenshots.length, 'inline': true}, content: screenshots .map( (screenshot) => @@ -250,6 +292,7 @@ Future<CallToolResult> takeScreenshots( final paths = _saveScreenshots(session.screenshotsDir, screenshots); return CallToolResult( + structuredContent: {'count': paths.length, 'inline': false, 'paths': paths}, content: [ TextContent( text: 'Saved ${paths.length} screenshot(s):\n${paths.join('\n')}', diff --git a/packages/marionette_mcp/lib/mcp/tools/keyboard_tools.dart b/packages/marionette_mcp/lib/mcp/tools/keyboard_tools.dart index d04e6d71..eff1faaa 100644 --- a/packages/marionette_mcp/lib/mcp/tools/keyboard_tools.dart +++ b/packages/marionette_mcp/lib/mcp/tools/keyboard_tools.dart @@ -1,9 +1,10 @@ import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/src/vm_service/tools/tool_runner.dart'; +import 'package:marionette_mcp/mcp/tools/tool_runner.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; import 'base_tool.dart'; +import 'tool_results.dart'; /// Creates the keyboard tools with their shared dependencies. List<BaseTool> createKeyboardTools({ @@ -37,6 +38,9 @@ class PressKeyTool extends BaseTool { @override ToolAnnotations get annotations => const ToolAnnotations(title: 'Press Key'); + @override + ToolOutputSchema get outputSchema => messageOutputSchema; + @override ToolInputSchema get inputSchema => ToolInputSchema( properties: { @@ -77,9 +81,7 @@ class PressKeyTool extends BaseTool { return runTool(logger, 'press key', () async { final response = await connector.pressKey(key, modifiers: modifiers); final message = response['message'] as String?; - return CallToolResult( - content: [TextContent(text: message ?? 'Successfully pressed key')], - ); + return messageResult(message ?? 'Successfully pressed key'); }); } } diff --git a/packages/marionette_mcp/lib/mcp/tools/system_tools.dart b/packages/marionette_mcp/lib/mcp/tools/system_tools.dart index 6d98dfe4..0501f8c8 100644 --- a/packages/marionette_mcp/lib/mcp/tools/system_tools.dart +++ b/packages/marionette_mcp/lib/mcp/tools/system_tools.dart @@ -3,6 +3,7 @@ import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; import 'base_tool.dart'; +import 'tool_results.dart'; /// Creates the system tools with their shared dependencies. List<BaseTool> createSystemTools({ @@ -28,6 +29,9 @@ class HotReloadTool extends BaseTool { @override ToolAnnotations get annotations => const ToolAnnotations(title: 'Hot Reload'); + @override + ToolOutputSchema get outputSchema => messageOutputSchema; + @override ToolInputSchema get inputSchema => const ToolInputSchema(properties: {}); @@ -41,11 +45,7 @@ class HotReloadTool extends BaseTool { try { final reloaded = await connector.hotReload(); if (reloaded) { - return CallToolResult( - content: [ - const TextContent(text: 'Hot reload completed successfully'), - ], - ); + return messageResult('Hot reload completed successfully'); } return CallToolResult( isError: true, @@ -83,6 +83,9 @@ class HotRestartTool extends BaseTool { ToolAnnotations get annotations => const ToolAnnotations(title: 'Hot Restart'); + @override + ToolOutputSchema get outputSchema => messageOutputSchema; + @override ToolInputSchema get inputSchema => const ToolInputSchema(properties: {}); @@ -96,11 +99,7 @@ class HotRestartTool extends BaseTool { try { final restarted = await connector.hotRestart(); if (restarted) { - return CallToolResult( - content: [ - const TextContent(text: 'Hot restart completed successfully'), - ], - ); + return messageResult('Hot restart completed successfully'); } return CallToolResult( isError: true, diff --git a/packages/marionette_mcp/lib/mcp/tools/text_tools.dart b/packages/marionette_mcp/lib/mcp/tools/text_tools.dart index 935018e4..c8f1ba52 100644 --- a/packages/marionette_mcp/lib/mcp/tools/text_tools.dart +++ b/packages/marionette_mcp/lib/mcp/tools/text_tools.dart @@ -1,11 +1,12 @@ import 'package:logging/logging.dart' as logging; +import 'package:marionette_mcp/mcp/tools/ancestor_keys_description.dart'; +import 'package:marionette_mcp/mcp/tools/tool_runner.dart'; import 'package:marionette_mcp/src/formatting.dart'; -import 'package:marionette_mcp/src/vm_service/tools/ancestor_keys_description.dart'; -import 'package:marionette_mcp/src/vm_service/tools/tool_runner.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; import 'base_tool.dart'; +import 'tool_results.dart'; /// Creates the text tools with their shared dependencies. List<BaseTool> createTextTools({ @@ -31,6 +32,9 @@ class EnterTextTool extends BaseTool { @override ToolAnnotations get annotations => const ToolAnnotations(title: 'Enter Text'); + @override + ToolOutputSchema get outputSchema => messageOutputSchema; + @override ToolInputSchema get inputSchema => ToolInputSchema( properties: { @@ -92,9 +96,7 @@ class EnterTextTool extends BaseTool { return runTool(logger, 'enter text', () async { final response = await connector.enterText(matcher, input); final message = response['message'] as String?; - return CallToolResult( - content: [TextContent(text: message ?? 'Successfully entered text')], - ); + return messageResult(message ?? 'Successfully entered text'); }); } } diff --git a/packages/marionette_mcp/lib/mcp/tools/tool_results.dart b/packages/marionette_mcp/lib/mcp/tools/tool_results.dart new file mode 100644 index 00000000..d185ac82 --- /dev/null +++ b/packages/marionette_mcp/lib/mcp/tools/tool_results.dart @@ -0,0 +1,34 @@ +import 'dart:convert'; + +import 'package:mcp_dart/mcp_dart.dart'; + +/// Output contract for tools that return a short action confirmation. +final messageOutputSchema = ToolOutputSchema( + properties: {'message': JsonSchema.string()}, + required: ['message'], +); + +/// App extensions may return arbitrary fields inside the response envelope. +final extensionResponseOutputSchema = ToolOutputSchema( + properties: { + 'response': JsonSchema.object(properties: {}, additionalProperties: true), + }, + required: ['response'], +); + +/// Keeps the existing confirmation text alongside its structured message. +CallToolResult messageResult( + String message, { + Map<String, dynamic> fields = const {}, +}) => + CallToolResult( + content: [TextContent(text: message)], + structuredContent: {'message': message, ...fields}, + ); + +/// Keeps an extension's JSON text alongside the original response object. +CallToolResult extensionResponseResult(Map<String, dynamic> response) => + CallToolResult( + content: [TextContent(text: jsonEncode(response))], + structuredContent: {'response': response}, + ); diff --git a/packages/marionette_mcp/lib/mcp/tools/tool_runner.dart b/packages/marionette_mcp/lib/mcp/tools/tool_runner.dart new file mode 100644 index 00000000..c2abb93d --- /dev/null +++ b/packages/marionette_mcp/lib/mcp/tools/tool_runner.dart @@ -0,0 +1,25 @@ +import 'package:logging/logging.dart' as logging; +import 'package:mcp_dart/mcp_dart.dart'; + +/// Wraps a tool callback body in the standard error-handling boilerplate: +/// run the body, and on any throw log a warning and return a +/// [CallToolResult] with `isError: true` and the exception's message. +/// +/// Used by tools that want the default failure semantics. Tools with +/// non-trivial mid-flow control (e.g. `connect` rolling back on a version +/// mismatch) should not use this helper. +Future<CallToolResult> runTool( + logging.Logger logger, + String operation, + Future<CallToolResult> Function() body, +) async { + try { + return await body(); + } catch (err) { + logger.warning('Failed to $operation', err); + return CallToolResult( + isError: true, + content: [TextContent(text: err.toString())], + ); + } +} diff --git a/packages/marionette_mcp/lib/mcp/tools/tools.dart b/packages/marionette_mcp/lib/mcp/tools/tools.dart index 4b2d2954..f0c49acc 100644 --- a/packages/marionette_mcp/lib/mcp/tools/tools.dart +++ b/packages/marionette_mcp/lib/mcp/tools/tools.dart @@ -1,11 +1,9 @@ /// Static MCP tools for inspecting and interacting with a Flutter app. library; -import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/src/session/step_logger.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; - +import '../server_context.dart'; import 'base_tool.dart'; +import 'connection_tools.dart'; import 'device_tools.dart'; import 'extension_tools.dart'; import 'gesture_tools.dart'; @@ -15,6 +13,7 @@ import 'system_tools.dart'; import 'text_tools.dart'; export 'base_tool.dart'; +export 'connection_tools.dart'; export 'device_tools.dart'; export 'extension_tools.dart'; export 'gesture_tools.dart'; @@ -23,22 +22,24 @@ export 'keyboard_tools.dart'; export 'system_tools.dart'; export 'text_tools.dart'; -/// Creates all static tools with their shared dependencies. -List<BaseTool> createAllTools({ - required VmServiceConnector connector, - required logging.Logger logger, - required StepLogger stepLogger, -}) => - [ +/// Creates the complete built-in catalog with shared connection/session state. +List<BaseTool> createAllTools({required VmServiceContext context}) => [ + ConnectTool(context), + DisconnectTool(context), ...createInspectionTools( - connector: connector, - logger: logger, - stepLogger: stepLogger, + connector: context.connector, + logger: context.logger, + stepLogger: context.stepLogger, ), - ...createGestureTools(connector: connector, logger: logger), - ...createTextTools(connector: connector, logger: logger), - ...createKeyboardTools(connector: connector, logger: logger), - ...createDeviceTools(connector: connector, logger: logger), - ...createExtensionTools(connector: connector, logger: logger), - ...createSystemTools(connector: connector, logger: logger), + ...createGestureTools( + connector: context.connector, logger: context.logger), + ...createTextTools(connector: context.connector, logger: context.logger), + ...createKeyboardTools( + connector: context.connector, logger: context.logger), + ...createDeviceTools( + connector: context.connector, logger: context.logger), + ...createExtensionTools( + connector: context.connector, logger: context.logger), + ...createSystemTools( + connector: context.connector, logger: context.logger), ]; diff --git a/packages/marionette_mcp/lib/runtime.dart b/packages/marionette_mcp/lib/runtime.dart index 5ff84747..0155268d 100644 --- a/packages/marionette_mcp/lib/runtime.dart +++ b/packages/marionette_mcp/lib/runtime.dart @@ -6,7 +6,7 @@ library; export 'src/formatting.dart'; export 'src/session/session.dart'; export 'src/session/session_manager.dart'; -export 'src/session/step_logger.dart'; +export 'src/session/report_steps.dart'; export 'src/video/ffmpeg_process.dart'; export 'src/video/frame_protocol.dart'; export 'src/video/recording_session.dart'; diff --git a/packages/marionette_mcp/lib/src/mcp_server_runner.dart b/packages/marionette_mcp/lib/src/mcp_server_runner.dart index c3a6529a..63dcf7aa 100644 --- a/packages/marionette_mcp/lib/src/mcp_server_runner.dart +++ b/packages/marionette_mcp/lib/src/mcp_server_runner.dart @@ -1,195 +1,2 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/mcp/mcp.dart'; -import 'package:mcp_dart/mcp_dart.dart'; - -/// Runs the Marionette MCP server with the given configuration. -/// -/// Sets up logging, creates the MCP server with tools, and runs it on either -/// stdio or SSE transport depending on whether [ssePort] is provided. -Future<int> runMcpServer({ - required String logLevel, - String? logFile, - int? ssePort, -}) async { - final logSubscription = setupLogging(logLevel, logFile); - - final vmService = VmServiceContext(); - - final server = createMcpServer(context: vmService); - try { - return ssePort == null - ? await _runStdioServer(server) - : await _runSseServer(server, ssePort); - } finally { - // The app connection outlives individual tool calls, so the server owns it. - // Closing only the MCP transport leaves its VM-service socket alive. - try { - await vmService.dispose(); - } finally { - try { - await server.close(); - } finally { - await logSubscription.cancel(); - } - } - } -} - -StreamSubscription<logging.LogRecord> setupLogging( - String logLevelName, - String? logFile, -) { - final logLevel = logging.Level.LEVELS.firstWhere( - (e) => e.name == logLevelName, - orElse: () => logging.Level.INFO, - ); - - logging.Logger.root.level = logLevel; - - if (logFile != null) { - final file = File(logFile)..createSync(recursive: true); - return logging.Logger.root.onRecord.listen((record) { - file.writeAsStringSync( - '[${record.level.name}][${record.loggerName}][${_formatTime(record.time)}] ${record.message}\n', - mode: FileMode.append, - ); - }); - } else { - return logging.Logger.root.onRecord.listen((record) { - stderr.writeln( - '[${record.level.name}][${record.loggerName}][${_formatTime(record.time)}] ${record.message}', - ); - }); - } -} - -String _formatTime(DateTime time) { - return '${time.hour.toString().padLeft(2, '0')}:' - '${time.minute.toString().padLeft(2, '0')}:' - '${time.second.toString().padLeft(2, '0')}'; -} - -Future<int> _runStdioServer(McpServer server) async { - final logger = logging.Logger('main'); - - final transport = StdioServerTransport(); - final exitSignal = ExitSignal(); - final stdinClosed = Completer<void>(); - - // Install the close handler before connecting. An immediate stdin EOF - // (e.g. `</dev/null`) can close the transport during or right after - // connect(); registering afterwards risks missing that close and hanging - // until a signal. Fires when the transport closes — including stdin EOF, - // i.e. the MCP host went away without sending a signal. Per the stdio - // lifecycle the server should shut down then, not wait for SIGINT/SIGTERM. - server.server.onclose = () { - if (!stdinClosed.isCompleted) stdinClosed.complete(); - }; - - try { - logger.fine('Running MCP server on stdio'); - await server.connect(transport); - logger.info('Server started'); - } catch (e, st) { - logger.severe('Error when starting the Stdio transport', e, st); - exitSignal.dispose(); - return 1; - } - - // Stop on whichever happens first: an OS signal or the transport closing. - // Only the first cause is logged — closing the server below re-triggers - // `onclose`, which would otherwise log a misleading second reason. - var stopping = false; - void logStop(String reason) { - if (stopping) return; - stopping = true; - logger.info('$reason, stopping'); - } - - await Future.any([ - exitSignal.wait.then((signal) => logStop('Received ${signal.name}')), - stdinClosed.future.then((_) => logStop('stdin closed')), - ]); - - // Release the signal subscriptions so the event loop can drain and the - // process actually exits on the stdin-EOF path. - exitSignal.dispose(); - await transport.close(); - logger.info('Stopped'); - return 0; -} - -Future<int> _runSseServer(McpServer server, int ssePort) async { - final logger = logging.Logger('main'); - final manager = SseServerManager(server); - final exitSignal = ExitSignal(); - HttpServer? httpServer; - try { - final listener = await HttpServer.bind( - InternetAddress.loopbackIPv4, - ssePort, - ); - httpServer = listener; - logger.fine('Running MCP server on SSE port $ssePort'); - unawaited(exitSignal.wait.then((signal) async { - logger.info('Received ${signal.name}, stopping'); - await listener.close(force: true); - })); - await for (final request in listener) { - unawaited(manager.handleRequest(request)); - } - logger.info('Stopped'); - return 0; - } catch (e, st) { - logger.severe('Error when waiting for MCP client connection', e, st); - return 1; - } finally { - exitSignal.dispose(); - await httpServer?.close(force: true); - await Future.wait([ - for (final transport in manager.activeSseTransports.values.toList()) - transport.close(), - ]); - } -} - -/// Waits for SIGINT or SIGTERM to signal graceful shutdown. -class ExitSignal { - ExitSignal() { - if (!Platform.isWindows) { - _sigtermSubscription = ProcessSignal.sigterm.watch().listen( - _handleSignal, - ); - } - _sigintSubscription = ProcessSignal.sigint.watch().listen(_handleSignal); - } - - final _completer = Completer<ProcessSignal>(); - StreamSubscription<ProcessSignal>? _sigtermSubscription; - late final StreamSubscription<ProcessSignal> _sigintSubscription; - bool _disposed = false; - - Future<ProcessSignal> get wait => _completer.future; - - /// Cancels the signal subscriptions so they no longer keep the event loop - /// alive. Safe to call multiple times — in normal operation it runs once - /// from [_handleSignal] and again from the shutdown path. - void dispose() => _cleanup(); - - void _handleSignal(ProcessSignal signal) { - if (!_completer.isCompleted) { - _completer.complete(signal); - _cleanup(); - } - } - - void _cleanup() { - if (_disposed) return; - _disposed = true; - _sigtermSubscription?.cancel(); - _sigintSubscription.cancel(); - } -} +// Compatibility export for the former implementation path. +export 'package:marionette_mcp/mcp/server_runner.dart'; diff --git a/packages/marionette_mcp/lib/src/session/report_steps.dart b/packages/marionette_mcp/lib/src/session/report_steps.dart new file mode 100644 index 00000000..de13fa88 --- /dev/null +++ b/packages/marionette_mcp/lib/src/session/report_steps.dart @@ -0,0 +1,241 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:logging/logging.dart' as logging; +import 'package:marionette_mcp/src/session/session.dart'; +import 'package:stack_trace/stack_trace.dart'; + +/// Field names that hint at a sensitive value (a password, PIN, token, +/// etc.) — matched against `enter_text`'s selector (key/identifier) to +/// decide how hard to redact the entered value in steps.md. +final _sensitiveSelectorPattern = RegExp( + r'pass|pin|token|secret|cvv', + caseSensitive: false, +); + +const _maxOutcomeLength = 200; + +/// A longer budget for an error outcome that carries a summarized stack +/// trace (see [describeStepError]) — a bare message fits the normal budget +/// comfortably, but a message plus a few compact frames often doesn't. +const _maxErrorOutcomeLength = 500; + +/// Stack frames kept when an error carries a structured exception/stack +/// (see [describeStepError]) — enough to usually show where the failure +/// actually happened, without pasting the whole trace. +const _maxStackFrames = 4; + +/// Arg names shown in a step's selector summary. Deliberately excludes +/// free-text payload fields (e.g. `enter_text`'s `input`) — steps.md +/// records what was acted on, not the payload. +const _selectorArgKeys = ['key', 'identifier', 'text', 'type', 'uri', 'x', 'y']; + +/// Appends one formatted step to [session], treating filesystem failures as +/// best-effort bookkeeping so they cannot turn a completed action into an error. +void appendReportStep( + Session session, + String toolName, + Map<String, dynamic> args, + String outcome, +) { + final selector = describeStepSelector(toolName, args); + final line = formatStepLine(toolName, selector, outcome); + try { + session.stepsFile.writeAsStringSync('$line\n', mode: FileMode.append); + } on FileSystemException catch (err) { + logging.Logger('StepLogger').warning('Failed to write steps.md', err); + } +} + +/// Sanitizes a short confirmation message for a session's step log. +/// +/// Callers must only use this for synthesized status messages, never arbitrary +/// application data. Paths inside the session become relative, embedded URIs +/// lose credentials, and the result fits on one bounded physical line. Returns +/// null when removing the session path leaves no confirmation text. +String? describeStepConfirmation(String text, Session session) { + final relativeText = _relativizeSessionPaths(text, session); + return relativeText.isEmpty + ? null + : _truncateText(_oneLine(_redactUrisIn(relativeText))); +} + +/// Builds an error outcome (prefixed `error: `) from raw error text — either +/// an MCP tool result's own message or a CLI command's caught exception's +/// `toString()`. Both transports can surface the same +/// `VmServiceExtensionException`-shaped text +/// (`"Extension X failed\nError: {"exception": "...", "stack": "...", "method": "..."}"` +/// — see `VmServiceExtensionException.toString()` and +/// `registerInternalMarionetteExtension`'s catch-all in +/// `marionette_flutter`), so both get the same treatment: the exception +/// message plus a few compact stack frames when that shape is present, +/// instead of blindly truncating raw JSON apart mid-structure and losing the +/// message entirely on a long selector. A deliberate, expected error (e.g. +/// "no log collector configured") isn't JSON-wrapped and falls back to the +/// plain, redacted, truncated message. +String describeStepError(String text) { + if (text.isEmpty) return 'error: failed'; + + final exceptionData = _tryParseExceptionJson(text); + if (exceptionData != null) { + final exception = + _oneLine(_redactUrisIn(exceptionData['exception'] as String)); + final frames = _summarizeStackTrace(exceptionData['stack'] as String); + final body = frames.isEmpty ? exception : '$exception [$frames]'; + return 'error: ${_truncateText(body, _maxErrorOutcomeLength)}'; + } + + return 'error: ${_truncateText(_oneLine(_redactUrisIn(text)))}'; +} + +/// Looks for the `Error: {...}` suffix `VmServiceExtensionException` appends +/// (see [describeStepError]) and parses it as JSON. Returns null for any +/// text that isn't in exactly this shape — a deliberate error message, a +/// locally-thrown exception's plain `toString()`, or anything else — rather +/// than guessing. +Map<String, dynamic>? _tryParseExceptionJson(String text) { + const marker = 'Error: '; + final index = text.indexOf('$marker{'); + if (index == -1) return null; + try { + final decoded = jsonDecode(text.substring(index + marker.length)); + if (decoded is! Map<String, dynamic>) return null; + return decoded['exception'] is String && decoded['stack'] is String + ? decoded + : null; + } on FormatException { + return null; + } +} + +/// The first [_maxStackFrames] frames of [stackTrace] after [Trace.terse] +/// folds together consecutive Dart-core/SDK frames — so `dart:async`/ +/// `dart:developer-patch` zone plumbing collapses to at most one frame (and +/// is dropped entirely if it's trailing, i.e. outermost) instead of eating +/// the whole budget on frames that don't point at application code. Falls +/// back to the raw text, one-lined, if it doesn't parse as a Dart stack +/// trace at all (defensive — the text always comes from a real +/// `StackTrace.toString()` on the app side, but a parse failure here +/// shouldn't break error logging). +String _summarizeStackTrace(String stackTrace) { + final Trace trace; + try { + trace = Trace.parse(stackTrace).terse; + } on FormatException { + return _truncateText(_oneLine(stackTrace), _maxErrorOutcomeLength); + } + return trace.frames.take(_maxStackFrames).map(_formatFrame).join(' › '); +} + +/// `member (path:line)` — `path` package-relative (falling back to +/// [Frame.library]'s pretty-printed form for anything else, e.g. a lone, +/// unfolded `dart:` frame), column dropped. More compact than +/// [Frame.location] (which keeps the full `package:` scheme and the +/// column), which matters against [_maxErrorOutcomeLength]. +String _formatFrame(Frame frame) { + final path = frame.uri.scheme == 'package' ? frame.uri.path : frame.library; + return frame.line == null + ? '${frame.member} ($path)' + : '${frame.member} ($path:${frame.line})'; +} + +/// Rewrites absolute paths inside [session]'s directory as paths relative +/// to it — e.g. `take_screenshots`' saved paths become `screenshots/01.png`, +/// which is also how a report living next to steps.md cites them. +String _relativizeSessionPaths(String text, Session session) => + text.replaceAll('${session.directory.path}${Platform.pathSeparator}', ''); + +String _truncateText(String text, [int maxLength = _maxOutcomeLength]) => + text.length <= maxLength ? text : '${text.substring(0, maxLength)}…'; + +/// Collapses whitespace (including newlines) into single spaces and trims +/// the ends — keeps a value that might itself span multiple lines (a +/// multiline `text` selector, a multi-line tool message) from breaking +/// steps.md's one-physical-line-per-call format. +String _oneLine(String text) => text.replaceAll(RegExp(r'\s+'), ' ').trim(); + +/// Matches a `ws(s)://`/`http(s)://` URI so one can be found and redacted +/// wherever it appears in free-form text — not just in the `uri` selector +/// field. `connect`'s own success/error text embeds the full uri a second +/// time (`"Successfully connected to app at $uri..."`, and a thrown +/// connection error's message commonly does too), so redacting only the +/// selector still left that same credential reaching steps.md through the +/// outcome. +final _uriPattern = RegExp(r'(?:wss?|https?)://\S+'); + +/// A VM service (or DevTools) URI stripped to `scheme://host:port`. +/// Flutter's VM service URIs commonly embed an auth token as a path segment +/// (`ws://127.0.0.1:PORT/TOKEN=/ws`) — logging one verbatim would persist a +/// live credential to steps.md. +String _redactUri(String raw) { + final parsed = Uri.tryParse(raw); + if (parsed == null || parsed.host.isEmpty) return '[uri]'; + return '${parsed.scheme}://${parsed.host}:${parsed.port}'; +} + +/// Redacts (see [_redactUri]) every URI found anywhere within [text]. +String _redactUrisIn(String text) => + text.replaceAllMapped(_uriPattern, (m) => _redactUri(m[0]!)); + +/// Builds the selector portion of a step line from [args] — the small set +/// of curated fields (key, identifier, text, etc.) that describe what a call +/// acted on, deliberately excluding free-text payload fields. Shared between +/// MCP tool calls and CLI command invocations, which +/// use the same field names. +String describeStepSelector(String toolName, Map<String, dynamic> args) { + final parts = <String>[ + for (final key in _selectorArgKeys) + if (args[key] != null) + key == 'uri' + ? 'uri=${_redactUri(args[key].toString())}' + : '$key=${_oneLine(args[key].toString())}', + ]; + // The MCP field is `ancestor_keys`, the CLI option `--ancestor-keys`. The + // keys are joined with `>` rather than written as a Dart list, whose + // `[a, b]` form would put a space inside a single part of the line. + final ancestorKeys = args['ancestor_keys'] ?? args['ancestor-keys']; + if (ancestorKeys is List && ancestorKeys.isNotEmpty) { + parts.add('ancestor_keys=${_oneLine(ancestorKeys.join('>'))}'); + } + // Only present when the caller overrode the app's default, so the log shows + // whether a listing was the full payload or the compact one. + if (args['compaction'] case final String compaction) { + parts.add('compaction=${_oneLine(compaction)}'); + } + if (args['focused_element'] == true) parts.add('focused_element=true'); + if (args['coordinates'] case final Map<String, dynamic> coordinates) { + parts.add('x=${coordinates['x']} y=${coordinates['y']}'); + } + // Matches both the MCP tool name (enter_text) and the CLI command name + // (enter-text). + if (toolName.replaceAll('-', '_') == 'enter_text') { + parts.add(_describeEnterTextInput(args)); + } + return parts.join(' '); +} + +/// `enter_text`'s value is never written in full: only its length, or — when +/// the selector looks like a sensitive field (password, PIN, token, secret, +/// CVV) — not even that. +String _describeEnterTextInput(Map<String, dynamic> args) { + final selectorValue = (args['key'] ?? args['identifier'] ?? '').toString(); + if (_sensitiveSelectorPattern.hasMatch(selectorValue)) { + return 'input=[redacted]'; + } + final input = args['input'] as String? ?? ''; + return 'input_len=${input.length}'; +} + +/// Formats one steps.md line: a timestamp, the tool/command name, its +/// selector (if any), and the outcome. +String formatStepLine(String toolName, String selector, String outcome) { + final time = _formatTime(DateTime.now()); + return selector.isEmpty + ? '- [$time] $toolName -> $outcome' + : '- [$time] $toolName $selector -> $outcome'; +} + +String _formatTime(DateTime time) { + String two(int n) => n.toString().padLeft(2, '0'); + return '${two(time.hour)}:${two(time.minute)}:${two(time.second)}'; +} diff --git a/packages/marionette_mcp/lib/src/session/step_logger.dart b/packages/marionette_mcp/lib/src/session/step_logger.dart index 76243f15..7607c8d7 100644 --- a/packages/marionette_mcp/lib/src/session/step_logger.dart +++ b/packages/marionette_mcp/lib/src/session/step_logger.dart @@ -1,356 +1,4 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/src/session/session.dart'; -import 'package:mcp_dart/mcp_dart.dart'; -import 'package:stack_trace/stack_trace.dart'; - -/// Field names that hint at a sensitive value (a password, PIN, token, -/// etc.) — matched against `enter_text`'s selector (key/identifier) to -/// decide how hard to redact the entered value in steps.md. -final _sensitiveSelectorPattern = RegExp( - r'pass|pin|token|secret|cvv', - caseSensitive: false, -); - -const _maxOutcomeLength = 200; - -/// A longer budget for an error outcome that carries a summarized stack -/// trace (see [describeStepError]) — a bare message fits the normal budget -/// comfortably, but a message plus a few compact frames often doesn't. -const _maxErrorOutcomeLength = 500; - -/// Stack frames kept when an error carries a structured exception/stack -/// (see [describeStepError]) — enough to usually show where the failure -/// actually happened, without pasting the whole trace. -const _maxStackFrames = 4; - -/// Arg names shown in a step's selector summary. Deliberately excludes -/// free-text payload fields (e.g. `enter_text`'s `input`) — steps.md -/// records what was acted on, not the payload. -const _selectorArgKeys = ['key', 'identifier', 'text', 'type', 'uri', 'x', 'y']; - -/// Tools whose successful result is a short, synthesized confirmation -/// message (e.g. "Successfully tapped"), never a dump of application data — -/// safe to echo verbatim (truncated) in steps.md. Everything else — -/// data-listing tools (`get_interactive_elements`, `get_logs`, -/// `call_custom_extension`, `list_custom_extensions`) and any -/// dynamically-promoted custom extension tool, whose result may carry -/// arbitrary app data — gets a generic outcome on success instead, so -/// steps.md never ends up persisting a payload. Error messages are shown -/// regardless of the tool (see [_describeOutcome]): they're diagnostic -/// context the report contract explicitly wants, not a payload. -const _shortConfirmationTools = { - 'connect', - 'disconnect', - 'tap', - 'secondary_tap', - 'double_tap', - 'long_press', - 'swipe', - 'pinch_zoom', - 'scroll_to', - 'press_back_button', - 'enter_text', - 'press_key', - 'set_device_config', - 'hot_reload', - 'hot_restart', - 'take_screenshots', -}; - -/// Tools whose success message is one status line followed by session -/// bookkeeping aimed at the agent — `connect`'s "Opened session: <path>", -/// `disconnect`'s "Session: <path>" and its report nudge. That tail is -/// redundant inside the very session it points at (and, being an absolute -/// path, mostly lost to truncation), so only the first line is logged. -const _firstLineOnlyTools = {'connect', 'disconnect'}; - -/// Appends one line per tool call to the active session's steps.md: tool, -/// selector, outcome. No payloads. No-ops when there is no active session -/// (nothing connected yet). -class StepLogger { - final _logger = logging.Logger('StepLogger'); - - /// The session tool calls are currently logged into. Set by `connect` on - /// success; cleared both at the very start of every `connect` attempt and - /// once `disconnect`'s own step has been logged (see `VmServiceContext`), - /// so neither a stray call made while disconnected nor one that slips in - /// during a reconnect attempt — successful or not — can silently append - /// to a session whose report may already be written. - Session? session; - - void logStep( - String toolName, - Map<String, dynamic> args, - CallToolResult result, - ) => - _logStep(session, toolName, args, result); - - void _logStep( - Session? activeSession, - String toolName, - Map<String, dynamic> args, - CallToolResult result, - ) { - if (activeSession == null) return; - - final selector = describeStepSelector(toolName, args); - final outcome = _describeOutcome(toolName, result, activeSession); - final line = formatStepLine(toolName, selector, outcome); - - // steps.md is best-effort bookkeeping, not the tool call itself — a - // failed write here (a full disk, a since-removed session directory) - // must never turn an already-completed action into a reported error. - // Still worth a warning, though, so a persistent problem doesn't go - // completely unnoticed. - try { - activeSession.stepsFile - .writeAsStringSync('$line\n', mode: FileMode.append); - } on FileSystemException catch (err) { - _logger.warning('Failed to write steps.md', err); - } - } - - String _describeOutcome( - String toolName, - CallToolResult result, - Session session, - ) { - if (result.isError) { - return describeStepError(_firstNonEmptyText(result)); - } - - if (_shortConfirmationTools.contains(toolName)) { - var text = _firstNonEmptyText(result); - if (_firstLineOnlyTools.contains(toolName)) { - text = text.split('\n').first; - } - text = _relativizeSessionPaths(text, session); - if (text.isNotEmpty) return _truncateText(_oneLine(_redactUrisIn(text))); - } - - final imageCount = result.content.whereType<ImageContent>().length; - return imageCount > 0 ? '$imageCount screenshot(s) captured' : 'ok'; - } - - String _firstNonEmptyText(CallToolResult result) => result.content - .whereType<TextContent>() - .map((c) => c.text) - .firstWhere((t) => t.isNotEmpty, orElse: () => ''); -} - -/// Builds an error outcome (prefixed `error: `) from raw error text — either -/// an MCP tool result's own message or a CLI command's caught exception's -/// `toString()`. Both transports can surface the same -/// `VmServiceExtensionException`-shaped text -/// (`"Extension X failed\nError: {"exception": "...", "stack": "...", "method": "..."}"` -/// — see `VmServiceExtensionException.toString()` and -/// `registerInternalMarionetteExtension`'s catch-all in -/// `marionette_flutter`), so both get the same treatment: the exception -/// message plus a few compact stack frames when that shape is present, -/// instead of blindly truncating raw JSON apart mid-structure and losing the -/// message entirely on a long selector. A deliberate, expected error (e.g. -/// "no log collector configured") isn't JSON-wrapped and falls back to the -/// plain, redacted, truncated message. -String describeStepError(String text) { - if (text.isEmpty) return 'error: failed'; - - final exceptionData = _tryParseExceptionJson(text); - if (exceptionData != null) { - final exception = - _oneLine(_redactUrisIn(exceptionData['exception'] as String)); - final frames = _summarizeStackTrace(exceptionData['stack'] as String); - final body = frames.isEmpty ? exception : '$exception [$frames]'; - return 'error: ${_truncateText(body, _maxErrorOutcomeLength)}'; - } - - return 'error: ${_truncateText(_oneLine(_redactUrisIn(text)))}'; -} - -/// Looks for the `Error: {...}` suffix `VmServiceExtensionException` appends -/// (see [describeStepError]) and parses it as JSON. Returns null for any -/// text that isn't in exactly this shape — a deliberate error message, a -/// locally-thrown exception's plain `toString()`, or anything else — rather -/// than guessing. -Map<String, dynamic>? _tryParseExceptionJson(String text) { - const marker = 'Error: '; - final index = text.indexOf('$marker{'); - if (index == -1) return null; - try { - final decoded = jsonDecode(text.substring(index + marker.length)); - if (decoded is! Map<String, dynamic>) return null; - return decoded['exception'] is String && decoded['stack'] is String - ? decoded - : null; - } on FormatException { - return null; - } -} - -/// The first [_maxStackFrames] frames of [stackTrace] after [Trace.terse] -/// folds together consecutive Dart-core/SDK frames — so `dart:async`/ -/// `dart:developer-patch` zone plumbing collapses to at most one frame (and -/// is dropped entirely if it's trailing, i.e. outermost) instead of eating -/// the whole budget on frames that don't point at application code. Falls -/// back to the raw text, one-lined, if it doesn't parse as a Dart stack -/// trace at all (defensive — the text always comes from a real -/// `StackTrace.toString()` on the app side, but a parse failure here -/// shouldn't break error logging). -String _summarizeStackTrace(String stackTrace) { - final Trace trace; - try { - trace = Trace.parse(stackTrace).terse; - } on FormatException { - return _truncateText(_oneLine(stackTrace), _maxErrorOutcomeLength); - } - return trace.frames.take(_maxStackFrames).map(_formatFrame).join(' › '); -} - -/// `member (path:line)` — `path` package-relative (falling back to -/// [Frame.library]'s pretty-printed form for anything else, e.g. a lone, -/// unfolded `dart:` frame), column dropped. More compact than -/// [Frame.location] (which keeps the full `package:` scheme and the -/// column), which matters against [_maxErrorOutcomeLength]. -String _formatFrame(Frame frame) { - final path = frame.uri.scheme == 'package' ? frame.uri.path : frame.library; - return frame.line == null - ? '${frame.member} ($path)' - : '${frame.member} ($path:${frame.line})'; -} - -/// Rewrites absolute paths inside [session]'s directory as paths relative -/// to it — e.g. `take_screenshots`' saved paths become `screenshots/01.png`, -/// which is also how a report living next to steps.md cites them. -String _relativizeSessionPaths(String text, Session session) => - text.replaceAll('${session.directory.path}${Platform.pathSeparator}', ''); - -String _truncateText(String text, [int maxLength = _maxOutcomeLength]) => - text.length <= maxLength ? text : '${text.substring(0, maxLength)}…'; - -/// Collapses whitespace (including newlines) into single spaces and trims -/// the ends — keeps a value that might itself span multiple lines (a -/// multiline `text` selector, a multi-line tool message) from breaking -/// steps.md's one-physical-line-per-call format. -String _oneLine(String text) => text.replaceAll(RegExp(r'\s+'), ' ').trim(); - -/// Matches a `ws(s)://`/`http(s)://` URI so one can be found and redacted -/// wherever it appears in free-form text — not just in the `uri` selector -/// field. `connect`'s own success/error text embeds the full uri a second -/// time (`"Successfully connected to app at $uri..."`, and a thrown -/// connection error's message commonly does too), so redacting only the -/// selector still left that same credential reaching steps.md through the -/// outcome. -final _uriPattern = RegExp(r'(?:wss?|https?)://\S+'); - -/// A VM service (or DevTools) URI stripped to `scheme://host:port`. -/// Flutter's VM service URIs commonly embed an auth token as a path segment -/// (`ws://127.0.0.1:PORT/TOKEN=/ws`) — logging one verbatim would persist a -/// live credential to steps.md. -String _redactUri(String raw) { - final parsed = Uri.tryParse(raw); - if (parsed == null || parsed.host.isEmpty) return '[uri]'; - return '${parsed.scheme}://${parsed.host}:${parsed.port}'; -} - -/// Redacts (see [_redactUri]) every URI found anywhere within [text]. -String _redactUrisIn(String text) => - text.replaceAllMapped(_uriPattern, (m) => _redactUri(m[0]!)); - -/// Builds the selector portion of a step line from [args] — the small set -/// of curated fields (key, identifier, text, etc.) that describe what a call -/// acted on, deliberately excluding free-text payload fields. Shared between -/// [StepLogger] (MCP tool calls) and the CLI (command invocations), which -/// use the same field names. -String describeStepSelector(String toolName, Map<String, dynamic> args) { - final parts = <String>[ - for (final key in _selectorArgKeys) - if (args[key] != null) - key == 'uri' - ? 'uri=${_redactUri(args[key].toString())}' - : '$key=${_oneLine(args[key].toString())}', - ]; - // The MCP field is `ancestor_keys`, the CLI option `--ancestor-keys`. The - // keys are joined with `>` rather than written as a Dart list, whose - // `[a, b]` form would put a space inside a single part of the line. - final ancestorKeys = args['ancestor_keys'] ?? args['ancestor-keys']; - if (ancestorKeys is List && ancestorKeys.isNotEmpty) { - parts.add('ancestor_keys=${_oneLine(ancestorKeys.join('>'))}'); - } - // Only present when the caller overrode the app's default, so the log shows - // whether a listing was the full payload or the compact one. - if (args['compaction'] case final String compaction) { - parts.add('compaction=${_oneLine(compaction)}'); - } - if (args['focused_element'] == true) parts.add('focused_element=true'); - if (args['coordinates'] case final Map<String, dynamic> coordinates) { - parts.add('x=${coordinates['x']} y=${coordinates['y']}'); - } - // Matches both the MCP tool name (enter_text) and the CLI command name - // (enter-text). - if (toolName.replaceAll('-', '_') == 'enter_text') { - parts.add(_describeEnterTextInput(args)); - } - return parts.join(' '); -} - -/// `enter_text`'s value is never written in full: only its length, or — when -/// the selector looks like a sensitive field (password, PIN, token, secret, -/// CVV) — not even that. -String _describeEnterTextInput(Map<String, dynamic> args) { - final selectorValue = (args['key'] ?? args['identifier'] ?? '').toString(); - if (_sensitiveSelectorPattern.hasMatch(selectorValue)) { - return 'input=[redacted]'; - } - final input = args['input'] as String? ?? ''; - return 'input_len=${input.length}'; -} - -/// Formats one steps.md line: a timestamp, the tool/command name, its -/// selector (if any), and the outcome. -String formatStepLine(String toolName, String selector, String outcome) { - final time = _formatTime(DateTime.now()); - return selector.isEmpty - ? '- [$time] $toolName -> $outcome' - : '- [$time] $toolName $selector -> $outcome'; -} - -String _formatTime(DateTime time) { - String two(int n) => n.toString().padLeft(2, '0'); - return '${two(time.hour)}:${two(time.minute)}:${two(time.second)}'; -} - -/// Wraps [callback] so every call is recorded as one line in the active -/// session's steps.md via [stepLogger] before the result is returned to the -/// client — including a call [callback] itself throws out of (an -/// unexpected, un-caught error rather than the usual `isError: true` -/// result), so the report contract's `Tested:` line — rendered from -/// steps.md, not the agent's own claim — never silently undercounts an -/// attempt just because it failed in an unanticipated way. The session is -/// captured when the call starts, so reconnects cannot reattribute its result. -ToolFunction withStepLogging( - StepLogger stepLogger, - String name, - ToolFunction callback, -) { - return (args, extra) async { - final session = stepLogger.session; - try { - final result = await callback(args, extra); - stepLogger._logStep(session, name, args, result); - return result; - } catch (err) { - stepLogger._logStep( - session, - name, - args, - CallToolResult( - isError: true, - content: [TextContent(text: 'error: $err')], - ), - ); - rethrow; - } - }; -} +// Compatibility boundary for existing internal consumers. New runtime code +// imports report_steps.dart; MCP adapters import mcp/step_logging.dart. +export '../../mcp/step_logging.dart'; +export 'report_steps.dart'; diff --git a/packages/marionette_mcp/lib/src/vm_service/dynamic_extension_tools.dart b/packages/marionette_mcp/lib/src/vm_service/dynamic_extension_tools.dart index 3f961d9e..7b0833c2 100644 --- a/packages/marionette_mcp/lib/src/vm_service/dynamic_extension_tools.dart +++ b/packages/marionette_mcp/lib/src/vm_service/dynamic_extension_tools.dart @@ -1,287 +1,2 @@ -import 'dart:convert'; - -import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/src/session/step_logger.dart'; -import 'package:marionette_mcp/src/vm_service/tools/arg_coercion.dart'; -import 'package:marionette_mcp/src/vm_service/tools/tool_runner.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; -import 'package:mcp_dart/mcp_dart.dart'; - -/// Registers MCP tools at runtime for each custom extension exposed by the -/// connected Flutter app via `registerMarionetteExtension`. -/// -/// Registrations belong to the current app connection and are removed on -/// disconnect. Reconnecting registers the current schemas from scratch. -/// -/// Only extensions that ship an `inputSchema` get promoted — schema-less -/// extensions remain reachable through the generic `call_custom_extension` -/// tool, preserving backward compatibility with apps that haven't migrated. -/// -/// Extension names are sanitized to a client-safe MCP tool name (see -/// [sanitizeToolName]). The `namespace.method` convention is out of spec for -/// the underlying tool-calling APIs — Anthropic allows `[a-zA-Z0-9_]`, OpenAI -/// `[a-zA-Z0-9_-]`, both rejecting the `.` separator — and clients diverge on -/// how they cope (Claude Code rewrites the name transparently; VS Code Copilot -/// rejects anything outside `[a-z0-9_-]` and drops the tool). Sanitizing to -/// the strictest set keeps the tool usable everywhere. The underlying -/// extension is still invoked by its real name, so `appNavigation.goToPage` is -/// exposed as the tool `app_navigation_go_to_page` but dispatched to -/// `appNavigation.goToPage` on the VM service. -class DynamicExtensionTools { - DynamicExtensionTools({ - required McpServer server, - required VmServiceConnector connector, - required logging.Logger logger, - StepLogger? stepLogger, - }) : _server = server, - _connector = connector, - _logger = logger, - _stepLogger = stepLogger; - - final McpServer _server; - final VmServiceConnector _connector; - final logging.Logger _logger; - final StepLogger? _stepLogger; - - final Map<String, RegisteredTool> _tools = {}; - final Map<String, String> _extensionByTool = {}; - int _generation = 0; - - /// The tools registered by the most recent [registerAll] call that have not - /// yet been removed. Exposed for tests. - List<RegisteredTool> get registeredTools => List.unmodifiable(_tools.values); - - /// The real extension name backing the promoted MCP tool [toolName], or - /// null if no such tool is registered. The promoted tool name is - /// sanitized (see [sanitizeToolName]); this maps it back to the name the - /// VM service extension is actually invoked with. Exposed for introspection - /// and tests. - String? extensionNameForTool(String toolName) => _extensionByTool[toolName]; - - /// Reads the connected app's custom extensions and promotes each one - /// with an `inputSchema` to a first-class MCP tool. - /// - /// Tools whose name collides with a built-in (or another custom) tool - /// are skipped with a warning — the operator sees the conflict in logs - /// and renames their extension. Tools whose schema fails to parse are - /// likewise skipped with a warning. - Future<void> registerAll() async { - removeAll(); - final generation = _generation; - final Map<String, dynamic> response; - try { - response = await _connector.listExtensions(); - } catch (err) { - _logger.warning( - 'Failed to fetch custom extensions for dynamic registration; ' - 'skipping. Generic call_custom_extension still works.', - err, - ); - return; - } - - // A disconnect may finish while the app's listExtensions call is pending. - if (generation != _generation) return; - - final extensionsRaw = response['extensions']; - if (extensionsRaw is! List) { - _logger.warning( - 'marionette.listExtensions response missing "extensions" list; ' - 'got: $extensionsRaw', - ); - return; - } - - var registered = 0; - var skippedNoSchema = 0; - for (final ext in extensionsRaw) { - if (ext is! Map) { - _logger.warning('Skipping malformed extension entry: $ext'); - continue; - } - final name = ext['name']; - if (name is! String || name.isEmpty) { - _logger.warning('Skipping extension with missing/invalid name: $ext'); - continue; - } - final description = ext['description'] as String?; - final schemaJson = ext['inputSchema']; - if (schemaJson == null) { - skippedNoSchema++; - continue; - } - if (schemaJson is! Map<String, dynamic>) { - _logger.warning( - 'Skipping extension "$name": inputSchema is not a JSON object', - ); - continue; - } - - final toolName = sanitizeToolName(name); - final tool = _promote( - extensionName: name, - toolName: toolName, - description: description, - schemaJson: schemaJson, - ); - if (tool != null) { - registered++; - } - } - - _logger.info( - 'Promoted $registered custom extension(s) to MCP tools ' - '(${skippedNoSchema} schema-less extension(s) remain reachable via ' - 'call_custom_extension).', - ); - } - - /// Removes the tools owned by the current app connection. - /// - /// mcp_dart 2.4.3 releases their registry names, so reconnects need no pool - /// of disabled handles and a different app can reuse a sanitized name. - void removeAll() { - ++_generation; - for (final tool in _tools.values) { - tool.remove(); - } - _tools.clear(); - _extensionByTool.clear(); - } - - RegisteredTool? _promote({ - required String extensionName, - required String toolName, - required String? description, - required Map<String, dynamic> schemaJson, - }) { - final ToolInputSchema schema; - try { - final parsed = JsonSchema.fromJson(schemaJson); - if (parsed is! ToolInputSchema) { - _logger.warning( - 'Skipping extension "$extensionName": inputSchema parsed to ' - '${parsed.runtimeType} but MCP requires a JSON object schema.', - ); - return null; - } - schema = parsed; - } catch (err) { - _logger.warning( - 'Skipping extension "$extensionName": failed to parse inputSchema', - err, - ); - return null; - } - - // The extension is always invoked by its real name; only the MCP tool - // name is sanitized for clients that restrict the character set. - final callback = _buildCallback(toolName, extensionName); - final effectiveDescription = - _describe(extensionName, toolName, description); - - final existing = _tools[toolName]; - if (existing != null) { - if (_extensionByTool[toolName] != extensionName) { - // Two distinct extensions sanitize to the same tool name. Reviving in - // place would silently hijack the first one's tool, so skip instead. - _logger.warning( - 'Skipping extension "$extensionName": its sanitized MCP tool name ' - '"$toolName" collides with extension "${_extensionByTool[toolName]}". ' - 'Rename one of them on the Flutter side.', - ); - return null; - } - // Repeated entries for the same extension update its current schema. - existing.update( - description: effectiveDescription, - inputSchema: schema, - callback: FunctionToolCallback(callback), - enabled: true, - ); - return existing; - } - - try { - final tool = _server.registerTool( - toolName, - description: effectiveDescription, - inputSchema: schema, - callback: callback, - ); - _tools[toolName] = tool; - _extensionByTool[toolName] = extensionName; - return tool; - } on ArgumentError catch (err) { - // mcp_dart throws ArgumentError on duplicate name — most likely a - // collision with a built-in marionette tool (tap, scroll_to, etc.). - // A conflicting built-in remains untouched across reconnects. - _logger.warning( - 'Skipping extension "$extensionName" (tool name "$toolName"): MCP ' - 'tool name collision. Rename the extension on the Flutter side. ' - 'Details: ${err.message}', - ); - return null; - } - } - - /// Builds the tool description, appending the real extension name when it - /// differs from the sanitized tool name so agents can still reach it via - /// `call_custom_extension` and understand what the tool maps to. - String? _describe(String extensionName, String toolName, String? base) { - if (toolName == extensionName) return base; - final note = 'Custom extension: $extensionName'; - return base == null || base.isEmpty ? note : '$base\n\n$note'; - } - - ToolFunction _buildCallback(String toolName, String extensionName) { - final callback = - (Map<String, dynamic> args, RequestHandlerExtra extra) async { - return runTool(_logger, 'call extension "$extensionName"', () async { - final stringArgs = coerceToStringMap(args); - final response = await _connector.callCustomExtension( - extensionName, - stringArgs, - ); - return CallToolResult( - content: [TextContent(text: jsonEncode(response))], - ); - }); - }; - final stepLogger = _stepLogger; - return stepLogger == null - ? callback - : withStepLogging(stepLogger, toolName, callback); - } -} - -/// Maps a custom-extension name to an MCP tool name accepted across clients. -/// The target set `[a-z0-9_-]` is the strictest in common use (VS Code Copilot -/// enforces it); it also stays within the `.`-rejecting character sets the -/// Anthropic and OpenAI tool-calling APIs require, which the `namespace.method` -/// convention would otherwise violate. -/// -/// A name already within the allowed set is returned verbatim — including -/// any leading, trailing, or repeated `_`/`-` the author chose — so valid -/// names are never silently rewritten. Otherwise the transform lower-cases -/// the name, inserts `_` at camelCase boundaries so `goToPage` reads as -/// `go_to_page`, replaces every other disallowed character (e.g. the `.` -/// namespace separator) with `_`, then collapses and trims runs of `_`. For -/// example `appNavigation.goToPage` becomes `app_navigation_go_to_page`. -String sanitizeToolName(String name) { - // Already client-safe: leave it exactly as the author wrote it. Collapsing - // or trimming here would change valid names (e.g. `__foo__` -> `foo`) and - // could create surprising collisions. - if (RegExp(r'^[a-z0-9_-]+$').hasMatch(name)) return name; - - final withBoundaries = name.replaceAllMapped( - RegExp('[a-z0-9][A-Z]'), - (m) => '${m[0]![0]}_${m[0]![1]}', - ); - final sanitized = withBoundaries - .toLowerCase() - .replaceAll(RegExp('[^a-z0-9_-]'), '_') - .replaceAll(RegExp('_+'), '_') - .replaceAll(RegExp(r'^_+|_+$'), ''); - return sanitized.isEmpty ? '_' : sanitized; -} +// Compatibility export for the former implementation path. +export 'package:marionette_mcp/mcp/tools/dynamic_extension_tools.dart'; diff --git a/packages/marionette_mcp/lib/src/vm_service/tools/ancestor_keys_description.dart b/packages/marionette_mcp/lib/src/vm_service/tools/ancestor_keys_description.dart index 93395e2b..b9882d1b 100644 --- a/packages/marionette_mcp/lib/src/vm_service/tools/ancestor_keys_description.dart +++ b/packages/marionette_mcp/lib/src/vm_service/tools/ancestor_keys_description.dart @@ -1,11 +1,2 @@ -/// Shared description of the `ancestor_keys` field across every matcher-based -/// tool. -/// -/// Kept short on purpose: it is repeated in every schema, so its length is -/// paid on every connection. The full contract — strict nesting, the failure -/// rule, the `scroll_to` timing, what ignores the field — is stated once in -/// the server instructions. -const ancestorKeysDescription = - 'Optional wrapper keys (ValueKey<String>), outermost first; each is ' - 'looked up inside the previous one. Limits the search to that subtree. ' - 'Fails if a key has no element.'; +// Compatibility export for the former implementation path. +export 'package:marionette_mcp/mcp/tools/ancestor_keys_description.dart'; diff --git a/packages/marionette_mcp/lib/src/vm_service/tools/tool_runner.dart b/packages/marionette_mcp/lib/src/vm_service/tools/tool_runner.dart index c2abb93d..f13cbb39 100644 --- a/packages/marionette_mcp/lib/src/vm_service/tools/tool_runner.dart +++ b/packages/marionette_mcp/lib/src/vm_service/tools/tool_runner.dart @@ -1,25 +1,2 @@ -import 'package:logging/logging.dart' as logging; -import 'package:mcp_dart/mcp_dart.dart'; - -/// Wraps a tool callback body in the standard error-handling boilerplate: -/// run the body, and on any throw log a warning and return a -/// [CallToolResult] with `isError: true` and the exception's message. -/// -/// Used by tools that want the default failure semantics. Tools with -/// non-trivial mid-flow control (e.g. `connect` rolling back on a version -/// mismatch) should not use this helper. -Future<CallToolResult> runTool( - logging.Logger logger, - String operation, - Future<CallToolResult> Function() body, -) async { - try { - return await body(); - } catch (err) { - logger.warning('Failed to $operation', err); - return CallToolResult( - isError: true, - content: [TextContent(text: err.toString())], - ); - } -} +// Compatibility export for the former implementation path. +export 'package:marionette_mcp/mcp/tools/tool_runner.dart'; diff --git a/packages/marionette_mcp/test/server_api_test.dart b/packages/marionette_mcp/test/server_api_test.dart index e0cf78fd..fa1f2890 100644 --- a/packages/marionette_mcp/test/server_api_test.dart +++ b/packages/marionette_mcp/test/server_api_test.dart @@ -1,6 +1,7 @@ @Timeout(Duration(seconds: 60)) library; +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -41,7 +42,14 @@ void main() { } } final catalog = await client.listTools(); - expect(catalog.tools.map((tool) => tool.toJson()).toList(), expected); + final actual = catalog.tools.map((tool) => tool.toJson()).toList(); + // Structured results intentionally add an output contract. All original + // names, input schemas, descriptions and annotations stay compatible. + for (final tool in actual) { + expect(tool.remove('outputSchema'), isNotNull, + reason: '${tool['name']} must advertise its structured result'); + } + expect(actual, expected); final result = await client.callTool( const CallToolRequest(name: 'get_logs', arguments: {}), @@ -52,6 +60,69 @@ void main() { }); } + for (final protocol in [McpProtocol.legacy, McpProtocol.stable]) { + test('public client receives validated structured results on $protocol', + () async { + final context = VmServiceContext(connector: _OutputConnector()); + final server = createMcpServer(context: context); + final client = McpClient( + const Implementation(name: 'structured-api-test', version: '1'), + options: McpClientOptions(protocol: protocol), + ); + final toServer = StreamController<List<int>>(); + final toClient = StreamController<List<int>>(); + addTearDown(() async { + await client.close(); + await context.dispose(); + await server.close(); + }); + await server.connect( + IOStreamTransport(stream: toServer.stream, sink: toClient.sink)); + await client.connect( + IOStreamTransport(stream: toClient.stream, sink: toServer.sink)); + Future<CallToolResult> call(String name, + [Map<String, dynamic> args = const {}]) => + client.callTool(CallToolRequest(name: name, arguments: args)); + final connected = await call('connect', {'uri': 'ws://fixture/ws'}); + expect(connected.isError, isFalse); + expect(connected.structuredContent, { + 'uri': 'ws://fixture/ws', + 'sessionReportsEnabled': false, + }); + final logs = await call('get_logs'); + expect(logs.structuredContent, { + 'logs': ['ready'], + 'count': 1 + }); + expect((logs.content.single as TextContent).text, contains('ready')); + final reloaded = await call('hot_reload'); + expect(reloaded.structuredContent, + {'message': 'Hot reload completed successfully'}); + final screenshots = await call('take_screenshots'); + expect(screenshots.structuredContent, {'count': 0, 'inline': true}); + final dynamicTool = (await client.listTools()) + .tools + .singleWhere((t) => t.name == 'app_echo'); + expect(dynamicTool.outputSchema, isNotNull); + final echoed = await call('app_echo', {'value': 'hello'}); + expect(echoed.structuredContent, { + 'response': { + 'value': 'hello', + 'nested': {'ok': true} + } + }); + expect(jsonDecode((echoed.content.single as TextContent).text), + echoed.structuredContent!['response']); + final disconnected = await call('disconnect'); + expect(disconnected.structuredContent, {'disconnected': true}); + expect((await client.listTools()).tools.any((t) => t.name == 'app_echo'), + isFalse); + final failed = await call('get_logs'); + expect(failed.isError, isTrue); + expect(failed.structuredContent, isNull); + }); + } + test('public server composition accepts an owned runtime dependency', () async { final connector = _TrackingConnector(); @@ -75,3 +146,59 @@ class _TrackingConnector extends VmServiceConnector { await super.disconnect(); } } + +class _OutputConnector extends VmServiceConnector { + bool _connected = false; + + @override + Future<void> connect(String uri) async => _connected = true; + + @override + Future<void> disconnect() async => _connected = false; + + @override + Future<String> getVersion() async => version; + + @override + Future<bool> getSessionReportsEnabled() async => false; + + @override + Future<bool> hotReload() async => true; + + @override + Future<Map<String, dynamic>> getLogs() async { + if (!_connected) throw StateError('Not connected'); + return { + 'logs': ['ready'], + 'count': 1 + }; + } + + @override + Future<Map<String, dynamic>> takeScreenshots() async => + {'screenshots': <String>[]}; + + @override + Future<Map<String, dynamic>> listExtensions() async => { + 'extensions': [ + { + 'name': 'app.echo', + 'inputSchema': { + 'type': 'object', + 'properties': { + 'value': {'type': 'string'} + }, + 'required': ['value'] + } + }, + ], + }; + + @override + Future<Map<String, dynamic>> callCustomExtension(String extensionName, + [Map<String, dynamic> args = const {}]) async => + { + 'value': args['value'], + 'nested': {'ok': true}, + }; +} diff --git a/packages/marionette_mcp/test/server_context_test.dart b/packages/marionette_mcp/test/server_context_test.dart index a3f374f0..82f86d01 100644 --- a/packages/marionette_mcp/test/server_context_test.dart +++ b/packages/marionette_mcp/test/server_context_test.dart @@ -12,7 +12,7 @@ void main() { final connector = _DelayedVersionConnector(); final context = VmServiceContext(connector: connector); final server = _CapturingServer(); - context.registerTools(server); + registerMcpTools(server, context: context); Future<CallToolResult> connect(String title) async => server.callbacks['connect']!({ 'uri': 'ws://$title/ws', @@ -54,7 +54,7 @@ void main() { final connector = _DelayedDisconnectConnector(); final context = VmServiceContext(connector: connector); final server = _CapturingServer(); - context.registerTools(server); + registerMcpTools(server, context: context); addTearDown(() async { if (!connector.release.isCompleted) connector.release.complete(); await context.dispose(); diff --git a/packages/marionette_mcp/test/structured_tool_outputs_test.dart b/packages/marionette_mcp/test/structured_tool_outputs_test.dart new file mode 100644 index 00000000..9411f846 --- /dev/null +++ b/packages/marionette_mcp/test/structured_tool_outputs_test.dart @@ -0,0 +1,299 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:logging/logging.dart' as logging; +import 'package:marionette_mcp/mcp/tools/base_tool.dart'; +import 'package:marionette_mcp/mcp/tools/device_tools.dart'; +import 'package:marionette_mcp/mcp/tools/extension_tools.dart'; +import 'package:marionette_mcp/mcp/tools/gesture_tools.dart'; +import 'package:marionette_mcp/mcp/tools/inspection_tools.dart'; +import 'package:marionette_mcp/mcp/tools/keyboard_tools.dart'; +import 'package:marionette_mcp/mcp/tools/system_tools.dart'; +import 'package:marionette_mcp/mcp/tools/text_tools.dart'; +import 'package:marionette_mcp/src/session/session.dart'; +import 'package:marionette_mcp/src/session/step_logger.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:mcp_dart/mcp_dart.dart'; +import 'package:test/test.dart'; + +class _Connector implements VmServiceConnector { + Map<String, dynamic> response = {}; + bool systemSuccess = true; + bool fails = false; + + @override + dynamic noSuchMethod(Invocation invocation) { + if (fails) return Future<Map<String, dynamic>>.error(StateError('offline')); + if (invocation.memberName == #hotReload || + invocation.memberName == #hotRestart) { + return Future<bool>.value(systemSuccess); + } + return Future<Map<String, dynamic>>.value(response); + } +} + +class _Extra implements RequestHandlerExtra { + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} + +void main() { + late _Connector connector; + late Map<String, BaseTool> tools; + final extra = _Extra(); + + setUp(() { + connector = _Connector(); + final logger = logging.Logger.detached('structured-results'); + tools = { + for (final tool in [ + ...createDeviceTools(connector: connector, logger: logger), + ...createExtensionTools(connector: connector, logger: logger), + ...createGestureTools(connector: connector, logger: logger), + ...createInspectionTools( + connector: connector, + logger: logger, + stepLogger: StepLogger(), + ), + ...createKeyboardTools(connector: connector, logger: logger), + ...createSystemTools(connector: connector, logger: logger), + ...createTextTools(connector: connector, logger: logger), + ]) + tool.name: tool, + }; + }); + + void validate(String name, CallToolResult result) { + expect(result.isError, isFalse); + expect(result.structuredContent, isNotNull); + tools[name]!.outputSchema!.validate(result.structuredContent); + } + + final actionCases = <String, (Map<String, dynamic>, String)>{ + 'set_device_config': ({'reset': true}, 'Device config updated'), + 'tap': ({'key': 'target'}, 'Successfully tapped'), + 'secondary_tap': ({'key': 'target'}, 'Successfully secondary tapped'), + 'double_tap': ({'key': 'target'}, 'Successfully double tapped'), + 'long_press': ({'key': 'target'}, 'Successfully long pressed'), + 'swipe': ({'key': 'target', 'direction': 'up'}, 'Successfully swiped'), + 'pinch_zoom': ({'key': 'target', 'scale': 2}, 'Successfully pinch zoomed'), + 'press_back_button': ({}, 'Back button pressed, no route to pop'), + 'scroll_to': ({'key': 'target'}, 'Successfully scrolled to element'), + 'press_key': ({'key': 'enter'}, 'Successfully pressed key'), + 'enter_text': ( + {'key': 'target', 'input': 'hello'}, + 'Successfully entered text' + ), + 'hot_reload': ({}, 'Hot reload completed successfully'), + 'hot_restart': ({}, 'Hot restart completed successfully'), + }; + + test('all 18 static tools have schemas with required fields', () { + expect(tools, hasLength(18)); + for (final tool in tools.values) { + expect(tool.outputSchema, isNotNull, reason: tool.name); + expect(tool.outputSchema!.required, isNotEmpty, reason: tool.name); + expect( + () => tool.outputSchema!.validate({}), + throwsA(isA<JsonSchemaValidationException>()), + reason: tool.name, + ); + } + }); + + for (final entry in actionCases.entries) { + test('${entry.key} preserves fallback text with structured message', + () async { + final result = await tools[entry.key]!.execute(entry.value.$1, extra); + validate(entry.key, result); + expect((result.content.single as TextContent).text, entry.value.$2); + expect(result.structuredContent, {'message': entry.value.$2}); + }); + + if (!entry.key.startsWith('hot_')) { + test('${entry.key} preserves app-provided confirmation', () async { + connector.response = {'message': 'App confirmation'}; + final result = await tools[entry.key]!.execute(entry.value.$1, extra); + validate(entry.key, result); + expect((result.content.single as TextContent).text, 'App confirmation'); + expect(result.structuredContent, {'message': 'App confirmation'}); + }); + } + } + + test('back result includes the runtime didPop boolean', () async { + for (final didPop in [true, false]) { + connector.response = {'didPop': didPop}; + final result = await tools['press_back_button']!.execute({}, extra); + validate('press_back_button', result); + expect(result.structuredContent!['didPop'], didPop); + expect( + (result.content.single as TextContent).text, + didPop + ? 'Back button pressed, route was popped' + : 'Back button pressed, no route to pop'); + } + }); + + test('inspection preserves extensible element objects and count', () async { + final elements = [ + { + 'type': 'Button', + 'key': 'submit', + 'futureField': {'enabled': true} + }, + ]; + connector.response = {'elements': elements}; + final result = await tools['get_interactive_elements']!.execute({}, extra); + validate('get_interactive_elements', result); + expect(result.structuredContent, {'elements': elements, 'count': 1}); + expect((result.content.single as TextContent).text, + startsWith('Found 1 interactive element(s):\n')); + }); + + test('empty inspection still provides an empty array', () async { + connector.response = {'elements': []}; + final result = await tools['get_interactive_elements']!.execute({}, extra); + validate('get_interactive_elements', result); + expect(result.structuredContent, {'elements': [], 'count': 0}); + expect((result.content.single as TextContent).text, + 'Found 0 interactive element(s):\n\n'); + }); + + test('logs preserve strings and empty results', () async { + for (final logs in <List<String>>[ + [], + ['hello', 'second\nline'] + ]) { + connector.response = {'logs': logs, 'count': logs.length}; + final result = await tools['get_logs']!.execute({}, extra); + validate('get_logs', result); + expect(result.structuredContent, {'logs': logs, 'count': logs.length}); + expect( + (result.content.single as TextContent).text, + logs.isEmpty + ? 'No logs collected' + : 'Collected 2 log entries:\n\nhello\nsecond\nline\n'); + } + }); + + test('extension discovery preserves empty and extensible entries', () async { + for (final extensions in <List<Map<String, dynamic>>>[ + [], + [ + {'name': 'app.status', 'description': 'Status', 'extra': 1} + ], + ]) { + connector.response = {'extensions': extensions}; + final result = await tools['list_custom_extensions']!.execute({}, extra); + validate('list_custom_extensions', result); + expect(result.structuredContent, + {'extensions': extensions, 'count': extensions.length}); + expect( + (result.content.single as TextContent).text, + extensions.isEmpty + ? 'No custom extensions registered.' + : 'Found 1 custom extension(s):\n\n- app.status: Status\n'); + } + }); + + test('generic extension response uses a required extensible envelope', + () async { + connector.response = { + 'custom': { + 'nested': [1, true, null] + }, + 'ok': true + }; + final result = await tools['call_custom_extension']! + .execute({'extension': 'app.status'}, extra); + validate('call_custom_extension', result); + expect(result.structuredContent, {'response': connector.response}); + expect((result.content.single as TextContent).text, + jsonEncode(connector.response)); + }); + + test('inline screenshots preserve images without duplicating base64', + () async { + connector.response = { + 'screenshots': ['aGVsbG8=', 'd29ybGQ='] + }; + final result = await tools['take_screenshots']!.execute({}, extra); + validate('take_screenshots', result); + expect(result.structuredContent, {'count': 2, 'inline': true}); + expect(result.content.map((c) => (c as ImageContent).data), + ['aGVsbG8=', 'd29ybGQ=']); + expect( + result.content + .every((c) => (c as ImageContent).mimeType == 'image/png'), + isTrue); + }); + + test('empty screenshots retain the requested inline mode', () async { + connector.response = {'screenshots': []}; + for (final inline in [true, false]) { + final result = + await tools['take_screenshots']!.execute({'inline': inline}, extra); + validate('take_screenshots', result); + expect(result.structuredContent, {'count': 0, 'inline': inline}); + expect((result.content.single as TextContent).text, + 'No screenshots captured'); + } + }); + + test('saved screenshots report the actual written paths', () async { + final directory = + Directory.systemTemp.createTempSync('structured-screenshots-'); + addTearDown(() => directory.deleteSync(recursive: true)); + final session = Session.open(directory); + connector.response = { + 'screenshots': ['aGVsbG8='] + }; + final result = await takeScreenshots(connector, session, {'inline': false}); + validate('take_screenshots', result); + final paths = (result.structuredContent!['paths'] as List<String>); + expect(result.structuredContent, + {'count': 1, 'inline': false, 'paths': paths}); + expect(File(paths.single).readAsBytesSync(), utf8.encode('hello')); + expect((result.content.single as TextContent).text, + 'Saved 1 screenshot(s):\n${paths.single}'); + }); + + test('connector failures preserve error results without structured content', + () async { + connector.fails = true; + for (final entry + in actionCases.entries.where((e) => !e.key.startsWith('hot_'))) { + final result = await tools[entry.key]!.execute(entry.value.$1, extra); + expect(result.isError, isTrue, reason: entry.key); + expect(result.structuredContent, isNull); + expect((result.content.single as TextContent).text, contains('offline')); + } + }); + + test('validation and system failures retain isError semantics', () async { + connector.systemSuccess = false; + for (final (name, args) in <(String, Map<String, dynamic>)>[ + ('set_device_config', {}), + ('double_tap', {'delay': 0}), + ('swipe', {}), + ('pinch_zoom', {'scale': 2}), + ('press_key', {'key': 'enter', 'modifiers': 'invalid'}), + ('enter_text', {'input': 'hello'}), + ('get_interactive_elements', {'compaction': 'invalid'}), + ('hot_reload', {}), + ('hot_restart', {}), + ]) { + final result = await tools[name]!.execute(args, extra); + expect(result.isError, isTrue, reason: name); + expect(result.structuredContent, isNull, reason: name); + } + connector.response = { + 'screenshots': ['aGVsbG8='] + }; + final result = + await tools['take_screenshots']!.execute({'inline': false}, extra); + expect(result.isError, isTrue); + expect(result.structuredContent, isNull); + }); +} From 6ec27238829adadfe6b0cc328ab1877d0eb87445 Mon Sep 17 00:00:00 2001 From: Jhin Lee <jhin.lee@unity3d.com> Date: Sat, 24 Oct 2026 11:00:33 -0400 Subject: [PATCH 05/12] Scope MCP refactor separately from CLI improvements --- .github/workflows/ci.yaml | 10 +- CHANGELOG.md | 7 +- docs/architecture.md | 31 +- .../lib/src/cli/commands/doctor_command.dart | 2 +- .../src/cli/commands/double_tap_command.dart | 24 +- .../src/cli/commands/enter_text_command.dart | 12 +- .../get_interactive_elements_command.dart | 3 +- .../src/cli/commands/get_logs_command.dart | 2 +- .../src/cli/commands/hot_reload_command.dart | 2 +- .../src/cli/commands/hot_restart_command.dart | 2 +- .../src/cli/commands/long_press_command.dart | 18 +- .../lib/src/cli/commands/mcp_command.dart | 30 +- .../src/cli/commands/pinch_zoom_command.dart | 35 +- .../commands/press_back_button_command.dart | 2 +- .../src/cli/commands/press_key_command.dart | 2 +- .../cli/commands/record_video_command.dart | 43 ++- .../src/cli/commands/scroll_to_command.dart | 10 +- .../cli/commands/secondary_tap_command.dart | 18 +- .../commands/set_device_config_command.dart | 2 +- .../lib/src/cli/commands/swipe_command.dart | 33 +- .../commands/take_screenshots_command.dart | 2 +- .../lib/src/cli/commands/tap_command.dart | 18 +- .../src/cli/commands/unregister_command.dart | 2 +- .../lib/src/cli/instance_command.dart | 5 +- .../lib/src/cli/matcher_builder.dart | 89 ++--- .../test/record_video_command_test.dart | 132 +------- .../test/selector_commands_test.dart | 318 ------------------ .../marionette-flutter-drive-app/SKILL.md | 14 +- .../marionette_mcp/lib/mcp/server_config.dart | 2 +- packages/marionette_mcp/lib/runtime.dart | 15 +- tool/check_frame_protocol.dart | 141 -------- 31 files changed, 221 insertions(+), 805 deletions(-) delete mode 100644 packages/marionette_cli/test/selector_commands_test.dart delete mode 100644 tool/check_frame_protocol.dart diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ba026829..161ac436 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,9 +37,6 @@ jobs: exit 1 fi - - name: Check frame protocol compatibility - run: dart tool/check_frame_protocol.dart - - name: Analyze marionette_mcp run: | cd packages/marionette_mcp @@ -48,7 +45,7 @@ jobs: - name: Analyze marionette_cli run: | cd packages/marionette_cli - dart analyze --fatal-infos lib bin test + dart analyze --fatal-infos lib bin - name: Analyze marionette_flutter run: | @@ -141,16 +138,13 @@ jobs: YAML dart pub get --no-example - - name: Check frame protocol compatibility - run: dart tool/check_frame_protocol.dart - - name: Analyze marionette_mcp working-directory: packages/marionette_mcp run: dart analyze --fatal-infos lib bin test - name: Analyze marionette_cli working-directory: packages/marionette_cli - run: dart analyze --fatal-infos lib bin test + run: dart analyze --fatal-infos lib bin - name: Run tests for marionette_mcp working-directory: packages/marionette_mcp diff --git a/CHANGELOG.md b/CHANGELOG.md index c2d4b46e..dafb1486 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,10 @@ - Add output schemas and structured success results to MCP tools while preserving existing text and images. - Upgrade `mcp_dart` to `^2.4.3` and use its fixed dynamic-tool removal on reconnect. -- Fix server shutdown leaving app connections open and failed web recording startup leaving its WebSocket listener open. +- Fix MCP server shutdown leaving app connections open. - Keep in-flight tool steps in their original report across reconnects, and clear connection status when the app disconnects. -- Reject incomplete, malformed, or nonfinite CLI coordinates and pinch-zoom values, and conflicting targeting modes, before calling the app. -- Reject invalid SSE ports instead of falling back to stdio, and report server startup failures with a nonzero exit code. -- Expose public server and shared runtime entrypoints, with typed MCP tools separated from startup and configuration. +- Reject invalid SSE ports in the standalone MCP executable instead of falling back to stdio, and report server startup failures with a nonzero exit code. +- Expose public server and app-connection entrypoints, with typed MCP tools separated from startup and configuration. - Fix `record-video --width/--height` recording only the top-left corner of the screen at full scale instead of the whole screen scaled down to that size. Web recordings were not affected ([#134](https://github.com/leancodepl/marionette_mcp/issues/134)) # 0.7.0 diff --git a/docs/architecture.md b/docs/architecture.md index 073d8337..7432a300 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,10 +7,10 @@ composition, configuration, and typed tools: packages/marionette_mcp/ bin/marionette_mcp.dart parse options and start the server lib/marionette_mcp.dart public server API - lib/runtime.dart public app/session/recording runtime API + lib/runtime.dart public app connection API for embedding lib/mcp/ mcp.dart createMcpServer and server instructions - server_config.dart options shared with marionette mcp + server_config.dart standalone MCP server options server_context.dart shared connection/session state and ownership server_runner.dart logging, transport startup, and shutdown step_logging.dart MCP result/session logging adapter @@ -36,10 +36,10 @@ connection attempt cannot add a step to its replacement's report. ## Public package boundaries -CLI adapters import `package:marionette_mcp/runtime.dart` for app connections, -selectors, reports, and recording. They import -`package:marionette_mcp/marionette_mcp.dart` for server startup. Other packages -should use these entrypoints rather than importing `lib/src` files. Existing +Embedders import `package:marionette_mcp/marionette_mcp.dart` for server +composition and startup, and `package:marionette_mcp/runtime.dart` for the app +connector. Existing CLI adapters continue using retained internal compatibility +exports for server startup and shared runtime access. MCP tools and executable options keep their names and input schemas. Shared report formatting and persistence live in `src/session/report_steps.dart` without an MCP dependency; MCP result interpretation lives in `mcp/step_logging.dart`. @@ -74,20 +74,6 @@ Disposing the context retires dynamic tools and closes its app connection. Disconnecting releases dynamic tool names, so reconnecting uses the current app's schemas without retaining disabled registrations. -Recording setup owns its WebSocket listener until a `RecordingSession` has -been created successfully. Startup failure closes the listener; after a -successful transfer the session owns cleanup. - -## CLI selection - -CLI coordinate selectors require both `--x` and `--y`, with finite numeric -values. Coordinate swipes require all four start/end coordinates. Malformed -coordinates fail before invoking the app instead of silently dropping a value. -Coordinate, element, and focused targeting are separate modes; an element -mode may retain combined key, identifier, text, and type filters. Ancestor -scope keeps its existing behavior, including being ignored by coordinate and -focused targeting. - ## Verification From the workspace root, resolve dependencies with `flutter pub get`. Run @@ -105,11 +91,6 @@ output schemas while preserving every original input contract and annotation. Shutdown regressions use a real Dart VM service fixture and cover connected EOF and signals. -`dart tool/check_frame_protocol.dart` checks Flutter-encoded frames against -the MCP consumer, including fragmented delivery. CI runs this check on both -the normal and minimum supported toolchains. This keeps the production -packages independent while detecting drift in their shared wire format. - The minimum-toolchain CI job selects separate workspace groups for the standalone Dart server/CLI and the Flutter binding/adapters. Flutter 3.27's `flutter_test` pins `meta` to 1.15, while `mcp_dart` 2.4.3 requires 1.16 or diff --git a/packages/marionette_cli/lib/src/cli/commands/doctor_command.dart b/packages/marionette_cli/lib/src/cli/commands/doctor_command.dart index b4fb36a4..823fba2b 100644 --- a/packages/marionette_cli/lib/src/cli/commands/doctor_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/doctor_command.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:args/command_runner.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; class DoctorCommand extends Command<int> { DoctorCommand(this._registry); diff --git a/packages/marionette_cli/lib/src/cli/commands/double_tap_command.dart b/packages/marionette_cli/lib/src/cli/commands/double_tap_command.dart index eedaf8b6..1a6e5176 100644 --- a/packages/marionette_cli/lib/src/cli/commands/double_tap_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/double_tap_command.dart @@ -4,7 +4,7 @@ import 'package:marionette_cli/src/cli/ancestor_keys_option.dart'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/cli/matcher_builder.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; class DoubleTapCommand extends InstanceCommand { DoubleTapCommand(this._registry) { @@ -37,11 +37,22 @@ class DoubleTapCommand extends InstanceCommand { @override Future<int> execute(VmServiceConnector connector) async { - final matcher = buildValidatedMatcherFromArgs( - argResults, - fail: usageException, + final matcher = buildMatcherFromArgs( + key: argResults?['key'] as String?, + identifier: argResults?['identifier'] as String?, + text: argResults?['text'] as String?, + type: argResults?['type'] as String?, + x: _parseNum(argResults?['x'] as String?), + y: _parseNum(argResults?['y'] as String?), + ancestorKeys: argResults?['ancestor-keys'] as List<String>? ?? const [], ); + final xStr = argResults?['x'] as String?; + final yStr = argResults?['y'] as String?; + if ((xStr == null) != (yStr == null)) { + usageException('--x and --y must be provided together.'); + } + if (!hasSelector(matcher)) { usageException( 'At least one matcher required: --key, --identifier, --text, --type, ' @@ -64,4 +75,9 @@ class DoubleTapCommand extends InstanceCommand { stdout.writeln(message); return 0; } + + num? _parseNum(String? value) { + if (value == null) return null; + return num.tryParse(value); + } } diff --git a/packages/marionette_cli/lib/src/cli/commands/enter_text_command.dart b/packages/marionette_cli/lib/src/cli/commands/enter_text_command.dart index b6e6492f..8e735777 100644 --- a/packages/marionette_cli/lib/src/cli/commands/enter_text_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/enter_text_command.dart @@ -4,7 +4,7 @@ import 'package:marionette_cli/src/cli/ancestor_keys_option.dart'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/cli/matcher_builder.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; class EnterTextCommand extends InstanceCommand { EnterTextCommand(this._registry) { @@ -39,9 +39,13 @@ class EnterTextCommand extends InstanceCommand { @override Future<int> execute(VmServiceConnector connector) async { final input = argResults!['input'] as String; - final matcher = buildValidatedMatcherFromArgs( - argResults, - fail: usageException, + final focused = argResults!['focused'] as bool; + final matcher = buildMatcherFromArgs( + key: argResults?['key'] as String?, + identifier: argResults?['identifier'] as String?, + text: argResults?['text'] as String?, + focused: focused, + ancestorKeys: argResults?['ancestor-keys'] as List<String>? ?? const [], ); if (!hasSelector(matcher)) { diff --git a/packages/marionette_cli/lib/src/cli/commands/get_interactive_elements_command.dart b/packages/marionette_cli/lib/src/cli/commands/get_interactive_elements_command.dart index 12c4e6bf..0705b0b5 100644 --- a/packages/marionette_cli/lib/src/cli/commands/get_interactive_elements_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/get_interactive_elements_command.dart @@ -2,7 +2,8 @@ import 'dart:io'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; +import 'package:marionette_mcp/src/formatting.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; class ElementsCommand extends InstanceCommand { ElementsCommand(this._registry) { diff --git a/packages/marionette_cli/lib/src/cli/commands/get_logs_command.dart b/packages/marionette_cli/lib/src/cli/commands/get_logs_command.dart index 446f00e0..73dd345f 100644 --- a/packages/marionette_cli/lib/src/cli/commands/get_logs_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/get_logs_command.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; class LogsCommand extends InstanceCommand { LogsCommand(this._registry); diff --git a/packages/marionette_cli/lib/src/cli/commands/hot_reload_command.dart b/packages/marionette_cli/lib/src/cli/commands/hot_reload_command.dart index ef496577..56e38be4 100644 --- a/packages/marionette_cli/lib/src/cli/commands/hot_reload_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/hot_reload_command.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; class HotReloadCommand extends InstanceCommand { HotReloadCommand(this._registry); diff --git a/packages/marionette_cli/lib/src/cli/commands/hot_restart_command.dart b/packages/marionette_cli/lib/src/cli/commands/hot_restart_command.dart index 1aaaa1da..b8a8b85d 100644 --- a/packages/marionette_cli/lib/src/cli/commands/hot_restart_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/hot_restart_command.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; class HotRestartCommand extends InstanceCommand { HotRestartCommand(this._registry); diff --git a/packages/marionette_cli/lib/src/cli/commands/long_press_command.dart b/packages/marionette_cli/lib/src/cli/commands/long_press_command.dart index 2403cb81..04bf55c6 100644 --- a/packages/marionette_cli/lib/src/cli/commands/long_press_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/long_press_command.dart @@ -4,7 +4,7 @@ import 'package:marionette_cli/src/cli/ancestor_keys_option.dart'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/cli/matcher_builder.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; class LongPressCommand extends InstanceCommand { LongPressCommand(this._registry) { @@ -37,9 +37,14 @@ class LongPressCommand extends InstanceCommand { @override Future<int> execute(VmServiceConnector connector) async { - final matcher = buildValidatedMatcherFromArgs( - argResults, - fail: usageException, + final matcher = buildMatcherFromArgs( + key: argResults?['key'] as String?, + identifier: argResults?['identifier'] as String?, + text: argResults?['text'] as String?, + type: argResults?['type'] as String?, + x: _parseNum(argResults?['x'] as String?), + y: _parseNum(argResults?['y'] as String?), + ancestorKeys: argResults?['ancestor-keys'] as List<String>? ?? const [], ); if (!hasSelector(matcher)) { @@ -61,4 +66,9 @@ class LongPressCommand extends InstanceCommand { stdout.writeln(message); return 0; } + + num? _parseNum(String? value) { + if (value == null) return null; + return num.tryParse(value); + } } diff --git a/packages/marionette_cli/lib/src/cli/commands/mcp_command.dart b/packages/marionette_cli/lib/src/cli/commands/mcp_command.dart index ce1e3a93..a4615805 100644 --- a/packages/marionette_cli/lib/src/cli/commands/mcp_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/mcp_command.dart @@ -1,9 +1,23 @@ import 'package:args/command_runner.dart'; -import 'package:marionette_mcp/marionette_mcp.dart'; +import 'package:marionette_mcp/src/mcp_server_runner.dart'; class McpCommand extends Command<int> { McpCommand() { - ServerConfig.addOptions(argParser); + argParser + ..addOption( + 'log-level', + abbr: 'l', + defaultsTo: 'INFO', + help: 'Log level (FINEST, FINER, FINE, CONFIG, INFO, WARNING, SEVERE).', + ) + ..addOption( + 'log-file', + help: 'Path to log file. If not set, logs to stderr.', + ) + ..addOption( + 'sse-port', + help: 'Port for SSE server. If not set, uses stdio transport.', + ); } @override @@ -15,11 +29,11 @@ class McpCommand extends Command<int> { @override Future<int> run() async { - final config = ServerConfig.fromResults(argResults!); - return runMcpServer( - logLevel: config.logLevel, - logFile: config.logFile, - ssePort: config.ssePort, - ); + final logLevel = (argResults!.option('log-level') ?? 'INFO').toUpperCase(); + final logFile = argResults!.option('log-file'); + final ssePortStr = argResults!.option('sse-port'); + final ssePort = ssePortStr != null ? int.tryParse(ssePortStr) : null; + + return runMcpServer(logLevel: logLevel, logFile: logFile, ssePort: ssePort); } } diff --git a/packages/marionette_cli/lib/src/cli/commands/pinch_zoom_command.dart b/packages/marionette_cli/lib/src/cli/commands/pinch_zoom_command.dart index b91813ee..e47dd58f 100644 --- a/packages/marionette_cli/lib/src/cli/commands/pinch_zoom_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/pinch_zoom_command.dart @@ -4,7 +4,7 @@ import 'package:marionette_cli/src/cli/ancestor_keys_option.dart'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/cli/matcher_builder.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; class PinchZoomCommand extends InstanceCommand { PinchZoomCommand(this._registry) { @@ -43,11 +43,22 @@ class PinchZoomCommand extends InstanceCommand { @override Future<int> execute(VmServiceConnector connector) async { - final matcher = buildValidatedMatcherFromArgs( - argResults, - fail: usageException, + final matcher = buildMatcherFromArgs( + key: argResults?['key'] as String?, + identifier: argResults?['identifier'] as String?, + text: argResults?['text'] as String?, + type: argResults?['type'] as String?, + x: _parseNum(argResults?['x'] as String?), + y: _parseNum(argResults?['y'] as String?), + ancestorKeys: argResults?['ancestor-keys'] as List<String>? ?? const [], ); + final xStr = argResults?['x'] as String?; + final yStr = argResults?['y'] as String?; + if ((xStr == null) != (yStr == null)) { + usageException('--x and --y must be provided together.'); + } + if (!hasSelector(matcher)) { usageException( 'At least one matcher required: --key, --identifier, --text, --type, ' @@ -56,17 +67,14 @@ class PinchZoomCommand extends InstanceCommand { } final scaleStr = argResults?['scale'] as String; - final scale = - parseFiniteNumber('scale', scaleStr, usageException).toDouble(); - if (scale <= 0) { + final scale = double.tryParse(scaleStr); + if (scale == null || scale <= 0) { usageException('--scale must be a positive number, got "$scaleStr".'); } final distanceStr = argResults?['start-distance'] as String; - final startDistance = - parseFiniteNumber('start-distance', distanceStr, usageException) - .toDouble(); - if (startDistance <= 0) { + final startDistance = double.tryParse(distanceStr); + if (startDistance == null || startDistance <= 0) { usageException( '--start-distance must be a positive number, got "$distanceStr".', ); @@ -82,4 +90,9 @@ class PinchZoomCommand extends InstanceCommand { stdout.writeln(message); return 0; } + + num? _parseNum(String? value) { + if (value == null) return null; + return num.tryParse(value); + } } diff --git a/packages/marionette_cli/lib/src/cli/commands/press_back_button_command.dart b/packages/marionette_cli/lib/src/cli/commands/press_back_button_command.dart index d2655916..df5f90fd 100644 --- a/packages/marionette_cli/lib/src/cli/commands/press_back_button_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/press_back_button_command.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; class PressBackButtonCommand extends InstanceCommand { PressBackButtonCommand(this._registry); diff --git a/packages/marionette_cli/lib/src/cli/commands/press_key_command.dart b/packages/marionette_cli/lib/src/cli/commands/press_key_command.dart index f384b886..26a81691 100644 --- a/packages/marionette_cli/lib/src/cli/commands/press_key_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/press_key_command.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; class PressKeyCommand extends InstanceCommand { PressKeyCommand(this._registry) { diff --git a/packages/marionette_cli/lib/src/cli/commands/record_video_command.dart b/packages/marionette_cli/lib/src/cli/commands/record_video_command.dart index 518df6c7..557dd996 100644 --- a/packages/marionette_cli/lib/src/cli/commands/record_video_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/record_video_command.dart @@ -4,7 +4,13 @@ import 'dart:io'; import 'package:marionette_cli/src/cli/adb_helper.dart'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; +import 'package:marionette_mcp/src/video/ffmpeg_process.dart'; +import 'package:marionette_mcp/src/video/recording_session.dart'; +import 'package:marionette_mcp/src/video/tcp_frame_reader.dart'; +import 'package:marionette_mcp/src/video/video_options.dart'; +import 'package:marionette_mcp/src/video/video_recorder.dart'; +import 'package:marionette_mcp/src/video/ws_frame_server.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; /// Signature for checking whether ffmpeg is available. typedef FfmpegAvailabilityChecker = Future<bool> Function({String ffmpegPath}); @@ -347,29 +353,18 @@ class RecordVideoCommand extends InstanceCommand { // returned transport info without actually starting the screencast, // so we just need one real startScreencast with wsPort. final wsServer = await _wsFrameServerFactory(); - try { - await connector.startScreencast( - maxWidth: effectiveSize?.width, - maxHeight: effectiveSize?.height, - wsPort: wsServer.port, - ); - session = await _wsSessionFactory( - frameSource: wsServer, - outputFile: outputPath, - width: videoSize.width, - height: videoSize.height, - ffmpegPath: ffmpegPath, - ); - // The session now owns the server and closes it on stop(). - } catch (_) { - // Until the session is created, this scope owns the server. - try { - await wsServer.close(); - } catch (_) { - // Preserve the startup error if cleanup also fails. - } - rethrow; - } + await connector.startScreencast( + maxWidth: effectiveSize?.width, + maxHeight: effectiveSize?.height, + wsPort: wsServer.port, + ); + session = await _wsSessionFactory( + frameSource: wsServer, + outputFile: outputPath, + width: videoSize.width, + height: videoSize.height, + ffmpegPath: ffmpegPath, + ); } else { // Auto or forced TCP: try TCP first. // diff --git a/packages/marionette_cli/lib/src/cli/commands/scroll_to_command.dart b/packages/marionette_cli/lib/src/cli/commands/scroll_to_command.dart index ebd62a63..2fb28275 100644 --- a/packages/marionette_cli/lib/src/cli/commands/scroll_to_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/scroll_to_command.dart @@ -4,7 +4,7 @@ import 'package:marionette_cli/src/cli/ancestor_keys_option.dart'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/cli/matcher_builder.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; class ScrollToCommand extends InstanceCommand { ScrollToCommand(this._registry) { @@ -29,9 +29,11 @@ class ScrollToCommand extends InstanceCommand { @override Future<int> execute(VmServiceConnector connector) async { - final matcher = buildValidatedMatcherFromArgs( - argResults, - fail: usageException, + final matcher = buildMatcherFromArgs( + key: argResults?['key'] as String?, + identifier: argResults?['identifier'] as String?, + text: argResults?['text'] as String?, + ancestorKeys: argResults?['ancestor-keys'] as List<String>? ?? const [], ); if (!hasSelector(matcher)) { diff --git a/packages/marionette_cli/lib/src/cli/commands/secondary_tap_command.dart b/packages/marionette_cli/lib/src/cli/commands/secondary_tap_command.dart index 24d3055e..7e93f5de 100644 --- a/packages/marionette_cli/lib/src/cli/commands/secondary_tap_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/secondary_tap_command.dart @@ -4,7 +4,7 @@ import 'package:marionette_cli/src/cli/ancestor_keys_option.dart'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/cli/matcher_builder.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; class SecondaryTapCommand extends InstanceCommand { SecondaryTapCommand(this._registry) { @@ -33,9 +33,14 @@ class SecondaryTapCommand extends InstanceCommand { @override Future<int> execute(VmServiceConnector connector) async { - final matcher = buildValidatedMatcherFromArgs( - argResults, - fail: usageException, + final matcher = buildMatcherFromArgs( + key: argResults?['key'] as String?, + identifier: argResults?['identifier'] as String?, + text: argResults?['text'] as String?, + type: argResults?['type'] as String?, + x: _parseNum(argResults?['x'] as String?), + y: _parseNum(argResults?['y'] as String?), + ancestorKeys: argResults?['ancestor-keys'] as List<String>? ?? const [], ); if (!hasSelector(matcher)) { @@ -51,4 +56,9 @@ class SecondaryTapCommand extends InstanceCommand { stdout.writeln(message); return 0; } + + num? _parseNum(String? value) { + if (value == null) return null; + return num.tryParse(value); + } } diff --git a/packages/marionette_cli/lib/src/cli/commands/set_device_config_command.dart b/packages/marionette_cli/lib/src/cli/commands/set_device_config_command.dart index caa1146f..cbfca03b 100644 --- a/packages/marionette_cli/lib/src/cli/commands/set_device_config_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/set_device_config_command.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; class SetDeviceConfigCommand extends InstanceCommand { SetDeviceConfigCommand(this._registry) { diff --git a/packages/marionette_cli/lib/src/cli/commands/swipe_command.dart b/packages/marionette_cli/lib/src/cli/commands/swipe_command.dart index 385e5e8b..65329670 100644 --- a/packages/marionette_cli/lib/src/cli/commands/swipe_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/swipe_command.dart @@ -4,7 +4,7 @@ import 'package:marionette_cli/src/cli/ancestor_keys_option.dart'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/cli/matcher_builder.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; class SwipeCommand extends InstanceCommand { SwipeCommand(this._registry) { @@ -24,19 +24,13 @@ class SwipeCommand extends InstanceCommand { help: 'Swipe distance in pixels for element-based mode.', defaultsTo: '200', ) - ..addOption( - 'start-x', - help: 'Start X coordinate for coordinate-based swipe.', - ) - ..addOption( - 'start-y', - help: 'Start Y coordinate for coordinate-based swipe.', - ) + ..addOption('start-x', + help: 'Start X coordinate for coordinate-based swipe.') + ..addOption('start-y', + help: 'Start Y coordinate for coordinate-based swipe.') ..addOption('end-x', help: 'End X coordinate for coordinate-based swipe.') - ..addOption( - 'end-y', - help: 'End Y coordinate for coordinate-based swipe.', - ); + ..addOption('end-y', + help: 'End Y coordinate for coordinate-based swipe.'); } final InstanceRegistry _registry; @@ -99,9 +93,12 @@ class SwipeCommand extends InstanceCommand { swipeArgs['endX'] = endX; swipeArgs['endY'] = endY; } else { - final matcher = buildValidatedMatcherFromArgs( - argResults, - fail: usageException, + final matcher = buildMatcherFromArgs( + key: argResults?['key'] as String?, + identifier: argResults?['identifier'] as String?, + text: argResults?['text'] as String?, + type: argResults?['type'] as String?, + ancestorKeys: argResults?['ancestor-keys'] as List<String>? ?? const [], ); if (!hasSelector(matcher)) { usageException( @@ -135,6 +132,8 @@ class SwipeCommand extends InstanceCommand { } void _ensureNumber(String flag, String value) { - parseFiniteNumber(flag.substring(2), value, usageException); + if (double.tryParse(value) == null) { + usageException('$flag must be a number, got "$value".'); + } } } diff --git a/packages/marionette_cli/lib/src/cli/commands/take_screenshots_command.dart b/packages/marionette_cli/lib/src/cli/commands/take_screenshots_command.dart index 93e488c3..369a9cec 100644 --- a/packages/marionette_cli/lib/src/cli/commands/take_screenshots_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/take_screenshots_command.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:path/path.dart' as p; class ScreenshotCommand extends InstanceCommand { diff --git a/packages/marionette_cli/lib/src/cli/commands/tap_command.dart b/packages/marionette_cli/lib/src/cli/commands/tap_command.dart index 8eaa34f1..2f39ece1 100644 --- a/packages/marionette_cli/lib/src/cli/commands/tap_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/tap_command.dart @@ -4,7 +4,7 @@ import 'package:marionette_cli/src/cli/ancestor_keys_option.dart'; import 'package:marionette_cli/src/cli/instance_command.dart'; import 'package:marionette_cli/src/cli/matcher_builder.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; class TapCommand extends InstanceCommand { TapCommand(this._registry) { @@ -32,9 +32,14 @@ class TapCommand extends InstanceCommand { @override Future<int> execute(VmServiceConnector connector) async { - final matcher = buildValidatedMatcherFromArgs( - argResults, - fail: usageException, + final matcher = buildMatcherFromArgs( + key: argResults?['key'] as String?, + identifier: argResults?['identifier'] as String?, + text: argResults?['text'] as String?, + type: argResults?['type'] as String?, + x: _parseNum(argResults?['x'] as String?), + y: _parseNum(argResults?['y'] as String?), + ancestorKeys: argResults?['ancestor-keys'] as List<String>? ?? const [], ); if (!hasSelector(matcher)) { @@ -49,4 +54,9 @@ class TapCommand extends InstanceCommand { stdout.writeln(message); return 0; } + + num? _parseNum(String? value) { + if (value == null) return null; + return num.tryParse(value); + } } diff --git a/packages/marionette_cli/lib/src/cli/commands/unregister_command.dart b/packages/marionette_cli/lib/src/cli/commands/unregister_command.dart index 623396d2..02f664f7 100644 --- a/packages/marionette_cli/lib/src/cli/commands/unregister_command.dart +++ b/packages/marionette_cli/lib/src/cli/commands/unregister_command.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:args/command_runner.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; class UnregisterCommand extends Command<int> { UnregisterCommand(this._registry) { diff --git a/packages/marionette_cli/lib/src/cli/instance_command.dart b/packages/marionette_cli/lib/src/cli/instance_command.dart index c9d9a6ea..a1f633b8 100644 --- a/packages/marionette_cli/lib/src/cli/instance_command.dart +++ b/packages/marionette_cli/lib/src/cli/instance_command.dart @@ -3,7 +3,10 @@ import 'dart:io'; import 'package:args/command_runner.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; +import 'package:marionette_mcp/src/session/session.dart'; +import 'package:marionette_mcp/src/session/session_manager.dart'; +import 'package:marionette_mcp/src/session/step_logger.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; /// Base class for commands that operate on a connected Flutter app instance. /// diff --git a/packages/marionette_cli/lib/src/cli/matcher_builder.dart b/packages/marionette_cli/lib/src/cli/matcher_builder.dart index 5b4a22a8..f60af7b6 100644 --- a/packages/marionette_cli/lib/src/cli/matcher_builder.dart +++ b/packages/marionette_cli/lib/src/cli/matcher_builder.dart @@ -1,7 +1,4 @@ -import 'package:args/args.dart'; -import 'package:marionette_mcp/runtime.dart' show buildMatcher; - -export 'package:marionette_mcp/runtime.dart' show hasSelector; +import 'dart:convert'; /// Builds a widget matcher map from CLI arguments. /// @@ -18,74 +15,26 @@ Map<String, dynamic> buildMatcherFromArgs({ bool focused = false, List<String> ancestorKeys = const [], }) { - return buildMatcher({ - if (key != null) 'key': key, - if (identifier != null) 'identifier': identifier, - if (text != null) 'text': text, - if (type != null) 'type': type, - if (x != null) 'x': x, - if (y != null) 'y': y, - if (focused) 'focused_element': true, - if (ancestorKeys.isNotEmpty) 'ancestor_keys': ancestorKeys, - }); -} - -/// Parses a finite numeric CLI option without silently discarding invalid input. -num parseFiniteNumber( - String option, - String value, - Never Function(String) fail, -) { - final number = num.tryParse(value); - if (number == null || !number.isFinite) { - fail('--$option must be a finite number, got "$value".'); + final matcher = <String, dynamic>{}; + if (key != null) matcher['key'] = key; + if (identifier != null) matcher['identifier'] = identifier; + if (text != null) matcher['text'] = text; + if (type != null) matcher['type'] = type; + if (x != null) matcher['x'] = x; + if (y != null) matcher['y'] = y; + if (focused) matcher['focused'] = true; + // The VM service only carries string values, so the scope chain travels + // JSON-encoded — a delimiter could appear inside a key. + if (ancestorKeys.isNotEmpty) { + matcher['ancestor_keys'] = jsonEncode(ancestorKeys); } - return number; + return matcher; } -/// Builds a matcher after validating the command's targeting options. +/// Whether [matcher] identifies an element to act on. /// -/// Element selectors may be combined, but coordinate and focused targeting -/// cannot be combined with another targeting mode. Ancestor scope retains its -/// existing wire representation, including when the target ignores that scope. -Map<String, dynamic> buildValidatedMatcherFromArgs( - ArgResults? args, { - required Never Function(String) fail, -}) { - String? option(String name) => - args?.options.contains(name) == true ? args![name] as String? : null; - - final key = option('key'); - final identifier = option('identifier'); - final text = option('text'); - final type = option('type'); - final xText = option('x'); - final yText = option('y'); - if ((xText == null) != (yText == null)) { - fail('--x and --y must be provided together.'); - } - final x = xText == null ? null : parseFiniteNumber('x', xText, fail); - final y = yText == null ? null : parseFiniteNumber('y', yText, fail); - final focused = - args?.options.contains('focused') == true && args!['focused'] == true; - final hasElement = - key != null || identifier != null || text != null || type != null; - if (x != null && (hasElement || focused)) { - fail('Cannot mix --x/--y with element or focused selectors.'); - } - if (focused && hasElement) { - fail('Cannot mix --focused with --key, --identifier, --text, or --type.'); - } - return buildMatcherFromArgs( - key: key, - identifier: identifier, - text: text, - type: type, - x: x, - y: y, - focused: focused, - ancestorKeys: args?.options.contains('ancestor-keys') == true - ? args!['ancestor-keys'] as List<String> - : const [], - ); +/// `ancestor_keys` only narrows where the search happens, so a matcher +/// carrying nothing but a scope still selects nothing. +bool hasSelector(Map<String, dynamic> matcher) { + return matcher.keys.any((field) => field != 'ancestor_keys'); } diff --git a/packages/marionette_cli/test/record_video_command_test.dart b/packages/marionette_cli/test/record_video_command_test.dart index 00559830..fa740136 100644 --- a/packages/marionette_cli/test/record_video_command_test.dart +++ b/packages/marionette_cli/test/record_video_command_test.dart @@ -6,7 +6,10 @@ import 'package:args/args.dart'; import 'package:marionette_cli/src/cli/adb_helper.dart'; import 'package:marionette_cli/src/cli/commands/record_video_command.dart'; import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; +import 'package:marionette_mcp/src/video/ffmpeg_process.dart'; +import 'package:marionette_mcp/src/video/recording_session.dart'; +import 'package:marionette_mcp/src/video/ws_frame_server.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:test/test.dart'; /// Mirrors ScreencastService.computeFrameSize for mock connectors. @@ -107,12 +110,10 @@ class MockRecordingSession implements RecordingSession { bool stopCalled = false; int frameCount; Duration duration; - final Future<void> Function()? onStop; MockRecordingSession({ this.frameCount = 10, this.duration = const Duration(seconds: 2), - this.onStop, }); @override @@ -123,7 +124,6 @@ class MockRecordingSession implements RecordingSession { @override Future<RecordingResult> stop() async { stopCalled = true; - await onStop?.call(); return RecordingResult( outputFile: '/tmp/test.webm', duration: duration, @@ -893,118 +893,23 @@ void main() { TestableRecordVideoCommand createWsCommand({ MockRecordingSession? session, - WsRecordingSessionFactory? wsSessionFactory, }) { final s = session ?? mockSession; return TestableRecordVideoCommand( ffmpegChecker: ({String ffmpegPath = 'ffmpeg'}) async => true, sessionFactory: _mockSessionFactory(s), - wsSessionFactory: wsSessionFactory ?? - ({ - required FrameSource frameSource, - required String outputFile, - required int width, - required int height, - required String ffmpegPath, - }) async => - s, + wsSessionFactory: ({ + required FrameSource frameSource, + required String outputFile, + required int width, + required int height, + required String ffmpegPath, + }) async => + s, wsFrameServerFactory: () async => fakeWsServer, ); } - test( - 'When WS screencast startup throws, Then server is closed', - () async { - final error = StateError('screencast startup failed'); - connector.wsStartError = error; - var sessionFactoryCalled = false; - final testCommand = createWsCommand( - wsSessionFactory: ({ - required FrameSource frameSource, - required String outputFile, - required int width, - required int height, - required String ffmpegPath, - }) async { - sessionFactoryCalled = true; - return mockSession; - }, - ); - - await expectLater( - testCommand.executeWithArgs(['-o', '/tmp/test.webm'], connector), - throwsA(same(error)), - ); - - expect(sessionFactoryCalled, isFalse); - expect(fakeWsServer.closeCallCount, 1); - expect(connector.stopScreencastCallCount, 1); - }, - ); - - for (final closeThrows in [false, true]) { - test( - 'When WS session creation throws and closeThrows=$closeThrows, ' - 'Then server cleanup preserves startup error', () async { - final error = StateError('session startup failed'); - if (closeThrows) { - fakeWsServer.closeError = StateError('server close failed'); - } - final testCommand = createWsCommand( - wsSessionFactory: ({ - required FrameSource frameSource, - required String outputFile, - required int width, - required int height, - required String ffmpegPath, - }) async => - throw error, - ); - - await expectLater( - testCommand.executeWithArgs(['-o', '/tmp/test.webm'], connector), - throwsA(same(error)), - ); - - expect(connector.lastWsPort, fakeWsServer.port); - expect(fakeWsServer.closeCallCount, 1); - expect(connector.stopScreencastCallCount, 1); - }); - } - - test( - 'When WS session owns the server, Then only session closes it', - () async { - final testCommand = createWsCommand( - wsSessionFactory: ({ - required FrameSource frameSource, - required String outputFile, - required int width, - required int height, - required String ffmpegPath, - }) async { - expect(frameSource, same(fakeWsServer)); - return MockRecordingSession( - onStop: () async { - expect(fakeWsServer.closeCallCount, 0); - await frameSource.close(); - }, - ); - }, - ); - - final exitCode = await testCommand.executeWithArgs([ - '-o', - '/tmp/test.webm', - '-d', - '1', - ], connector); - - expect(exitCode, 0); - expect(fakeWsServer.closeCallCount, 1); - }, - ); - test( 'When transport is ws, Then wsPort is passed to startScreencast', () async { @@ -1284,8 +1189,6 @@ class WsMockConnector implements VmServiceConnector { int get startScreencastCallCount => startScreencastCalls.length; int? get lastWsPort => startScreencastCalls.lastOrNull?.wsPort; - Object? wsStartError; - int viewportWidth = 800; int viewportHeight = 600; @@ -1302,9 +1205,6 @@ class WsMockConnector implements VmServiceConnector { wsPort: wsPort, ), ); - if (wsPort != null && wsStartError != null) { - throw wsStartError!; - } final (frameW, frameH) = _computeFrameSize( viewportWidth, viewportHeight, @@ -1338,17 +1238,11 @@ class FakeWsFrameServer implements WebSocketFrameServer { @override final int port; - int closeCallCount = 0; - Object? closeError; - @override Stream<SourceFrame> get frames => const Stream.empty(); @override - Future<void> close() async { - closeCallCount++; - if (closeError != null) throw closeError!; - } + Future<void> close() async {} @override Future<void> start() async {} diff --git a/packages/marionette_cli/test/selector_commands_test.dart b/packages/marionette_cli/test/selector_commands_test.dart deleted file mode 100644 index 6078bfd9..00000000 --- a/packages/marionette_cli/test/selector_commands_test.dart +++ /dev/null @@ -1,318 +0,0 @@ -import 'package:args/args.dart'; -import 'package:args/command_runner.dart'; -import 'package:marionette_cli/src/cli/commands/tap_command.dart'; -import 'package:marionette_cli/src/cli/commands/double_tap_command.dart'; -import 'package:marionette_cli/src/cli/commands/secondary_tap_command.dart'; -import 'package:marionette_cli/src/cli/commands/long_press_command.dart'; -import 'package:marionette_cli/src/cli/commands/pinch_zoom_command.dart'; -import 'package:marionette_cli/src/cli/commands/enter_text_command.dart'; -import 'package:marionette_cli/src/cli/commands/scroll_to_command.dart'; -import 'package:marionette_cli/src/cli/commands/swipe_command.dart'; -import 'package:marionette_cli/src/cli/instance_command.dart'; -import 'package:marionette_cli/src/instance_registry.dart'; -import 'package:marionette_mcp/runtime.dart'; -import 'package:test/test.dart'; - -mixin _ParsedCommand on InstanceCommand { - ArgResults? parsed; - - @override - ArgResults? get argResults => parsed; - - Future<int> executeWithArgs(List<String> args, VmServiceConnector connector) { - CommandRunner<int>('marionette', 'Selector command tests').addCommand(this); - parsed = argParser.parse(args); - return execute(connector); - } -} - -class _Connector implements VmServiceConnector { - final calls = <Invocation>[]; - - @override - dynamic noSuchMethod(Invocation invocation) { - calls.add(invocation); - return Future<Map<String, dynamic>>.value({}); - } -} - -class _Tap extends TapCommand with _ParsedCommand { - _Tap() : super(InstanceRegistry()); -} - -class _DoubleTap extends DoubleTapCommand with _ParsedCommand { - _DoubleTap() : super(InstanceRegistry()); -} - -class _SecondaryTap extends SecondaryTapCommand with _ParsedCommand { - _SecondaryTap() : super(InstanceRegistry()); -} - -class _LongPress extends LongPressCommand with _ParsedCommand { - _LongPress() : super(InstanceRegistry()); -} - -class _PinchZoom extends PinchZoomCommand with _ParsedCommand { - _PinchZoom() : super(InstanceRegistry()); -} - -class _EnterText extends EnterTextCommand with _ParsedCommand { - _EnterText() : super(InstanceRegistry()); -} - -class _ScrollTo extends ScrollToCommand with _ParsedCommand { - _ScrollTo() : super(InstanceRegistry()); -} - -class _Swipe extends SwipeCommand with _ParsedCommand { - _Swipe() : super(InstanceRegistry()); -} - -void main() { - final gestures = <String, _ParsedCommand Function()>{ - 'tap': _Tap.new, - 'double-tap': _DoubleTap.new, - 'secondary-tap': _SecondaryTap.new, - 'long-press': _LongPress.new, - 'pinch-zoom': _PinchZoom.new, - }; - for (final entry in gestures.entries) { - group(entry.key, () { - final requiredArgs = - entry.key == 'pinch-zoom' ? ['--scale', '2'] : <String>[]; - final invalidArgs = <List<String>>[ - ['--x', '10'], - ['--y', '20'], - ['--x', 'typo', '--y', '20'], - ['--key', 'buy', '--x', 'typo', '--y', '20'], - ['--x', '10', '--y', 'typo'], - ['--x', 'NaN', '--y', '20'], - ['--x', '10', '--y', 'Infinity'], - ['--x', '-Infinity', '--y', '20'], - ['--key', 'buy', '--x', '10', '--y', '20'], - ['--identifier', 'buy', '--x', '10', '--y', '20'], - ['--text', 'Buy', '--x', '10', '--y', '20'], - ['--type', 'Button', '--x', '10', '--y', '20'], - ['--ancestor-keys', 'card'], - ]; - for (final args in invalidArgs) { - test('rejects ${args.join(' ')} before calling connector', () async { - final connector = _Connector(); - await expectLater( - entry.value().executeWithArgs([ - ...args, - ...requiredArgs, - ], connector), - throwsA(isA<UsageException>()), - ); - expect(connector.calls, isEmpty); - }); - } - test('preserves scoped combined element selectors', () async { - final connector = _Connector(); - await entry.value().executeWithArgs([ - '--key', - 'buy', - '--identifier', - 'purchase', - '--text', - 'Buy', - '--type', - 'Button', - '--ancestor-keys', - 'outer', - '--ancestor-keys', - 'inner', - ...requiredArgs, - ], connector); - expect(connector.calls.single.positionalArguments.first, { - 'key': 'buy', - 'identifier': 'purchase', - 'text': 'Buy', - 'type': 'Button', - 'ancestor_keys': '["outer","inner"]', - }); - }); - test('preserves finite coordinate targeting and scope', () async { - final connector = _Connector(); - await entry.value().executeWithArgs([ - '--x', - '-10.5', - '--y', - '2e2', - '--ancestor-keys', - 'card', - ...requiredArgs, - ], connector); - expect(connector.calls.single.positionalArguments.first, { - 'x': -10.5, - 'y': 200, - 'ancestor_keys': '["card"]', - }); - }); - }); - } - for (final option in ['scale', 'start-distance']) { - for (final value in ['NaN', 'Infinity', '-Infinity']) { - test('pinch-zoom rejects --$option $value before calling connector', - () async { - final connector = _Connector(); - await expectLater( - _PinchZoom().executeWithArgs([ - '--key', - 'image', - '--scale', - '2', - '--$option', - value, - ], connector), - throwsA(isA<UsageException>()), - ); - expect(connector.calls, isEmpty); - }); - } - } - test('pinch-zoom preserves positive finite scale and start distance', - () async { - final connector = _Connector(); - await _PinchZoom().executeWithArgs([ - '--key', - 'image', - '--scale', - '0.5', - '--start-distance', - '150.5', - ], connector); - expect(connector.calls.single.namedArguments, { - #scale: 0.5, - #startDistance: 150.5, - }); - }); - test('enter-text preserves focused targeting with ancestor scope', () async { - final connector = _Connector(); - await _EnterText().executeWithArgs([ - '--focused', - '--ancestor-keys', - 'card', - '--input', - 'Hello', - ], connector); - expect(connector.calls.single.positionalArguments, [ - {'focused': true, 'ancestor_keys': '["card"]'}, - 'Hello', - ]); - }); - for (final selector in ['key', 'identifier', 'text']) { - test('enter-text rejects focused plus $selector', () async { - final connector = _Connector(); - await expectLater( - _EnterText().executeWithArgs([ - '--focused', - '--$selector', - 'field', - '--input', - 'Hello', - ], connector), - throwsA(isA<UsageException>()), - ); - expect(connector.calls, isEmpty); - }); - } - test('scroll-to preserves scoped text selector', () async { - final connector = _Connector(); - await _ScrollTo().executeWithArgs([ - '--text', - 'Buy', - '--ancestor-keys', - 'card', - ], connector); - expect(connector.calls.single.positionalArguments.first, { - 'text': 'Buy', - 'ancestor_keys': '["card"]', - }); - }); - for (final value in ['typo', 'NaN', 'Infinity', '-Infinity']) { - test('swipe rejects nonfinite or malformed coordinate $value', () async { - final connector = _Connector(); - await expectLater( - _Swipe().executeWithArgs([ - '--start-x', - value, - '--start-y', - '10', - '--end-x', - '20', - '--end-y', - '30', - ], connector), - throwsA(isA<UsageException>()), - ); - expect(connector.calls, isEmpty); - }); - } - for (final args in <List<String>>[ - ['--start-x', '10'], - ['--start-x', '10', '--start-y', '20', '--end-x', '30'], - [ - '--key', - 'carousel', - '--start-x', - '10', - '--start-y', - '20', - '--end-x', - '30', - '--end-y', - '40', - ], - ['--ancestor-keys', 'card', '--direction', 'left'], - ]) { - test( - 'swipe rejects incomplete or mixed target ${args.join(' ')}', - () async { - final connector = _Connector(); - await expectLater( - _Swipe().executeWithArgs(args, connector), - throwsA(isA<UsageException>()), - ); - expect(connector.calls, isEmpty); - }, - ); - } - test('swipe preserves scoped element targeting', () async { - final connector = _Connector(); - await _Swipe().executeWithArgs([ - '--key', - 'carousel', - '--ancestor-keys', - 'card', - '--direction', - 'left', - ], connector); - expect(connector.calls.single.positionalArguments.first, { - 'key': 'carousel', - 'ancestor_keys': '["card"]', - 'direction': 'left', - 'distance': '200', - }); - }); - test('swipe preserves finite coordinate representation', () async { - final connector = _Connector(); - await _Swipe().executeWithArgs([ - '--start-x', - '-1.5', - '--start-y', - '10', - '--end-x', - '20', - '--end-y', - '30', - ], connector); - expect(connector.calls.single.positionalArguments.first, { - 'startX': '-1.5', - 'startY': '10', - 'endX': '20', - 'endY': '30', - }); - }); -} diff --git a/packages/marionette_flutter/skills/marionette-flutter-drive-app/SKILL.md b/packages/marionette_flutter/skills/marionette-flutter-drive-app/SKILL.md index a68343c3..2f95fa5d 100644 --- a/packages/marionette_flutter/skills/marionette-flutter-drive-app/SKILL.md +++ b/packages/marionette_flutter/skills/marionette-flutter-drive-app/SKILL.md @@ -622,20 +622,14 @@ table above maps onto it one-for-one (`tap` ↔ this side. `ancestor_keys` is `--ancestor-keys`, repeated once per key, outermost first: `tap --key cell.joinButton --ancestor-keys session_2 --ancestor-keys grid.cell_3`. -Coordinate targeting requires both `--x` and `--y` as finite numbers; coordinate -swipes require all four start/end coordinates. Do not mix coordinate or focused -targeting with element selectors. Invalid combinations fail before invoking -the app. Ancestor scope still applies only to element targeting. -Pinch-zoom `--scale` and `--start-distance` must be positive, finite numbers. Use `--uri <ws-uri>` for a one-off session and `register <name> <uri>` + `-i <name>` for repeated interaction with the same app; `marionette doctor` checks connectivity of every registered instance and `unregister` cleans up stale ones. Session reports work the same -way here: each invocation creates a fresh session directory. Pass -`--session <title>` (the CLI's equivalent of `session_title`) to label it; -reusing a title does not resume an earlier directory. For a multi-command run, -keep the session paths printed by each command and cite the relevant directories -in the report — see *Reporting what you found*, above. +way here: pass `--session <title>` (the CLI's equivalent of `session_title`) +consistently across a script's invocations to log a multi-step run into one +session directory instead of a fresh, untitled one per command — see +*Reporting what you found*, above. ## Keeping this skill in sync diff --git a/packages/marionette_mcp/lib/mcp/server_config.dart b/packages/marionette_mcp/lib/mcp/server_config.dart index 6b49eaa2..3130036c 100644 --- a/packages/marionette_mcp/lib/mcp/server_config.dart +++ b/packages/marionette_mcp/lib/mcp/server_config.dart @@ -1,6 +1,6 @@ import 'package:args/args.dart'; -/// Command-line configuration shared by both Marionette server entrypoints. +/// Command-line configuration for the standalone Marionette MCP server. class ServerConfig { const ServerConfig({this.logLevel = 'INFO', this.logFile, this.ssePort}); diff --git a/packages/marionette_mcp/lib/runtime.dart b/packages/marionette_mcp/lib/runtime.dart index 0155268d..020fdb1a 100644 --- a/packages/marionette_mcp/lib/runtime.dart +++ b/packages/marionette_mcp/lib/runtime.dart @@ -1,17 +1,4 @@ -/// Shared app connection, session reporting, and recording runtime. -/// -/// Both CLI adapters and MCP tools use this supported package boundary. +/// App connection APIs for embedding the Marionette MCP server. library; -export 'src/formatting.dart'; -export 'src/session/session.dart'; -export 'src/session/session_manager.dart'; -export 'src/session/report_steps.dart'; -export 'src/video/ffmpeg_process.dart'; -export 'src/video/frame_protocol.dart'; -export 'src/video/recording_session.dart'; -export 'src/video/tcp_frame_reader.dart'; -export 'src/video/video_options.dart'; -export 'src/video/video_recorder.dart'; -export 'src/video/ws_frame_server.dart'; export 'src/vm_service/vm_service_connector.dart'; diff --git a/tool/check_frame_protocol.dart b/tool/check_frame_protocol.dart deleted file mode 100644 index bf5cbf10..00000000 --- a/tool/check_frame_protocol.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'dart:typed_data'; - -import '../packages/marionette_flutter/lib/src/services/frame_protocol.dart' - as producer; -import '../packages/marionette_mcp/lib/src/video/frame_protocol.dart' - as consumer; - -// Exercise both production implementations without coupling their packages. -void main() { - _check(producer.FrameHeader.byteLength == 20, 'Producer header length'); - _check(consumer.FrameHeader.byteLength == 20, 'Consumer header length'); - _check(producer.FrameHeader.magic == 0x4D524E54, 'Producer magic'); - _check(consumer.FrameHeader.magic == 0x4D524E54, 'Consumer magic'); - - const endianHeader = producer.FrameHeader( - frameLength: 0x01020304, - width: 0x05060708, - height: 0x090A0B0C, - timestampMs: 0x0D0E0F10, - ); - _checkBytes( - endianHeader.encode(), - [ - 0x54, - 0x4E, - 0x52, - 0x4D, - 0x04, - 0x03, - 0x02, - 0x01, - 0x08, - 0x07, - 0x06, - 0x05, - 0x0C, - 0x0B, - 0x0A, - 0x09, - 0x10, - 0x0F, - 0x0E, - 0x0D, - ], - 'Little-endian wire bytes'); - - for (final value in [0, 1, 0x7FFFFFFF, 0x80000000, 0xFFFFFFFF]) { - final header = producer.FrameHeader( - frameLength: value, - width: value, - height: value, - timestampMs: value, - ); - _checkHeader(consumer.FrameHeader.decode(header.encode()), header); - } - - final fixtures = [ - _Fixture(0, 0, 0), - _Fixture(1, 1, 1), - _Fixture(2, 3, 0x80000000), - _Fixture(256, 1, 0xFFFFFFFF), - ]; - final stream = BytesBuilder(); - for (final fixture in fixtures) { - final encoded = fixture.header.encodeWithPayload(fixture.payload); - _check( - encoded.length == 20 + fixture.payload.length, - 'Encoded frame length', - ); - _checkBytes( - encoded.sublist(0, 20), - fixture.header.encode(), - 'Combined frame header', - ); - _checkBytes(encoded.sublist(20), fixture.payload, 'Combined frame payload'); - stream.add(encoded); - } - final bytes = stream.takeBytes(); - // Cover single-byte delivery, header/payload boundaries, and coalesced frames. - for (final chunkSizes in [ - [1], - [19, 1, 3, 20, 7], - [20, 4, 21, 23], - [bytes.length], - ]) { - final assembler = consumer.FrameAssembler(); - var offset = 0; - var chunkIndex = 0; - var frameIndex = 0; - while (offset < bytes.length) { - final requestedEnd = offset + chunkSizes[chunkIndex % chunkSizes.length]; - final end = requestedEnd < bytes.length ? requestedEnd : bytes.length; - assembler.addData(Uint8List.sublistView(bytes, offset, end)); - offset = end; - chunkIndex++; - for (final frame in assembler.drain()) { - _check(frameIndex < fixtures.length, 'Unexpected extra frame'); - final fixture = fixtures[frameIndex++]; - _checkHeader(frame.header, fixture.header); - _checkBytes(frame.rgbaBytes, fixture.payload, 'Assembled RGBA payload'); - } - } - _check(frameIndex == fixtures.length, 'Missing assembled frames'); - _check(assembler.drain().isEmpty, 'Unexpected trailing frame'); - } - print('Frame protocol verified: header boundaries and 4 fragmented streams.'); -} - -class _Fixture { - _Fixture(int width, int height, int timestampMs) - : payload = Uint8List.fromList( - List.generate(width * height * 4, (index) => (index * 31 + 7) & 0xFF), - ), - header = producer.FrameHeader( - frameLength: width * height * 4, - width: width, - height: height, - timestampMs: timestampMs, - ); - - final producer.FrameHeader header; - final Uint8List payload; -} - -void _checkHeader(consumer.FrameHeader actual, producer.FrameHeader expected) { - _check(actual.frameLength == expected.frameLength, 'Header frameLength'); - _check(actual.width == expected.width, 'Header width'); - _check(actual.height == expected.height, 'Header height'); - _check(actual.timestampMs == expected.timestampMs, 'Header timestampMs'); -} - -void _checkBytes(List<int> actual, List<int> expected, String label) { - _check(actual.length == expected.length, '$label length'); - for (var index = 0; index < expected.length; index++) { - _check(actual[index] == expected[index], '$label at byte $index'); - } -} - -void _check(bool condition, String label) { - if (!condition) throw StateError('Frame protocol mismatch: $label'); -} From 16fe0f2ac97fb279b05a51a046340dc1d7d5da60 Mon Sep 17 00:00:00 2001 From: Jhin Lee <jhin.lee@unity3d.com> Date: Sat, 24 Oct 2026 11:22:03 -0400 Subject: [PATCH 06/12] Separate MCP adapters from runtime workflows --- docs/architecture.md | 134 ++++++-- .../marionette_mcp/lib/marionette_mcp.dart | 7 +- .../lib/mcp/server_context.dart | 63 ---- .../lib/mcp/tools/connection_tools.dart | 307 ------------------ .../marionette_mcp/lib/{ => src}/mcp/mcp.dart | 0 .../lib/{ => src}/mcp/server_config.dart | 0 .../lib/src/mcp/server_context.dart | 41 +++ .../lib/{ => src}/mcp/server_runner.dart | 2 +- .../lib/{ => src}/mcp/step_logging.dart | 4 +- .../mcp/tools/ancestor_keys_description.dart | 0 .../lib/{ => src}/mcp/tools/base_tool.dart | 0 .../lib/src/mcp/tools/connection_tools.dart | 187 +++++++++++ .../lib/{ => src}/mcp/tools/device_tools.dart | 2 +- .../mcp/tools/dynamic_extension_tools.dart | 4 +- .../{ => src}/mcp/tools/extension_tools.dart | 2 +- .../{ => src}/mcp/tools/gesture_tools.dart | 4 +- .../{ => src}/mcp/tools/inspection_tools.dart | 119 +++---- .../{ => src}/mcp/tools/keyboard_tools.dart | 2 +- .../lib/{ => src}/mcp/tools/system_tools.dart | 0 .../lib/{ => src}/mcp/tools/text_tools.dart | 4 +- .../lib/{ => src}/mcp/tools/tool_results.dart | 0 .../lib/{ => src}/mcp/tools/tool_runner.dart | 0 .../lib/{ => src}/mcp/tools/tools.dart | 4 +- .../lib/src/mcp_server_runner.dart | 2 +- .../lib/src/session/screenshot_service.dart | 95 ++++++ .../lib/src/session/step_logger.dart | 2 +- .../vm_service/app_connection_service.dart | 210 ++++++++++++ .../vm_service/dynamic_extension_tools.dart | 2 +- .../tools/ancestor_keys_description.dart | 2 +- .../lib/src/vm_service/tools/tool_runner.dart | 2 +- .../test/architecture_test.dart | 79 +++++ .../test/session/screenshot_service_test.dart | 142 ++++++++ .../test/structured_tool_outputs_test.dart | 37 ++- .../app_connection_service_test.dart | 303 +++++++++++++++++ .../vm_service/tools/device_tools_test.dart | 2 +- .../tools/extension_tools_test.dart | 2 +- .../tools/inspection_tools_test.dart | 2 +- 37 files changed, 1258 insertions(+), 510 deletions(-) delete mode 100644 packages/marionette_mcp/lib/mcp/server_context.dart delete mode 100644 packages/marionette_mcp/lib/mcp/tools/connection_tools.dart rename packages/marionette_mcp/lib/{ => src}/mcp/mcp.dart (100%) rename packages/marionette_mcp/lib/{ => src}/mcp/server_config.dart (100%) create mode 100644 packages/marionette_mcp/lib/src/mcp/server_context.dart rename packages/marionette_mcp/lib/{ => src}/mcp/server_runner.dart (99%) rename packages/marionette_mcp/lib/{ => src}/mcp/step_logging.dart (98%) rename packages/marionette_mcp/lib/{ => src}/mcp/tools/ancestor_keys_description.dart (100%) rename packages/marionette_mcp/lib/{ => src}/mcp/tools/base_tool.dart (100%) create mode 100644 packages/marionette_mcp/lib/src/mcp/tools/connection_tools.dart rename packages/marionette_mcp/lib/{ => src}/mcp/tools/device_tools.dart (98%) rename packages/marionette_mcp/lib/{ => src}/mcp/tools/dynamic_extension_tools.dart (98%) rename packages/marionette_mcp/lib/{ => src}/mcp/tools/extension_tools.dart (99%) rename packages/marionette_mcp/lib/{ => src}/mcp/tools/gesture_tools.dart (99%) rename packages/marionette_mcp/lib/{ => src}/mcp/tools/inspection_tools.dart (80%) rename packages/marionette_mcp/lib/{ => src}/mcp/tools/keyboard_tools.dart (97%) rename packages/marionette_mcp/lib/{ => src}/mcp/tools/system_tools.dart (100%) rename packages/marionette_mcp/lib/{ => src}/mcp/tools/text_tools.dart (96%) rename packages/marionette_mcp/lib/{ => src}/mcp/tools/tool_results.dart (100%) rename packages/marionette_mcp/lib/{ => src}/mcp/tools/tool_runner.dart (100%) rename packages/marionette_mcp/lib/{ => src}/mcp/tools/tools.dart (89%) create mode 100644 packages/marionette_mcp/lib/src/session/screenshot_service.dart create mode 100644 packages/marionette_mcp/lib/src/vm_service/app_connection_service.dart create mode 100644 packages/marionette_mcp/test/architecture_test.dart create mode 100644 packages/marionette_mcp/test/session/screenshot_service_test.dart create mode 100644 packages/marionette_mcp/test/vm_service/app_connection_service_test.dart diff --git a/docs/architecture.md b/docs/architecture.md index 7432a300..40d21822 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,45 +5,94 @@ composition, configuration, and typed tools: ```text packages/marionette_mcp/ - bin/marionette_mcp.dart parse options and start the server - lib/marionette_mcp.dart public server API - lib/runtime.dart public app connection API for embedding - lib/mcp/ - mcp.dart createMcpServer and server instructions - server_config.dart standalone MCP server options - server_context.dart shared connection/session state and ownership - server_runner.dart logging, transport startup, and shutdown - step_logging.dart MCP result/session logging adapter - tools/ - base_tool.dart metadata and execute contract - tools.dart createAllTools and registration - connection_tools.dart connect/disconnect contracts and lifecycle calls - dynamic_extension_tools.dart app-defined typed tools and registrations - *_tools.dart tools grouped by responsibility - lib/src/ runtime implementation details + bin/marionette_mcp.dart parse options and run the server + lib/ + marionette_mcp.dart public server entrypoint + runtime.dart public app connection entrypoint + src/ + mcp/ + mcp.dart composition root and server instructions + server_config.dart standalone MCP server options + server_context.dart runtime-to-MCP lifecycle wiring + server_runner.dart logging, transports, and shutdown + step_logging.dart MCP result logging adapter + tools/ + base_tool.dart schema/metadata/execution contract + tools.dart complete built-in tool factory + *_tools.dart thin MCP adapters by capability + dynamic_extension_tools.dart app-specific registration + vm_service/ + app_connection_service.dart handshake, sessions, ownership + vm_service_connector.dart VM transport and app operations + session/ + screenshot_service.dart capture and persistence + session_manager.dart report directories and retention + report_steps.dart report formatting and persistence + video/ recording implementation ``` -Each static tool extends `BaseTool` and receives its connector and logger -through its constructor. Screenshot tools also receive the session step -logger. `createAllTools` composes all 20 built-in tools, including connection -tools, without opening sockets or starting a process. `createMcpServer` uses -`registerMcpTools` to register them and apply step logging once, at the -composition boundary. The context contains no tool definitions or registration -loop. Connection tools own their additional -session-report transitions; app-defined tools are discovered dynamically. -In-flight tool calls retain their original report session; a superseded -connection attempt cannot add a step to its replacement's report. +Only the two `lib/` entrypoints are intended for imports by consumers. All +implementation lives under `src/`, following Dart's package convention. The +`mcp/` directory groups protocol adapters within that internal implementation. + +## Responsibility boundaries + +| Layer | Owns | +| --- | --- | +| MCP tools | Input/output schemas, request validation, invoking services, mapping results/errors to MCP text/images/structured data | +| Runtime services | Connection workflow, binding compatibility, report-session policy, transition ownership, screenshot storage | +| VM connector | VM transport, app service-extension calls, and app operations | +| Composition root | Constructing dependencies, registering tools, and wiring lifecycle callbacks | +| Server runner | Transport startup and deterministic shutdown | + +A tool does not create report directories, decide connection compatibility, or +manage asynchronous connection state. It translates between the MCP contract +and typed runtime inputs/results. Input validation and presentation stay in the +adapter; workflow and persistence stay in services. Simple app-operation tools +can delegate directly to the existing VM connector without an extra service +wrapper for every method. + +```mermaid +flowchart LR + Client[MCP client] --> Tool[MCP tool adapter] + Tool --> Service[Runtime service] + Service --> Connector[VM connector] + Connector --> App[Flutter app] +``` + +`AppConnectionService` has no MCP dependency. It owns version checks, session +opt-in/setup, overlapping transitions, and disposal, returning typed outcomes. +The MCP context wires its lifecycle callbacks to dynamic registration and step +logging. This keeps app state authoritative in the service while protocol +callbacks remain adapters. A superseded attempt cannot create a replacement +session or log into it; disconnect retains its original session for its final +report step. + +`ScreenshotService` likewise has no MCP dependency. It captures images and +saves numbered files without overwriting prior captures. The tool supplies the +session captured at invocation and maps the typed capture into MCP images or +saved paths. + +`createAllTools` builds all 20 built-in tools without opening sockets. +`registerMcpTools` registers them once and applies shared step logging. +Connection tools log their own session transitions because their report owner +changes during execution. App-defined tools have a dedicated dynamic registry, +which retains registration handles and removes them on disconnect/reconnect. ## Public package boundaries Embedders import `package:marionette_mcp/marionette_mcp.dart` for server -composition and startup, and `package:marionette_mcp/runtime.dart` for the app -connector. Existing CLI adapters continue using retained internal compatibility -exports for server startup and shared runtime access. +composition, owned context, configuration, and startup. Its exports are explicit; +tool implementation classes remain internal. They import +`package:marionette_mcp/runtime.dart` for the app connector. +Existing CLI adapters continue using retained internal compatibility +exports for server startup and shared runtime access; their migration is +separate from the MCP refactor. + MCP tools and executable options keep their names and input schemas. -Shared report formatting and persistence live in `src/session/report_steps.dart` -without an MCP dependency; MCP result interpretation lives in `mcp/step_logging.dart`. -Former internal MCP paths are compatibility exports of the new implementations. +Runtime implementations cannot import the MCP SDK or MCP adapters. The few +former internal MCP paths remain export-only compatibility bridges for existing +consumers, and runtime code does not depend on those bridges. ## Structured tool results @@ -74,6 +123,21 @@ Disposing the context retires dynamic tools and closes its app connection. Disconnecting releases dynamic tool names, so reconnecting uses the current app's schemas without retaining disabled registrations. +## Adding or changing a tool + +1. Put reusable workflow/state/persistence behavior in a runtime service. Keep + the service API typed and independent of MCP. +2. Implement a `BaseTool` adapter with its input schema, output schema, metadata, + and result/error formatting. Inject its dependencies through the constructor. +3. Add it to the appropriate factory and the central built-in registry. Keep + external resource acquisition out of tool constructors and registration. +4. Test service behavior directly, then check the adapter's structured output, + errors, and public SDK interaction. Update consumer guidance for observable + changes. + +The architecture test enforces the public entrypoints and dependency direction; +it also prevents compatibility bridges from accumulating implementation logic. + ## Verification From the workspace root, resolve dependencies with `flutter pub get`. Run @@ -82,8 +146,10 @@ and `packages/marionette_cli`, then `flutter test --no-pub` in `packages/marionette_flutter`. `server_api_test.dart` drives the real executable through public SDK client -APIs under legacy initialization and stateless discovery, including successful -structured results, dynamic extension removal, and ordinary error results. +APIs to verify catalogs and disconnected errors under legacy initialization +and stateless discovery. Additional in-process public SDK tests use +`IOStreamTransport` to verify successful structured results, dynamic extension +removal, and ordinary error results. Its catalog fixture was captured from the original 0.7.0 implementation with SDK 2.4.3; update it only for an intentional tool-contract change. The comparison permits the new diff --git a/packages/marionette_mcp/lib/marionette_mcp.dart b/packages/marionette_mcp/lib/marionette_mcp.dart index 4f598a17..dc90304a 100644 --- a/packages/marionette_mcp/lib/marionette_mcp.dart +++ b/packages/marionette_mcp/lib/marionette_mcp.dart @@ -1,7 +1,8 @@ /// Marionette MCP server composition and startup. library; -export 'mcp/mcp.dart'; -export 'mcp/server_config.dart'; -export 'mcp/server_runner.dart' show runMcpServer; +export 'src/mcp/mcp.dart' show createMcpServer, registerMcpTools; +export 'src/mcp/server_config.dart' show ServerConfig; +export 'src/mcp/server_context.dart' show VmServiceContext; +export 'src/mcp/server_runner.dart' show runMcpServer; export 'src/version.g.dart' show version; diff --git a/packages/marionette_mcp/lib/mcp/server_context.dart b/packages/marionette_mcp/lib/mcp/server_context.dart deleted file mode 100644 index 2bb4efd0..00000000 --- a/packages/marionette_mcp/lib/mcp/server_context.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/mcp/step_logging.dart'; -import 'package:marionette_mcp/src/session/session_manager.dart'; -import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; - -import 'tools/dynamic_extension_tools.dart'; - -/// Shared app connection and session state, owned by the server lifecycle. -/// -/// Tool metadata and registration live in the MCP composition and tool classes. -final class VmServiceContext { - VmServiceContext({VmServiceConnector? connector}) - : connector = connector ?? VmServiceConnector(); - - final VmServiceConnector connector; - final logging.Logger logger = logging.Logger('VmServiceContext'); - final SessionManager sessionManager = SessionManager(); - final StepLogger stepLogger = StepLogger(); - - DynamicExtensionTools? _dynamicTools; - bool _disposed = false; - int _connectionEpoch = 0; - - /// Whether the server owner has retired this context. - bool get isDisposed => _disposed; - - /// Starts a transition that supersedes older connection/session work. - int beginTransition() => ++_connectionEpoch; - - /// Whether asynchronous work still belongs to the active connection. - bool ownsConnection(int epoch) => !_disposed && epoch == _connectionEpoch; - - /// Rejects asynchronous work superseded by another transition or disposal. - void ensureCurrentConnection(int epoch) { - if (!ownsConnection(epoch)) { - throw StateError('The app connection was closed or superseded.'); - } - } - - /// Gives this context ownership of its app-specific MCP registrations. - void attachDynamicTools(DynamicExtensionTools tools) { - if (_disposed || _dynamicTools != null) { - throw StateError( - 'The context is disposed or already attached to a server.'); - } - _dynamicTools = tools; - } - - /// Discovers the connected app's schema-bearing extension tools. - Future<void> registerDynamicTools() async => _dynamicTools?.registerAll(); - - /// Retires all app-specific tools before replacing or closing a connection. - void removeDynamicTools() => _dynamicTools?.removeAll(); - - /// Releases dynamic registrations, reports, and the VM-service connection. - Future<void> dispose() async { - _disposed = true; - ++_connectionEpoch; - removeDynamicTools(); - stepLogger.session = null; - await connector.disconnect(); - } -} diff --git a/packages/marionette_mcp/lib/mcp/tools/connection_tools.dart b/packages/marionette_mcp/lib/mcp/tools/connection_tools.dart deleted file mode 100644 index 146f4759..00000000 --- a/packages/marionette_mcp/lib/mcp/tools/connection_tools.dart +++ /dev/null @@ -1,307 +0,0 @@ -import 'package:marionette_mcp/mcp/step_logging.dart'; -import 'package:marionette_mcp/src/version.g.dart' as v; -import 'package:mcp_dart/mcp_dart.dart'; - -import '../server_context.dart'; -import 'base_tool.dart'; - -/// Connection tools log their own session transitions instead of using the -/// ordinary invocation wrapper, which captures the session before execution. -abstract class ConnectionTool extends BaseTool { - ConnectionTool(this.context); - - final VmServiceContext context; -} - -/// Connects an app and opens its optional report session. -class ConnectTool extends ConnectionTool { - ConnectTool(super.context); - - @override - String get name => 'connect'; - - @override - String get description => - 'Connects to a Flutter app via its VM service URI. This must be called before using any other tools. The VM service URI is typically in the format ws://127.0.0.1:PORT/ws and can be found in the Flutter app output when running in debug mode. If the app enabled session reports (MarionetteConfiguration.enableSessionReports), this also opens a fresh session directory under .marionette/sessions/ where the step log and screenshots for this run are kept.'; - - @override - ToolAnnotations get annotations => - const ToolAnnotations(title: 'Connect to App'); - - @override - ToolInputSchema get inputSchema => ToolInputSchema( - properties: { - 'uri': JsonSchema.string( - description: - 'VM service URI (e.g., ws://127.0.0.1:8181/ws). This is printed in the Flutter app console when running in debug mode.', - ), - 'session_title': JsonSchema.string( - description: - 'A short title for this run, e.g. "profile validation". ' - 'Keep it under ~60 characters — longer is truncated. ' - 'Ignored unless the app enabled session reports. ' - 'Slugified and timestamped into the session directory ' - 'name. Purely a human-readable label — every connect ' - 'opens its own fresh session directory, even if this ' - 'matches an earlier title. Falls back to "run-<timestamp>" ' - 'when omitted.', - ), - 'session_dir': JsonSchema.string( - description: - 'Base directory .marionette/sessions/ is created under. ' - 'Overrides the MARIONETTE_SESSION_DIR environment variable. ' - 'Only needed when the MCP client hasn\'t set that variable ' - 'and the process\'s working directory isn\'t the project root.', - ), - }, - required: ['uri'], - ); - - @override - ToolOutputSchema get outputSchema => ToolOutputSchema( - properties: { - 'uri': - JsonSchema.string(description: 'The connected VM service URI.'), - 'sessionReportsEnabled': JsonSchema.boolean(), - 'sessionPath': JsonSchema.string( - description: 'The new report directory, when enabled.'), - 'warning': - JsonSchema.string(description: 'Nonfatal report setup failure.'), - }, - required: ['uri', 'sessionReportsEnabled'], - ); - - @override - Future<CallToolResult> execute( - Map<String, dynamic> args, RequestHandlerExtra extra) async { - final epoch = context.beginTransition(); - final result = await _connect(args, epoch); - if (context.ownsConnection(epoch)) { - context.stepLogger.logStep(name, args, result); - } - return result; - } - - Future<CallToolResult> _connect( - Map<String, dynamic> args, - int epoch, - ) async { - final uri = args['uri'] as String; - context.logger.info('Connecting to app at $uri'); - // A new connect attempt immediately supersedes whatever session - // was active before, regardless of whether this attempt itself - // succeeds — otherwise a call that slips in while this attempt is - // still in flight (or after it fails) would append to a session - // that's no longer the one actually in use. - context.stepLogger.session = null; - context.removeDynamicTools(); - - try { - context.ensureCurrentConnection(epoch); - await context.connector.connect(uri); - context.ensureCurrentConnection(epoch); - - // Version compatibility check — unwind the connection on mismatch - // so the next call to connect can start fresh. - try { - final bindingVersion = await context.connector.getVersion(); - context.ensureCurrentConnection(epoch); - if (bindingVersion != v.version) { - await context.connector.disconnect(); - return CallToolResult( - isError: true, - content: [ - TextContent( - text: 'Version mismatch: marionette_mcp is ${v.version}, ' - 'but marionette_flutter binding is $bindingVersion. ' - 'Please ensure both packages are the same version.', - ), - ], - ); - } - } catch (err) { - context.logger.warning('Failed to check binding version', err); - if (context.ownsConnection(epoch)) { - await context.connector.disconnect(); - } - return CallToolResult( - isError: true, - content: [ - TextContent( - text: 'Failed to verify marionette_flutter binding version. ' - 'Please ensure marionette_flutter is up to date. ' - 'Error: $err', - ), - ], - ); - } - - // Promote each schema-bearing custom extension into a first-class - // MCP tool. Failures here are logged but don't fail the connect — - // the generic call_custom_extension fallback keeps working. - await context.registerDynamicTools(); - context.ensureCurrentConnection(epoch); - - // Session reports are opt-in app-side - // (MarionetteConfiguration.enableSessionReports): with them off, - // connect writes nothing to disk and steps go unlogged. Failing - // to even determine that counts as off too, same as a session - // directory that fails to open below — the app connection and - // every other tool already work fine either way, so neither is - // worth dropping an otherwise-working connection over. - var sessionReportsEnabled = false; - try { - sessionReportsEnabled = - await context.connector.getSessionReportsEnabled(); - } catch (err) { - context.logger.warning( - 'Failed to read binding configuration; continuing without ' - 'session reports', - err, - ); - } - context.ensureCurrentConnection(epoch); - if (!sessionReportsEnabled) { - context.stepLogger.session = null; - return CallToolResult( - structuredContent: {'uri': uri, 'sessionReportsEnabled': false}, - content: [ - TextContent(text: 'Successfully connected to app at $uri'), - ], - ); - } - - try { - final session = context.sessionManager.create( - title: args['session_title'] as String?, - baseDirOverride: args['session_dir'] as String?, - ); - context.stepLogger.session = session; - - return CallToolResult( - structuredContent: { - 'uri': uri, - 'sessionReportsEnabled': true, - 'sessionPath': session.directory.path, - }, - content: [ - TextContent( - text: 'Successfully connected to app at $uri\n' - 'Opened session: ${session.directory.path}', - ), - ], - ); - } catch (err) { - // Session setup is best-effort: the app connection and every - // other tool already work fine without it, so a directory - // creation failure (e.g. an unwritable session_dir) shouldn't - // drop an otherwise-working connection. Degrade the same way - // an app that opted out of session reports already does — - // connect succeeds, just without step logging. - context.logger.warning( - 'Failed to open session directory; continuing without ' - 'session reports', - err, - ); - context.stepLogger.session = null; - return CallToolResult( - structuredContent: { - 'uri': uri, - 'sessionReportsEnabled': false, - 'warning': err.toString(), - }, - content: [ - TextContent( - text: 'Successfully connected to app at $uri\n' - 'Could not open a session directory, so this run ' - 'will not be logged: $err', - ), - ], - ); - } - } catch (err) { - context.logger.severe('Failed to connect to app', err); - return CallToolResult( - isError: true, - content: [TextContent(text: 'Failed to connect to app: $err')], - ); - } - } -} - -/// Disconnects an app while retaining its report until the final step is logged. -class DisconnectTool extends ConnectionTool { - DisconnectTool(super.context); - - @override - String get name => 'disconnect'; - - @override - String get description => - 'Disconnects from the currently connected Flutter app. After disconnecting, you must call connect again to use any other tools.'; - - @override - ToolAnnotations get annotations => - const ToolAnnotations(title: 'Disconnect from App'); - - @override - ToolInputSchema get inputSchema => const ToolInputSchema(properties: {}); - - @override - ToolOutputSchema get outputSchema => ToolOutputSchema( - properties: { - 'disconnected': JsonSchema.boolean(), - 'sessionPath': JsonSchema.string( - description: 'The report directory of the closed session.'), - }, - required: ['disconnected'], - ); - - @override - Future<CallToolResult> execute( - Map<String, dynamic> args, RequestHandlerExtra extra) async { - final epoch = context.beginTransition(); - final disconnectLogger = StepLogger()..session = context.stepLogger.session; - context.logger.info('Disconnecting from app'); - - try { - // Retire dynamic tools before tearing down the connection so a - // mid-disconnect tools/list reflects only what's still callable. - context.removeDynamicTools(); - await context.connector.disconnect(); - - final session = disconnectLogger.session; - final nudge = session == null - ? '' - : '\nSession: ${session.directory.path}\nWrite report.md now.'; - final result = CallToolResult( - structuredContent: { - 'disconnected': true, - if (session != null) 'sessionPath': session.directory.path, - }, - content: [ - TextContent( - text: 'Successfully disconnected from app$nudge', - ), - ], - ); - // Attribute the step to the connection being closed. A newer - // connect may already own a different session while cleanup waits. - if (!context.isDisposed) { - disconnectLogger.logStep('disconnect', args, result); - } - if (context.ownsConnection(epoch)) context.stepLogger.session = null; - return result; - } catch (err) { - context.logger.severe('Error during disconnect', err); - final result = CallToolResult( - isError: true, - content: [TextContent(text: 'Error during disconnect: $err')], - ); - if (!context.isDisposed) { - disconnectLogger.logStep('disconnect', args, result); - } - return result; - } - } -} diff --git a/packages/marionette_mcp/lib/mcp/mcp.dart b/packages/marionette_mcp/lib/src/mcp/mcp.dart similarity index 100% rename from packages/marionette_mcp/lib/mcp/mcp.dart rename to packages/marionette_mcp/lib/src/mcp/mcp.dart diff --git a/packages/marionette_mcp/lib/mcp/server_config.dart b/packages/marionette_mcp/lib/src/mcp/server_config.dart similarity index 100% rename from packages/marionette_mcp/lib/mcp/server_config.dart rename to packages/marionette_mcp/lib/src/mcp/server_config.dart diff --git a/packages/marionette_mcp/lib/src/mcp/server_context.dart b/packages/marionette_mcp/lib/src/mcp/server_context.dart new file mode 100644 index 00000000..4e85f68e --- /dev/null +++ b/packages/marionette_mcp/lib/src/mcp/server_context.dart @@ -0,0 +1,41 @@ +import 'package:logging/logging.dart' as logging; +import 'package:marionette_mcp/src/mcp/step_logging.dart'; +import 'package:marionette_mcp/src/vm_service/app_connection_service.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; + +import 'tools/dynamic_extension_tools.dart'; + +/// Bridges the app runtime to MCP registrations and session step logging. +final class VmServiceContext { + VmServiceContext({VmServiceConnector? connector}) { + connection = AppConnectionService( + connector: connector, + logger: logger, + onConnected: () async => _dynamicTools?.registerAll(), + onRetired: () => _dynamicTools?.removeAll(), + onSessionChanged: (session) => stepLogger.session = session, + ); + } + + late final AppConnectionService connection; + VmServiceConnector get connector => connection.connector; + final logging.Logger logger = logging.Logger('VmServiceContext'); + final StepLogger stepLogger = StepLogger(); + + DynamicExtensionTools? _dynamicTools; + + /// Whether the server owner has retired this context. + bool get isDisposed => connection.isDisposed; + + /// Gives this context ownership of its app-specific MCP registrations. + void attachDynamicTools(DynamicExtensionTools tools) { + if (isDisposed || _dynamicTools != null) { + throw StateError( + 'The context is disposed or already attached to a server.'); + } + _dynamicTools = tools; + } + + /// Releases dynamic registrations, reports, and the VM-service connection. + Future<void> dispose() => connection.dispose(); +} diff --git a/packages/marionette_mcp/lib/mcp/server_runner.dart b/packages/marionette_mcp/lib/src/mcp/server_runner.dart similarity index 99% rename from packages/marionette_mcp/lib/mcp/server_runner.dart rename to packages/marionette_mcp/lib/src/mcp/server_runner.dart index c3a6529a..dea4b0b1 100644 --- a/packages/marionette_mcp/lib/mcp/server_runner.dart +++ b/packages/marionette_mcp/lib/src/mcp/server_runner.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/mcp/mcp.dart'; +import 'package:marionette_mcp/src/mcp/mcp.dart'; import 'package:mcp_dart/mcp_dart.dart'; /// Runs the Marionette MCP server with the given configuration. diff --git a/packages/marionette_mcp/lib/mcp/step_logging.dart b/packages/marionette_mcp/lib/src/mcp/step_logging.dart similarity index 98% rename from packages/marionette_mcp/lib/mcp/step_logging.dart rename to packages/marionette_mcp/lib/src/mcp/step_logging.dart index 41883426..594201d1 100644 --- a/packages/marionette_mcp/lib/mcp/step_logging.dart +++ b/packages/marionette_mcp/lib/src/mcp/step_logging.dart @@ -1,7 +1,7 @@ import 'package:mcp_dart/mcp_dart.dart'; -import '../src/session/report_steps.dart'; -import '../src/session/session.dart'; +import '../session/report_steps.dart'; +import '../session/session.dart'; /// Tools whose successful result is a short, synthesized confirmation /// message (e.g. "Successfully tapped"), never a dump of application data — diff --git a/packages/marionette_mcp/lib/mcp/tools/ancestor_keys_description.dart b/packages/marionette_mcp/lib/src/mcp/tools/ancestor_keys_description.dart similarity index 100% rename from packages/marionette_mcp/lib/mcp/tools/ancestor_keys_description.dart rename to packages/marionette_mcp/lib/src/mcp/tools/ancestor_keys_description.dart diff --git a/packages/marionette_mcp/lib/mcp/tools/base_tool.dart b/packages/marionette_mcp/lib/src/mcp/tools/base_tool.dart similarity index 100% rename from packages/marionette_mcp/lib/mcp/tools/base_tool.dart rename to packages/marionette_mcp/lib/src/mcp/tools/base_tool.dart diff --git a/packages/marionette_mcp/lib/src/mcp/tools/connection_tools.dart b/packages/marionette_mcp/lib/src/mcp/tools/connection_tools.dart new file mode 100644 index 00000000..08167f89 --- /dev/null +++ b/packages/marionette_mcp/lib/src/mcp/tools/connection_tools.dart @@ -0,0 +1,187 @@ +import 'package:marionette_mcp/src/mcp/step_logging.dart'; +import 'package:marionette_mcp/src/vm_service/app_connection_service.dart'; +import 'package:mcp_dart/mcp_dart.dart'; + +import '../server_context.dart'; +import 'base_tool.dart'; + +/// Connection tools log their own session transitions instead of using the +/// ordinary invocation wrapper, which captures the session before execution. +abstract class ConnectionTool extends BaseTool { + ConnectionTool(this.context); + + final VmServiceContext context; +} + +/// Connects an app and opens its optional report session. +class ConnectTool extends ConnectionTool { + ConnectTool(super.context); + + @override + String get name => 'connect'; + + @override + String get description => + 'Connects to a Flutter app via its VM service URI. This must be called before using any other tools. The VM service URI is typically in the format ws://127.0.0.1:PORT/ws and can be found in the Flutter app output when running in debug mode. If the app enabled session reports (MarionetteConfiguration.enableSessionReports), this also opens a fresh session directory under .marionette/sessions/ where the step log and screenshots for this run are kept.'; + + @override + ToolAnnotations get annotations => + const ToolAnnotations(title: 'Connect to App'); + + @override + ToolInputSchema get inputSchema => ToolInputSchema( + properties: { + 'uri': JsonSchema.string( + description: + 'VM service URI (e.g., ws://127.0.0.1:8181/ws). This is printed in the Flutter app console when running in debug mode.', + ), + 'session_title': JsonSchema.string( + description: + 'A short title for this run, e.g. "profile validation". ' + 'Keep it under ~60 characters — longer is truncated. ' + 'Ignored unless the app enabled session reports. ' + 'Slugified and timestamped into the session directory ' + 'name. Purely a human-readable label — every connect ' + 'opens its own fresh session directory, even if this ' + 'matches an earlier title. Falls back to "run-<timestamp>" ' + 'when omitted.', + ), + 'session_dir': JsonSchema.string( + description: + 'Base directory .marionette/sessions/ is created under. ' + 'Overrides the MARIONETTE_SESSION_DIR environment variable. ' + 'Only needed when the MCP client hasn\'t set that variable ' + 'and the process\'s working directory isn\'t the project root.', + ), + }, + required: ['uri'], + ); + + @override + ToolOutputSchema get outputSchema => ToolOutputSchema( + properties: { + 'uri': + JsonSchema.string(description: 'The connected VM service URI.'), + 'sessionReportsEnabled': JsonSchema.boolean(), + 'sessionPath': JsonSchema.string( + description: 'The new report directory, when enabled.'), + 'warning': + JsonSchema.string(description: 'Nonfatal report setup failure.'), + }, + required: ['uri', 'sessionReportsEnabled'], + ); + + @override + Future<CallToolResult> execute( + Map<String, dynamic> args, RequestHandlerExtra extra) async { + final outcome = await context.connection.connect( + uri: args['uri'] as String, + sessionTitle: args['session_title'] as String?, + sessionDir: args['session_dir'] as String?, + ); + final result = _result(outcome); + if (context.connection.ownsConnection(outcome.generation)) { + context.stepLogger.logStep(name, args, result); + } + return result; + } + + CallToolResult _result(AppConnectionResult outcome) { + final failure = outcome.failure; + if (failure != null) { + final text = switch (failure.kind) { + AppConnectionFailureKind.versionMismatch => + 'Version mismatch: marionette_mcp is ${context.connection.expectedVersion}, ' + 'but marionette_flutter binding is ${failure.actualVersion}. ' + 'Please ensure both packages are the same version.', + AppConnectionFailureKind.versionCheck => + 'Failed to verify marionette_flutter binding version. ' + 'Please ensure marionette_flutter is up to date. ' + 'Error: ${failure.cause}', + AppConnectionFailureKind.connection => + 'Failed to connect to app: ${failure.cause}', + }; + return CallToolResult(isError: true, content: [TextContent(text: text)]); + } + final session = outcome.session; + final warning = outcome.reportWarning; + final text = 'Successfully connected to app at ${outcome.uri}'; + return CallToolResult( + structuredContent: { + 'uri': outcome.uri, + 'sessionReportsEnabled': session != null, + if (session != null) 'sessionPath': session.directory.path, + if (warning != null) 'warning': warning.toString(), + }, + content: [ + TextContent( + text: session != null + ? '$text\nOpened session: ${session.directory.path}' + : warning != null + ? '$text\nCould not open a session directory, so this run ' + 'will not be logged: $warning' + : text, + ), + ], + ); + } +} + +/// Disconnects an app while retaining its report until the final step is logged. +class DisconnectTool extends ConnectionTool { + DisconnectTool(super.context); + + @override + String get name => 'disconnect'; + + @override + String get description => + 'Disconnects from the currently connected Flutter app. After disconnecting, you must call connect again to use any other tools.'; + + @override + ToolAnnotations get annotations => + const ToolAnnotations(title: 'Disconnect from App'); + + @override + ToolInputSchema get inputSchema => const ToolInputSchema(properties: {}); + + @override + ToolOutputSchema get outputSchema => ToolOutputSchema( + properties: { + 'disconnected': JsonSchema.boolean(), + 'sessionPath': JsonSchema.string( + description: 'The report directory of the closed session.'), + }, + required: ['disconnected'], + ); + + @override + Future<CallToolResult> execute( + Map<String, dynamic> args, RequestHandlerExtra extra) async { + final outcome = await context.connection.disconnect(); + final disconnectLogger = StepLogger()..session = outcome.closingSession; + final session = outcome.closingSession; + final error = outcome.error; + final nudge = session == null + ? '' + : '\nSession: ${session.directory.path}\nWrite report.md now.'; + final result = error == null + ? CallToolResult( + structuredContent: { + 'disconnected': true, + if (session != null) 'sessionPath': session.directory.path, + }, + content: [ + TextContent(text: 'Successfully disconnected from app$nudge'), + ], + ) + : CallToolResult( + isError: true, + content: [TextContent(text: 'Error during disconnect: $error')], + ); + if (!context.isDisposed) { + disconnectLogger.logStep('disconnect', args, result); + } + return result; + } +} diff --git a/packages/marionette_mcp/lib/mcp/tools/device_tools.dart b/packages/marionette_mcp/lib/src/mcp/tools/device_tools.dart similarity index 98% rename from packages/marionette_mcp/lib/mcp/tools/device_tools.dart rename to packages/marionette_mcp/lib/src/mcp/tools/device_tools.dart index 9453a8a6..8e74a7a9 100644 --- a/packages/marionette_mcp/lib/mcp/tools/device_tools.dart +++ b/packages/marionette_mcp/lib/src/mcp/tools/device_tools.dart @@ -1,5 +1,5 @@ import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/mcp/tools/tool_runner.dart'; +import 'package:marionette_mcp/src/mcp/tools/tool_runner.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; diff --git a/packages/marionette_mcp/lib/mcp/tools/dynamic_extension_tools.dart b/packages/marionette_mcp/lib/src/mcp/tools/dynamic_extension_tools.dart similarity index 98% rename from packages/marionette_mcp/lib/mcp/tools/dynamic_extension_tools.dart rename to packages/marionette_mcp/lib/src/mcp/tools/dynamic_extension_tools.dart index 27100cb0..ab1fd381 100644 --- a/packages/marionette_mcp/lib/mcp/tools/dynamic_extension_tools.dart +++ b/packages/marionette_mcp/lib/src/mcp/tools/dynamic_extension_tools.dart @@ -1,6 +1,6 @@ import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/mcp/step_logging.dart'; -import 'package:marionette_mcp/mcp/tools/tool_runner.dart'; +import 'package:marionette_mcp/src/mcp/step_logging.dart'; +import 'package:marionette_mcp/src/mcp/tools/tool_runner.dart'; import 'package:marionette_mcp/src/vm_service/tools/arg_coercion.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; diff --git a/packages/marionette_mcp/lib/mcp/tools/extension_tools.dart b/packages/marionette_mcp/lib/src/mcp/tools/extension_tools.dart similarity index 99% rename from packages/marionette_mcp/lib/mcp/tools/extension_tools.dart rename to packages/marionette_mcp/lib/src/mcp/tools/extension_tools.dart index 033d3c8a..8e6397bc 100644 --- a/packages/marionette_mcp/lib/mcp/tools/extension_tools.dart +++ b/packages/marionette_mcp/lib/src/mcp/tools/extension_tools.dart @@ -1,5 +1,5 @@ import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/mcp/tools/tool_runner.dart'; +import 'package:marionette_mcp/src/mcp/tools/tool_runner.dart'; import 'package:marionette_mcp/src/vm_service/tools/arg_coercion.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; diff --git a/packages/marionette_mcp/lib/mcp/tools/gesture_tools.dart b/packages/marionette_mcp/lib/src/mcp/tools/gesture_tools.dart similarity index 99% rename from packages/marionette_mcp/lib/mcp/tools/gesture_tools.dart rename to packages/marionette_mcp/lib/src/mcp/tools/gesture_tools.dart index 60fc826b..65abfa84 100644 --- a/packages/marionette_mcp/lib/mcp/tools/gesture_tools.dart +++ b/packages/marionette_mcp/lib/src/mcp/tools/gesture_tools.dart @@ -1,7 +1,7 @@ import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/mcp/tools/ancestor_keys_description.dart'; -import 'package:marionette_mcp/mcp/tools/tool_runner.dart'; import 'package:marionette_mcp/src/formatting.dart'; +import 'package:marionette_mcp/src/mcp/tools/ancestor_keys_description.dart'; +import 'package:marionette_mcp/src/mcp/tools/tool_runner.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; diff --git a/packages/marionette_mcp/lib/mcp/tools/inspection_tools.dart b/packages/marionette_mcp/lib/src/mcp/tools/inspection_tools.dart similarity index 80% rename from packages/marionette_mcp/lib/mcp/tools/inspection_tools.dart rename to packages/marionette_mcp/lib/src/mcp/tools/inspection_tools.dart index 17a5a87b..e2c53ce1 100644 --- a/packages/marionette_mcp/lib/mcp/tools/inspection_tools.dart +++ b/packages/marionette_mcp/lib/src/mcp/tools/inspection_tools.dart @@ -1,14 +1,10 @@ -import 'dart:convert'; -import 'dart:io'; - import 'package:logging/logging.dart' as logging; import 'package:marionette_mcp/src/formatting.dart'; +import 'package:marionette_mcp/src/mcp/tools/tool_runner.dart'; +import 'package:marionette_mcp/src/session/screenshot_service.dart'; import 'package:marionette_mcp/src/session/session.dart'; -import 'package:marionette_mcp/mcp/step_logging.dart'; -import 'package:marionette_mcp/mcp/tools/tool_runner.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; -import 'package:path/path.dart' as p; import 'base_tool.dart'; @@ -16,12 +12,13 @@ import 'base_tool.dart'; List<BaseTool> createInspectionTools({ required VmServiceConnector connector, required logging.Logger logger, - required StepLogger stepLogger, + required ScreenshotService screenshotService, + required Session? Function() currentSession, }) => [ GetInteractiveElementsTool(connector, logger), GetLogsTool(connector, logger), - TakeScreenshotsTool(connector, logger, stepLogger), + TakeScreenshotsTool(screenshotService, logger, currentSession), ]; /// MCP tool `get_interactive_elements`. @@ -189,11 +186,11 @@ class GetLogsTool extends BaseTool { /// MCP tool `take_screenshots`. class TakeScreenshotsTool extends BaseTool { - TakeScreenshotsTool(this.connector, this.logger, this.stepLogger); + TakeScreenshotsTool(this.screenshotService, this.logger, this.currentSession); - final VmServiceConnector connector; + final ScreenshotService screenshotService; final logging.Logger logger; - final StepLogger stepLogger; + final Session? Function() currentSession; @override String get name => 'take_screenshots'; @@ -237,7 +234,7 @@ class TakeScreenshotsTool extends BaseTool { return runTool( logger, 'take screenshots', - () => takeScreenshots(connector, stepLogger.session, args), + () => _captureScreenshots(screenshotService, currentSession(), args), ); } } @@ -252,31 +249,21 @@ Future<CallToolResult> takeScreenshots( VmServiceConnector connector, Session? session, Map<String, dynamic> args, -) async { - final inline = args['inline'] != false; - final response = await connector.takeScreenshots(); - final screenshots = (response['screenshots'] as List<dynamic>).cast<String>(); - - if (screenshots.isEmpty) { - return CallToolResult( - content: [const TextContent(text: 'No screenshots captured')], - structuredContent: {'count': 0, 'inline': inline}, - ); - } +) => + _captureScreenshots(ScreenshotService(connector), session, args); - if (inline) { - return CallToolResult( - structuredContent: {'count': screenshots.length, 'inline': true}, - content: screenshots - .map( - (screenshot) => - ImageContent(data: screenshot, mimeType: 'image/png'), - ) - .toList(), +Future<CallToolResult> _captureScreenshots( + ScreenshotService screenshotService, + Session? session, + Map<String, dynamic> args, +) async { + try { + final capture = await screenshotService.capture( + inline: args['inline'] != false, + session: session, ); - } - - if (session == null) { + return _screenshotResult(capture); + } on ScreenshotSessionRequired { return CallToolResult( isError: true, content: [ @@ -289,52 +276,40 @@ Future<CallToolResult> takeScreenshots( ], ); } +} + +CallToolResult _screenshotResult(ScreenshotCapture capture) { + if (capture.count == 0) { + return CallToolResult( + content: [const TextContent(text: 'No screenshots captured')], + structuredContent: {'count': 0, 'inline': capture.inline}, + ); + } + + if (capture.inline) { + return CallToolResult( + structuredContent: {'count': capture.count, 'inline': true}, + content: capture.images + .map((image) => ImageContent(data: image, mimeType: 'image/png')) + .toList(), + ); + } - final paths = _saveScreenshots(session.screenshotsDir, screenshots); return CallToolResult( - structuredContent: {'count': paths.length, 'inline': false, 'paths': paths}, + structuredContent: { + 'count': capture.count, + 'inline': false, + 'paths': capture.paths, + }, content: [ TextContent( - text: 'Saved ${paths.length} screenshot(s):\n${paths.join('\n')}', + text: + 'Saved ${capture.count} screenshot(s):\n${capture.paths.join('\n')}', ), ], ); } -/// Saves each base64-encoded PNG in [screenshots] into [screenshotsDir], -/// numbered sequentially after whatever is already there, and returns the -/// paths written to. -List<String> _saveScreenshots( - Directory screenshotsDir, - List<String> screenshots, -) { - screenshotsDir.createSync(recursive: true); - // The next index is one past the highest existing numeric name, not a - // count of PNGs present — a gap (e.g. 01.png and 03.png after 02.png was - // deleted) would otherwise make a count-based index collide with, and - // overwrite, the existing 03.png. - final highestExisting = screenshotsDir - .listSync() - .whereType<File>() - .where((f) => p.extension(f.path) == '.png') - .map((f) => int.tryParse(p.basenameWithoutExtension(f.path))) - .whereType<int>() - .fold(0, (highest, n) => n > highest ? n : highest); - var nextIndex = highestExisting + 1; - - final paths = <String>[]; - for (final screenshot in screenshots) { - final path = p.join( - screenshotsDir.path, - '${nextIndex.toString().padLeft(2, '0')}.png', - ); - File(path).writeAsBytesSync(base64Decode(screenshot)); - paths.add(path); - nextIndex++; - } - return paths; -} - /// `ancestor_keys` as worded for `get_interactive_elements`, which lists a /// subtree rather than matching one element inside it. /// diff --git a/packages/marionette_mcp/lib/mcp/tools/keyboard_tools.dart b/packages/marionette_mcp/lib/src/mcp/tools/keyboard_tools.dart similarity index 97% rename from packages/marionette_mcp/lib/mcp/tools/keyboard_tools.dart rename to packages/marionette_mcp/lib/src/mcp/tools/keyboard_tools.dart index eff1faaa..421e1869 100644 --- a/packages/marionette_mcp/lib/mcp/tools/keyboard_tools.dart +++ b/packages/marionette_mcp/lib/src/mcp/tools/keyboard_tools.dart @@ -1,5 +1,5 @@ import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/mcp/tools/tool_runner.dart'; +import 'package:marionette_mcp/src/mcp/tools/tool_runner.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; diff --git a/packages/marionette_mcp/lib/mcp/tools/system_tools.dart b/packages/marionette_mcp/lib/src/mcp/tools/system_tools.dart similarity index 100% rename from packages/marionette_mcp/lib/mcp/tools/system_tools.dart rename to packages/marionette_mcp/lib/src/mcp/tools/system_tools.dart diff --git a/packages/marionette_mcp/lib/mcp/tools/text_tools.dart b/packages/marionette_mcp/lib/src/mcp/tools/text_tools.dart similarity index 96% rename from packages/marionette_mcp/lib/mcp/tools/text_tools.dart rename to packages/marionette_mcp/lib/src/mcp/tools/text_tools.dart index c8f1ba52..6335ba3b 100644 --- a/packages/marionette_mcp/lib/mcp/tools/text_tools.dart +++ b/packages/marionette_mcp/lib/src/mcp/tools/text_tools.dart @@ -1,7 +1,7 @@ import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/mcp/tools/ancestor_keys_description.dart'; -import 'package:marionette_mcp/mcp/tools/tool_runner.dart'; import 'package:marionette_mcp/src/formatting.dart'; +import 'package:marionette_mcp/src/mcp/tools/ancestor_keys_description.dart'; +import 'package:marionette_mcp/src/mcp/tools/tool_runner.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; diff --git a/packages/marionette_mcp/lib/mcp/tools/tool_results.dart b/packages/marionette_mcp/lib/src/mcp/tools/tool_results.dart similarity index 100% rename from packages/marionette_mcp/lib/mcp/tools/tool_results.dart rename to packages/marionette_mcp/lib/src/mcp/tools/tool_results.dart diff --git a/packages/marionette_mcp/lib/mcp/tools/tool_runner.dart b/packages/marionette_mcp/lib/src/mcp/tools/tool_runner.dart similarity index 100% rename from packages/marionette_mcp/lib/mcp/tools/tool_runner.dart rename to packages/marionette_mcp/lib/src/mcp/tools/tool_runner.dart diff --git a/packages/marionette_mcp/lib/mcp/tools/tools.dart b/packages/marionette_mcp/lib/src/mcp/tools/tools.dart similarity index 89% rename from packages/marionette_mcp/lib/mcp/tools/tools.dart rename to packages/marionette_mcp/lib/src/mcp/tools/tools.dart index f0c49acc..d6f39664 100644 --- a/packages/marionette_mcp/lib/mcp/tools/tools.dart +++ b/packages/marionette_mcp/lib/src/mcp/tools/tools.dart @@ -1,6 +1,7 @@ /// Static MCP tools for inspecting and interacting with a Flutter app. library; +import '../../session/screenshot_service.dart'; import '../server_context.dart'; import 'base_tool.dart'; import 'connection_tools.dart'; @@ -29,7 +30,8 @@ List<BaseTool> createAllTools({required VmServiceContext context}) => [ ...createInspectionTools( connector: context.connector, logger: context.logger, - stepLogger: context.stepLogger, + screenshotService: ScreenshotService(context.connector), + currentSession: () => context.connection.session, ), ...createGestureTools( connector: context.connector, logger: context.logger), diff --git a/packages/marionette_mcp/lib/src/mcp_server_runner.dart b/packages/marionette_mcp/lib/src/mcp_server_runner.dart index 63dcf7aa..0206adde 100644 --- a/packages/marionette_mcp/lib/src/mcp_server_runner.dart +++ b/packages/marionette_mcp/lib/src/mcp_server_runner.dart @@ -1,2 +1,2 @@ // Compatibility export for the former implementation path. -export 'package:marionette_mcp/mcp/server_runner.dart'; +export 'package:marionette_mcp/src/mcp/server_runner.dart'; diff --git a/packages/marionette_mcp/lib/src/session/screenshot_service.dart b/packages/marionette_mcp/lib/src/session/screenshot_service.dart new file mode 100644 index 00000000..bc7a26ae --- /dev/null +++ b/packages/marionette_mcp/lib/src/session/screenshot_service.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:path/path.dart' as p; + +import 'session.dart'; + +/// Captures application screenshots and optionally persists them in a session. +class ScreenshotService { + ScreenshotService(this.connector); + + final VmServiceConnector connector; + + /// Captures all views, returning inline images or the saved image paths. + /// + /// Empty captures succeed without a session. Nonempty save-only captures + /// require [session], which is retained throughout this operation. + Future<ScreenshotCapture> capture({ + required bool inline, + Session? session, + }) async { + final response = await connector.takeScreenshots(); + final images = (response['screenshots'] as List<dynamic>).cast<String>(); + if (images.isEmpty || inline) { + return ScreenshotCapture._( + count: images.length, + inline: inline, + images: images, + ); + } + if (session == null) throw const ScreenshotSessionRequired(); + + final paths = _saveScreenshots(session.screenshotsDir, images); + return ScreenshotCapture._( + count: paths.length, + inline: false, + paths: paths, + ); + } +} + +/// Immutable capture data, containing base64 PNG images or persisted paths. +class ScreenshotCapture { + ScreenshotCapture._({ + required this.count, + required this.inline, + List<String> images = const [], + List<String> paths = const [], + }) : images = List.unmodifiable(images), + paths = List.unmodifiable(paths); + + final int count; + final bool inline; + + /// Base64-encoded PNG images for inline captures. + final List<String> images; + + /// Files written for save-only captures. + final List<String> paths; +} + +/// A nonempty save-only capture was requested without an active session. +class ScreenshotSessionRequired implements Exception { + const ScreenshotSessionRequired(); +} + +List<String> _saveScreenshots( + Directory screenshotsDir, + List<String> screenshots, +) { + screenshotsDir.createSync(recursive: true); + // Use the highest numeric name rather than the file count: deleted images + // leave gaps that must not cause existing captures to be overwritten. + final highestExisting = screenshotsDir + .listSync() + .whereType<File>() + .where((f) => p.extension(f.path) == '.png') + .map((f) => int.tryParse(p.basenameWithoutExtension(f.path))) + .whereType<int>() + .fold(0, (highest, n) => n > highest ? n : highest); + var nextIndex = highestExisting + 1; + + final paths = <String>[]; + for (final screenshot in screenshots) { + final path = p.join( + screenshotsDir.path, + '${nextIndex.toString().padLeft(2, '0')}.png', + ); + File(path).writeAsBytesSync(base64Decode(screenshot)); + paths.add(path); + nextIndex++; + } + return paths; +} diff --git a/packages/marionette_mcp/lib/src/session/step_logger.dart b/packages/marionette_mcp/lib/src/session/step_logger.dart index 7607c8d7..b12509d9 100644 --- a/packages/marionette_mcp/lib/src/session/step_logger.dart +++ b/packages/marionette_mcp/lib/src/session/step_logger.dart @@ -1,4 +1,4 @@ // Compatibility boundary for existing internal consumers. New runtime code // imports report_steps.dart; MCP adapters import mcp/step_logging.dart. -export '../../mcp/step_logging.dart'; +export '../mcp/step_logging.dart'; export 'report_steps.dart'; diff --git a/packages/marionette_mcp/lib/src/vm_service/app_connection_service.dart b/packages/marionette_mcp/lib/src/vm_service/app_connection_service.dart new file mode 100644 index 00000000..4d816d42 --- /dev/null +++ b/packages/marionette_mcp/lib/src/vm_service/app_connection_service.dart @@ -0,0 +1,210 @@ +import 'package:logging/logging.dart' as logging; + +import '../session/session.dart'; +import '../session/session_manager.dart'; +import '../version.g.dart' as v; +import 'vm_service_connector.dart'; + +/// The phase in which an app connection failed. +enum AppConnectionFailureKind { versionMismatch, versionCheck, connection } + +/// A connection failure without transport-specific presentation. +final class AppConnectionFailure { + const AppConnectionFailure(this.kind, {this.cause, this.actualVersion}); + + final AppConnectionFailureKind kind; + final Object? cause; + final String? actualVersion; +} + +/// The outcome of a connection attempt and optional report setup. +final class AppConnectionResult { + const AppConnectionResult({ + required this.generation, + required this.uri, + this.session, + this.reportWarning, + this.failure, + }); + + final int generation; + final String uri; + final Session? session; + final Object? reportWarning; + final AppConnectionFailure? failure; +} + +/// The outcome of closing a connection, including its original report session. +final class AppDisconnectionResult { + const AppDisconnectionResult({this.closingSession, this.error}); + + final Session? closingSession; + final Object? error; +} + +/// Owns app connection transitions, version checks, and optional report sessions. +class AppConnectionService { + AppConnectionService({ + VmServiceConnector? connector, + SessionManager? sessionManager, + this.expectedVersion = v.version, + logging.Logger? logger, + this.onConnected, + this.onRetired, + this.onSessionChanged, + }) : connector = connector ?? VmServiceConnector(), + sessionManager = sessionManager ?? SessionManager(), + logger = logger ?? logging.Logger('AppConnectionService'); + + final VmServiceConnector connector; + final SessionManager sessionManager; + final String expectedVersion; + final logging.Logger logger; + final Future<void> Function()? onConnected; + final void Function()? onRetired; + final void Function(Session?)? onSessionChanged; + + bool _disposed = false; + int _generation = 0; + Session? _session; + + bool get isDisposed => _disposed; + Session? get session => _session; + + /// Whether work belongs to the currently active transition. + bool ownsConnection(int generation) => + !_disposed && generation == _generation; + + void _ensureCurrent(int generation) { + if (!ownsConnection(generation)) { + throw StateError('The app connection was closed or superseded.'); + } + } + + void _setSession(Session? session) { + _session = session; + onSessionChanged?.call(session); + } + + /// Connects and performs the binding handshake before opening a report. + Future<AppConnectionResult> connect({ + required String uri, + String? sessionTitle, + String? sessionDir, + }) async { + final generation = ++_generation; + logger.info('Connecting to app at $uri'); + _setSession(null); + onRetired?.call(); + AppConnectionResult result({ + Session? session, + Object? reportWarning, + AppConnectionFailure? failure, + }) => + AppConnectionResult( + generation: generation, + uri: uri, + session: session, + reportWarning: reportWarning, + failure: failure, + ); + + try { + _ensureCurrent(generation); + await connector.connect(uri); + _ensureCurrent(generation); + try { + final bindingVersion = await connector.getVersion(); + _ensureCurrent(generation); + if (bindingVersion != expectedVersion) { + await connector.disconnect(); + _ensureCurrent(generation); + return result( + failure: AppConnectionFailure( + AppConnectionFailureKind.versionMismatch, + actualVersion: bindingVersion, + ), + ); + } + } catch (err) { + logger.warning('Failed to check binding version', err); + if (ownsConnection(generation)) { + await connector.disconnect(); + } + return result( + failure: AppConnectionFailure( + AppConnectionFailureKind.versionCheck, + cause: err, + ), + ); + } + + await onConnected?.call(); + _ensureCurrent(generation); + var reportsEnabled = false; + try { + reportsEnabled = await connector.getSessionReportsEnabled(); + } catch (err) { + logger.warning( + 'Failed to read binding configuration; continuing without ' + 'session reports', + err, + ); + } + _ensureCurrent(generation); + if (!reportsEnabled) { + _setSession(null); + return result(); + } + try { + final session = sessionManager.create( + title: sessionTitle, + baseDirOverride: sessionDir, + ); + _setSession(session); + return result(session: session); + } catch (err) { + logger.warning( + 'Failed to open session directory; continuing without ' + 'session reports', + err, + ); + _setSession(null); + return result(reportWarning: err); + } + } catch (err) { + logger.severe('Failed to connect to app', err); + return result( + failure: AppConnectionFailure( + AppConnectionFailureKind.connection, + cause: err, + ), + ); + } + } + + /// Retires registrations before closing, preserving a newer session. + Future<AppDisconnectionResult> disconnect() async { + final generation = ++_generation; + final closingSession = _session; + logger.info('Disconnecting from app'); + try { + onRetired?.call(); + await connector.disconnect(); + if (ownsConnection(generation)) _setSession(null); + return AppDisconnectionResult(closingSession: closingSession); + } catch (err) { + logger.severe('Error during disconnect', err); + return AppDisconnectionResult(closingSession: closingSession, error: err); + } + } + + /// Retires all work and releases the active connection and report. + Future<void> dispose() async { + _disposed = true; + ++_generation; + onRetired?.call(); + _setSession(null); + await connector.disconnect(); + } +} diff --git a/packages/marionette_mcp/lib/src/vm_service/dynamic_extension_tools.dart b/packages/marionette_mcp/lib/src/vm_service/dynamic_extension_tools.dart index 7b0833c2..7da274be 100644 --- a/packages/marionette_mcp/lib/src/vm_service/dynamic_extension_tools.dart +++ b/packages/marionette_mcp/lib/src/vm_service/dynamic_extension_tools.dart @@ -1,2 +1,2 @@ // Compatibility export for the former implementation path. -export 'package:marionette_mcp/mcp/tools/dynamic_extension_tools.dart'; +export 'package:marionette_mcp/src/mcp/tools/dynamic_extension_tools.dart'; diff --git a/packages/marionette_mcp/lib/src/vm_service/tools/ancestor_keys_description.dart b/packages/marionette_mcp/lib/src/vm_service/tools/ancestor_keys_description.dart index b9882d1b..03614ba9 100644 --- a/packages/marionette_mcp/lib/src/vm_service/tools/ancestor_keys_description.dart +++ b/packages/marionette_mcp/lib/src/vm_service/tools/ancestor_keys_description.dart @@ -1,2 +1,2 @@ // Compatibility export for the former implementation path. -export 'package:marionette_mcp/mcp/tools/ancestor_keys_description.dart'; +export 'package:marionette_mcp/src/mcp/tools/ancestor_keys_description.dart'; diff --git a/packages/marionette_mcp/lib/src/vm_service/tools/tool_runner.dart b/packages/marionette_mcp/lib/src/vm_service/tools/tool_runner.dart index f13cbb39..3da11a78 100644 --- a/packages/marionette_mcp/lib/src/vm_service/tools/tool_runner.dart +++ b/packages/marionette_mcp/lib/src/vm_service/tools/tool_runner.dart @@ -1,2 +1,2 @@ // Compatibility export for the former implementation path. -export 'package:marionette_mcp/mcp/tools/tool_runner.dart'; +export 'package:marionette_mcp/src/mcp/tools/tool_runner.dart'; diff --git a/packages/marionette_mcp/test/architecture_test.dart b/packages/marionette_mcp/test/architecture_test.dart new file mode 100644 index 00000000..9e42bae1 --- /dev/null +++ b/packages/marionette_mcp/test/architecture_test.dart @@ -0,0 +1,79 @@ +import 'dart:io'; + +import 'package:test/test.dart'; + +void main() { + final lib = Directory('lib').absolute; + final protocolRoot = lib.uri.resolve('src/mcp/'); + const compatibilityExports = { + 'src/mcp_server_runner.dart', + 'src/session/step_logger.dart', + 'src/vm_service/dynamic_extension_tools.dart', + 'src/vm_service/tools/ancestor_keys_description.dart', + 'src/vm_service/tools/tool_runner.dart', + }; + + test('lib exposes explicit entrypoints with implementation under src', () { + final entrypoints = lib + .listSync() + .whereType<File>() + .where((file) => file.path.endsWith('.dart')) + .map((file) => file.uri.pathSegments.last); + expect( + entrypoints, unorderedEquals(['marionette_mcp.dart', 'runtime.dart'])); + expect(Directory.fromUri(lib.uri.resolve('mcp/')).existsSync(), isFalse); + expect(Directory.fromUri(protocolRoot).existsSync(), isTrue); + }); + + test('runtime code has no MCP dependency or dependency on legacy bridges', + () { + final directives = RegExp( + r'''^\s*(?:import|export)\s+['"]([^'"]+)['"]''', + multiLine: true, + ); + for (final file in Directory.fromUri(lib.uri.resolve('src/')) + .listSync(recursive: true) + .whereType<File>() + .where((file) => file.path.endsWith('.dart'))) { + final relative = file.absolute.uri.path.substring(lib.uri.path.length); + final source = file.readAsStringSync(); + if (compatibilityExports.contains(relative)) { + // Existing consumers can keep their import paths, but these bridges + // must never become a second implementation or a runtime dependency. + final lines = source + .replaceAll(RegExp(r'//[^\n]*'), '') + .split('\n') + .map((line) => line.trim()) + .where((line) => line.isNotEmpty); + expect(lines, isNotEmpty, reason: relative); + expect( + lines.every( + (line) => line.startsWith('export ') && line.endsWith(';')), + isTrue, + reason: '$relative must contain exports only'); + continue; + } + if (file.absolute.uri.path.startsWith(protocolRoot.path)) continue; + for (final directive in directives.allMatches(source)) { + final imported = directive.group(1)!; + expect(imported.startsWith('package:mcp_dart/'), isFalse, + reason: '$relative must stay independent of the MCP SDK'); + final Uri target; + if (imported.startsWith('package:marionette_mcp/')) { + target = lib.uri + .resolve(imported.substring('package:marionette_mcp/'.length)); + } else if (!Uri.parse(imported).hasScheme) { + target = file.absolute.uri.resolve(imported); + } else { + continue; + } + expect(target.path.startsWith(protocolRoot.path), isFalse, + reason: '$relative must not depend on MCP adapters'); + for (final bridge in compatibilityExports) { + expect(target, isNot(lib.uri.resolve(bridge)), + reason: '$relative must import runtime APIs directly'); + } + } + } + }); +} diff --git a/packages/marionette_mcp/test/session/screenshot_service_test.dart b/packages/marionette_mcp/test/session/screenshot_service_test.dart new file mode 100644 index 00000000..d338a0ad --- /dev/null +++ b/packages/marionette_mcp/test/session/screenshot_service_test.dart @@ -0,0 +1,142 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:marionette_mcp/src/session/screenshot_service.dart'; +import 'package:marionette_mcp/src/session/session.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +class _Connector implements VmServiceConnector { + List<String> images = ['aGVsbG8=']; + int calls = 0; + Completer<Map<String, dynamic>>? pending; + Object? error; + + @override + Future<Map<String, dynamic>> takeScreenshots() async { + calls++; + if (error case final error?) throw error; + return pending?.future ?? {'screenshots': images}; + } + + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} + +void main() { + late _Connector connector; + late ScreenshotService service; + late Directory directory; + + setUp(() { + connector = _Connector(); + service = ScreenshotService(connector); + directory = Directory.systemTemp.createTempSync('screenshot-service-'); + }); + tearDown(() => directory.deleteSync(recursive: true)); + + test('inline capture returns immutable base64 images without a session', + () async { + final result = await service.capture(inline: true); + expect(result.count, 1); + expect(result.inline, isTrue); + expect(result.images, ['aGVsbG8=']); + expect(result.paths, isEmpty); + connector.images.add('d29ybGQ='); + expect(result.images, ['aGVsbG8=']); + expect(() => result.images.add('other'), throwsUnsupportedError); + expect(directory.listSync(), isEmpty); + }); + + test('empty capture preserves either inline mode without a session', + () async { + connector.images = []; + for (final inline in [true, false]) { + final result = await service.capture(inline: inline); + expect(result.count, 0); + expect(result.inline, inline); + expect(result.images, isEmpty); + expect(result.paths, isEmpty); + } + expect(connector.calls, 2); + }); + + test('save-only captures persist decoded images in the session', () async { + connector.images = ['aGVsbG8=', 'd29ybGQ=']; + final session = Session.open(directory); + final result = await service.capture(inline: false, session: session); + expect(result.count, 2); + expect(result.inline, isFalse); + expect(result.images, isEmpty); + expect(result.paths, [ + p.join(session.screenshotsDir.path, '01.png'), + p.join(session.screenshotsDir.path, '02.png'), + ]); + expect(File(result.paths[0]).readAsBytesSync(), utf8.encode('hello')); + expect(File(result.paths[1]).readAsBytesSync(), utf8.encode('world')); + expect(() => result.paths.clear(), throwsUnsupportedError); + }); + + test('nonempty save-only capture requires a session after capture', () async { + await expectLater( + service.capture(inline: false), + throwsA(isA<ScreenshotSessionRequired>()), + ); + expect(connector.calls, 1); + expect(directory.listSync(), isEmpty); + }); + + test('numbering continues after highest numeric PNG and preserves gaps', + () async { + final session = Session.open(directory); + final screenshotsDir = session.screenshotsDir; + for (final name in ['01.png', '03.png', 'other.png', '100.txt']) { + File(p.join(screenshotsDir.path, name)).writeAsStringSync('existing'); + } + Directory(p.join(screenshotsDir.path, '200.png')).createSync(); + final first = await service.capture(inline: false, session: session); + final second = await service.capture(inline: false, session: session); + expect(first.paths.single, p.join(screenshotsDir.path, '04.png')); + expect(second.paths.single, p.join(screenshotsDir.path, '05.png')); + for (final name in ['01.png', '03.png', 'other.png', '100.txt']) { + expect(File(p.join(screenshotsDir.path, name)).readAsStringSync(), + 'existing'); + } + expect(File(p.join(screenshotsDir.path, '02.png')).existsSync(), isFalse); + }); + + test('capture retains the supplied session across the connector await', + () async { + var currentSession = + Session.open(Directory(p.join(directory.path, 'first'))); + final capturedSession = currentSession; + connector.pending = Completer<Map<String, dynamic>>(); + final capture = service.capture(inline: false, session: currentSession); + currentSession = Session.open(Directory(p.join(directory.path, 'second'))); + connector.pending!.complete({ + 'screenshots': ['aGVsbG8='], + }); + final result = await capture; + expect(result.paths.single, + p.join(capturedSession.screenshotsDir.path, '01.png')); + expect(currentSession.screenshotsDir.listSync(), isEmpty); + }); + + test('connector failures propagate before checking session requirements', + () async { + final error = StateError('offline'); + connector.error = error; + await expectLater(service.capture(inline: false), throwsA(same(error))); + expect(connector.calls, 1); + }); + + test('invalid image data propagates from persistence', () async { + connector.images = ['invalid base64']; + await expectLater( + service.capture(inline: false, session: Session.open(directory)), + throwsFormatException, + ); + }); +} diff --git a/packages/marionette_mcp/test/structured_tool_outputs_test.dart b/packages/marionette_mcp/test/structured_tool_outputs_test.dart index 9411f846..221fd7ba 100644 --- a/packages/marionette_mcp/test/structured_tool_outputs_test.dart +++ b/packages/marionette_mcp/test/structured_tool_outputs_test.dart @@ -2,16 +2,16 @@ import 'dart:convert'; import 'dart:io'; import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/mcp/tools/base_tool.dart'; -import 'package:marionette_mcp/mcp/tools/device_tools.dart'; -import 'package:marionette_mcp/mcp/tools/extension_tools.dart'; -import 'package:marionette_mcp/mcp/tools/gesture_tools.dart'; -import 'package:marionette_mcp/mcp/tools/inspection_tools.dart'; -import 'package:marionette_mcp/mcp/tools/keyboard_tools.dart'; -import 'package:marionette_mcp/mcp/tools/system_tools.dart'; -import 'package:marionette_mcp/mcp/tools/text_tools.dart'; +import 'package:marionette_mcp/src/mcp/tools/base_tool.dart'; +import 'package:marionette_mcp/src/mcp/tools/device_tools.dart'; +import 'package:marionette_mcp/src/mcp/tools/extension_tools.dart'; +import 'package:marionette_mcp/src/mcp/tools/gesture_tools.dart'; +import 'package:marionette_mcp/src/mcp/tools/inspection_tools.dart'; +import 'package:marionette_mcp/src/mcp/tools/keyboard_tools.dart'; +import 'package:marionette_mcp/src/mcp/tools/system_tools.dart'; +import 'package:marionette_mcp/src/mcp/tools/text_tools.dart'; +import 'package:marionette_mcp/src/session/screenshot_service.dart'; import 'package:marionette_mcp/src/session/session.dart'; -import 'package:marionette_mcp/src/session/step_logger.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; import 'package:test/test.dart'; @@ -53,7 +53,8 @@ void main() { ...createInspectionTools( connector: connector, logger: logger, - stepLogger: StepLogger(), + screenshotService: ScreenshotService(connector), + currentSession: () => null, ), ...createKeyboardTools(connector: connector, logger: logger), ...createSystemTools(connector: connector, logger: logger), @@ -271,6 +272,15 @@ void main() { } }); + test('screenshot connector failures retain the standard error wrapper', + () async { + connector.fails = true; + final result = await tools['take_screenshots']!.execute({}, extra); + expect(result.isError, isTrue); + expect(result.structuredContent, isNull); + expect((result.content.single as TextContent).text, 'Bad state: offline'); + }); + test('validation and system failures retain isError semantics', () async { connector.systemSuccess = false; for (final (name, args) in <(String, Map<String, dynamic>)>[ @@ -295,5 +305,12 @@ void main() { await tools['take_screenshots']!.execute({'inline': false}, extra); expect(result.isError, isTrue); expect(result.structuredContent, isNull); + expect( + (result.content.single as TextContent).text, + 'inline: false requires an active session, which is ' + 'only created once connect succeeds and only when the app ' + 'enables session reports ' + '(MarionetteConfiguration.enableSessionReports).', + ); }); } diff --git a/packages/marionette_mcp/test/vm_service/app_connection_service_test.dart b/packages/marionette_mcp/test/vm_service/app_connection_service_test.dart new file mode 100644 index 00000000..8dae8356 --- /dev/null +++ b/packages/marionette_mcp/test/vm_service/app_connection_service_test.dart @@ -0,0 +1,303 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:marionette_mcp/src/session/session.dart'; +import 'package:marionette_mcp/src/vm_service/app_connection_service.dart'; +import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; +import 'package:test/test.dart'; + +void main() { + late Directory directory; + late _Connector connector; + late AppConnectionService service; + + setUp(() { + directory = Directory.systemTemp.createTempSync('app-connection-'); + connector = _Connector(); + service = AppConnectionService( + connector: connector, + expectedVersion: 'fixture', + ); + }); + tearDown(() async { + await service.dispose(); + directory.deleteSync(recursive: true); + }); + Future<AppConnectionResult> connect([String title = 'run']) => + service.connect( + uri: 'ws://$title/ws', + sessionTitle: title, + sessionDir: directory.path, + ); + + test('mismatch disconnects before reporting the actual binding version', + () async { + connector.version = () async => 'other'; + final result = await connect(); + expect(result.failure?.kind, AppConnectionFailureKind.versionMismatch); + expect(result.failure?.actualVersion, 'other'); + expect(connector.disconnects, 1); + expect(connector.configurationReads, 0); + expect(directory.listSync(), isEmpty); + }); + + test('failed version check disconnects and retains the original cause', + () async { + final error = StateError('missing binding'); + connector.version = () async => throw error; + final result = await connect(); + expect(result.failure?.kind, AppConnectionFailureKind.versionCheck); + expect(result.failure?.cause, same(error)); + expect(connector.disconnects, 1); + }); + + test('report opt-out and unreadable configuration create no files', () async { + connector.reports = () async => false; + expect((await connect()).session, isNull); + connector.reports = () async => throw StateError('unsupported'); + final result = await connect(); + expect(result.failure, isNull); + expect(result.reportWarning, isNull); + expect(result.session, isNull); + expect(directory.listSync(), isEmpty); + expect(connector.disconnects, 0); + }); + + test('report creation failure keeps the app connected with a warning', + () async { + final file = File('${directory.path}/file')..writeAsStringSync('fixture'); + final result = await service.connect( + uri: 'ws://fixture/ws', + sessionDir: file.path, + ); + expect(result.failure, isNull); + expect(result.reportWarning, isA<FileSystemException>()); + expect(result.session, isNull); + expect(service.session, isNull); + expect(connector.disconnects, 0); + }); + + test('callbacks retire first and expose only the replacement report', + () async { + final events = <String>[]; + final sessions = <Session?>[]; + service = AppConnectionService( + connector: connector, + expectedVersion: 'fixture', + onRetired: () => events.add('retired'), + onConnected: () async => events.add('connected'), + onSessionChanged: (session) { + sessions.add(session); + events.add(session == null ? 'clear' : 'session'); + }, + ); + final first = await connect('first'); + final second = await connect('second'); + expect(events, [ + 'clear', + 'retired', + 'connected', + 'session', + 'clear', + 'retired', + 'connected', + 'session' + ]); + expect(sessions, [null, first.session, null, second.session]); + expect( + first.session?.directory.path, isNot(second.session?.directory.path)); + expect(service.session, same(second.session)); + expect(service.ownsConnection(first.generation), isFalse); + expect(service.ownsConnection(second.generation), isTrue); + }); + + test('a failed replacement connect immediately retires the old report', + () async { + await connect('first'); + connector.connectError = StateError('offline'); + final pending = connect('replacement'); + expect(service.session, isNull); + expect((await pending).failure?.kind, AppConnectionFailureKind.connection); + }); + + test('superseded handshake cannot disconnect or replace the current session', + () async { + final started = Completer<void>(); + final release = Completer<String>(); + connector.version = () { + connector.version = () async => 'fixture'; + started.complete(); + return release.future; + }; + final old = connect('old'); + await started.future; + final replacement = await connect('replacement'); + release.complete('other'); + final stale = await old; + expect(stale.failure?.kind, AppConnectionFailureKind.versionCheck); + expect(service.ownsConnection(stale.generation), isFalse); + expect(service.session, same(replacement.session)); + expect(connector.disconnects, 0); + }); + + for (final phase in ['discovery', 'configuration']) { + test('a connect superseded during $phase cannot create a report', () async { + final started = Completer<void>(); + final release = Completer<void>(); + var first = true; + Future<void> holdFirst() async { + if (!first) return; + first = false; + started.complete(); + await release.future; + } + + service = AppConnectionService( + connector: connector, + expectedVersion: 'fixture', + onConnected: phase == 'discovery' ? holdFirst : null, + ); + if (phase == 'configuration') { + connector.reports = () async { + await holdFirst(); + return true; + }; + } + final old = connect('old'); + await started.future; + final replacement = await connect('replacement'); + release.complete(); + expect((await old).failure?.kind, AppConnectionFailureKind.connection); + expect(service.session, same(replacement.session)); + final reportDirs = + Directory('${directory.path}/.marionette/sessions').listSync(); + expect(reportDirs, hasLength(1)); + }); + } + + for (final fail in [false, true]) { + test( + 'older disconnect ${fail ? 'failure' : 'success'} preserves replacement', + () async { + final first = await connect('first'); + final started = Completer<void>(); + final release = Completer<void>(); + connector.close = () { + connector.close = null; + started.complete(); + return release.future; + }; + final closing = service.disconnect(); + await started.future; + final replacement = await connect('replacement'); + if (fail) { + release.completeError(StateError('close failed')); + } else { + release.complete(); + } + final result = await closing; + expect(result.closingSession, same(first.session)); + expect(result.error, fail ? isA<StateError>() : isNull); + expect(service.session, same(replacement.session)); + }); + } + + test('disconnect during transport connect prevents handshake and reports', + () async { + final started = Completer<void>(); + final release = Completer<void>(); + connector.open = () async { + started.complete(); + await release.future; + }; + final pending = connect(); + await started.future; + await service.disconnect(); + release.complete(); + final result = await pending; + expect(result.failure?.kind, AppConnectionFailureKind.connection); + expect(service.ownsConnection(result.generation), isFalse); + expect(connector.configurationReads, 0); + expect(directory.listSync(), isEmpty); + }); + + test('disposal during handshake does not run stale disconnect cleanup', + () async { + final started = Completer<void>(); + final release = Completer<String>(); + connector.version = () { + started.complete(); + return release.future; + }; + final pending = connect(); + await started.future; + await service.dispose(); + release.completeError(StateError('socket closed')); + final result = await pending; + expect(result.failure?.kind, AppConnectionFailureKind.versionCheck); + expect(connector.disconnects, 1); + expect(service.session, isNull); + expect(service.ownsConnection(result.generation), isFalse); + }); + + test('disposal invalidates discovery work and clears the session bridge', + () async { + final started = Completer<void>(); + final release = Completer<void>(); + final sessions = <Session?>[]; + var retirements = 0; + service = AppConnectionService( + connector: connector, + expectedVersion: 'fixture', + onRetired: () => retirements++, + onConnected: () async { + started.complete(); + await release.future; + }, + onSessionChanged: sessions.add, + ); + final pending = connect(); + await started.future; + await service.dispose(); + release.complete(); + final result = await pending; + expect(result.failure?.kind, AppConnectionFailureKind.connection); + expect(service.ownsConnection(result.generation), isFalse); + expect(service.isDisposed, isTrue); + expect(service.session, isNull); + expect(sessions, [null, null]); + expect(retirements, 2); + expect(directory.listSync(), isEmpty); + }); +} + +class _Connector extends VmServiceConnector { + Future<String> Function() version = () async => 'fixture'; + Future<bool> Function() reports = () async => true; + Future<void> Function()? close; + Future<void> Function()? open; + Object? connectError; + int disconnects = 0; + int configurationReads = 0; + + @override + Future<void> connect(String uri) async { + if (connectError case final error?) throw error; + await open?.call(); + } + + @override + Future<void> disconnect() async { + disconnects++; + await close?.call(); + } + + @override + Future<String> getVersion() => version(); + + @override + Future<bool> getSessionReportsEnabled() { + configurationReads++; + return reports(); + } +} diff --git a/packages/marionette_mcp/test/vm_service/tools/device_tools_test.dart b/packages/marionette_mcp/test/vm_service/tools/device_tools_test.dart index e875e049..2ca2ecf7 100644 --- a/packages/marionette_mcp/test/vm_service/tools/device_tools_test.dart +++ b/packages/marionette_mcp/test/vm_service/tools/device_tools_test.dart @@ -1,5 +1,5 @@ import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/mcp/tools/device_tools.dart'; +import 'package:marionette_mcp/src/mcp/tools/device_tools.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; import 'package:test/test.dart'; diff --git a/packages/marionette_mcp/test/vm_service/tools/extension_tools_test.dart b/packages/marionette_mcp/test/vm_service/tools/extension_tools_test.dart index ac5b85bd..28d07c22 100644 --- a/packages/marionette_mcp/test/vm_service/tools/extension_tools_test.dart +++ b/packages/marionette_mcp/test/vm_service/tools/extension_tools_test.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/mcp/tools/extension_tools.dart'; +import 'package:marionette_mcp/src/mcp/tools/extension_tools.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; import 'package:test/test.dart'; diff --git a/packages/marionette_mcp/test/vm_service/tools/inspection_tools_test.dart b/packages/marionette_mcp/test/vm_service/tools/inspection_tools_test.dart index 58290944..641b7e71 100644 --- a/packages/marionette_mcp/test/vm_service/tools/inspection_tools_test.dart +++ b/packages/marionette_mcp/test/vm_service/tools/inspection_tools_test.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:marionette_mcp/src/session/session.dart'; -import 'package:marionette_mcp/mcp/tools/inspection_tools.dart'; +import 'package:marionette_mcp/src/mcp/tools/inspection_tools.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; import 'package:path/path.dart' as p; From 15a18e19463f25575c84c5dc2a71487b7b25a21e Mon Sep 17 00:00:00 2001 From: Jhin Lee <jhin.lee@unity3d.com> Date: Sat, 24 Oct 2026 11:30:45 -0400 Subject: [PATCH 07/12] Centralize MCP bootstrap lifecycle and startup cleanup --- CHANGELOG.md | 2 +- docs/architecture.md | 14 ++ .../lib/src/mcp/server_runner.dart | 164 +++++++++--------- .../test/server_runner_test.dart | 66 +++++++ 4 files changed, 164 insertions(+), 82 deletions(-) create mode 100644 packages/marionette_mcp/test/server_runner_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index dafb1486..b6263bf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - Upgrade `mcp_dart` to `^2.4.3` and use its fixed dynamic-tool removal on reconnect. - Fix MCP server shutdown leaving app connections open. - Keep in-flight tool steps in their original report across reconnects, and clear connection status when the app disconnects. -- Reject invalid SSE ports in the standalone MCP executable instead of falling back to stdio, and report server startup failures with a nonzero exit code. +- Reject invalid SSE ports in the standalone MCP executable instead of falling back to stdio, and report startup failures, including log-file setup, with a nonzero exit code. - Expose public server and app-connection entrypoints, with typed MCP tools separated from startup and configuration. - Fix `record-video --width/--height` recording only the top-left corner of the screen at full scale instead of the whole screen scaled down to that size. Web recordings were not affected ([#134](https://github.com/leancodepl/marionette_mcp/issues/134)) diff --git a/docs/architecture.md b/docs/architecture.md index 40d21822..fdb51bca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -123,6 +123,20 @@ Disposing the context retires dynamic tools and closes its app connection. Disconnecting releases dynamic tool names, so reconnecting uses the current app's schemas without retaining disabled registrations. +## Bootstrap and shutdown + +The executable parses options and delegates to `runMcpServer`. That runner owns +logging, a shared signal owner, the app context, the protocol server, and +the selected transport. Transport startup helpers provide completion and close +operations; they do not each implement their own process lifecycle. + +The runner waits for a signal or transport completion, then closes the listener +and active transports, disposes the app runtime, closes the protocol server, +and cancels logging. Cleanup also covers partial startup. Stdio completion +includes immediate stdin EOF; SSE retains its existing endpoints and explicitly +closes active sessions. The SDK owns protocol negotiation, dispatch, validation, +and transport framing; the application owns its process and app resources. + ## Adding or changing a tool 1. Put reusable workflow/state/persistence behavior in a runtime service. Keep diff --git a/packages/marionette_mcp/lib/src/mcp/server_runner.dart b/packages/marionette_mcp/lib/src/mcp/server_runner.dart index dea4b0b1..f85b366e 100644 --- a/packages/marionette_mcp/lib/src/mcp/server_runner.dart +++ b/packages/marionette_mcp/lib/src/mcp/server_runner.dart @@ -14,27 +14,54 @@ Future<int> runMcpServer({ String? logFile, int? ssePort, }) async { - final logSubscription = setupLogging(logLevel, logFile); - - final vmService = VmServiceContext(); + final logger = logging.Logger('main'); + StreamSubscription<logging.LogRecord>? logSubscription; + VmServiceContext? context; + McpServer? server; + ExitSignal? exitSignal; + _RunningTransport? transport; - final server = createMcpServer(context: vmService); try { - return ssePort == null - ? await _runStdioServer(server) - : await _runSseServer(server, ssePort); - } finally { - // The app connection outlives individual tool calls, so the server owns it. - // Closing only the MCP transport leaves its VM-service socket alive. try { - await vmService.dispose(); + logSubscription = setupLogging(logLevel, logFile); + context = VmServiceContext(); + server = createMcpServer(context: context); + exitSignal = ExitSignal(); + transport = ssePort == null + ? await _startStdioServer(server, logger) + : await _startSseServer(server, ssePort, logger); + + final reason = await Future.any([ + exitSignal.wait.then((signal) => 'Received ${signal.name}'), + transport.done.then( + (_) => ssePort == null ? 'stdin closed' : 'HTTP listener closed'), + ]); + logger.info('$reason, stopping'); } finally { + exitSignal?.dispose(); try { - await server.close(); + await transport?.close(); } finally { - await logSubscription.cancel(); + // The app connection outlives individual tool calls. Its owner must + // also cancel pending VM WebSocket upgrades during shutdown. + try { + await context?.dispose(); + } finally { + await server?.close(); + } } } + logger.info('Stopped'); + return 0; + } catch (error, stackTrace) { + if (logSubscription == null) { + stderr.writeln('Error when starting MCP server: $error'); + } else { + logger.severe('Error when running MCP server', error, stackTrace); + } + return 1; + } finally { + await logSubscription?.cancel(); } } @@ -72,88 +99,63 @@ String _formatTime(DateTime time) { '${time.second.toString().padLeft(2, '0')}'; } -Future<int> _runStdioServer(McpServer server) async { - final logger = logging.Logger('main'); +typedef _RunningTransport = ({ + Future<void> done, + Future<void> Function() close, +}); +Future<_RunningTransport> _startStdioServer( + McpServer server, + logging.Logger logger, +) async { final transport = StdioServerTransport(); - final exitSignal = ExitSignal(); final stdinClosed = Completer<void>(); + final previousOnClose = server.server.onclose; - // Install the close handler before connecting. An immediate stdin EOF - // (e.g. `</dev/null`) can close the transport during or right after - // connect(); registering afterwards risks missing that close and hanging - // until a signal. Fires when the transport closes — including stdin EOF, - // i.e. the MCP host went away without sending a signal. Per the stdio - // lifecycle the server should shut down then, not wait for SIGINT/SIGTERM. + // EOF may arrive during connect, so install the handler before starting. + // Protocol.connect owns transport.onclose; use its public server callback. server.server.onclose = () { - if (!stdinClosed.isCompleted) stdinClosed.complete(); + try { + previousOnClose?.call(); + } finally { + if (!stdinClosed.isCompleted) stdinClosed.complete(); + } }; - try { - logger.fine('Running MCP server on stdio'); - await server.connect(transport); - logger.info('Server started'); - } catch (e, st) { - logger.severe('Error when starting the Stdio transport', e, st); - exitSignal.dispose(); - return 1; - } - - // Stop on whichever happens first: an OS signal or the transport closing. - // Only the first cause is logged — closing the server below re-triggers - // `onclose`, which would otherwise log a misleading second reason. - var stopping = false; - void logStop(String reason) { - if (stopping) return; - stopping = true; - logger.info('$reason, stopping'); - } - - await Future.any([ - exitSignal.wait.then((signal) => logStop('Received ${signal.name}')), - stdinClosed.future.then((_) => logStop('stdin closed')), - ]); - - // Release the signal subscriptions so the event loop can drain and the - // process actually exits on the stdin-EOF path. - exitSignal.dispose(); - await transport.close(); - logger.info('Stopped'); - return 0; + logger.fine('Running MCP server on stdio'); + await server.connect(transport); + logger.info('Server started'); + return (done: stdinClosed.future, close: transport.close); } -Future<int> _runSseServer(McpServer server, int ssePort) async { - final logger = logging.Logger('main'); +Future<_RunningTransport> _startSseServer( + McpServer server, + int port, + logging.Logger logger, +) async { final manager = SseServerManager(server); - final exitSignal = ExitSignal(); - HttpServer? httpServer; - try { - final listener = await HttpServer.bind( - InternetAddress.loopbackIPv4, - ssePort, - ); - httpServer = listener; - logger.fine('Running MCP server on SSE port $ssePort'); - unawaited(exitSignal.wait.then((signal) async { - logger.info('Received ${signal.name}, stopping'); - await listener.close(force: true); - })); + final listener = await HttpServer.bind(InternetAddress.loopbackIPv4, port); + logger.fine('Running MCP server on SSE port $port'); + + Future<void> serveRequests() async { await for (final request in listener) { unawaited(manager.handleRequest(request)); } - logger.info('Stopped'); - return 0; - } catch (e, st) { - logger.severe('Error when waiting for MCP client connection', e, st); - return 1; - } finally { - exitSignal.dispose(); - await httpServer?.close(force: true); - await Future.wait([ - for (final transport in manager.activeSseTransports.values.toList()) - transport.close(), - ]); } + + Future<void> close() async { + try { + await listener.close(force: true); + } finally { + // Legacy SSE has no manager.stop API; close every active session. + await Future.wait([ + for (final transport in manager.activeSseTransports.values.toList()) + transport.close(), + ]); + } + } + + return (done: serveRequests(), close: close); } /// Waits for SIGINT or SIGTERM to signal graceful shutdown. diff --git a/packages/marionette_mcp/test/server_runner_test.dart b/packages/marionette_mcp/test/server_runner_test.dart new file mode 100644 index 00000000..84a239ba --- /dev/null +++ b/packages/marionette_mcp/test/server_runner_test.dart @@ -0,0 +1,66 @@ +import 'dart:io'; + +import 'package:logging/logging.dart' as logging; +import 'package:marionette_mcp/marionette_mcp.dart'; +import 'package:marionette_mcp/src/mcp_server_runner.dart' as legacy; +import 'package:test/test.dart'; + +void main() { + test('runner reports log setup failure with a failing result', () async { + final directory = await Directory.systemTemp.createTemp('runner-log-'); + addTearDown(() => directory.delete(recursive: true)); + + // A directory cannot be opened as the log file. This fails before any + // transport starts, so the public runner must report the startup failure. + expect( + await runMcpServer(logLevel: 'INFO', logFile: directory.path), + 1, + ); + }); + + test('SSE bind failure returns a failing result and cancels logging', + () async { + final listener = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => listener.close(force: true)); + final directory = await Directory.systemTemp.createTemp('runner-bind-'); + addTearDown(() => directory.delete(recursive: true)); + final log = File('${directory.path}/server.log'); + + expect( + await runMcpServer( + logLevel: 'INFO', + logFile: log.path, + ssePort: listener.port, + ), + 1, + ); + final before = log.readAsStringSync(); + expect(before, contains('Error when running MCP server')); + logging.Logger('runner-test').severe('after runner completion'); + expect(log.readAsStringSync(), before); + + // The executable must also drain its signal subscriptions on startup + // failure, exit promptly, and keep diagnostics off the JSON-RPC channel. + final process = await Process.start(Platform.resolvedExecutable, [ + 'run', + 'bin/marionette_mcp.dart', + '--sse-port', + '${listener.port}', + '--log-level', + 'SEVERE', + ]); + addTearDown(process.kill); + final output = process.stdout.toList(); + final errors = process.stderr.toList(); + expect(await process.exitCode.timeout(const Duration(seconds: 10)), 1); + expect(await output, isEmpty); + expect(await errors, isNotEmpty); + }); + + test('former runner import retains the entrypoint and signal helper', () { + expect(legacy.runMcpServer, same(runMcpServer)); + final signal = legacy.ExitSignal(); + signal.dispose(); + signal.dispose(); + }); +} From ba312c83e07621f5a5c9a84115204a25f2fa929d Mon Sep 17 00:00:00 2001 From: Jhin Lee <jhin.lee@unity3d.com> Date: Sat, 24 Oct 2026 11:39:49 -0400 Subject: [PATCH 08/12] Remove unused compatibility exports --- docs/architecture.md | 8 +++++--- .../lib/src/mcp_server_runner.dart | 2 +- .../lib/src/session/step_logger.dart | 4 ++-- .../src/vm_service/dynamic_extension_tools.dart | 2 -- .../tools/ancestor_keys_description.dart | 2 -- .../lib/src/vm_service/tools/tool_runner.dart | 2 -- .../marionette_mcp/test/architecture_test.dart | 16 +++------------- .../marionette_mcp/test/server_runner_test.dart | 7 +++---- .../test/session/step_logger_test.dart | 3 ++- .../vm_service/dynamic_extension_tools_test.dart | 2 +- 10 files changed, 17 insertions(+), 31 deletions(-) delete mode 100644 packages/marionette_mcp/lib/src/vm_service/dynamic_extension_tools.dart delete mode 100644 packages/marionette_mcp/lib/src/vm_service/tools/ancestor_keys_description.dart delete mode 100644 packages/marionette_mcp/lib/src/vm_service/tools/tool_runner.dart diff --git a/docs/architecture.md b/docs/architecture.md index fdb51bca..5ea5026f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -90,9 +90,11 @@ exports for server startup and shared runtime access; their migration is separate from the MCP refactor. MCP tools and executable options keep their names and input schemas. -Runtime implementations cannot import the MCP SDK or MCP adapters. The few -former internal MCP paths remain export-only compatibility bridges for existing -consumers, and runtime code does not depend on those bridges. +Runtime implementations cannot import the MCP SDK or MCP adapters. Only two +temporary compatibility exports remain for the CLI's existing imports: +`src/mcp_server_runner.dart` and `src/session/step_logger.dart`. The CLI migration +removes them once it uses the public entrypoints. Runtime implementations do +not depend on these bridges, and new code imports the canonical modules. ## Structured tool results diff --git a/packages/marionette_mcp/lib/src/mcp_server_runner.dart b/packages/marionette_mcp/lib/src/mcp_server_runner.dart index 0206adde..01407736 100644 --- a/packages/marionette_mcp/lib/src/mcp_server_runner.dart +++ b/packages/marionette_mcp/lib/src/mcp_server_runner.dart @@ -1,2 +1,2 @@ // Compatibility export for the former implementation path. -export 'package:marionette_mcp/src/mcp/server_runner.dart'; +export 'package:marionette_mcp/src/mcp/server_runner.dart' show runMcpServer; diff --git a/packages/marionette_mcp/lib/src/session/step_logger.dart b/packages/marionette_mcp/lib/src/session/step_logger.dart index b12509d9..4964d025 100644 --- a/packages/marionette_mcp/lib/src/session/step_logger.dart +++ b/packages/marionette_mcp/lib/src/session/step_logger.dart @@ -1,4 +1,4 @@ // Compatibility boundary for existing internal consumers. New runtime code // imports report_steps.dart; MCP adapters import mcp/step_logging.dart. -export '../mcp/step_logging.dart'; -export 'report_steps.dart'; +export 'report_steps.dart' + show describeStepError, describeStepSelector, formatStepLine; diff --git a/packages/marionette_mcp/lib/src/vm_service/dynamic_extension_tools.dart b/packages/marionette_mcp/lib/src/vm_service/dynamic_extension_tools.dart deleted file mode 100644 index 7da274be..00000000 --- a/packages/marionette_mcp/lib/src/vm_service/dynamic_extension_tools.dart +++ /dev/null @@ -1,2 +0,0 @@ -// Compatibility export for the former implementation path. -export 'package:marionette_mcp/src/mcp/tools/dynamic_extension_tools.dart'; diff --git a/packages/marionette_mcp/lib/src/vm_service/tools/ancestor_keys_description.dart b/packages/marionette_mcp/lib/src/vm_service/tools/ancestor_keys_description.dart deleted file mode 100644 index 03614ba9..00000000 --- a/packages/marionette_mcp/lib/src/vm_service/tools/ancestor_keys_description.dart +++ /dev/null @@ -1,2 +0,0 @@ -// Compatibility export for the former implementation path. -export 'package:marionette_mcp/src/mcp/tools/ancestor_keys_description.dart'; diff --git a/packages/marionette_mcp/lib/src/vm_service/tools/tool_runner.dart b/packages/marionette_mcp/lib/src/vm_service/tools/tool_runner.dart deleted file mode 100644 index 3da11a78..00000000 --- a/packages/marionette_mcp/lib/src/vm_service/tools/tool_runner.dart +++ /dev/null @@ -1,2 +0,0 @@ -// Compatibility export for the former implementation path. -export 'package:marionette_mcp/src/mcp/tools/tool_runner.dart'; diff --git a/packages/marionette_mcp/test/architecture_test.dart b/packages/marionette_mcp/test/architecture_test.dart index 9e42bae1..f09f340b 100644 --- a/packages/marionette_mcp/test/architecture_test.dart +++ b/packages/marionette_mcp/test/architecture_test.dart @@ -8,9 +8,6 @@ void main() { const compatibilityExports = { 'src/mcp_server_runner.dart', 'src/session/step_logger.dart', - 'src/vm_service/dynamic_extension_tools.dart', - 'src/vm_service/tools/ancestor_keys_description.dart', - 'src/vm_service/tools/tool_runner.dart', }; test('lib exposes explicit entrypoints with implementation under src', () { @@ -40,16 +37,9 @@ void main() { if (compatibilityExports.contains(relative)) { // Existing consumers can keep their import paths, but these bridges // must never become a second implementation or a runtime dependency. - final lines = source - .replaceAll(RegExp(r'//[^\n]*'), '') - .split('\n') - .map((line) => line.trim()) - .where((line) => line.isNotEmpty); - expect(lines, isNotEmpty, reason: relative); - expect( - lines.every( - (line) => line.startsWith('export ') && line.endsWith(';')), - isTrue, + final exports = source.replaceAll(RegExp(r'//[^\n]*'), '').trim(); + expect(exports, isNotEmpty, reason: relative); + expect(RegExp(r'^(?:export\s+[^;]+;\s*)+$').hasMatch(exports), isTrue, reason: '$relative must contain exports only'); continue; } diff --git a/packages/marionette_mcp/test/server_runner_test.dart b/packages/marionette_mcp/test/server_runner_test.dart index 84a239ba..0c36a433 100644 --- a/packages/marionette_mcp/test/server_runner_test.dart +++ b/packages/marionette_mcp/test/server_runner_test.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:logging/logging.dart' as logging; import 'package:marionette_mcp/marionette_mcp.dart'; -import 'package:marionette_mcp/src/mcp_server_runner.dart' as legacy; +import 'package:marionette_mcp/src/mcp/server_runner.dart' show ExitSignal; import 'package:test/test.dart'; void main() { @@ -57,9 +57,8 @@ void main() { expect(await errors, isNotEmpty); }); - test('former runner import retains the entrypoint and signal helper', () { - expect(legacy.runMcpServer, same(runMcpServer)); - final signal = legacy.ExitSignal(); + test('signal owner disposal is idempotent', () { + final signal = ExitSignal(); signal.dispose(); signal.dispose(); }); diff --git a/packages/marionette_mcp/test/session/step_logger_test.dart b/packages/marionette_mcp/test/session/step_logger_test.dart index debfa981..5a90a123 100644 --- a/packages/marionette_mcp/test/session/step_logger_test.dart +++ b/packages/marionette_mcp/test/session/step_logger_test.dart @@ -2,8 +2,9 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:marionette_mcp/src/mcp/step_logging.dart'; +import 'package:marionette_mcp/src/session/report_steps.dart'; import 'package:marionette_mcp/src/session/session.dart'; -import 'package:marionette_mcp/src/session/step_logger.dart'; import 'package:mcp_dart/mcp_dart.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; diff --git a/packages/marionette_mcp/test/vm_service/dynamic_extension_tools_test.dart b/packages/marionette_mcp/test/vm_service/dynamic_extension_tools_test.dart index 733f7e58..6bb6cad3 100644 --- a/packages/marionette_mcp/test/vm_service/dynamic_extension_tools_test.dart +++ b/packages/marionette_mcp/test/vm_service/dynamic_extension_tools_test.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/src/vm_service/dynamic_extension_tools.dart'; +import 'package:marionette_mcp/src/mcp/tools/dynamic_extension_tools.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; import 'package:test/test.dart'; From a5516c66ed39d56834a47e52e4f415eef70eb4ab Mon Sep 17 00:00:00 2001 From: Jhin Lee <jhin.lee@unity3d.com> Date: Sat, 24 Oct 2026 11:51:11 -0400 Subject: [PATCH 09/12] Simplify MCP tool catalog and execution paths --- docs/architecture.md | 13 ++- .../lib/src/mcp/tools/device_tools.dart | 95 ++++++++----------- .../lib/src/mcp/tools/extension_tools.dart | 55 +++-------- .../lib/src/mcp/tools/gesture_tools.dart | 16 ---- .../lib/src/mcp/tools/inspection_tools.dart | 78 ++++----------- .../lib/src/mcp/tools/keyboard_tools.dart | 7 -- .../lib/src/mcp/tools/system_tools.dart | 7 -- .../lib/src/mcp/tools/text_tools.dart | 7 -- .../lib/src/mcp/tools/tools.dart | 51 ++++++---- .../test/structured_tool_outputs_test.dart | 43 ++++----- .../vm_service/tools/device_tools_test.dart | 40 ++++---- .../tools/extension_tools_test.dart | 48 +++++++--- .../tools/inspection_tools_test.dart | 79 +++++++++++---- 13 files changed, 250 insertions(+), 289 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 5ea5026f..2bb59559 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -79,6 +79,17 @@ Connection tools log their own session transitions because their report owner changes during execution. App-defined tools have a dedicated dynamic registry, which retains registration handles and removes them on disconnect/reconnect. +## DRY and KISS + +`createAllTools` is the only catalog definition; structured-output tests consume +that same registry. Tools implement their adapter flow directly in `execute`, +without constructor-only group factories or alternate invocation functions. +Shared result envelopes and ancestor descriptions have one definition. Tool +schemas stay explicit where their selectors, descriptions, or recovery messages +differ. Connection ownership types and callbacks remain because they express +real lifecycle rules; simple connector operations do not gain extra service +wrappers just to share a few lines. + ## Public package boundaries Embedders import `package:marionette_mcp/marionette_mcp.dart` for server @@ -145,7 +156,7 @@ and transport framing; the application owns its process and app resources. the service API typed and independent of MCP. 2. Implement a `BaseTool` adapter with its input schema, output schema, metadata, and result/error formatting. Inject its dependencies through the constructor. -3. Add it to the appropriate factory and the central built-in registry. Keep +3. Add it once to the central `createAllTools` registry. Keep external resource acquisition out of tool constructors and registration. 4. Test service behavior directly, then check the adapter's structured output, errors, and public SDK interaction. Update consumer guidance for observable diff --git a/packages/marionette_mcp/lib/src/mcp/tools/device_tools.dart b/packages/marionette_mcp/lib/src/mcp/tools/device_tools.dart index 8e74a7a9..a99f5932 100644 --- a/packages/marionette_mcp/lib/src/mcp/tools/device_tools.dart +++ b/packages/marionette_mcp/lib/src/mcp/tools/device_tools.dart @@ -6,13 +6,6 @@ import 'package:mcp_dart/mcp_dart.dart'; import 'base_tool.dart'; import 'tool_results.dart'; -/// Creates the device tools with their shared dependencies. -List<BaseTool> createDeviceTools({ - required VmServiceConnector connector, - required logging.Logger logger, -}) => - [SetDeviceConfigTool(connector, logger)]; - /// MCP tool `set_device_config`. class SetDeviceConfigTool extends BaseTool { SetDeviceConfigTool(this.connector, this.logger); @@ -67,60 +60,48 @@ class SetDeviceConfigTool extends BaseTool { Future<CallToolResult> execute( Map<String, dynamic> args, RequestHandlerExtra extra, - ) => - setDeviceConfig(connector, logger, args); -} + ) { + final rawTextScale = args['text_scale'] as num?; + final boldText = args['bold_text'] as bool?; + final brightness = args['platform_brightness'] as String?; + final reset = args['reset'] == true; + + final setsAnyField = + rawTextScale != null || boldText != null || brightness != null; + + // A bare `reset: false` would otherwise report success having changed + // nothing. + if (!setsAnyField && !reset) { + return _invalid( + 'At least one parameter is required: text_scale, bold_text, ' + 'platform_brightness, or reset=true.', + ); + } -/// Handles a `set_device_config` invocation: validates the args and forwards -/// them to [connector]. -/// -/// Extracted from the tool callback so the validation branches can be -/// exercised in isolation. -Future<CallToolResult> setDeviceConfig( - VmServiceConnector connector, - logging.Logger logger, - Map<String, dynamic> args, -) { - final rawTextScale = args['text_scale'] as num?; - final boldText = args['bold_text'] as bool?; - final brightness = args['platform_brightness'] as String?; - final reset = args['reset'] == true; - - final setsAnyField = - rawTextScale != null || boldText != null || brightness != null; - - // A bare `reset: false` would otherwise report success having changed - // nothing. - if (!setsAnyField && !reset) { - return _invalid( - 'At least one parameter is required: text_scale, bold_text, ' - 'platform_brightness, or reset=true.', - ); - } + if (rawTextScale != null && (!rawTextScale.isFinite || rawTextScale <= 0)) { + return _invalid('text_scale must be greater than 0.'); + } - if (rawTextScale != null && (!rawTextScale.isFinite || rawTextScale <= 0)) { - return _invalid('text_scale must be greater than 0.'); - } + if (invalidBrightnessError(brightness) case final error?) { + return _invalid(error); + } - if (invalidBrightnessError(brightness) case final error?) { - return _invalid(error); - } - - logger.info( - 'Setting device config: textScale=$rawTextScale, boldText=$boldText, ' - 'platformBrightness=$brightness, reset=$reset', - ); - - return runTool(logger, 'set device config', () async { - final response = await connector.setDeviceConfig( - textScale: rawTextScale?.toDouble(), - boldText: boldText, - platformBrightness: brightness, - reset: reset, + logger.info( + 'Setting device config: textScale=$rawTextScale, boldText=$boldText, ' + 'platformBrightness=$brightness, reset=$reset', ); - final message = response['message'] as String?; - return messageResult(message ?? 'Device config updated'); - }); + + return runTool(logger, 'set device config', () async { + final response = await connector.setDeviceConfig( + textScale: rawTextScale?.toDouble(), + boldText: boldText, + platformBrightness: brightness, + reset: reset, + ); + final message = response['message'] as String?; + return messageResult(message ?? 'Device config updated'); + }); + } } Future<CallToolResult> _invalid(String message) async => diff --git a/packages/marionette_mcp/lib/src/mcp/tools/extension_tools.dart b/packages/marionette_mcp/lib/src/mcp/tools/extension_tools.dart index 8e6397bc..b05f8541 100644 --- a/packages/marionette_mcp/lib/src/mcp/tools/extension_tools.dart +++ b/packages/marionette_mcp/lib/src/mcp/tools/extension_tools.dart @@ -7,16 +7,6 @@ import 'package:mcp_dart/mcp_dart.dart'; import 'base_tool.dart'; import 'tool_results.dart'; -/// Creates the extension tools with their shared dependencies. -List<BaseTool> createExtensionTools({ - required VmServiceConnector connector, - required logging.Logger logger, -}) => - [ - ListCustomExtensionsTool(connector, logger), - CallCustomExtensionTool(connector, logger), - ]; - /// MCP tool `list_custom_extensions`. class ListCustomExtensionsTool extends BaseTool { ListCustomExtensionsTool(this.connector, this.logger); @@ -156,35 +146,20 @@ class CallCustomExtensionTool extends BaseTool { Future<CallToolResult> execute( Map<String, dynamic> args, RequestHandlerExtra extra, - ) => - callCustomExtension(connector, logger, args); -} - -/// Handles a `call_custom_extension` invocation: coerces the supplied `args` -/// into the wire shape, forwards them to [connector], and wraps the response. -/// -/// Extracted from the tool callback so the coercion contract can be exercised -/// in isolation. Args go through [coerceToStringMap] — the same path the -/// dynamically-promoted extension tools use — so both produce identical wire -/// args: scalars are stringified and nested object/array values become valid -/// JSON (not Dart's un-parseable `toString()` form). -Future<CallToolResult> callCustomExtension( - VmServiceConnector connector, - logging.Logger logger, - Map<String, dynamic> args, -) { - final extensionName = args['extension'] as String; - final extensionArgs = coerceToStringMap( - (args['args'] as Map<String, dynamic>?) ?? const <String, dynamic>{}, - ); - logger.info( - 'Calling custom extension: $extensionName with args: $extensionArgs', - ); - return runTool(logger, 'call custom extension', () async { - final response = await connector.callCustomExtension( - extensionName, - extensionArgs, + ) { + final extensionName = args['extension'] as String; + final extensionArgs = coerceToStringMap( + (args['args'] as Map<String, dynamic>?) ?? const <String, dynamic>{}, + ); + logger.info( + 'Calling custom extension: $extensionName with args: $extensionArgs', ); - return extensionResponseResult(response); - }); + return runTool(logger, 'call custom extension', () async { + final response = await connector.callCustomExtension( + extensionName, + extensionArgs, + ); + return extensionResponseResult(response); + }); + } } diff --git a/packages/marionette_mcp/lib/src/mcp/tools/gesture_tools.dart b/packages/marionette_mcp/lib/src/mcp/tools/gesture_tools.dart index 65abfa84..73ea8df8 100644 --- a/packages/marionette_mcp/lib/src/mcp/tools/gesture_tools.dart +++ b/packages/marionette_mcp/lib/src/mcp/tools/gesture_tools.dart @@ -8,22 +8,6 @@ import 'package:mcp_dart/mcp_dart.dart'; import 'base_tool.dart'; import 'tool_results.dart'; -/// Creates the gesture tools with their shared dependencies. -List<BaseTool> createGestureTools({ - required VmServiceConnector connector, - required logging.Logger logger, -}) => - [ - TapTool(connector, logger), - SecondaryTapTool(connector, logger), - DoubleTapTool(connector, logger), - LongPressTool(connector, logger), - SwipeTool(connector, logger), - PinchZoomTool(connector, logger), - PressBackButtonTool(connector, logger), - ScrollToTool(connector, logger), - ]; - /// MCP tool `tap`. class TapTool extends BaseTool { TapTool(this.connector, this.logger); diff --git a/packages/marionette_mcp/lib/src/mcp/tools/inspection_tools.dart b/packages/marionette_mcp/lib/src/mcp/tools/inspection_tools.dart index e2c53ce1..6f3e3c45 100644 --- a/packages/marionette_mcp/lib/src/mcp/tools/inspection_tools.dart +++ b/packages/marionette_mcp/lib/src/mcp/tools/inspection_tools.dart @@ -8,19 +8,6 @@ import 'package:mcp_dart/mcp_dart.dart'; import 'base_tool.dart'; -/// Creates the inspection tools with their shared dependencies. -List<BaseTool> createInspectionTools({ - required VmServiceConnector connector, - required logging.Logger logger, - required ScreenshotService screenshotService, - required Session? Function() currentSession, -}) => - [ - GetInteractiveElementsTool(connector, logger), - GetLogsTool(connector, logger), - TakeScreenshotsTool(screenshotService, logger, currentSession), - ]; - /// MCP tool `get_interactive_elements`. class GetInteractiveElementsTool extends BaseTool { GetInteractiveElementsTool(this.connector, this.logger); @@ -231,50 +218,27 @@ class TakeScreenshotsTool extends BaseTool { RequestHandlerExtra extra, ) async { logger.info('Taking screenshots'); - return runTool( - logger, - 'take screenshots', - () => _captureScreenshots(screenshotService, currentSession(), args), - ); - } -} - -/// Handles a `take_screenshots` invocation: captures via [connector], then -/// either returns the images inline or — when `args['inline'] == false` — -/// saves them into [session]'s screenshots directory and returns their paths. -/// -/// Extracted from the tool callback so the inline/save-only branching can be -/// exercised in isolation. -Future<CallToolResult> takeScreenshots( - VmServiceConnector connector, - Session? session, - Map<String, dynamic> args, -) => - _captureScreenshots(ScreenshotService(connector), session, args); - -Future<CallToolResult> _captureScreenshots( - ScreenshotService screenshotService, - Session? session, - Map<String, dynamic> args, -) async { - try { - final capture = await screenshotService.capture( - inline: args['inline'] != false, - session: session, - ); - return _screenshotResult(capture); - } on ScreenshotSessionRequired { - return CallToolResult( - isError: true, - content: [ - const TextContent( - text: 'inline: false requires an active session, which is ' - 'only created once connect succeeds and only when the app ' - 'enables session reports ' - '(MarionetteConfiguration.enableSessionReports).', - ), - ], - ); + return runTool(logger, 'take screenshots', () async { + try { + final capture = await screenshotService.capture( + inline: args['inline'] != false, + session: currentSession(), + ); + return _screenshotResult(capture); + } on ScreenshotSessionRequired { + return CallToolResult( + isError: true, + content: [ + const TextContent( + text: 'inline: false requires an active session, which is ' + 'only created once connect succeeds and only when the app ' + 'enables session reports ' + '(MarionetteConfiguration.enableSessionReports).', + ), + ], + ); + } + }); } } diff --git a/packages/marionette_mcp/lib/src/mcp/tools/keyboard_tools.dart b/packages/marionette_mcp/lib/src/mcp/tools/keyboard_tools.dart index 421e1869..3e48bd62 100644 --- a/packages/marionette_mcp/lib/src/mcp/tools/keyboard_tools.dart +++ b/packages/marionette_mcp/lib/src/mcp/tools/keyboard_tools.dart @@ -6,13 +6,6 @@ import 'package:mcp_dart/mcp_dart.dart'; import 'base_tool.dart'; import 'tool_results.dart'; -/// Creates the keyboard tools with their shared dependencies. -List<BaseTool> createKeyboardTools({ - required VmServiceConnector connector, - required logging.Logger logger, -}) => - [PressKeyTool(connector, logger)]; - /// MCP tool `press_key`. class PressKeyTool extends BaseTool { PressKeyTool(this.connector, this.logger); diff --git a/packages/marionette_mcp/lib/src/mcp/tools/system_tools.dart b/packages/marionette_mcp/lib/src/mcp/tools/system_tools.dart index 0501f8c8..e12beca2 100644 --- a/packages/marionette_mcp/lib/src/mcp/tools/system_tools.dart +++ b/packages/marionette_mcp/lib/src/mcp/tools/system_tools.dart @@ -5,13 +5,6 @@ import 'package:mcp_dart/mcp_dart.dart'; import 'base_tool.dart'; import 'tool_results.dart'; -/// Creates the system tools with their shared dependencies. -List<BaseTool> createSystemTools({ - required VmServiceConnector connector, - required logging.Logger logger, -}) => - [HotReloadTool(connector, logger), HotRestartTool(connector, logger)]; - /// MCP tool `hot_reload`. class HotReloadTool extends BaseTool { HotReloadTool(this.connector, this.logger); diff --git a/packages/marionette_mcp/lib/src/mcp/tools/text_tools.dart b/packages/marionette_mcp/lib/src/mcp/tools/text_tools.dart index 6335ba3b..f79f57d4 100644 --- a/packages/marionette_mcp/lib/src/mcp/tools/text_tools.dart +++ b/packages/marionette_mcp/lib/src/mcp/tools/text_tools.dart @@ -8,13 +8,6 @@ import 'package:mcp_dart/mcp_dart.dart'; import 'base_tool.dart'; import 'tool_results.dart'; -/// Creates the text tools with their shared dependencies. -List<BaseTool> createTextTools({ - required VmServiceConnector connector, - required logging.Logger logger, -}) => - [EnterTextTool(connector, logger)]; - /// MCP tool `enter_text`. class EnterTextTool extends BaseTool { EnterTextTool(this.connector, this.logger); diff --git a/packages/marionette_mcp/lib/src/mcp/tools/tools.dart b/packages/marionette_mcp/lib/src/mcp/tools/tools.dart index d6f39664..c7453f4e 100644 --- a/packages/marionette_mcp/lib/src/mcp/tools/tools.dart +++ b/packages/marionette_mcp/lib/src/mcp/tools/tools.dart @@ -24,24 +24,33 @@ export 'system_tools.dart'; export 'text_tools.dart'; /// Creates the complete built-in catalog with shared connection/session state. -List<BaseTool> createAllTools({required VmServiceContext context}) => [ - ConnectTool(context), - DisconnectTool(context), - ...createInspectionTools( - connector: context.connector, - logger: context.logger, - screenshotService: ScreenshotService(context.connector), - currentSession: () => context.connection.session, - ), - ...createGestureTools( - connector: context.connector, logger: context.logger), - ...createTextTools(connector: context.connector, logger: context.logger), - ...createKeyboardTools( - connector: context.connector, logger: context.logger), - ...createDeviceTools( - connector: context.connector, logger: context.logger), - ...createExtensionTools( - connector: context.connector, logger: context.logger), - ...createSystemTools( - connector: context.connector, logger: context.logger), - ]; +List<BaseTool> createAllTools({required VmServiceContext context}) { + final connector = context.connector; + final logger = context.logger; + return [ + ConnectTool(context), + DisconnectTool(context), + GetInteractiveElementsTool(connector, logger), + GetLogsTool(connector, logger), + TakeScreenshotsTool( + ScreenshotService(connector), + logger, + () => context.connection.session, + ), + TapTool(connector, logger), + SecondaryTapTool(connector, logger), + DoubleTapTool(connector, logger), + LongPressTool(connector, logger), + SwipeTool(connector, logger), + PinchZoomTool(connector, logger), + PressBackButtonTool(connector, logger), + ScrollToTool(connector, logger), + EnterTextTool(connector, logger), + PressKeyTool(connector, logger), + SetDeviceConfigTool(connector, logger), + ListCustomExtensionsTool(connector, logger), + CallCustomExtensionTool(connector, logger), + HotReloadTool(connector, logger), + HotRestartTool(connector, logger), + ]; +} diff --git a/packages/marionette_mcp/test/structured_tool_outputs_test.dart b/packages/marionette_mcp/test/structured_tool_outputs_test.dart index 221fd7ba..db95a775 100644 --- a/packages/marionette_mcp/test/structured_tool_outputs_test.dart +++ b/packages/marionette_mcp/test/structured_tool_outputs_test.dart @@ -2,14 +2,8 @@ import 'dart:convert'; import 'dart:io'; import 'package:logging/logging.dart' as logging; -import 'package:marionette_mcp/src/mcp/tools/base_tool.dart'; -import 'package:marionette_mcp/src/mcp/tools/device_tools.dart'; -import 'package:marionette_mcp/src/mcp/tools/extension_tools.dart'; -import 'package:marionette_mcp/src/mcp/tools/gesture_tools.dart'; -import 'package:marionette_mcp/src/mcp/tools/inspection_tools.dart'; -import 'package:marionette_mcp/src/mcp/tools/keyboard_tools.dart'; -import 'package:marionette_mcp/src/mcp/tools/system_tools.dart'; -import 'package:marionette_mcp/src/mcp/tools/text_tools.dart'; +import 'package:marionette_mcp/src/mcp/server_context.dart'; +import 'package:marionette_mcp/src/mcp/tools/tools.dart'; import 'package:marionette_mcp/src/session/screenshot_service.dart'; import 'package:marionette_mcp/src/session/session.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; @@ -21,6 +15,9 @@ class _Connector implements VmServiceConnector { bool systemSuccess = true; bool fails = false; + @override + Future<void> disconnect() async {} + @override dynamic noSuchMethod(Invocation invocation) { if (fails) return Future<Map<String, dynamic>>.error(StateError('offline')); @@ -44,23 +41,10 @@ void main() { setUp(() { connector = _Connector(); - final logger = logging.Logger.detached('structured-results'); + final context = VmServiceContext(connector: connector); + addTearDown(context.dispose); tools = { - for (final tool in [ - ...createDeviceTools(connector: connector, logger: logger), - ...createExtensionTools(connector: connector, logger: logger), - ...createGestureTools(connector: connector, logger: logger), - ...createInspectionTools( - connector: connector, - logger: logger, - screenshotService: ScreenshotService(connector), - currentSession: () => null, - ), - ...createKeyboardTools(connector: connector, logger: logger), - ...createSystemTools(connector: connector, logger: logger), - ...createTextTools(connector: connector, logger: logger), - ]) - tool.name: tool, + for (final tool in createAllTools(context: context)) tool.name: tool }; }); @@ -89,8 +73,8 @@ void main() { 'hot_restart': ({}, 'Hot restart completed successfully'), }; - test('all 18 static tools have schemas with required fields', () { - expect(tools, hasLength(18)); + test('all 20 built-in tools have schemas with required fields', () { + expect(tools, hasLength(20)); for (final tool in tools.values) { expect(tool.outputSchema, isNotNull, reason: tool.name); expect(tool.outputSchema!.required, isNotEmpty, reason: tool.name); @@ -250,7 +234,12 @@ void main() { connector.response = { 'screenshots': ['aGVsbG8='] }; - final result = await takeScreenshots(connector, session, {'inline': false}); + final tool = TakeScreenshotsTool( + ScreenshotService(connector), + logging.Logger.detached('structured-screenshots'), + () => session, + ); + final result = await tool.execute({'inline': false}, extra); validate('take_screenshots', result); final paths = (result.structuredContent!['paths'] as List<String>); expect(result.structuredContent, diff --git a/packages/marionette_mcp/test/vm_service/tools/device_tools_test.dart b/packages/marionette_mcp/test/vm_service/tools/device_tools_test.dart index 2ca2ecf7..b8816b25 100644 --- a/packages/marionette_mcp/test/vm_service/tools/device_tools_test.dart +++ b/packages/marionette_mcp/test/vm_service/tools/device_tools_test.dart @@ -36,25 +36,31 @@ class _CapturingConnector implements VmServiceConnector { throw UnimplementedError('${invocation.memberName}'); } +class _Extra implements RequestHandlerExtra { + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} + void main() { + final extra = _Extra(); group('set_device_config', () { late _CapturingConnector connector; - late logging.Logger logger; + late SetDeviceConfigTool tool; setUp(() { connector = _CapturingConnector(); - logger = logging.Logger.detached('test'); + tool = SetDeviceConfigTool(connector, logging.Logger.detached('test')); }); String textOf(CallToolResult result) => (result.content.single as TextContent).text; test('forwards every field', () async { - await setDeviceConfig(connector, logger, { + await tool.execute({ 'text_scale': 1.5, 'bold_text': true, 'platform_brightness': 'dark', - }); + }, extra); expect(connector.calls.single, { 'textScale': 1.5, @@ -65,13 +71,13 @@ void main() { }); test('widens an integer text scale to a double', () async { - await setDeviceConfig(connector, logger, {'text_scale': 2}); + await tool.execute({'text_scale': 2}, extra); expect(connector.calls.single['textScale'], 2.0); }); test('forwards reset on its own', () async { - await setDeviceConfig(connector, logger, {'reset': true}); + await tool.execute({'reset': true}, extra); expect(connector.calls.single['reset'], isTrue); }); @@ -79,14 +85,14 @@ void main() { test('relays the message from the app', () async { connector.nextResponse = const {'message': 'Device config reset'}; - final result = await setDeviceConfig(connector, logger, {'reset': true}); + final result = await tool.execute({'reset': true}, extra); expect(textOf(result), 'Device config reset'); expect(result.isError, isNot(true)); }); test('rejects a call that sets nothing', () async { - final result = await setDeviceConfig(connector, logger, const {}); + final result = await tool.execute(const {}, extra); expect(result.isError, isTrue); expect(textOf(result), contains('At least one parameter')); @@ -94,16 +100,16 @@ void main() { }); test('rejects reset=false with no other parameter', () async { - final result = await setDeviceConfig(connector, logger, {'reset': false}); + final result = await tool.execute({'reset': false}, extra); expect(result.isError, isTrue); expect(connector.calls, isEmpty); }); test('rejects a non-positive text scale', () async { - final result = await setDeviceConfig(connector, logger, { + final result = await tool.execute({ 'text_scale': 0, - }); + }, extra); expect(result.isError, isTrue); expect(textOf(result), contains('greater than 0')); @@ -111,9 +117,9 @@ void main() { }); test('rejects a non-finite text scale', () async { - final result = await setDeviceConfig(connector, logger, { + final result = await tool.execute({ 'text_scale': double.nan, - }); + }, extra); expect(result.isError, isTrue); expect(textOf(result), contains('greater than 0')); @@ -121,9 +127,9 @@ void main() { }); test('rejects an unknown brightness', () async { - final result = await setDeviceConfig(connector, logger, { + final result = await tool.execute({ 'platform_brightness': 'sepia', - }); + }, extra); expect(result.isError, isTrue); expect(textOf(result), contains('Unsupported brightness: sepia')); @@ -138,9 +144,9 @@ void main() { error: 'Device config overrides are not available in this app.', ); - final result = await setDeviceConfig(connector, logger, { + final result = await tool.execute({ 'text_scale': 2, - }); + }, extra); expect(result.isError, isTrue); expect(textOf(result), contains('not available in this app')); diff --git a/packages/marionette_mcp/test/vm_service/tools/extension_tools_test.dart b/packages/marionette_mcp/test/vm_service/tools/extension_tools_test.dart index 28d07c22..a49a4abd 100644 --- a/packages/marionette_mcp/test/vm_service/tools/extension_tools_test.dart +++ b/packages/marionette_mcp/test/vm_service/tools/extension_tools_test.dart @@ -6,11 +6,12 @@ import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; import 'package:test/test.dart'; -/// Captures the args forwarded to [callCustomExtension] so we can assert on the +/// Captures the args forwarded to [VmServiceConnector.callCustomExtension] so we can assert on the /// exact wire shape produced by the coercion step. class _CapturingConnector implements VmServiceConnector { final List<({String name, Map<String, dynamic> args})> calls = []; Map<String, dynamic> nextResponse = const {'ok': true}; + Object? nextError; @override Future<Map<String, dynamic>> callCustomExtension( @@ -18,6 +19,7 @@ class _CapturingConnector implements VmServiceConnector { Map<String, dynamic> args = const {}, ]) async { calls.add((name: extensionName, args: args)); + if (nextError case final error?) throw error; return nextResponse; } @@ -26,29 +28,36 @@ class _CapturingConnector implements VmServiceConnector { throw UnimplementedError('${invocation.memberName}'); } +class _Extra implements RequestHandlerExtra { + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} + void main() { + final extra = _Extra(); group('callCustomExtension', () { late _CapturingConnector connector; - late logging.Logger logger; + late CallCustomExtensionTool tool; setUp(() { connector = _CapturingConnector(); - logger = logging.Logger.detached('test'); + tool = + CallCustomExtensionTool(connector, logging.Logger.detached('test')); }); test('forwards the extension name', () async { - await callCustomExtension(connector, logger, { + await tool.execute({ 'extension': 'appNavigation.goToPage', - }); + }, extra); expect(connector.calls.single.name, 'appNavigation.goToPage'); }); test('stringifies scalar args before sending', () async { - await callCustomExtension(connector, logger, { + await tool.execute({ 'extension': 'x', 'args': {'i': 42, 'd': 1.5, 'b': true, 's': 'hi'}, - }); + }, extra); expect(connector.calls.single.args, { 'i': '42', @@ -59,13 +68,13 @@ void main() { }); test('jsonEncodes nested object/array args (not Dart toString)', () async { - await callCustomExtension(connector, logger, { + await tool.execute({ 'extension': 'x', 'args': { 'range': {'min': 1, 'max': 9}, 'tags': ['a', 'b'], }, - }); + }, extra); // The bug being fixed: previously these arrived as Dart's `{min: 1, ...}` // / `[a, b]` toString() form. They must now be valid JSON. @@ -81,26 +90,37 @@ void main() { }); test('replaces null arg values with the empty string', () async { - await callCustomExtension(connector, logger, { + await tool.execute({ 'extension': 'x', 'args': {'maybe': null}, - }); + }, extra); expect(connector.calls.single.args, {'maybe': ''}); }); test('sends an empty map when args are omitted', () async { - await callCustomExtension(connector, logger, {'extension': 'x'}); + await tool.execute({'extension': 'x'}, extra); expect(connector.calls.single.args, isEmpty); }); + test('returns connector failures as tool errors', () async { + final error = StateError('offline'); + connector.nextError = error; + + final result = await tool.execute({'extension': 'x'}, extra); + + expect(result.isError, isTrue); + expect((result.content.single as TextContent).text, error.toString()); + expect(connector.calls.single.name, 'x'); + }); + test('wraps the connector response as JSON text content', () async { connector.nextResponse = const {'result': 'done'}; - final result = await callCustomExtension(connector, logger, { + final result = await tool.execute({ 'extension': 'x', - }); + }, extra); final content = result.content.single as TextContent; expect(jsonDecode(content.text), {'result': 'done'}); diff --git a/packages/marionette_mcp/test/vm_service/tools/inspection_tools_test.dart b/packages/marionette_mcp/test/vm_service/tools/inspection_tools_test.dart index 641b7e71..4605cb6c 100644 --- a/packages/marionette_mcp/test/vm_service/tools/inspection_tools_test.dart +++ b/packages/marionette_mcp/test/vm_service/tools/inspection_tools_test.dart @@ -1,8 +1,10 @@ import 'dart:convert'; import 'dart:io'; -import 'package:marionette_mcp/src/session/session.dart'; +import 'package:logging/logging.dart' as logging; import 'package:marionette_mcp/src/mcp/tools/inspection_tools.dart'; +import 'package:marionette_mcp/src/session/screenshot_service.dart'; +import 'package:marionette_mcp/src/session/session.dart'; import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; import 'package:mcp_dart/mcp_dart.dart'; import 'package:path/path.dart' as p; @@ -15,23 +17,42 @@ const _fakePng = class _FakeConnector implements VmServiceConnector { List<String> nextScreenshots = [_fakePng]; + Object? nextError; + int calls = 0; @override - Future<Map<String, dynamic>> takeScreenshots() async => - {'screenshots': nextScreenshots}; + Future<Map<String, dynamic>> takeScreenshots() async { + calls++; + if (nextError case final error?) throw error; + return {'screenshots': nextScreenshots}; + } @override dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError('${invocation.memberName}'); } +class _Extra implements RequestHandlerExtra { + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} + void main() { + final extra = _Extra(); group('takeScreenshots', () { late _FakeConnector connector; late Directory tempDir; + late TakeScreenshotsTool tool; + Session? session; setUp(() { connector = _FakeConnector(); + session = null; + tool = TakeScreenshotsTool( + ScreenshotService(connector), + logging.Logger.detached('test'), + () => session, + ); tempDir = Directory.systemTemp.createTempSync('take_screenshots_test_'); }); @@ -40,7 +61,7 @@ void main() { }); test('returns inline image content by default', () async { - final result = await takeScreenshots(connector, null, const {}); + final result = await tool.execute(const {}, extra); expect(result.isError, isFalse); expect(result.content, hasLength(1)); @@ -49,15 +70,14 @@ void main() { test('reports when nothing was captured', () async { connector.nextScreenshots = []; - final result = await takeScreenshots(connector, null, const {}); + final result = await tool.execute(const {}, extra); expect((result.content.single as TextContent).text, contains('No screenshots captured')); }); test('errors when inline: false has no active session', () async { - final result = - await takeScreenshots(connector, null, const {'inline': false}); + final result = await tool.execute(const {'inline': false}, extra); expect(result.isError, isTrue); expect( @@ -66,27 +86,50 @@ void main() { ); }); + test('returns connector failures before checking session requirements', + () async { + final error = StateError('offline'); + connector.nextError = error; + + final result = await tool.execute(const {'inline': false}, extra); + + expect(result.isError, isTrue); + expect((result.content.single as TextContent).text, error.toString()); + expect(connector.calls, 1); + }); + + test('returns invalid image persistence failures as tool errors', () async { + session = Session.open(Directory(p.join(tempDir.path, 'session'))); + connector.nextScreenshots = ['invalid base64']; + + final result = await tool.execute(const {'inline': false}, extra); + + expect(result.isError, isTrue); + expect((result.content.single as TextContent).text, + startsWith('FormatException:')); + expect(session!.screenshotsDir.listSync(), isEmpty); + }); + test('saves to the session screenshots directory when inline: false', () async { - final session = Session.open(Directory(p.join(tempDir.path, 'session'))); + session = Session.open(Directory(p.join(tempDir.path, 'session'))); - final result = - await takeScreenshots(connector, session, const {'inline': false}); + final result = await tool.execute(const {'inline': false}, extra); expect(result.isError, isFalse); - final savedFile = File(p.join(session.screenshotsDir.path, '01.png')); + final savedFile = File(p.join(session!.screenshotsDir.path, '01.png')); expect(savedFile.existsSync(), isTrue); expect(savedFile.readAsBytesSync(), base64Decode(_fakePng)); expect((result.content.single as TextContent).text, contains('01.png')); }); test('numbers subsequent saves after what is already there', () async { - final session = Session.open(Directory(p.join(tempDir.path, 'session'))); - await takeScreenshots(connector, session, const {'inline': false}); - await takeScreenshots(connector, session, const {'inline': false}); + session = Session.open(Directory(p.join(tempDir.path, 'session'))); + await tool.execute(const {'inline': false}, extra); + await tool.execute(const {'inline': false}, extra); expect( - File(p.join(session.screenshotsDir.path, '02.png')).existsSync(), + File(p.join(session!.screenshotsDir.path, '02.png')).existsSync(), isTrue, ); }); @@ -98,14 +141,14 @@ void main() { // 02.png is deleted, two files (01.png, 03.png) remain, and a // count-based index of 3 would overwrite 03.png instead of writing // 04.png. - final session = Session.open(Directory(p.join(tempDir.path, 'session'))); - final screenshotsDir = session.screenshotsDir; + session = Session.open(Directory(p.join(tempDir.path, 'session'))); + final screenshotsDir = session!.screenshotsDir; File(p.join(screenshotsDir.path, '01.png')).writeAsBytesSync([1]); final existingThree = File(p.join(screenshotsDir.path, '03.png')) ..writeAsBytesSync([3]); final existingThreeBytesBefore = existingThree.readAsBytesSync(); - await takeScreenshots(connector, session, const {'inline': false}); + await tool.execute(const {'inline': false}, extra); expect( File(p.join(screenshotsDir.path, '04.png')).existsSync(), From 02f4654349945f2f8a32b50d019148b4dae3a462 Mon Sep 17 00:00:00 2001 From: Jhin Lee <jhin.lee@unity3d.com> Date: Sat, 24 Oct 2026 12:15:13 -0400 Subject: [PATCH 10/12] Clarify toolchain guidance and narrow CI compatibility changes --- .github/workflows/ci.yaml | 73 ++++++++++++++----- docs/architecture.md | 9 +++ .../marionette-flutter-drive-app/SKILL.md | 6 ++ packages/marionette_mcp/example/README.md | 31 +++++--- website/lib/pages/docs/start_page.dart | 12 +++ 5 files changed, 102 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 161ac436..32b02e04 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -26,8 +26,35 @@ jobs: flutter-version-file: .fvmrc cache: true - - name: Install workspace dependencies - run: flutter pub get + - name: Install dependencies for tool + run: | + cd tool + dart pub get + + - name: Install dependencies for marionette_mcp + run: | + cd packages/marionette_mcp + dart pub get + + - name: Install dependencies for marionette_cli + run: | + cd packages/marionette_cli + dart pub get + + - name: Install dependencies for marionette_flutter + run: | + cd packages/marionette_flutter + flutter pub get + + - name: Install dependencies for marionette_logger + run: | + cd packages/marionette_logger + flutter pub get + + - name: Install dependencies for marionette_logging + run: | + cd packages/marionette_logging + flutter pub get - name: Check version.g.dart is in sync run: | @@ -40,7 +67,7 @@ jobs: - name: Analyze marionette_mcp run: | cd packages/marionette_mcp - dart analyze --fatal-infos lib bin test + dart analyze --fatal-infos lib bin - name: Analyze marionette_cli run: | @@ -139,20 +166,24 @@ jobs: dart pub get --no-example - name: Analyze marionette_mcp - working-directory: packages/marionette_mcp - run: dart analyze --fatal-infos lib bin test + run: | + cd packages/marionette_mcp + dart analyze --fatal-infos lib bin - name: Analyze marionette_cli - working-directory: packages/marionette_cli - run: dart analyze --fatal-infos lib bin + run: | + cd packages/marionette_cli + dart analyze --fatal-infos lib bin - name: Run tests for marionette_mcp - working-directory: packages/marionette_mcp - run: dart test + run: | + cd packages/marionette_mcp + dart test - name: Run tests for marionette_cli - working-directory: packages/marionette_cli - run: dart test + run: | + cd packages/marionette_cli + dart test - name: Install minimum-Flutter workspace dependencies run: | @@ -166,20 +197,24 @@ jobs: flutter pub get - name: Analyze marionette_flutter - working-directory: packages/marionette_flutter - run: flutter analyze --fatal-infos lib + run: | + cd packages/marionette_flutter + flutter analyze --fatal-infos lib - name: Analyze marionette_logger - working-directory: packages/marionette_logger - run: flutter analyze --fatal-infos lib + run: | + cd packages/marionette_logger + flutter analyze --fatal-infos lib - name: Analyze marionette_logging - working-directory: packages/marionette_logging - run: flutter analyze --fatal-infos lib + run: | + cd packages/marionette_logging + flutter analyze --fatal-infos lib - name: Run tests for marionette_flutter - working-directory: packages/marionette_flutter - run: flutter test --no-pub + run: | + cd packages/marionette_flutter + flutter test --no-pub - name: Remove workspace selection if: always() diff --git a/docs/architecture.md b/docs/architecture.md index 2bb59559..fc216166 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -150,6 +150,15 @@ includes immediate stdin EOF; SSE retains its existing endpoints and explicitly closes active sessions. The SDK owns protocol negotiation, dispatch, validation, and transport framing; the application owns its process and app resources. +### Future Streamable HTTP support + +Streamable HTTP is deferred from this refactor. In `mcp_dart` 2.4.3 the stateless +HTTP profile creates a server for each request, while Marionette's `connect` +workflow retains app state. A future transport must first choose explicit +client-state ownership and cleanup semantics. The server factory and transport +startup boundary are the extension points; no HTTP flags or state-routing +abstraction are added in advance. + ## Adding or changing a tool 1. Put reusable workflow/state/persistence behavior in a runtime service. Keep diff --git a/packages/marionette_flutter/skills/marionette-flutter-drive-app/SKILL.md b/packages/marionette_flutter/skills/marionette-flutter-drive-app/SKILL.md index 2f95fa5d..78c3adf6 100644 --- a/packages/marionette_flutter/skills/marionette-flutter-drive-app/SKILL.md +++ b/packages/marionette_flutter/skills/marionette-flutter-drive-app/SKILL.md @@ -206,6 +206,12 @@ in the app, and `dart pub global activate marionette_mcp`/`marionette_cli` for the bridge (or `dart pub add dev:marionette_mcp`/`dev:marionette_cli` if you'd rather pin it as a dev dependency instead of a global tool). +For Flutter 3.27 apps using `flutter_test`, install the bridge globally: +the test SDK's `meta` pin conflicts with `mcp_dart` 2.4.3 in the same dependency +graph. Repository examples resolve a combined graph and should use the Flutter +version pinned in `.fvmrc`. The binding still supports Flutter 3.27 when the +bridge runs separately. + ## When to use this Reach for Marionette any time the fastest way to know whether something works diff --git a/packages/marionette_mcp/example/README.md b/packages/marionette_mcp/example/README.md index 9498e65d..818570af 100644 --- a/packages/marionette_mcp/example/README.md +++ b/packages/marionette_mcp/example/README.md @@ -1,16 +1,27 @@ -# counter +# Marionette counter example -A new Flutter project. +This Flutter counter app initializes `MarionetteBinding` so an MCP client can +inspect the UI, tap the increment button, and capture screenshots. -## Getting Started +Use the Flutter version pinned in the repository's [`.fvmrc`](../../../.fvmrc). +This example includes the MCP server as a dev dependency. Its combined graph +cannot resolve on Flutter 3.27 because `flutter_test` and `mcp_dart` 2.4.3 +require incompatible `meta` versions. Flutter 3.27 apps using the binding with +a separately installed server remain supported. -This project is a starting point for a Flutter application. +From the repository root: -A few resources to get you started if this is your first Flutter project: +```bash +cd packages/marionette_mcp/example +flutter pub get +flutter run +``` -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) +Configure your MCP client to start `dart run marionette_mcp` from this directory. +Pass the VM service URI printed by `flutter run` to `connect`, then use +`get_interactive_elements` to discover the increment control before tapping it. -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +For custom widgets, log collection, and session reports, use the richer +[repository example](../../../example/). See the +[architecture guide](../../../docs/architecture.md) for the server's structure +and extension points. diff --git a/website/lib/pages/docs/start_page.dart b/website/lib/pages/docs/start_page.dart index 1a5c90f2..86001ea9 100644 --- a/website/lib/pages/docs/start_page.dart +++ b/website/lib/pages/docs/start_page.dart @@ -162,6 +162,11 @@ class StartPage extends StatelessComponent { '`dart run marionette_mcp` as the server command.', ), ), + const Callout( + 'For a Flutter 3.27 app that uses `flutter_test`, install the ' + 'server globally. The test SDK and current MCP server require ' + 'incompatible dependency versions in a shared app graph.', + ), const h3([.text('CLI')]), p( rich( @@ -263,6 +268,13 @@ class StartPage extends StatelessComponent { 'risk first run.', ), ]), + const Callout( + 'Use the Flutter version pinned in ' + '[`.fvmrc`]($repoUrl/blob/main/.fvmrc) for this checkout. The ' + 'example resolves the full repository workspace. Flutter 3.27 ' + 'apps using the binding with a separately installed server ' + 'remain supported.', + ), const Terminal(transcript: _exampleCommands, title: 'terminal'), const CodeBlock( source: _examplePrompt, From 47aa0c0329254716029f33d04d2c0c7b35420608 Mon Sep 17 00:00:00 2001 From: Jhin Lee <jhin.lee@unity3d.com> Date: Sat, 24 Oct 2026 17:28:18 -0400 Subject: [PATCH 11/12] Tighten architecture guidance and code comments --- docs/architecture.md | 278 +++++++----------- .../lib/src/mcp/step_logging.dart | 40 +-- .../lib/src/mcp/tools/base_tool.dart | 29 +- .../mcp/tools/dynamic_extension_tools.dart | 63 +--- .../lib/src/session/report_steps.dart | 96 ++---- 5 files changed, 151 insertions(+), 355 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index fc216166..2dacdadf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,205 +1,131 @@ # Server and runtime architecture -The MCP server follows the `mcp_dart` CLI template's separation of server -composition, configuration, and typed tools: +Marionette uses typed MCP adapters over independent app runtime services. ```text -packages/marionette_mcp/ - bin/marionette_mcp.dart parse options and run the server - lib/ - marionette_mcp.dart public server entrypoint - runtime.dart public app connection entrypoint - src/ - mcp/ - mcp.dart composition root and server instructions - server_config.dart standalone MCP server options - server_context.dart runtime-to-MCP lifecycle wiring - server_runner.dart logging, transports, and shutdown - step_logging.dart MCP result logging adapter - tools/ - base_tool.dart schema/metadata/execution contract - tools.dart complete built-in tool factory - *_tools.dart thin MCP adapters by capability - dynamic_extension_tools.dart app-specific registration - vm_service/ - app_connection_service.dart handshake, sessions, ownership - vm_service_connector.dart VM transport and app operations - session/ - screenshot_service.dart capture and persistence - session_manager.dart report directories and retention - report_steps.dart report formatting and persistence - video/ recording implementation +lib/ + marionette_mcp.dart public server API + runtime.dart public app connection API + src/ + mcp/ + mcp.dart composition and registration + server_config.dart standalone options + server_context.dart runtime-to-MCP wiring + server_runner.dart transport and process lifecycle + step_logging.dart MCP outcome logging + tools/ schemas, execution, and result mapping + vm_service/ + app_connection_service.dart handshake, sessions, ownership + vm_service_connector.dart VM transport and app operations + session/ + screenshot_service.dart capture and storage + session_manager.dart report directories and retention + report_steps.dart formatting and persistence + video/ shared recording implementation ``` -Only the two `lib/` entrypoints are intended for imports by consumers. All -implementation lives under `src/`, following Dart's package convention. The -`mcp/` directory groups protocol adapters within that internal implementation. +Consumers import the two `lib/` entrypoints; implementations live under `src/`. +The public server API exports composition, context, configuration, and startup +explicitly. Tool implementation classes remain internal. -## Responsibility boundaries +## Responsibilities | Layer | Owns | | --- | --- | -| MCP tools | Input/output schemas, request validation, invoking services, mapping results/errors to MCP text/images/structured data | -| Runtime services | Connection workflow, binding compatibility, report-session policy, transition ownership, screenshot storage | -| VM connector | VM transport, app service-extension calls, and app operations | -| Composition root | Constructing dependencies, registering tools, and wiring lifecycle callbacks | -| Server runner | Transport startup and deterministic shutdown | - -A tool does not create report directories, decide connection compatibility, or -manage asynchronous connection state. It translates between the MCP contract -and typed runtime inputs/results. Input validation and presentation stay in the -adapter; workflow and persistence stay in services. Simple app-operation tools -can delegate directly to the existing VM connector without an extra service -wrapper for every method. +| MCP tools | Schemas, request validation, service calls, MCP results/errors | +| Runtime services | Connection workflow, compatibility, report sessions, screenshot storage | +| VM connector | Transport and app service-extension calls | +| Composition/context | Dependency construction, registration, lifecycle callbacks | +| Runner | Startup, signals, transport completion, shutdown | ```mermaid flowchart LR - Client[MCP client] --> Tool[MCP tool adapter] + Client[MCP client] --> Tool[Tool adapter] Tool --> Service[Runtime service] Service --> Connector[VM connector] + Tool --> Connector Connector --> App[Flutter app] ``` -`AppConnectionService` has no MCP dependency. It owns version checks, session -opt-in/setup, overlapping transitions, and disposal, returning typed outcomes. -The MCP context wires its lifecycle callbacks to dynamic registration and step -logging. This keeps app state authoritative in the service while protocol -callbacks remain adapters. A superseded attempt cannot create a replacement -session or log into it; disconnect retains its original session for its final -report step. - -`ScreenshotService` likewise has no MCP dependency. It captures images and -saves numbered files without overwriting prior captures. The tool supplies the -session captured at invocation and maps the typed capture into MCP images or -saved paths. - -`createAllTools` builds all 20 built-in tools without opening sockets. -`registerMcpTools` registers them once and applies shared step logging. -Connection tools log their own session transitions because their report owner -changes during execution. App-defined tools have a dedicated dynamic registry, -which retains registration handles and removes them on disconnect/reconnect. - -## DRY and KISS - -`createAllTools` is the only catalog definition; structured-output tests consume -that same registry. Tools implement their adapter flow directly in `execute`, -without constructor-only group factories or alternate invocation functions. -Shared result envelopes and ancestor descriptions have one definition. Tool -schemas stay explicit where their selectors, descriptions, or recovery messages -differ. Connection ownership types and callbacks remain because they express -real lifecycle rules; simple connector operations do not gain extra service -wrappers just to share a few lines. - -## Public package boundaries - -Embedders import `package:marionette_mcp/marionette_mcp.dart` for server -composition, owned context, configuration, and startup. Its exports are explicit; -tool implementation classes remain internal. They import -`package:marionette_mcp/runtime.dart` for the app connector. -Existing CLI adapters continue using retained internal compatibility -exports for server startup and shared runtime access; their migration is -separate from the MCP refactor. - -MCP tools and executable options keep their names and input schemas. -Runtime implementations cannot import the MCP SDK or MCP adapters. Only two -temporary compatibility exports remain for the CLI's existing imports: -`src/mcp_server_runner.dart` and `src/session/step_logger.dart`. The CLI migration -removes them once it uses the public entrypoints. Runtime implementations do -not depend on these bridges, and new code imports the canonical modules. - -## Structured tool results - -Every built-in tool advertises an `outputSchema` and returns matching -`structuredContent` on success. The SDK validates these results. Existing -human-readable text and inline images remain available in `content`; error -results retain `isError: true` and need not match a success schema. +Runtime code cannot import MCP. `AppConnectionService` owns handshake and session +transitions; `ScreenshotService` owns capture and file numbering. Tools translate +arguments and typed outcomes. Simple app operations can call the connector +directly without an extra service wrapper. + +`createAllTools` is the single catalog used by registration and structured-output +tests. Adapters execute directly; shared result envelopes and ancestor descriptions +have one definition. Operation-specific schemas and recovery messages stay local. +The architecture test enforces entrypoints and dependency direction. + +Two temporary exports support the unchanged CLI's internal imports: +`src/mcp_server_runner.dart` and `src/session/step_logger.dart`. Its migration to +the public entrypoints removes these bridges; runtime code does not use them. + +## Ownership and shutdown + +- Constructors and registration acquire no external resources. +- A superseded connection attempt cannot replace the current session or + registrations. Invocation logging and screenshot capture retain their original + session; disconnect logs against the closing session. +- Dynamic tools belong to the app connection. Disconnect removes their handles; + reconnect discovers fresh schemas. +- The runner owns logging, signals, context, server, and transport. It closes + ingress and active transports, disposes the app, closes MCP, and cancels logging, + including after partial startup, immediate stdin EOF, or a signal. + +Embedders using `createMcpServer(context: context)` must await `context.dispose()` +and `server.close()` when their transport ends. `runMcpServer` handles both. +The SDK owns negotiation, dispatch, validation, and framing. + +## Structured results + +All built-in tools advertise an `outputSchema` and return matching +`structuredContent` on success. The SDK validates these results. Existing text +and images remain in `content`; `isError: true` results need not match the success +schema. Tool names, input schemas, and annotations remain compatible. | Tool family | Structured success data | | --- | --- | -| Actions, keyboard, device config, reload/restart | `message`; back navigation may also include `didPop` | +| Actions, keyboard, device config, reload/restart | `message`; back navigation may include `didPop` | | UI inspection | `elements`, `count` | | Logs | `logs`, `count` | -| Screenshots | `count`, `inline`, and saved `paths` when applicable; image bytes stay in `content` | +| Screenshots | `count`, `inline`, optional saved `paths`; image bytes stay in `content` | | Extension discovery | `extensions`, `count` | -| Generic and dynamically promoted extensions | `response`, an object containing the app's result | -| Connect | `uri`, `sessionReportsEnabled`, optional `sessionPath` or nonfatal `warning` | +| Generic/promoted extensions | `response`, allowing app-defined fields | +| Connect | `uri`, `sessionReportsEnabled`, optional `sessionPath` or `warning` | | Disconnect | `disconnected`, optional closing `sessionPath` | -Extension output fields are app-defined, so their response envelope permits -arbitrary properties rather than claiming a schema the app has not supplied. - -For embedding, `createMcpServer(context: context)` accepts an explicitly owned -`VmServiceContext`. The caller must await `context.dispose()` and -`server.close()` when its transport ends. The standard `runMcpServer` entrypoint -performs both operations on EOF, signals, and transport startup failure. -Disposing the context retires dynamic tools and closes its app connection. -Disconnecting releases dynamic tool names, so reconnecting uses the current -app's schemas without retaining disabled registrations. - -## Bootstrap and shutdown - -The executable parses options and delegates to `runMcpServer`. That runner owns -logging, a shared signal owner, the app context, the protocol server, and -the selected transport. Transport startup helpers provide completion and close -operations; they do not each implement their own process lifecycle. - -The runner waits for a signal or transport completion, then closes the listener -and active transports, disposes the app runtime, closes the protocol server, -and cancels logging. Cleanup also covers partial startup. Stdio completion -includes immediate stdin EOF; SSE retains its existing endpoints and explicitly -closes active sessions. The SDK owns protocol negotiation, dispatch, validation, -and transport framing; the application owns its process and app resources. - -### Future Streamable HTTP support - -Streamable HTTP is deferred from this refactor. In `mcp_dart` 2.4.3 the stateless -HTTP profile creates a server for each request, while Marionette's `connect` -workflow retains app state. A future transport must first choose explicit -client-state ownership and cleanup semantics. The server factory and transport -startup boundary are the extension points; no HTTP flags or state-routing -abstraction are added in advance. - -## Adding or changing a tool - -1. Put reusable workflow/state/persistence behavior in a runtime service. Keep - the service API typed and independent of MCP. -2. Implement a `BaseTool` adapter with its input schema, output schema, metadata, - and result/error formatting. Inject its dependencies through the constructor. -3. Add it once to the central `createAllTools` registry. Keep - external resource acquisition out of tool constructors and registration. -4. Test service behavior directly, then check the adapter's structured output, - errors, and public SDK interaction. Update consumer guidance for observable - changes. - -The architecture test enforces the public entrypoints and dependency direction; -it also prevents compatibility bridges from accumulating implementation logic. +## Adding a tool + +1. Put reusable workflow, state, or persistence in an MCP-independent service. +2. Implement a `BaseTool` with schemas and result/error mapping; inject its + dependencies through the constructor. +3. Add it once to `createAllTools`. +4. Test service behavior, adapter outputs/errors, and public SDK interaction. + Update consumer guidance for observable changes. ## Verification -From the workspace root, resolve dependencies with `flutter pub get`. Run -`dart analyze lib bin test` and `dart test` in both `packages/marionette_mcp` -and `packages/marionette_cli`, then `flutter test --no-pub` in -`packages/marionette_flutter`. - -`server_api_test.dart` drives the real executable through public SDK client -APIs to verify catalogs and disconnected errors under legacy initialization -and stateless discovery. Additional in-process public SDK tests use -`IOStreamTransport` to verify successful structured results, dynamic extension -removal, and ordinary error results. -Its catalog fixture -was captured from the original 0.7.0 implementation with SDK 2.4.3; update it -only for an intentional tool-contract change. The comparison permits the new -output schemas while preserving every original input contract and annotation. -Shutdown regressions use a real -Dart VM service fixture and cover connected EOF and signals. - -The minimum-toolchain CI job selects separate workspace groups for the -standalone Dart server/CLI and the Flutter binding/adapters. Flutter 3.27's -`flutter_test` pins `meta` to 1.15, while `mcp_dart` 2.4.3 requires 1.16 or -newer. Temporary `pubspec_overrides.yaml` files select workspace members only; -they never override dependency constraints. Dart checks use `--no-example` to -avoid resolving Flutter examples in the server-only workspace. The normal -CI job continues to validate the complete workspace on the pinned Flutter SDK. -The complete workspace and the MCP package's Flutter example combine both -dependency graphs and therefore require a newer Flutter SDK. Flutter 3.27 is -supported for apps using the binding with the server running separately. +Resolve the workspace with `flutter pub get`. Run `dart analyze lib bin test` +and `dart test` in `packages/marionette_mcp` and `packages/marionette_cli`, then +`flutter test --no-pub` in `packages/marionette_flutter`. + +Public SDK tests cover executable catalog/error behavior and in-process structured +results under legacy initialization and stateless discovery. The catalog fixture +comes from the original 0.7.0 server with SDK 2.4.3; change it only for intentional +contract changes. Process tests and real VM fixtures cover connection shutdown, +pending WebSocket upgrades, EOF, signals, SSE, and startup failures. + +Minimum-version CI selects separate Dart and Flutter workspace groups: +Flutter 3.27's `flutter_test` pins `meta` 1.15, while `mcp_dart` 2.4.3 requires +1.16+. Temporary `pubspec_overrides.yaml` files select members without changing +dependency constraints; `--no-example` excludes Flutter examples from Dart checks. +Normal CI validates the full workspace on the pinned Flutter SDK. The full +workspace and MCP package's Flutter example require that newer SDK. Flutter 3.27 +apps remain supported with the binding and a separately installed server. + +## Deferred Streamable HTTP support + +In SDK 2.4.3, stateless HTTP creates a server per request, while `connect` retains +app state. Future HTTP support must choose client-state ownership and cleanup +before extending the factory/bootstrap. No HTTP mode is added by this refactor. diff --git a/packages/marionette_mcp/lib/src/mcp/step_logging.dart b/packages/marionette_mcp/lib/src/mcp/step_logging.dart index 594201d1..9984ca65 100644 --- a/packages/marionette_mcp/lib/src/mcp/step_logging.dart +++ b/packages/marionette_mcp/lib/src/mcp/step_logging.dart @@ -3,16 +3,8 @@ import 'package:mcp_dart/mcp_dart.dart'; import '../session/report_steps.dart'; import '../session/session.dart'; -/// Tools whose successful result is a short, synthesized confirmation -/// message (e.g. "Successfully tapped"), never a dump of application data — -/// safe to echo verbatim (truncated) in steps.md. Everything else — -/// data-listing tools (`get_interactive_elements`, `get_logs`, -/// `call_custom_extension`, `list_custom_extensions`) and any -/// dynamically-promoted custom extension tool, whose result may carry -/// arbitrary app data — gets a generic outcome on success instead, so -/// steps.md never ends up persisting a payload. Error messages are shown -/// regardless of the tool (see [_describeOutcome]): they're diagnostic -/// context the report contract explicitly wants, not a payload. +// Record built-in confirmations, but summarize listing/extension payloads to +// avoid persisting application data. Error details remain diagnostic output. const _shortConfirmationTools = { 'connect', 'disconnect', @@ -32,23 +24,13 @@ const _shortConfirmationTools = { 'take_screenshots', }; -/// Tools whose success message is one status line followed by session -/// bookkeeping aimed at the agent — `connect`'s "Opened session: <path>", -/// `disconnect`'s "Session: <path>" and its report nudge. That tail is -/// redundant inside the very session it points at (and, being an absolute -/// path, mostly lost to truncation), so only the first line is logged. +// Session paths and report prompts are redundant inside the report itself. const _firstLineOnlyTools = {'connect', 'disconnect'}; -/// Appends one line per tool call to the active session's steps.md: tool, -/// selector, outcome. No payloads. No-ops when there is no active session -/// (nothing connected yet). +/// Records one bounded outcome per call without persisting application payloads. class StepLogger { - /// The session tool calls are currently logged into. Set by `connect` on - /// success; cleared both at the very start of every `connect` attempt and - /// once `disconnect`'s own step has been logged (see `VmServiceContext`), - /// so neither a stray call made while disconnected nor one that slips in - /// during a reconnect attempt — successful or not — can silently append - /// to a session whose report may already be written. + /// Current report session supplied by runtime lifecycle callbacks. + /// In-flight calls and disconnect retain their own session snapshots. Session? session; void logStep( @@ -102,14 +84,8 @@ class StepLogger { .firstWhere((t) => t.isNotEmpty, orElse: () => ''); } -/// Wraps [callback] so every call is recorded as one line in the active -/// session's steps.md via [stepLogger] before the result is returned to the -/// client — including a call [callback] itself throws out of (an -/// unexpected, un-caught error rather than the usual `isError: true` -/// result), so the report contract's `Tested:` line — rendered from -/// steps.md, not the agent's own claim — never silently undercounts an -/// attempt just because it failed in an unanticipated way. The session is -/// captured when the call starts, so reconnects cannot reattribute its result. +/// Records results and thrown failures against the invocation's original session. +/// Capturing before the await prevents reconnects from reattributing results. ToolFunction withStepLogging( StepLogger stepLogger, String name, diff --git a/packages/marionette_mcp/lib/src/mcp/tools/base_tool.dart b/packages/marionette_mcp/lib/src/mcp/tools/base_tool.dart index 7e32399f..6b2cb8c9 100644 --- a/packages/marionette_mcp/lib/src/mcp/tools/base_tool.dart +++ b/packages/marionette_mcp/lib/src/mcp/tools/base_tool.dart @@ -1,18 +1,9 @@ -/// Base class for MCP tools with dependency injection. -/// -/// Each tool implementation should extend this class and provide: -/// - [name]: Unique tool identifier -/// - [description]: Human-readable description of what the tool does -/// - [inputSchema]: JSON schema for tool arguments -/// - [outputSchema]: Optional JSON schema for tool output -/// - [annotations]: Optional tool behavior hints (readOnly, destructive, etc.) -/// - [meta]: Optional metadata for the tool -/// - [execute]: Implementation of the tool logic +/// Typed MCP tool contracts and registration. library; import 'package:mcp_dart/mcp_dart.dart'; -/// Base class for all MCP tools. +/// Defines a tool's metadata, schemas, and execution. abstract class BaseTool { /// Unique name for this tool. String get name; @@ -20,34 +11,28 @@ abstract class BaseTool { /// Human-readable description of what this tool does. String? get description; - /// JSON schema defining the input arguments. - /// Use [JsonSchema.object()] to create an object schema. + /// Object schema for tool arguments. ToolInputSchema get inputSchema; /// Optional JSON schema defining the output format. ToolOutputSchema? get outputSchema => null; - /// Optional tool annotations with hints about tool behavior. - /// Includes readOnlyHint, destructiveHint, idempotentHint, etc. + /// Optional hints about tool behavior. ToolAnnotations? get annotations => null; /// Optional metadata for the tool. Map<String, dynamic>? get meta => null; - /// Execute the tool with the given arguments. - /// - /// Returns a [CallToolResult] with either success content or an error. + /// Executes validated arguments and returns MCP content or a tool error. Future<CallToolResult> execute( Map<String, dynamic> args, RequestHandlerExtra extra, ); } -/// Extension to register tools with an MCP server. +/// Registers typed tools with an MCP server. extension ToolRegistration on McpServer { - /// Register a [BaseTool] with this server. - /// - /// Use [callback] to wrap execution with shared concerns such as logging. + /// Registers [tool], optionally wrapping execution with [callback]. RegisteredTool registerBaseTool(BaseTool tool, {ToolFunction? callback}) { return registerTool( tool.name, diff --git a/packages/marionette_mcp/lib/src/mcp/tools/dynamic_extension_tools.dart b/packages/marionette_mcp/lib/src/mcp/tools/dynamic_extension_tools.dart index ab1fd381..5972a02b 100644 --- a/packages/marionette_mcp/lib/src/mcp/tools/dynamic_extension_tools.dart +++ b/packages/marionette_mcp/lib/src/mcp/tools/dynamic_extension_tools.dart @@ -8,26 +8,10 @@ import 'package:mcp_dart/mcp_dart.dart'; import 'base_tool.dart'; import 'tool_results.dart'; -/// Registers MCP tools at runtime for each custom extension exposed by the -/// connected Flutter app via `registerMarionetteExtension`. -/// -/// Registrations belong to the current app connection and are removed on -/// disconnect. Reconnecting registers the current schemas from scratch. -/// -/// Only extensions that ship an `inputSchema` get promoted — schema-less -/// extensions remain reachable through the generic `call_custom_extension` -/// tool, preserving backward compatibility with apps that haven't migrated. -/// -/// Extension names are sanitized to a client-safe MCP tool name (see -/// [sanitizeToolName]). The `namespace.method` convention is out of spec for -/// the underlying tool-calling APIs — Anthropic allows `[a-zA-Z0-9_]`, OpenAI -/// `[a-zA-Z0-9_-]`, both rejecting the `.` separator — and clients diverge on -/// how they cope (Claude Code rewrites the name transparently; VS Code Copilot -/// rejects anything outside `[a-z0-9_-]` and drops the tool). Sanitizing to -/// the strictest set keeps the tool usable everywhere. The underlying -/// extension is still invoked by its real name, so `appNavigation.goToPage` is -/// exposed as the tool `app_navigation_go_to_page` but dispatched to -/// `appNavigation.goToPage` on the VM service. +/// Promotes schema-bearing app extensions for the current connection. +/// Schema-less extensions remain available through `call_custom_extension`. +/// Names are sanitized for clients; VM calls retain the original extension name. +/// Disconnect removes registrations; reconnect discovers fresh schemas. class DynamicExtensionTools { DynamicExtensionTools({ required McpServer server, @@ -48,24 +32,14 @@ class DynamicExtensionTools { final Map<String, String> _extensionByTool = {}; int _generation = 0; - /// The tools registered by the most recent [registerAll] call that have not - /// yet been removed. Exposed for tests. + /// Snapshot of active registrations. List<RegisteredTool> get registeredTools => List.unmodifiable(_tools.values); - /// The real extension name backing the promoted MCP tool [toolName], or - /// null if no such tool is registered. The promoted tool name is - /// sanitized (see [sanitizeToolName]); this maps it back to the name the - /// VM service extension is actually invoked with. Exposed for introspection - /// and tests. + /// Original VM extension name for a sanitized MCP tool name. String? extensionNameForTool(String toolName) => _extensionByTool[toolName]; - /// Reads the connected app's custom extensions and promotes each one - /// with an `inputSchema` to a first-class MCP tool. - /// - /// Tools whose name collides with a built-in (or another custom) tool - /// are skipped with a warning — the operator sees the conflict in logs - /// and renames their extension. Tools whose schema fails to parse are - /// likewise skipped with a warning. + /// Discovers schema-bearing extensions, skipping malformed schemas and + /// conflicting names with a warning. Future<void> registerAll() async { removeAll(); final generation = _generation; @@ -137,10 +111,7 @@ class DynamicExtensionTools { ); } - /// Removes the tools owned by the current app connection. - /// - /// mcp_dart 2.4.3 releases their registry names, so reconnects need no pool - /// of disabled handles and a different app can reuse a sanitized name. + /// Removes registrations and releases their names for the next connection. void removeAll() { ++_generation; for (final tool in _tools.values) { @@ -249,19 +220,9 @@ class DynamicExtensionTools { } } -/// Maps a custom-extension name to an MCP tool name accepted across clients. -/// The target set `[a-z0-9_-]` is the strictest in common use (VS Code Copilot -/// enforces it); it also stays within the `.`-rejecting character sets the -/// Anthropic and OpenAI tool-calling APIs require, which the `namespace.method` -/// convention would otherwise violate. -/// -/// A name already within the allowed set is returned verbatim — including -/// any leading, trailing, or repeated `_`/`-` the author chose — so valid -/// names are never silently rewritten. Otherwise the transform lower-cases -/// the name, inserts `_` at camelCase boundaries so `goToPage` reads as -/// `go_to_page`, replaces every other disallowed character (e.g. the `.` -/// namespace separator) with `_`, then collapses and trims runs of `_`. For -/// example `appNavigation.goToPage` becomes `app_navigation_go_to_page`. +/// Converts extension names to the client-safe `[a-z0-9_-]` set. +/// Already-valid names stay unchanged to avoid collisions. Otherwise camel-case +/// boundaries and punctuation become underscores: `app.goToPage` → `app_go_to_page`. String sanitizeToolName(String name) { // Already client-safe: leave it exactly as the author wrote it. Collapsing // or trimming here would change valid names (e.g. `__foo__` -> `foo`) and diff --git a/packages/marionette_mcp/lib/src/session/report_steps.dart b/packages/marionette_mcp/lib/src/session/report_steps.dart index de13fa88..8781c822 100644 --- a/packages/marionette_mcp/lib/src/session/report_steps.dart +++ b/packages/marionette_mcp/lib/src/session/report_steps.dart @@ -5,9 +5,7 @@ import 'package:logging/logging.dart' as logging; import 'package:marionette_mcp/src/session/session.dart'; import 'package:stack_trace/stack_trace.dart'; -/// Field names that hint at a sensitive value (a password, PIN, token, -/// etc.) — matched against `enter_text`'s selector (key/identifier) to -/// decide how hard to redact the entered value in steps.md. +// Sensitive selectors suppress even the entered value's length. final _sensitiveSelectorPattern = RegExp( r'pass|pin|token|secret|cvv', caseSensitive: false, @@ -15,19 +13,13 @@ final _sensitiveSelectorPattern = RegExp( const _maxOutcomeLength = 200; -/// A longer budget for an error outcome that carries a summarized stack -/// trace (see [describeStepError]) — a bare message fits the normal budget -/// comfortably, but a message plus a few compact frames often doesn't. +// Allow room for a bounded error message and stack summary. const _maxErrorOutcomeLength = 500; -/// Stack frames kept when an error carries a structured exception/stack -/// (see [describeStepError]) — enough to usually show where the failure -/// actually happened, without pasting the whole trace. +// Keep a few useful frames without persisting the full trace. const _maxStackFrames = 4; -/// Arg names shown in a step's selector summary. Deliberately excludes -/// free-text payload fields (e.g. `enter_text`'s `input`) — steps.md -/// records what was acted on, not the payload. +// Selector summaries exclude free-text input payloads. const _selectorArgKeys = ['key', 'identifier', 'text', 'type', 'uri', 'x', 'y']; /// Appends one formatted step to [session], treating filesystem failures as @@ -47,12 +39,9 @@ void appendReportStep( } } -/// Sanitizes a short confirmation message for a session's step log. -/// -/// Callers must only use this for synthesized status messages, never arbitrary -/// application data. Paths inside the session become relative, embedded URIs -/// lose credentials, and the result fits on one bounded physical line. Returns -/// null when removing the session path leaves no confirmation text. +/// Sanitizes synthesized confirmations; never pass arbitrary application data. +/// Relativizes paths, redacts URI credentials, and bounds the result to one line. +/// Returns null when no confirmation text remains. String? describeStepConfirmation(String text, Session session) { final relativeText = _relativizeSessionPaths(text, session); return relativeText.isEmpty @@ -60,19 +49,9 @@ String? describeStepConfirmation(String text, Session session) { : _truncateText(_oneLine(_redactUrisIn(relativeText))); } -/// Builds an error outcome (prefixed `error: `) from raw error text — either -/// an MCP tool result's own message or a CLI command's caught exception's -/// `toString()`. Both transports can surface the same -/// `VmServiceExtensionException`-shaped text -/// (`"Extension X failed\nError: {"exception": "...", "stack": "...", "method": "..."}"` -/// — see `VmServiceExtensionException.toString()` and -/// `registerInternalMarionetteExtension`'s catch-all in -/// `marionette_flutter`), so both get the same treatment: the exception -/// message plus a few compact stack frames when that shape is present, -/// instead of blindly truncating raw JSON apart mid-structure and losing the -/// message entirely on a long selector. A deliberate, expected error (e.g. -/// "no log collector configured") isn't JSON-wrapped and falls back to the -/// plain, redacted, truncated message. +/// Summarizes MCP or CLI errors without persisting URI credentials. +/// Structured extension errors include a few stack frames; other errors use +/// bounded plain text. String describeStepError(String text) { if (text.isEmpty) return 'error: failed'; @@ -88,11 +67,8 @@ String describeStepError(String text) { return 'error: ${_truncateText(_oneLine(_redactUrisIn(text)))}'; } -/// Looks for the `Error: {...}` suffix `VmServiceExtensionException` appends -/// (see [describeStepError]) and parses it as JSON. Returns null for any -/// text that isn't in exactly this shape — a deliberate error message, a -/// locally-thrown exception's plain `toString()`, or anything else — rather -/// than guessing. +// Recognize only the VM extension's structured error suffix; other formats +// fall back to plain text rather than guessing. Map<String, dynamic>? _tryParseExceptionJson(String text) { const marker = 'Error: '; final index = text.indexOf('$marker{'); @@ -108,15 +84,8 @@ Map<String, dynamic>? _tryParseExceptionJson(String text) { } } -/// The first [_maxStackFrames] frames of [stackTrace] after [Trace.terse] -/// folds together consecutive Dart-core/SDK frames — so `dart:async`/ -/// `dart:developer-patch` zone plumbing collapses to at most one frame (and -/// is dropped entirely if it's trailing, i.e. outermost) instead of eating -/// the whole budget on frames that don't point at application code. Falls -/// back to the raw text, one-lined, if it doesn't parse as a Dart stack -/// trace at all (defensive — the text always comes from a real -/// `StackTrace.toString()` on the app side, but a parse failure here -/// shouldn't break error logging). +// Trace.terse folds SDK plumbing so the budget favors application frames. +// Malformed traces fall back to bounded text without interrupting reporting. String _summarizeStackTrace(String stackTrace) { final Trace trace; try { @@ -127,11 +96,7 @@ String _summarizeStackTrace(String stackTrace) { return trace.frames.take(_maxStackFrames).map(_formatFrame).join(' › '); } -/// `member (path:line)` — `path` package-relative (falling back to -/// [Frame.library]'s pretty-printed form for anything else, e.g. a lone, -/// unfolded `dart:` frame), column dropped. More compact than -/// [Frame.location] (which keeps the full `package:` scheme and the -/// column), which matters against [_maxErrorOutcomeLength]. +// Omit package schemes and columns to keep frame summaries compact. String _formatFrame(Frame frame) { final path = frame.uri.scheme == 'package' ? frame.uri.path : frame.library; return frame.line == null @@ -139,49 +104,32 @@ String _formatFrame(Frame frame) { : '${frame.member} ($path:${frame.line})'; } -/// Rewrites absolute paths inside [session]'s directory as paths relative -/// to it — e.g. `take_screenshots`' saved paths become `screenshots/01.png`, -/// which is also how a report living next to steps.md cites them. +// Report links use paths relative to the owning session directory. String _relativizeSessionPaths(String text, Session session) => text.replaceAll('${session.directory.path}${Platform.pathSeparator}', ''); String _truncateText(String text, [int maxLength = _maxOutcomeLength]) => text.length <= maxLength ? text : '${text.substring(0, maxLength)}…'; -/// Collapses whitespace (including newlines) into single spaces and trims -/// the ends — keeps a value that might itself span multiple lines (a -/// multiline `text` selector, a multi-line tool message) from breaking -/// steps.md's one-physical-line-per-call format. +// One physical line per step, even for multiline selectors or errors. String _oneLine(String text) => text.replaceAll(RegExp(r'\s+'), ' ').trim(); -/// Matches a `ws(s)://`/`http(s)://` URI so one can be found and redacted -/// wherever it appears in free-form text — not just in the `uri` selector -/// field. `connect`'s own success/error text embeds the full uri a second -/// time (`"Successfully connected to app at $uri..."`, and a thrown -/// connection error's message commonly does too), so redacting only the -/// selector still left that same credential reaching steps.md through the -/// outcome. +// Redact credentials in outcomes as well as selectors: connect/error messages +// can repeat the VM service URI outside its dedicated argument. final _uriPattern = RegExp(r'(?:wss?|https?)://\S+'); -/// A VM service (or DevTools) URI stripped to `scheme://host:port`. -/// Flutter's VM service URIs commonly embed an auth token as a path segment -/// (`ws://127.0.0.1:PORT/TOKEN=/ws`) — logging one verbatim would persist a -/// live credential to steps.md. +// VM service paths can contain live authentication tokens; retain only origin. String _redactUri(String raw) { final parsed = Uri.tryParse(raw); if (parsed == null || parsed.host.isEmpty) return '[uri]'; return '${parsed.scheme}://${parsed.host}:${parsed.port}'; } -/// Redacts (see [_redactUri]) every URI found anywhere within [text]. +// Apply URI redaction throughout free-form text. String _redactUrisIn(String text) => text.replaceAllMapped(_uriPattern, (m) => _redactUri(m[0]!)); -/// Builds the selector portion of a step line from [args] — the small set -/// of curated fields (key, identifier, text, etc.) that describe what a call -/// acted on, deliberately excluding free-text payload fields. Shared between -/// MCP tool calls and CLI command invocations, which -/// use the same field names. +/// Summarizes the target shared by MCP calls and CLI commands, excluding input. String describeStepSelector(String toolName, Map<String, dynamic> args) { final parts = <String>[ for (final key in _selectorArgKeys) From 9aa007128bc05cb2ba755c853be2b09ff88c312a Mon Sep 17 00:00:00 2001 From: Jhin Lee <jhin.lee@unity3d.com> Date: Sat, 24 Oct 2026 19:18:48 -0400 Subject: [PATCH 12/12] Fix links in published package documentation --- CHANGELOG.md | 2 +- README.md | 2 +- .../lib/src/binding/marionette_configuration.dart | 2 +- packages/marionette_mcp/example/README.md | 11 ++++++----- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6263bf8..f833b171 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ - Fix `press_back_button` on a page-based `Navigator` (as produced by `go_router`) that refuses the pop while a route is still animating in — it now waits for the route transition to finish before popping (other animations, such as a loading spinner, don't hold it up) instead of tripping `navigator.dart`'s internal route-lifecycle assertion, which used to fail every navigation for the rest of the session ([#113](https://github.com/leancodepl/marionette_mcp/issues/113)) - Fix `register`/`unregister` rejecting instance names with dots or colons, such as device IDs like `192.168.1.1:5555` — only `/`, `\`, and control characters are disallowed now, and names are encoded into portable, Windows-safe file names on disk - Add `marionette-flutter-drive-app`, an Agent Skill shipped inside the `marionette_flutter` package, installable via `dart run skills@ get` (Dart's [package skills](https://dart.dev/tools/pub/package-skills) convention) or `npx skills add leancodepl/marionette_mcp` — see the new "Agent Skill" section in the README -- Add opt-in session reports: with `MarionetteConfiguration(enableSessionReports: true)`, `connect`/CLI commands log to a per-run `.marionette/sessions/` directory (`steps.md`, screenshots, and an agent-written `report.md`) — see [Session Reports](./docs/session-reports.md) +- Add opt-in session reports: with `MarionetteConfiguration(enableSessionReports: true)`, `connect`/CLI commands log to a per-run `.marionette/sessions/` directory (`steps.md`, screenshots, and an agent-written `report.md`) — see [Session Reports](https://marionette.leancode.co/docs/reports/) - Add `set_device_config` tool and `set-device-config` CLI command for overriding text scale, bold text, and platform brightness in a running app — opt in by wrapping your root widget in the new `MarionetteDeviceConfig` - Add `ancestor_keys` to `tap`, `secondary_tap`, `double_tap`, `long_press`, `swipe`, `pinch_zoom`, `scroll_to` and `enter_text`, and a repeatable `--ancestor-keys` to the matching CLI commands, to scope a match to one subtree when the same key repeats across identical subtrees (grid cells, repeated cards, embedded app instances) — keys nest outermost first, only descendants of the last one match, and a key with no element fails the call by name instead of widening the search; `scroll_to` keeps scrolling first, so a scope inside a lazily built list works - Add `ancestor_keys` to `get_interactive_elements`, and `--ancestor-keys` to `get-interactive-elements`, to list only one subtree — the same chain can then be passed to the matcher tools to act inside it diff --git a/README.md b/README.md index 1ff7a7a2..d589a2f8 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ npx skills add leancodepl/marionette_mcp # without the Dart toolchain ## Documentation -Contributors: see [Server and runtime architecture](docs/architecture.md) for +Contributors: see [Server and runtime architecture](https://github.com/leancodepl/marionette_mcp/blob/main/docs/architecture.md) for package boundaries, tool registration, resource ownership, and verification. | Page | What is inside | diff --git a/packages/marionette_flutter/lib/src/binding/marionette_configuration.dart b/packages/marionette_flutter/lib/src/binding/marionette_configuration.dart index 00257e50..869f6a1f 100644 --- a/packages/marionette_flutter/lib/src/binding/marionette_configuration.dart +++ b/packages/marionette_flutter/lib/src/binding/marionette_configuration.dart @@ -221,7 +221,7 @@ class MarionetteConfiguration { /// ); /// ``` /// - /// See https://github.com/leancodepl/marionette_mcp/blob/main/docs/session-reports.md + /// See https://marionette.leancode.co/docs/reports/ final bool enableSessionReports; /// Checks if an element's widget is interactive (built-in + custom). diff --git a/packages/marionette_mcp/example/README.md b/packages/marionette_mcp/example/README.md index 818570af..4bcf3b0e 100644 --- a/packages/marionette_mcp/example/README.md +++ b/packages/marionette_mcp/example/README.md @@ -3,16 +3,17 @@ This Flutter counter app initializes `MarionetteBinding` so an MCP client can inspect the UI, tap the increment button, and capture screenshots. -Use the Flutter version pinned in the repository's [`.fvmrc`](../../../.fvmrc). +Use the Flutter version pinned in the repository's [`.fvmrc`](https://github.com/leancodepl/marionette_mcp/blob/main/.fvmrc). This example includes the MCP server as a dev dependency. Its combined graph cannot resolve on Flutter 3.27 because `flutter_test` and `mcp_dart` 2.4.3 require incompatible `meta` versions. Flutter 3.27 apps using the binding with a separately installed server remain supported. -From the repository root: +Clone the repository to include the sibling packages this example depends on: ```bash -cd packages/marionette_mcp/example +git clone https://github.com/leancodepl/marionette_mcp.git +cd marionette_mcp/packages/marionette_mcp/example flutter pub get flutter run ``` @@ -22,6 +23,6 @@ Pass the VM service URI printed by `flutter run` to `connect`, then use `get_interactive_elements` to discover the increment control before tapping it. For custom widgets, log collection, and session reports, use the richer -[repository example](../../../example/). See the -[architecture guide](../../../docs/architecture.md) for the server's structure +[repository example](https://github.com/leancodepl/marionette_mcp/tree/main/example). See the +[architecture guide](https://github.com/leancodepl/marionette_mcp/blob/main/docs/architecture.md) for the server's structure and extension points.