flutter_ai_whisper
An MCP server that lets an AI coding agent read, change, and navigate a running Flutter app over the Dart VM Service. No Flutter dependency, no test instrumentation, and no edits to the app.
Start the app with flutter run. Ask the agent to connect. The counter, the
route, and the widgets update live.
Demo
Developer promo: connect, read state, inject (GetX, Bloc, Riverpod), and navigate over MCP — no sample app walkthrough.
pub.dev does not embed HTML video. Click the poster or the link below to open the MP4 (hosted on GitHub).
▶ Watch / download the demo video (MP4, ~5 MB)
On the GitHub repository you can also play it inline:
MCP client ──stdio──> flutter_ai_whisper ──WebSocket──> Dart VM Service ──> app
What it is for
Widget tests and integration tests drive the app through taps, or they require you to add keys and test hooks first. This server uses the debug protocol the Dart VM already exposes, so an agent can:
- list the apps that are running and connect without copying a URI
- read live state (cubits, notifiers, blocs, GetX controllers, plain objects)
- change that state by evaluating real Dart inside the app
- push a named route, with no
navigatorKeyadded to the app
It is a developer tool, not a Flutter plugin. You do not add it to the
app's pubspec.yaml. You register it with the agent that is editing the app.
Requirements
- Dart SDK
^3.8.0for the server. - A Flutter app running in debug mode.
flutter runandflutter run -d chromeboth qualify. - Profile builds, release builds, and obfuscated builds
(
--obfuscate --split-debug-info) do not work. They hide the VM Service or the class names discovery needs.
Install
dart pub global activate flutter_ai_whisper
Confirm the executable is on your PATH:
flutter_ai_whisper --help
The server speaks MCP on stdio. An agent launches it. Running it in a
terminal by itself produces no interactive session.
From a clone, skip activation and point the client at the entrypoint:
dart run /absolute/path/to/flutter_ai_whisper/bin/main.dart
Register it
Cursor
.cursor/mcp.json in the app you are working on (or your user MCP config):
{
"mcpServers": {
"flutter-ai-whisper": {
"command": "flutter_ai_whisper"
}
}
}
Claude Desktop
claude_desktop_config.json:
{
"mcpServers": {
"flutter-ai-whisper": {
"command": "flutter_ai_whisper"
}
}
}
VS Code
.vscode/mcp.json:
{
"servers": {
"flutter-ai-whisper": {
"type": "stdio",
"command": "flutter_ai_whisper"
}
}
}
Claude Code, or a project-local checkout
.mcp.json:
{
"mcpServers": {
"flutter-ai-whisper": {
"command": "dart",
"args": ["run", "/absolute/path/to/flutter_ai_whisper/bin/main.dart"]
}
}
}
opencode
~/.config/opencode/opencode.json:
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"flutter-ai-whisper": {
"type": "local",
"command": ["flutter_ai_whisper"],
"enabled": true
}
}
}
Quick start
-
Run the app in debug mode.
flutter run -
In the agent chat, paste:
Connect to the running Flutter app. Read the counter, set it to 42, and tell me what the screen shows.
-
The agent calls
connect_to_appwith no arguments. Discovery goes through the Dart Tooling Daemon, the same way the official Dart MCP server finds a debug session.
Do not paste the ws://127.0.0.1:PORT/AUTH=/ws line from the flutter run
console. That path is a per-run auth code. It is stale the moment the app
restarts. Pass vmUri only when discovery finds nothing, which happens when
the app was not started by flutter run or an IDE.
The address printed as the app URL (http://localhost:52362/) is the static
web server. Connecting to it fails with WebSocketException: HTTP status code: 200.
Tools
| Tool | What the agent uses it for |
|---|---|
discover_running_apps |
List debug sessions and their VM Service URIs. |
connect_to_app |
Connect. Safe with no arguments. |
list_active_state_holders |
Report live state, or probe expressions you name. |
inject_state_variable |
Evaluate Dart in the app to change state. |
trigger_route_navigation |
Push a named route that the app already registered. |
discover_running_apps
| Parameter | Required | Description |
|---|---|---|
workspaceFilter |
no | Substring of the workspace root, for example sample_app, when several apps are running. |
connect_to_app
| Parameter | Required | Description |
|---|---|---|
vmUri |
no | WebSocket URI of one specific app. Omit it to auto-discover. |
workspaceFilter |
no | Substring of the workspace root used during auto-discovery. |
The result also reports which VM features this target implements. Flutter Web cannot enumerate the heap. Native debug builds can.
list_active_state_holders
| Parameter | Required | Description |
|---|---|---|
filter |
no | Case-insensitive substring match on the holder name. |
expressions |
no | Dart expressions to evaluate, such as counterCubit.count. Required on Flutter Web. |
libraryUri |
no | Library to evaluate in. Required for private names. |
inject_state_variable
| Parameter | Required | Description |
|---|---|---|
expression |
one of | One Dart expression, such as counterCubit.setValue(42). |
targetExpression |
with newValueExpression |
Left-hand side of an assignment. |
newValueExpression |
with targetExpression |
Right-hand side of an assignment. |
libraryUri |
no | Library to evaluate in. |
Prefer expression. The pair targetExpression / newValueExpression builds
target = value, and that fails on final globals with Setter not found.
evaluate accepts one expression, not a list of statements. Wrap several
statements in an immediately invoked function:
(() { obj.a = 1; obj.b = 2; return 'done'; })()
trigger_route_navigation
| Parameter | Required | Description |
|---|---|---|
routePath |
yes | Named route, for example /profile. |
The tool walks the element tree until it finds a context under a Navigator.
Navigator.of on rootElement throws, because that element sits above the
navigator. The route must already exist in the app's routes table (or an
equivalent onGenerateRoute). The tool cannot invent a page that was never
registered.
Platform support
| Capability | Android, iOS, desktop | Flutter Web |
|---|---|---|
evaluate |
yes | yes |
getClassList and getInstances |
yes | no |
| Root-library variables | yes | empty |
dart:mirrors |
yes | no |
| Named-route navigation | yes | yes |
On Flutter Web, list_active_state_holders cannot search the heap. Read the
app source, then probe the names:
{
"name": "list_active_state_holders",
"arguments": {
"expressions": ["counterCubit.count", "authNotifier.isLoggedIn"]
}
}
On native targets, class-name discovery is a heuristic. It looks for names
containing Bloc, Cubit, Notifier, Provider, Controller, Store,
State, Manager, Model, or ViewModel. Holders outside that list need
expressions.
Rules that save a failed call
One expression, one library. Visibility follows that library's imports and
exports. The wrong library yields CompilationError: Undefined name even when
the object exists. A barrel file that exports the holder is often why the root
library can see it.
Private names need libraryUri. _field, _HomePageState, and
_increment all fail to compile unless evaluation happens in the library that
declares them:
{
"libraryUri": "package:my_app/pages/home_page.dart",
"expression": "_localCounter.toString()"
}
When the URI does not match, the error lists the app's libraries.
You can only call what the object exposes. A getter without a setter cannot be assigned. Call the method the class already has:
s.setState(() { for (var i = 0; i < 12; i++) { appState.incrementCounter(); } });
Changing a field does not repaint the screen. GetX Rx, Riverpod, Bloc,
ValueNotifier, and ChangeNotifier notify themselves. Plain objects and
private State fields need setState around the write. setState runs on the
next frame. If another route is covering that page, the rebuild waits until the
page is visible again. Pop back first, then read the Text widgets in the tree
to confirm the screen changed. Re-reading the variable only proves the object
changed.
Recipes
Plain object
{
"name": "inject_state_variable",
"arguments": { "expression": "counterCubit.setValue(42)" }
}
If the widget does not listen to that object, wrap the call in setState on
the State that builds the text. See the example below.
Riverpod
State on a notifier is @protected, so assign through a method the notifier
exposes, reached from a ProviderContainer the agent can name:
{
"name": "inject_state_variable",
"arguments": {
"expression": "container.read(counterProvider.notifier).setValue(77)"
}
}
Riverpod notifies listeners itself.
Bloc
There is no global registry. Keep a handle when the provider is created, and dispatch an event:
late final CounterBloc activeBloc;
// ...
BlocProvider(create: (_) => activeBloc = CounterBloc()),
{
"name": "inject_state_variable",
"arguments": { "expression": "activeBloc.add(const CounterSet(55))" }
}
BlocProvider(create:) is lazy. Open that route before the first read, or the
handle is still null.
GetX
{
"name": "inject_state_variable",
"arguments": { "expression": "Get.find<CounterController>().count.value = 9" }
}
GetX navigation does not need a BuildContext:
{
"name": "inject_state_variable",
"arguments": { "expression": "Get.toNamed('/profile')" }
}
Private widget state
Match the widget in the element tree, cast its State, and write inside
setState. Pass libraryUri of the file that declares _HomePageState.
(() {
Element? t;
void walk(Element e) {
if (t != null) return;
if (e.widget is HomePage) { t = e; return; }
e.visitChildren(walk);
}
walk(WidgetsBinding.instance.rootElement!);
if (t == null) return 'not-found';
final s = (t as StatefulElement).state as _HomePageState;
s.setState(() { s._localCounter = 12; });
return 'ok';
})()
Confirm the screen, not the variable
(() {
final texts = <String>[];
void walk(Element e) {
final w = e.widget;
if (w is Text && (w.data ?? '').trim().isNotEmpty) texts.add(w.data!);
e.visitChildren(walk);
}
walk(WidgetsBinding.instance.rootElement!);
return texts.join(' | ');
})()
Prompts that work
After the server is registered and the app is running:
Connect to the running app and list the state you can see. On web, read the source and probe the top-level holders by name.
Set the cart total to 19.99 by calling the method the store already exposes. If the page does not rebuild by itself, do it inside setState, then read the Text widgets to confirm.
Open the
/profileroute, then come back and tell me whether the home page rebuilt.
A worked counter app, including the private-state case, is in
example/README.md.
Troubleshooting
| What you see | What to do |
|---|---|
Not connected to a Flutter app |
Call connect_to_app with no arguments. |
WebSocketException and HTTP 200 |
You used the app URL. Use discovery, or the debug service WebSocket. |
| HTTP 403 | The auth code is missing or from a previous run. Reconnect with no URI. |
Setter not found |
The target is final. Call a method via expression. |
Undefined name '_x' |
Pass libraryUri of the declaring library. |
| Empty state list on web | Pass expressions. Heap enumeration does not exist on web. |
| Navigation says the route was rejected | Register that name in the app's route table. |
| Variable changed, screen did not | Wrap the write in setState, or pop back to that route first. |
Tool failures come back as isError: true. They do not drop the MCP session.
isError is nullable: null means success.
Security
The server evaluates arbitrary Dart inside the app process, with that app's privileges. Use it on a debug build you control, on localhost.
Do not point it at a production build, a release build, or an app you do not trust. Do not expose the VM Service port beyond your machine. Anyone who can open that socket can read and overwrite runtime state.
Development
dart pub get
dart analyze
dart test
Agent-facing rules (connection, privacy, rebuilds, state-management recipes)
live in AGENTS.md. llms.txt is the same guide for
other LLM tooling.
License
MIT. See LICENSE.
Libraries
- discovery
- Finds running Flutter apps through the Dart Tooling Daemon.
- state_discoverer
- Heuristic discovery of live state holders in a connected app.
- tools/connect_to_app
- The
connect_to_appMCP tool. - tools/discover_running_apps
- The
discover_running_appsMCP tool. - tools/inject_state_variable
- The
inject_state_variableMCP tool. - tools/list_active_state_holders
- The
list_active_state_holdersMCP tool. - The
trigger_route_navigationMCP tool. - vm_bridge
- Connection to one running app over the Dart VM Service.
