flutter_ai_whisper 1.2.0 copy "flutter_ai_whisper: ^1.2.0" to clipboard
flutter_ai_whisper: ^1.2.0 copied to clipboard

MCP server that inspects and injects state in a running Flutter app over the Dart VM Service, supporting route navigation and Riverpod, Bloc and GetX.

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.

pub package License: MIT

Demo #

Developer promo: connect, read state, inject (GetX, Bloc, Riverpod), and navigate over MCP — no sample app walkthrough.

On pub.dev the player may not embed; open the video on GitHub instead (~5 MB).

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 navigatorKey added 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.0 for the server.
  • A Flutter app running in debug mode. flutter run and flutter run -d chrome both 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 #

  1. Run the app in debug mode.

    flutter run
    
  2. 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.

  3. The agent calls connect_to_app with 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 /profile route, 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.

2
likes
160
points
435
downloads

Documentation

API reference

Publisher

verified publisherrajkavadia.dev

Weekly Downloads

MCP server that inspects and injects state in a running Flutter app over the Dart VM Service, supporting route navigation and Riverpod, Bloc and GetX.

Repository (GitHub)
View/report issues

Topics

#mcp #flutter #developer-tools #vm-service #debugging

License

MIT (license)

Dependencies

args, dtd, mcp_server, meta, vm_service

More

Packages that depend on flutter_ai_whisper