flutter_mcp_harness 7.0.0
flutter_mcp_harness: ^7.0.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 |
Drive the UI through the universal AutomationDriver contract |
ToolkitDriver: snapshot (semantic AxNode tree), perform (click/type/keys/navigate/evaluate), screenshot |
| Stream frames from a running app / browser | frame sources + universal_screencast — composition-level, see showcase/drivers (the harness itself stays pipeline-free) |
| 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_driver.dart— the toolkit'sAutomationDriverimplementation (ADR-0038): one observe/act/verify vocabulary shared with the CDP and OS-native drivers; passes the family conformance suite (universal_automation_conformance) against the same canned-extension seam.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.- The end-to-end showcase drives live in
showcase/drivers/— composition roots that wire this package to the frame pipeline (keeps pipeline dependencies out of the harness pubspec). 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.
Automation drivers (universal_automation_* family) #
[ToolkitDriver] implements the universal_automation_interface
AutomationDriver contract over the same ext.mcp.toolkit.* extensions
[WidgetDriver] speaks — ADR-0038's adoption of the shared automation
kernel. Agents get one observe/act/verify vocabulary across the
instrumented tier (this package), the browser tier
(universal_browser_cdp's [CdpDriver]), and the OS-native tier, plus the
family conformance suite for free in tests. Locator grammar: name
matches snapshot labels, role matches semantic roles, css accepts a
snapshot ref (s_12) or a label; navigation maps to the toolkit's named
routes and refuses loudly otherwise.
The invoke tier (ADR-0017) — surface actions, registry-native #
Framework- and app-specific verbs never grow the sealed action set.
ToolkitDriver also implements AutomationActionCatalog: actions()
lists the app's agent-call registry (the intent registry is the single
action source — one registration is an MCP tool, a projection, and a
drivable action), and InvokeAction(name, args) dispatches through the
agent_invoke wire verb, validating args against the registered schema
before the wire:
final actions = await driver.actions(); // the app's registry, as descriptors
await driver.perform(InvokeAction('app.checkout_flow', args: {'sku': 'x-1'}));
Intents route declaratively too — IntentAutomationAction.custom hints
(locator: {'name': 'app.checkout_flow'}) resolve to InvokeAction in
[IntentDriverRouter], with invocation arguments passing through.
Non-Flutter surfaces compose the same way over the CDP tier: any web app
(Jaspr, plain JS, Flutter web) publishes a window.__mcpActions registry —
{description, schema, invoke: async (args) => …} per action — and
CdpDriver lists and invokes it through the same InvokeAction. A page
opts in by assigning one object; no package dependency required.
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).