flutter_mcp_harness 0.1.0
flutter_mcp_harness: ^0.1.0 copied to clipboard
Programmatic E2E harness for Flutter apps: build and launch a debug binary, attach to its Dart VM service, drive widgets through the MCP toolkit service extensions, and assert with composable scenarios.
flutter_mcp_harness #
Programmatic E2E harness for Flutter apps: own the whole lifecycle —
build the app, launch the process, attach to its Dart VM service, and only
then drive/assert. The driving layer speaks the same ext.mcp.toolkit.*
service extensions the MCP server exposes — as a plain Dart API, no MCP
transport required.
This is the client-side counterpart of mcp_toolkit, extracted from a
production E2E loop (see ADR-0015).
A guided tour lives in the
E2E scenarios guide.
Quick start #
import 'package:flutter_mcp_harness/flutter_mcp_harness.dart';
LaunchedApp? app;
var passed = false;
try {
final scenario = Scenario('smoke', steps: [
('launch', (context) async {
app = await MacosAppTarget(
projectDir: 'my_app',
binaryPath: 'my_app/build/macos/Build/Products/Debug/My App.app/Contents/MacOS/My App',
).launch(); // full `flutter build macos --debug`, then run the binary
await app!.stdout.waitFor('app ready'); // wait on logs, never sleep
context.report.pass('attached at ${app!.vmUri}');
}),
('drive + assert', (context) async {
final driver = WidgetDriver(await app!.vm());
final ref = await driver.findRef('login');
if (ref == null) return context.report.fail('no login button');
await driver.enterText(await driver.findRef('email'), 'me@example.com');
await driver.tap(ref);
final banner = await driver.findValue((v) => v.contains('Welcome'));
banner != null
? context.report.pass('login confirmed')
: context.report.fail('no welcome banner');
}),
]);
passed = await scenario.run();
scenario.report.printSummary();
} finally {
await app?.stop(); // cleanup always runs
}
exit(passed ? 0 : 1);
Full two-instance composition root (host + controller pairing):
example/desktop_pair.dart.
Why this shape #
An earlier harness attempt was a declarative YAML runner on top of the MCP server — and never owned the hard parts. The valuable 80% is the boring 20%:
- Own the build. Run
flutter build <os> --debugyourself, then launch the binary directly. Noflutter run, whose incremental-kernel and tool-respawn behavior is flaky under automation. - Own the VM. Scrape the VM service URI from the child's own stdout and
connect over the
vm_servicepackage ([VmClient]). From there: list extensions, call toolkit extensions directly (semantic snapshot, tap, enter text), evaluate, hot-reload — no subprocess, no JSON parsing, no sticky state files. - Own the process lifecycle. [LaunchedApp] keeps the
Process, a broadcast [LogTap] on its stdout, and the [VmClient]. Scenarios stop what they started, infinally.
Only on top of those is a [Scenario] worth anything: named steps over a HarnessContext (a report + a bag for passing values between steps), first failure aborts, cleanup still runs, exit code reflects the result.
API map #
| You want to… | Reach for |
|---|---|
Launch an owning interactive flutter run (hot reload, showcase) |
FlutterRunTarget |
| Fresh full build → direct binary launch (no compile channel after) | BinaryAppTarget, MacosAppTarget, WindowsAppTarget |
| Attach to an app someone else owns (dev runner session, ADR-0014) | construct LaunchedApp around the owning process |
| Wait for log lines / assert on output | LogTap.waitFor / firstMatch / count / tail |
| Drive the UI | WidgetDriver: snapshot, findRef, tap, tapUntil, enterText, scroll, findValue |
| Structure steps, assertions, cleanup | Scenario, Check, ScenarioReport, HarnessContext, retry |
| Evaluate Dart / hot-reload / discover extensions in the app | VmClient.evaluate / hotReload / extensionNames |
| Reference the toolkit verb names | ToolkitExtensions (mirrors mcp_toolkit's interaction toolkit) |
Layout #
lib/src/log_tap.dart— line buffer +waitFor(pattern)(no sleeps).lib/src/vm_client.dart— WebSocket VM service connection, extension calls, evaluate, hot reload.lib/src/flutter_app.dart—AppTarget/LaunchedApp; [BinaryAppTarget] withMacosAppTarget/WindowsAppTargetpresets. Android/iOS device bring-up belongs to the owning dev session (seeoka_harness, which follows the runner-session contract).lib/src/flutter_run.dart— [FlutterRunTarget], the owning interactiveflutter runsession (hot reload via stdin; the showcase launch path).lib/src/widget_driver.dart— typed snapshot/tap/enterText/findValue over the toolkit service extensions.lib/src/toolkit_extensions.dart— the extension verb names the driver calls (source of truth:mcp_toolkit's interaction toolkit).lib/src/scenario.dart,lib/src/check.dart— steps, checks, report.example/desktop_pair.dart— a full two-instance composition root.tool/showcase.dart— this repo's showcase launcher (macOS /--web/--stop), the Dart rewrite of the formerscripts/*.shshowcase.tool/intentcall_session.dart— IntentCall doors against a running showcase (discover / bridge ping / MCP serve), a checked-in composition root — nothing under.showcase/is generated at runtime.
Running #
cd packages/harness
dart test
dart run example/desktop_pair.dart --skip-build
Scenario entrypoints live in the consuming project (composition roots are project knowledge, not package code).
Showcase #
The repo showcase is itself a composition root over this package
(tool/showcase.dart, the Dart rewrite of the former scripts/run_showcase.sh
family):
make showcase # macOS showcase, interactive foreground (r/R/q relayed)
make web-showcase # Chrome with WebMCP flags (--web --detach for CI-shaped runs)
make showcase-stop # idempotent teardown of stray sessions and the VM port
Logs and pid files land under .showcase/; the detached web variant prints
WS_URI=… and exits once the VM service is reachable.
IntentCall doors against the running showcase (second terminal):
dart run packages/harness/tool/intentcall_session.dart demo # discover + bridge ping
dart run packages/harness/tool/intentcall_session.dart serve-debug # MCP door pinned to this VM
The session tool resolves the VM service URI from the freshest announcement
in the showcase log (or --vm-service-uri) and the IntentCall CLI from
INTENTCALL_ROOT, sibling checkouts, or PATH.
Web targets #
Chrome/Chromium scenarios run through [ChromeAppTarget]
(lib/src/chrome_app_target.dart): same ownership philosophy — own the
process, publish the endpoint, attach the client — but the protocol
client is universal_browser_cdp (CDP), not the VM service, so it
produces a [LaunchedChrome] rather than a [LaunchedApp]. The compile
step is deliberately absent: serve the web build yourself and point
startUrl at it. A port already answering CDP is adopted as a borrowed
session and never killed. Real-Chrome smoke: XS_TEST_CHROME=1 dart test.
Relationship to the MCP server #
The server (mcp_server_dart) exposes the same toolkit primitives as MCP
tools (fmt_*) for interactive agent sessions. This package is the
programmatic path: repeatable E2E scenarios as checked-in Dart, no MCP
client needed. It adds no toolkit verbs and never owns app compilation; when
an external dev runner owns the session, connect to its published
vm_service_uri directly (ADR-0014).