Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,15 @@ CoreResult? validationFailureForDynamicSchema({
code: CoreErrorCode.invalidCommand,
message: e.message,
);
} on FormatException catch (e) {
// An array or object argument travels as wire text and is decoded while
// coercing. Text that is not JSON throws out of the decoder itself, and
// one mistyped argument must read back as an invalid command, not as a
// crash from inside validation.
return CoreResult.failure(
code: CoreErrorCode.invalidCommand,
message: 'Invalid argument for $subjectLabel: ${e.message}',
);
}
}

Expand Down
32 changes: 29 additions & 3 deletions mcp_server_dart/lib/src/shared_core/command_executor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ final class DefaultCoreCommandExecutor implements CoreCommandExecutor {
GetViewDetailsCommand() => _getViewDetails(),
InspectWidgetAtPointCommand() => _inspectWidgetAtPoint(command),
CaptureUiSnapshotCommand() => _captureUiSnapshot(command),
SemanticSnapshotCommand() => _semanticSnapshot(),
SemanticSnapshotCommand() => _semanticSnapshot(command),
TapWidgetCommand() => _tapWidget(command),
EnterTextCommand() => _enterText(command),
RevealSearchCommand() => _revealSearch(command),
Expand Down Expand Up @@ -930,15 +930,25 @@ final class DefaultCoreCommandExecutor implements CoreCommandExecutor {
}
}

