Marionette MCP

repository·main·Indexed 43 days ago

https://github.com/leancodepl/marionette_mcp

A Model Context Protocol (MCP) implementation that enables AI agents like Claude, Cursor, and Copilot to interact with running Flutter applications. It provides tools for inspecting widget trees, performing gestures (tap, scroll, pinch), entering text, and capturing screenshots for AI-driven smoke testing and runtime interaction. The ecosystem includes the marionette_flutter binding, the marionette_mcp server, and a marionette_cli for shell-based interaction.

Tokens
31.9K
Snippets
108
Records
128
Agent score
58%

What's inside Marionette MCP

  1. Understand the Single-binding rule

    main

    Flutter allows only one WidgetsBinding per process. Since MarionetteBinding is a binding, calling MarionetteBinding.ensureInitialized() while another binding (like AutomatedTestWidgetsFlutterBinding or IntegrationTestWidgetsFlutterBinding) is already active will throw an error.

    Avoiding conflicts during flutter test

    If your tests run in debug mode, they might trigger the Marionette initialization. Use one of these two strategies:

    Option A: Skip Marionette when running tests Check for the FLUTTER_TEST environment variable:

    import 'dart:io' show Platform;
    
    final isFlutterTest = Platform.environment.containsKey('FLUTTER_TEST');
    if (kDebugMode && !isFlutterTest) {
      MarionetteBinding.ensureInitialized();
    } else {
      WidgetsFlutterBinding.ensureInitialized();
    }

    Option B: Use a separate test entrypoint Keep MarionetteBinding in your production lib/main.dart and create a separate file (e.g., lib/main_test.dart) for tests that does not initialize MarionetteBinding.

  2. How Marionette MCP works

    main

    Marionette consists of two distinct components that work together to allow an AI agent to drive a Flutter application:

    1. marionette_flutter: A Flutter package added to your application. It installs a binding that exposes VM service extensions, which serve as the interface for tooling to communicate with your app.
    2. marionette_mcp (or marionette_cli): The agent bridge. This component connects to your app's VM service and translates high-level AI agent requests into specific actions within the Flutter app.

    You must always include marionette_flutter in your app. The choice between using the MCP server or the CLI depends on your specific AI agent's capabilities.

  3. Best practices for Semantics: label vs value vs identifier

    main

    To avoid degrading the experience for screen-reader users (VoiceOver/TalkBack) while still providing agents with necessary information, follow these patterns:

    • Pattern A (Clean Text): Use label for human-friendly strings and let Text.rich.toPlainText() handle the rendered version. The agent sees both Type: Semantics and Type: Text.
    • Pattern B (Stateful Controls): Use label for what the control is and value for its current state (e.g., label: 'Volume', value: '70%').
    • Pattern C (Machine Selectors): Use Semantics.identifier for stable handles used by agents to target elements (e.g., identifier: 'submit_button'). The identifier is never announced by screen readers, making it the recommended home for machine-readable selectors.

    If you need an agent to see raw markup that would be annoying for a screen reader to announce, use a custom widget with extractText configuration instead of Semantics.label.

  4. Use schema-less extensions for parameter-less or complex inputs

    main

    If an extension does not declare an inputSchema, it is not promoted to a first-class tool. Instead, it remains reachable via the generic call_custom_extension tool and is discoverable via list_custom_extensions.

    Use this fallback when:

    1. The action is parameter-less.
    2. The inputs are too complex for a flat scalar schema (though flat scalars are highly recommended for discoverability).
    registerMarionetteExtension(
      name: 'appNavigation.getPageInfo',
      description: 'Returns the current page and the list of available pages.',
      callback: (params) async => MarionetteExtensionResult.success({
        'currentPage': currentPageName(),
        'availablePages': allPageNames(),
      }),
    );
  5. Declare an input schema for custom extensions

    main

    When you provide an inputSchema to registerMarionetteExtension, the MCP server promotes the extension to a first-class, individually-named tool. This provides the agent with argument hints, enum autocomplete, and automatic validation. Invalid arguments are rejected by the MCP server before reaching your app.

    Schema Rules:

    • Flat Scalars Only: Schemas are restricted to flat scalar parameters because arguments travel over the VM service as a Map<String, String>. Nested objects or arrays cannot be reliably round-tripped.
    • Supported Types: Use ExtensionParam with .string(enumValues: [...]), .integer, .number, or .boolean.
    • Default Values: If a property declares a defaultValue, that value (as a string) is automatically populated in the params map if the caller omits it.
    • Parameter Parsing: The params map in your callback always arrives as Map<String, String>. You must manually parse non-string values (e.g., converting 'true' to true).
    registerMarionetteExtension(
      name: 'appNavigation.goToPage',
      description: 'Navigates to a page by name.',
      inputSchema: ExtensionInputSchema(
        properties: {
          'page': ExtensionParam.string(
            description: 'The page to navigate to.',
            enumValues: ['home', 'profile', 'settings'],
          ),
          'animate': ExtensionParam.boolean(
            description: 'Whether to animate the transition.',
            defaultValue: true,
          ),
        ,
        required: ['page'],
      ),
      callback: (params) async {
        final page = params['page'];
        if (page == null) {
          return MarionetteExtensionResult.invalidParams('Missing required parameter: page');
        }
        // params is Map<String, String>; parse non-string values manually
        final animate = params['animate'] != 'false';
        navigateTo(page, animate: animate);
        return MarionetteExtensionResult.success({'page': page});
      },
    );
  6. Marionette MCP vs Flutter MCP

    main

    Understanding the difference between these two tools helps in choosing the right one for your workflow:

    FeatureFlutter MCP (Official)Marionette MCP
    Primary FocusDevelopment-time tasks (pub.dev search, dependency management, code analysis).Runtime interaction (tapping, typing, scrolling, screenshots).
    UI InteractionUses Flutter Driver (requires extra instrumentation in the app).Uses Marionette (minimal changes, high-signal interaction).
    Best Use CaseBuilding and analyzing your app.Automated AI-driven smoke testing and interaction.
  7. How Semantics label and value work in discovery

    main

    The label and value properties of a Semantics widget are discovery-only. They are used to provide text to the agent via get_interactive_elements, but they are not used by the matcher path for gestures like tap, scroll_to, or enter_text.

    This prevents ambiguity: a label like "Submit" will not collide with an actual Text("Submit") widget during matching. Additionally, Semantics widgets without an explicit label or value are not reported in discovery output.

  8. Limitations of zero-config setup with custom widgets

    main

    The default Marionette setup automatically recognizes standard Flutter widgets (e.g., ElevatedButton, TextButton, IconButton, TextField, Switch, Checkbox, Slider, Text, RichText).

    Warning: If your application uses a custom design system (wrapped buttons, custom text fields, or bespoke controls), the agent will not be able to find or interact with them by text using the default configuration. For production apps using custom components, refer to the Production Setup Checklist.

  9. Avoid reducing coverage with shouldStopTraversal

    main

    The shouldStopTraversal callback tells Marionette to stop descending below a specific widget type. While it can be used to reduce traversal cost, it is a common source of bugs.

    Warning: Do not use this to filter scroll containers. In production testing, filtering scroll containers has been shown to reduce widget coverage significantly (e.g., from 25.8% to 17.8%), causing the agent to lose sight of nested content. Only use this if profiling shows a measured performance win and you are certain it won't hide necessary content.

  10. Initialize MarionetteBinding in main.dart

    main

    To make your Flutter app compatible with Marionette, you must initialize the MarionetteBinding. It is best practice to use MarionetteBinding.ensureInitialized() only when the app is running in debug mode to ensure production builds remain unaffected.

    void main() {
      if (kDebugMode) {
        MarionetteBinding.ensureInitialized();
      } else {
        WidgetsFlutterBinding.ensureInitialized();
      }
      runApp(const MyApp());
    }
  11. Quick Start: Connect an AI agent to your Flutter app

    main

    To enable AI agents (like Claude Code, Cursor, or Gemini CLI) to interact with your running Flutter application, follow these five steps:

    1. Prepare your app: Add the marionette_flutter package and initialize MarionetteBinding in your main.dart. It is recommended to only initialize it in kDebugMode to avoid overhead in production.
    2. Install the bridge: Activate the MCP server globally using dart pub global activate marionette_mcp.
    3. Configure your AI tool: Add the Marionette MCP server to your agent's configuration (e.g., using claude mcp add).
    4. Run your app in debug mode: Execute flutter run and copy the VM service URI (e.g., ws://127.0.0.1:9101/ws) from the console.
    5. Connect and interact: Provide the VM service URI to your AI agent and instruct it to start driving the app.

    Note: Standard Material widgets work out of the box. If you use a custom design system, you must follow the Configuration guide so the agent can identify and interact with your custom elements.

    # 1. Add the package
    flutter pub add marionette_flutter
    
    # 2. Activate the MCP server
    dart pub global activate marionette_mcp
    
    # 3. Example: Add to Claude Code
    claude mcp add --transport stdio marionette -- marionette_mcp
  12. Use Semantics to expose custom or complex content to agents

    main

    While Text.rich and RichText are automatically flattened via toPlainText(), certain content types are invisible to agents. Use the Semantics widget to provide an explicit label or value for:

    • Custom-painted text: Content rendered via CustomPaint or TextPainter.
    • Lossy WidgetSpan content: Text or icons embedded within WidgetSpan children.
    • Raw-source preservation: When you want the agent to see the markdown/markup source instead of rendered plaintext.

    Marionette surfaces these in get_interactive_elements as Type: Semantics, text: "<label>". If both label and value are provided, Marionette joins them as 'label: value' in the discovery output.

    // Structural label + dynamic value.
    // Screen reader: "Volume, 70 percent"
    // Agent: Type: Semantics, text: "Volume: 70%"
    Semantics(
      label: 'Volume',
      value: '70%',
      child: CustomPaint(painter: _VolumeBarPainter(level: 0.7)),
    )