diff --git a/mcp_server_dart/lib/src/shared_core/command_executor.dart b/mcp_server_dart/lib/src/shared_core/command_executor.dart index 64dab905..07f12789 100644 --- a/mcp_server_dart/lib/src/shared_core/command_executor.dart +++ b/mcp_server_dart/lib/src/shared_core/command_executor.dart @@ -1088,6 +1088,7 @@ final class DefaultCoreCommandExecutor implements CoreCommandExecutor { 'fromRef': command.fromRef, 'toRef': command.toRef, if (command.snapshotId != null) 'snapshotId': command.snapshotId, + if (command.kind case final kind?) 'kind': kind.wireName, }, ); return CoreResult.success(data: _map(result.json)); diff --git a/mcp_server_dart/lib/src/shared_core/commands/commands_catalog.dart b/mcp_server_dart/lib/src/shared_core/commands/commands_catalog.dart index c777b7e3..bf635826 100644 --- a/mcp_server_dart/lib/src/shared_core/commands/commands_catalog.dart +++ b/mcp_server_dart/lib/src/shared_core/commands/commands_catalog.dart @@ -801,6 +801,7 @@ final class CommandCatalog { fromRef: _stringArg(args, 'fromRef', fallback: ''), toRef: _stringArg(args, 'toRef', fallback: ''), snapshotId: _nullableIntArg(args, 'snapshotId', alias: 'snapshot-id'), + kind: parseDragPointerKind(_nullableStringArg(args, 'kind')), ), ), CommandSpec( diff --git a/mcp_toolkit/lib/src/services/gesture_interaction_service.dart b/mcp_toolkit/lib/src/services/gesture_interaction_service.dart index 9650d32b..09733da7 100644 --- a/mcp_toolkit/lib/src/services/gesture_interaction_service.dart +++ b/mcp_toolkit/lib/src/services/gesture_interaction_service.dart @@ -5,7 +5,8 @@ import 'dart:typed_data'; import 'dart:ui' as ui; -import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform, kIsWeb; import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; @@ -705,9 +706,16 @@ mixin GestureInteractionService { } /// Drag from the centre of [fromRef] to the centre of [toRef]. + /// + /// [kind] selects the synthesized pointer device; when null, defaults to + /// [PointerDeviceKind.mouse] on desktop platforms and + /// [PointerDeviceKind.touch] elsewhere — the device a real user would + /// drag with there. See [_dispatchDrag] for how the kind decides the + /// gesture-arena outcome. static Future> drag({ required final String fromRef, required final String toRef, + final PointerDeviceKind? kind, }) async { final from = SemanticSnapshotService.resolveCenter(fromRef); if (from == null) { @@ -735,11 +743,13 @@ mixin GestureInteractionService { 'mutate state directly via evaluate_dart_expression.', }; } - await _dispatchDrag(from, to, steps: 12); + final effectiveKind = kind ?? _defaultDragKind(); + await _dispatchDrag(from, to, steps: 12, kind: effectiveKind); return { 'success': true, 'via': 'pointer_events', 'action': 'drag', + 'kind': effectiveKind.name, 'fromRef': fromRef, 'toRef': toRef, 'from': _offsetToMap(from), @@ -747,6 +757,15 @@ mixin GestureInteractionService { }; } + /// The pointer device a real user drags with on the running platform. + static PointerDeviceKind _defaultDragKind() => + switch (defaultTargetPlatform) { + TargetPlatform.macOS || + TargetPlatform.windows || + TargetPlatform.linux => PointerDeviceKind.mouse, + _ => PointerDeviceKind.touch, + }; + /// Synthesize a mouse hover at the centre of the widget identified by /// [ref]. Drives `MouseRegion.onEnter`/`onExit` via the framework's /// mouse tracker (which computes enter/exit transitions from position @@ -853,16 +872,30 @@ mixin GestureInteractionService { await _waitFrame(); } + /// Dispatches a press-move-release sequence as [kind] pointer events. + /// + /// [kind] decides which recognizers compete for the gesture. Mouse wins a + /// drag-and-drop cleanly on desktop: scrollables don't track mouse drags + /// (default [ScrollBehavior.dragDevices] excludes the mouse), so the + /// target's pan recognizer takes the gesture uncontested — matching a + /// real user drag. Touch keeps scrollables in the arena, which a + /// swipe/fling relies on. static Future _dispatchDrag( final ui.Offset from, final ui.Offset to, { final int steps = 10, final Duration perStep = const Duration(milliseconds: 16), + final PointerDeviceKind kind = PointerDeviceKind.touch, }) async { final binding = GestureBinding.instance; final pointer = _nextPointerId++; binding.handlePointerEvent( - PointerDownEvent(pointer: pointer, position: from, timeStamp: _now()), + PointerDownEvent( + pointer: pointer, + position: from, + kind: kind, + timeStamp: _now(), + ), ); final dx = (to.dx - from.dx) / steps; @@ -876,6 +909,7 @@ mixin GestureInteractionService { pointer: pointer, position: pos, delta: pos - last, + kind: kind, timeStamp: _now(), ), ); @@ -883,7 +917,12 @@ mixin GestureInteractionService { } binding.handlePointerEvent( - PointerUpEvent(pointer: pointer, position: to, timeStamp: _now()), + PointerUpEvent( + pointer: pointer, + position: to, + kind: kind, + timeStamp: _now(), + ), ); await _waitFrame(); } diff --git a/mcp_toolkit/lib/src/toolkits/interaction_toolkit.dart b/mcp_toolkit/lib/src/toolkits/interaction_toolkit.dart index 46f819e0..ef2a2c8b 100644 --- a/mcp_toolkit/lib/src/toolkits/interaction_toolkit.dart +++ b/mcp_toolkit/lib/src/toolkits/interaction_toolkit.dart @@ -1,4 +1,5 @@ import 'package:dart_mcp/client.dart'; +import 'package:flutter/gestures.dart' show PointerDeviceKind; import 'package:flutter_mcp_toolkit_core/flutter_mcp_toolkit_core.dart'; import 'package:from_json_to_json/from_json_to_json.dart'; import 'package:intentcall_core/intentcall_core.dart'; @@ -487,9 +488,15 @@ extension type OnDragEntry._(AgentCallEntry entry) implements AgentCallEntry { }, ); } + final kind = switch (parameters['kind']) { + 'mouse' => PointerDeviceKind.mouse, + 'touch' => PointerDeviceKind.touch, + _ => null, + }; final result = await GestureInteractionService.drag( fromRef: fromRef, toRef: toRef, + kind: kind, ); return MCPCallResult( message: result['success'] == true diff --git a/packages/core/lib/src/commands/core_commands.dart b/packages/core/lib/src/commands/core_commands.dart index 082e8d6b..b9a84dc1 100644 --- a/packages/core/lib/src/commands/core_commands.dart +++ b/packages/core/lib/src/commands/core_commands.dart @@ -389,17 +389,57 @@ final class SwipeCommand extends CoreCommand { String get name => 'swipe'; } +/// Pointer device to synthesize for a [DragCommand]. +/// +/// Keeps command state typed; the wire protocol stays stringly. Values are +/// parsed once via [parseDragPointerKind] and serialized back through +/// [wireName] only at the extension-forwarding boundary. +enum DragPointerKind { + /// Desktop-style pointer: scrollables ignore mouse drags, so the drag + /// reaches the target's drag/pan recognizer (drag-and-drop). + mouse('mouse'), + + /// Touch-style pointer: scrollables compete for the gesture, so a drag + /// over scrollable content scrolls it. + touch('touch'); + + const DragPointerKind(this.wireName); + + /// String form used on the wire (tool schema enum and extension args). + final String wireName; +} + +/// Parses a wire [value] into a [DragPointerKind]. +/// +/// Returns null for null, empty and unknown values — the app then picks +/// the platform default. +DragPointerKind? parseDragPointerKind(final Object? value) { + if (value == null) return null; + final normalized = '$value'.trim().toLowerCase(); + for (final kind in DragPointerKind.values) { + if (kind.wireName == normalized) { + return kind; + } + } + return null; +} + final class DragCommand extends CoreCommand { const DragCommand({ required this.fromRef, required this.toRef, this.snapshotId, + this.kind, }); final String fromRef; final String toRef; final int? snapshotId; + /// Pointer device kind to synthesize. Null lets the app pick the + /// platform default. + final DragPointerKind? kind; + @override String get name => 'drag'; } diff --git a/packages/core/lib/src/tools/interaction_input_schemas.dart b/packages/core/lib/src/tools/interaction_input_schemas.dart index 64acba94..92f9de90 100644 --- a/packages/core/lib/src/tools/interaction_input_schemas.dart +++ b/packages/core/lib/src/tools/interaction_input_schemas.dart @@ -237,6 +237,16 @@ Map dragInputSchema() => { 'type': 'string', 'description': 'Target widget ref.', }, + 'kind': { + 'type': 'string', + 'enum': ['mouse', 'touch'], + 'description': + 'Pointer device to synthesize. Defaults to mouse on desktop ' + 'targets and touch elsewhere. Mouse keeps scrollables out of the ' + 'gesture arena, so the drag lands on the target\'s drag/pan ' + 'recognizer (drag-and-drop); touch keeps scrollables competing, ' + 'so a drag over scrollable content scrolls it instead.', + }, 'snapshotId': { 'type': 'integer', 'description': diff --git a/packages/server_capability_core/lib/src/tools/interaction_tools.dart b/packages/server_capability_core/lib/src/tools/interaction_tools.dart index 57309ed5..d88eb08e 100644 --- a/packages/server_capability_core/lib/src/tools/interaction_tools.dart +++ b/packages/server_capability_core/lib/src/tools/interaction_tools.dart @@ -161,10 +161,16 @@ void registerInteractionTools(final CapabilityContext context) { final fromRef = stringArgOrNull(args['fromRef']) ?? ''; final toRef = stringArgOrNull(args['toRef']) ?? ''; final snapshotId = intArgOrNull(args['snapshotId']); + final kind = parseDragPointerKind(args['kind']); return runCommand( runner, args, - DragCommand(fromRef: fromRef, toRef: toRef, snapshotId: snapshotId), + DragCommand( + fromRef: fromRef, + toRef: toRef, + snapshotId: snapshotId, + kind: kind, + ), ); }, ),