Future<CoreResult> _semanticSnapshot() async {
Future<CoreResult> _semanticSnapshot(
final SemanticSnapshotCommand command,
) async {
final ensureFailure = await _ensureVmConnected();
if (ensureFailure != null) return ensureFailure;

try {
final result = await connectionContext.callFlutterExtension(
mcpToolkitExtKeys.semanticSnapshot,
args: {
if (command.identifierPrefix != null)
'identifierPrefix': command.identifierPrefix,
if (command.subtreeOf != null) 'subtreeOf': command.subtreeOf,
// Extension parameters travel as strings; the app decodes a
// list from JSON, not from List.toString().
if (command.fields != null) 'fields': jsonEncode(command.fields),
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
return CoreResult.success(data: _map(result.json));
return routeSemanticSnapshotResponse(_map(result.json));
} on Exception catch (e) {
return CoreResult.failure(
code: CoreErrorCode.semanticSnapshotFailed,
Expand Down Expand Up @@ -2027,6 +2037,22 @@ CoreResult routeInteractionResponse(
);
}

/// Route a `semantic_snapshot` payload to a result that matches its verdict.
///
/// A captured snapshot carries no `success` key — only a refusal does, so an
/// absent key is a success. A filter that names an unresolvable `subtreeOf`
/// takes no snapshot at all, and reaching the caller as a successful command
/// would let it read stale refs as if they had just been issued.
CoreResult routeSemanticSnapshotResponse(final Map<String, Object?> data) {
if (data['success'] != false) return CoreResult.success(data: data);
return CoreResult.failure(
code: CoreErrorCode.semanticSnapshotFailed,
message:
'semantic_snapshot refused: ${data['error'] ?? 'no reason given'}',
details: data,
);
}

/// Route the toolkit's `wait_for` extension response to a [CoreResult].
///
/// [data] is the decoded toolkit response. Returns success for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -674,7 +674,30 @@ final class CommandCatalog {
requiresVm: true,
supportsWatch: true,
mcpExposed: true,
build: (final args) => const SemanticSnapshotCommand(),
build: (final args) {
// Schema validation has already rejected anything but an array;
// the enum inside it is not enforced there, so name a bad field
// here rather than after a VM round trip.
final rawFields = _findArg(args, 'fields');
final fields = rawFields is List
? rawFields.map((final f) => f.toString()).toList()
: null;
final unknown = fields
?.where((final f) => !semanticSnapshotNodeFields.contains(f))
.toList();
if (unknown != null && unknown.isNotEmpty) {
throw ArgumentError(
'Invalid value for "fields": ${unknown.join(', ')} '
'(accepted: ${semanticSnapshotNodeFields.join(', ')}; '
r'schema path: $.inputSchema.properties.fields.items)',
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return SemanticSnapshotCommand(
identifierPrefix: _nullableStringArg(args, 'identifierPrefix'),
subtreeOf: _nullableStringArg(args, 'subtreeOf'),
fields: fields,
);
},
),
CommandSpec(
name: 'tap_widget',
Expand Down
23 changes: 16 additions & 7 deletions mcp_server_dart/lib/src/skill_assets.g.dart
Original file line number Diff line number Diff line change
Expand Up @@ -498,16 +498,25 @@ Returns: `{"extensionRPCs": ["ext.flutter.inspector.getRootWidget", "ext.mcp.too

### semantic_snapshot

Return a compact accessibility tree of interactive widgets with stable `ref` strings and a `snapshot_id`.
Return a compact accessibility tree of interactive widgets with stable `ref` strings and a `snapshot_id`. A node is listed when it reads (label, value), acts (button, text field, tap, scroll…) or carries a `Semantics(identifier:)`.

- `identifierPrefix` (string, optional) — keep only nodes whose identifier starts with it (`"nav."` for one rail).
- `subtreeOf` (string, optional) — keep one node and its descendants; a ref from the latest snapshot or an identifier, the ref tried first.
- `fields` (array of strings, optional) — node keys to return; `ref` is always kept. Names: `ref id type identifier label value hint enabled focused checked toggled selected bounds actions children visibleInViewport centerInViewport center`.
- `connection` (object, optional) — connection override.

The tree is always walked whole, so a ref read off a filtered snapshot is the same ref the full snapshot would give and works with every interaction tool. A filtered reply adds `totalNodeCount` and echoes `filter`; `children` lists kept refs only.

```
semantic_snapshot()
semantic_snapshot(identifierPrefix: "nav.", fields: ["identifier", "selected"])
semantic_snapshot(subtreeOf: "panel.tabs")
```

Returns: `{"snapshot_id": 3, "nodes": [{"ref": "s_0", "label": "Increment", "actions": ["tap"]}]}`
Returns: `{"snapshot_id": 3, "nodeCount": 1, "viewport": {...}, "nodes": [{"ref": "s_0", "label": "Increment", "actions": ["tap"], "bounds": {...}, "visibleInViewport": true, "centerInViewport": true, "center": {...}}]}` — the viewport is stated once in the envelope, not on each node.

- `subtree_root_not_found` — `subtreeOf` is neither a ref of the latest snapshot nor an identifier in the tree; no snapshot was taken, refs and `snapshot_id` are unchanged.
- `unknown_field` — a name in `fields` is not a node key; `acceptedFields` lists them. Over MCP and the CLI the name is refused at the boundary instead — an invalid `fields` argument naming the accepted keys, without spending a call on the app.
- `vm_service_unavailable` — app not running or `MCPToolkitBinding.initialize()` not called.
- `connection_selection_required` — multiple targets; supply `connection.targetId`.

Expand Down Expand Up @@ -588,17 +597,17 @@ Use this skill when you need to drive a running Flutter app as a user would:

## Selectors

Most interaction tools target a widget by **ref** — a short string like `"s_0"` returned by `semantic_snapshot`. For visible widgets, call `semantic_snapshot`, scan the returned nodes, find the right ref, then pass it. For off-screen targets with stable semantics text or identifier, use `reveal_search`; it performs a bounded snapshot → match → scroll loop and returns a fresh `ref`/`snapshotId`.
Most interaction tools target a widget by **ref** — a short string like `"s_0"` returned by `semantic_snapshot`. For visible widgets, call `semantic_snapshot`, scan the returned nodes, find the right ref, then pass it. Narrow the node set with `identifierPrefix` or `subtreeOf` (a ref or an identifier), and cut every node down to the keys you read with `fields`; refs are those of the full tree either way. For off-screen targets with stable semantics text or identifier, use `reveal_search`; it performs a bounded snapshot → match → scroll loop and returns a fresh `ref`/`snapshotId`.

Snapshot node fields to filter on:
Snapshot node keys to scan:

| Want to find | Scan field | Example value |
|---|---|---|
| By semantics identifier | `identifier` | `"nav.tasks"` |
| By visible label / text | `label` | `"Login"` |
| By value or hint | `value` / `hint` | `"user@example.com"` |
| By tooltip | `tooltip` | `"Close"` |
| By widget key | `key` | `"[<'submitBtn'>]"` |
| By semantic role / type | `flags` or `actions` | `["tap"]` |
| By semantic role | `type` | `"button"` |
| By what it accepts | `actions` | `["tap"]` |

Example — find the "Login" button ref:
```
Expand Down
27 changes: 27 additions & 0 deletions mcp_server_dart/test/command_catalog_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,33 @@ void main() {
expect(command, isA<SemanticSnapshotCommand>());
});

test('carries semantic_snapshot filters into the command', () {
final command =
catalog.buildCommand('semantic_snapshot', {
'identifierPrefix': 'nav.',
'subtreeOf': 's_3',
'fields': ['identifier', 'label'],
})
as SemanticSnapshotCommand;
expect(command.identifierPrefix, 'nav.');
expect(command.subtreeOf, 's_3');
expect(command.fields, ['identifier', 'label']);
});

test('rejects semantic_snapshot fields that are not an array', () {
expect(
() => catalog.buildCommand('semantic_snapshot', {'fields': 'label'}),
throwsA(isA<ArgumentError>()),
);
expect(
() => catalog.buildCommand('semantic_snapshot', {
'fields': ['nope'],
}),
throwsA(isA<ArgumentError>()),
reason: 'the enum in the schema names the accepted fields',
);
});

test('reveal_search command is registered with bounded schema', () {
final spec = catalog.specFor('reveal_search');
expect(spec, isNotNull);
Expand Down
30 changes: 30 additions & 0 deletions mcp_server_dart/test/interaction_response_routing_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,34 @@ void main() {
expect(recovery.containsKey('fix_command'), isFalse);
});
});

group('routeSemanticSnapshotResponse', () {
test('a captured snapshot has no success key and still routes to ok', () {
final result = routeSemanticSnapshotResponse(<String, Object?>{
'snapshot_id': 7,
'nodes': <Object?>[
<String, Object?>{'ref': 's_0', 'identifier': 'nav.tasks'},
],
});

expect(result.ok, isTrue);
expect((result.data! as Map<String, Object?>)['snapshot_id'], 7);
});

test('an unresolvable subtreeOf is a failure, not an empty snapshot', () {
// No snapshot was taken, so the refs the caller already holds stay
// current — reporting success would read as a screen that went empty.
final result = routeSemanticSnapshotResponse(<String, Object?>{
'success': false,
'error': 'subtree_root_not_found',
'subtreeOf': 'panel.missing',
'hint': 'Take a full snapshot and read the identifier off it.',
});

expect(result.ok, isFalse);
expect(result.error!.code, CoreErrorCode.semanticSnapshotFailed);
expect(result.error!.message, contains('subtree_root_not_found'));
expect((result.error!.details! as Map)['subtreeOf'], 'panel.missing');
});
});
}
Loading
Loading