Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 35 additions & 32 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -152,41 +152,50 @@ jobs:
flutter-version: '3.27.0'
cache: true

- name: Install dependencies for marionette_mcp
# 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: Analyze marionette_mcp
run: |
cd packages/marionette_mcp
dart pub get
dart analyze --fatal-infos lib bin

- name: Install dependencies for marionette_cli
- name: Analyze marionette_cli
run: |
cd packages/marionette_cli
dart pub get
dart analyze --fatal-infos lib bin

- name: Install dependencies for marionette_flutter
- name: Run tests for marionette_mcp
run: |
cd packages/marionette_flutter
flutter pub get
cd packages/marionette_mcp
dart test

- name: Install dependencies for marionette_logger
- name: Run tests for marionette_cli
run: |
cd packages/marionette_logger
flutter pub get
cd packages/marionette_cli
dart test

- name: Install dependencies for marionette_logging
- name: Install minimum-Flutter workspace dependencies
run: |
cd packages/marionette_logging
cat > pubspec_overrides.yaml <<'YAML'
workspace:
- packages/marionette_flutter
- packages/marionette_logger
- packages/marionette_logging
- example
YAML
flutter pub get

- name: Analyze marionette_mcp
run: |
cd packages/marionette_mcp
dart analyze --fatal-infos lib bin

- name: Analyze marionette_cli
run: |
cd packages/marionette_cli
dart analyze --fatal-infos lib bin

- name: Analyze marionette_flutter
run: |
cd packages/marionette_flutter
Expand All @@ -202,21 +211,15 @@ jobs:
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

- name: Run tests for marionette_flutter
run: |
cd packages/marionette_flutter
flutter test --no-pub

- name: Remove workspace selection
if: always()
run: rm -f pubspec_overrides.yaml

publish:
name: Publish packages
needs: [analyze, analyze-min-version]
Expand Down
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# 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 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 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))

# 0.7.0
Expand All @@ -12,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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ npx skills add leancodepl/marionette_mcp # without the Dart toolchain

## Documentation

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 |
| --- | --- |
| [Start](https://marionette.leancode.co/docs/) | Setup, every agent's config, the first prompt, the example app, FAQ |
Expand Down
131 changes: 131 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Server and runtime architecture

Marionette uses typed MCP adapters over independent app runtime services.

```text
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
```

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.

## Responsibilities

| Layer | Owns |
| --- | --- |
| 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[Tool adapter]
Tool --> Service[Runtime service]
Service --> Connector[VM connector]
Tool --> Connector
Connector --> App[Flutter app]
```

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 include `didPop` |
| UI inspection | `elements`, `count` |
| Logs | `logs`, `count` |
| Screenshots | `count`, `inline`, optional saved `paths`; image bytes stay in `content` |
| Extension discovery | `extensions`, `count` |
| Generic/promoted extensions | `response`, allowing app-defined fields |
| Connect | `uri`, `sessionReportsEnabled`, optional `sessionPath` or `warning` |
| Disconnect | `disconnected`, optional closing `sessionPath` |

## 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

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.
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -307,6 +313,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
Expand Down
53 changes: 12 additions & 41 deletions packages/marionette_mcp/bin/marionette_mcp.dart
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -39,38 +13,35 @@ void printUsage(ArgParser argParser) {
..writeln(argParser.usage);
}

Future<int> main(List<String> arguments) async {
final argParser = buildParser();
Future<void> main(List<String> 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;
}
}
Loading