diff --git a/CHANGELOG.md b/CHANGELOG.md index 73f6efa..05130c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.3.0 - 2026-09-04 + +- Added cancellable registration attempts so Flutter scopes can abort stale + browser registrations before starting replacements. +- Replaced the asynchronous `WebMcpToolRegistrar` override with the cancellable + `WebMcpToolRegistrationStarter` contract. +- Reconciled scoped tools independently by name. + ## 0.2.1 - Aligned the browser bindings and compatibility tests with the WebMCP Draft diff --git a/README.md b/README.md index f4fdf6f..e417c45 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ instead of the draft's `Promise`. Both forms are supported. ```yaml dependencies: - flutter_webmcp: ^0.2.1 + flutter_webmcp: ^0.3.0 ``` For a local checkout: @@ -187,6 +187,24 @@ import 'package:flutter_webmcp/webmcp.dart'; Then call `WebMcp.registerTool()` and keep the returned `WebMcpRegistration` for manual cleanup. +To cancel while the browser is still registering the tool, start an attempt +and keep it for the whole lifecycle: + +```dart +final attempt = WebMcp.startToolRegistration(tool); +final registration = await attempt.ready; + +// When the owner ends. This may also run while `ready` is still pending. +await attempt.cancel(); +``` + +Call `cancel()` as soon as the owner ends, even if `ready` has not completed. +Cancelling a pending attempt makes `ready` complete with a `WebMcpException`. + +`WebMcpToolScope` uses this form internally. When a tool changes, its old +attempt is aborted before the replacement starts; slots whose tool instance +and exposure configuration did not change stay registered. + ## Browser setup WebMCP requires `document.modelContext`. It is available in ChatGPT's in-app diff --git a/lib/src/flutter/webmcp_tool_scope.dart b/lib/src/flutter/webmcp_tool_scope.dart index df467ea..f5b631d 100644 --- a/lib/src/flutter/webmcp_tool_scope.dart +++ b/lib/src/flutter/webmcp_tool_scope.dart @@ -5,13 +5,13 @@ import 'package:flutter/widgets.dart'; import '../../webmcp.dart'; -/// Registers one tool and returns its lifecycle handle. -typedef WebMcpToolRegistrar = Future Function( +/// Starts a registration that can be cancelled immediately. +typedef WebMcpToolRegistrationStarter = WebMcpRegistrationAttempt Function( WebMcpTool tool, { - List exposedTo, + required List exposedTo, }); -/// Registers [tools] while [child] is mounted and unregisters them on dispose. +/// Registers [tools] while [child] is mounted. class WebMcpToolScope extends StatefulWidget { /// Creates a lifecycle scope for [tools]. const WebMcpToolScope({ @@ -21,7 +21,7 @@ class WebMcpToolScope extends StatefulWidget { this.enabled = true, this.exposedTo = const [], this.onError, - this.registrar, + this.registrationStarter, this.supportCheck, }); @@ -41,7 +41,7 @@ class WebMcpToolScope extends StatefulWidget { final void Function(Object error, StackTrace stackTrace)? onError; /// Overrides registration for custom adapters and tests. - final WebMcpToolRegistrar? registrar; + final WebMcpToolRegistrationStarter? registrationStarter; /// Overrides feature detection for custom adapters and tests. final bool Function()? supportCheck; @@ -51,72 +51,87 @@ class WebMcpToolScope extends StatefulWidget { } class _WebMcpToolScopeState extends State { - List _registrations = []; - int _generation = 0; + final Map _slots = {}; + bool _reconcileScheduled = false; @override void initState() { super.initState(); - unawaited(_refresh()); + _scheduleReconcile(); } @override void didUpdateWidget(WebMcpToolScope oldWidget) { super.didUpdateWidget(oldWidget); final changed = oldWidget.enabled != widget.enabled || - oldWidget.registrar != widget.registrar || + oldWidget.registrationStarter != widget.registrationStarter || oldWidget.supportCheck != widget.supportCheck || !listEquals(oldWidget.tools, widget.tools) || !listEquals(oldWidget.exposedTo, widget.exposedTo); - if (changed) unawaited(_refresh()); + if (changed) _scheduleReconcile(); } - Future _refresh() async { - final generation = ++_generation; - await _unregisterCurrent(); - if (!mounted || generation != _generation || !widget.enabled) return; - - final supported = widget.supportCheck?.call() ?? WebMcp.isSupported; - if (!supported) return; + void _scheduleReconcile() { + if (_reconcileScheduled) return; + _reconcileScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _reconcileScheduled = false; + if (mounted) _reconcile(); + }); + } - final registrar = widget.registrar ?? _defaultRegistrar; - final next = []; - try { + void _reconcile() { + final supported = + widget.enabled && (widget.supportCheck?.call() ?? WebMcp.isSupported); + final desired = {}; + if (widget.enabled && supported) { + final starter = widget.registrationStarter ?? _defaultStarter; for (final tool in widget.tools) { - next.add( - await registrar(tool, exposedTo: widget.exposedTo), - ); - if (!mounted || generation != _generation) { - await _unregister(next); - return; + if (desired.containsKey(tool.name)) { + _reportError( + ArgumentError.value( + tool.name, 'tools', 'Tool names must be unique.'), + StackTrace.current, + ); + continue; } - } - _registrations = next; - } catch (error, stackTrace) { - await _unregister(next); - if (mounted && generation == _generation) { - _reportError(error, stackTrace); + desired[tool.name] = _ToolConfiguration( + tool, + List.of(widget.exposedTo), + starter, + ); } } - } - - Future _unregisterCurrent() async { - final current = _registrations; - _registrations = []; - await _unregister(current); - } - Future _unregister(List registrations) async { - for (final registration in registrations.reversed) { - try { - await registration.unregister(); - } catch (error, stackTrace) { - if (mounted) _reportError(error, stackTrace); + for (final entry in _slots.entries.toList()) { + final next = desired[entry.key]; + if (next == null || !entry.value.matches(next)) { + _slots.remove(entry.key)?.cancel(_reportError); } } + + for (final entry in desired.entries) { + if (_slots.containsKey(entry.key)) continue; + final slot = _ToolSlot(entry.value); + _slots[entry.key] = slot; + slot.start(_reportError); + } } void _reportError(Object error, StackTrace stackTrace) { + if (!mounted) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'flutter_webmcp', + context: ErrorDescription( + 'while cancelling WebMCP tools after scope disposal', + ), + ), + ); + return; + } final onError = widget.onError; if (onError != null) { onError(error, stackTrace); @@ -132,14 +147,30 @@ class _WebMcpToolScopeState extends State { ); } + void _reportErrorAfterDispose(Object error, StackTrace stackTrace) { + final onError = widget.onError; + scheduleMicrotask(() { + if (onError != null) { + onError(error, stackTrace); + return; + } + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'flutter_webmcp', + context: ErrorDescription('while disposing WebMCP tools'), + ), + ); + }); + } + @override void dispose() { - _generation++; - final current = _registrations; - _registrations = []; - for (final registration in current.reversed) { - unawaited(registration.unregister()); + for (final slot in _slots.values) { + slot.cancel(_reportErrorAfterDispose); } + _slots.clear(); super.dispose(); } @@ -147,8 +178,71 @@ class _WebMcpToolScopeState extends State { Widget build(BuildContext context) => widget.child; } -Future _defaultRegistrar( +final class _ToolConfiguration { + const _ToolConfiguration(this.tool, this.exposedTo, this.starter); + + final WebMcpTool tool; + final List exposedTo; + final WebMcpToolRegistrationStarter starter; +} + +final class _ToolSlot { + _ToolSlot(this.configuration); + + final _ToolConfiguration configuration; + WebMcpRegistrationAttempt? _attempt; + + bool matches(_ToolConfiguration other) => + identical(configuration.tool, other.tool) && + configuration.starter == other.starter && + listEquals(configuration.exposedTo, other.exposedTo); + + void start(void Function(Object, StackTrace) reportError) { + late final WebMcpRegistrationAttempt attempt; + try { + attempt = configuration.starter( + configuration.tool, + exposedTo: configuration.exposedTo, + ); + } catch (error, stackTrace) { + reportError(error, stackTrace); + return; + } + + _attempt = attempt; + unawaited( + attempt.ready.then( + (_) {}, + onError: (Object error, StackTrace stackTrace) { + if (identical(_attempt, attempt) && !attempt.isCancelled) { + reportError(error, stackTrace); + } + }, + ), + ); + } + + void cancel(void Function(Object, StackTrace) reportError) { + final attempt = _attempt; + _attempt = null; + if (attempt == null) return; + try { + unawaited( + attempt.cancel().then( + (_) {}, + onError: (Object error, StackTrace stackTrace) { + reportError(error, stackTrace); + }, + ), + ); + } catch (error, stackTrace) { + reportError(error, stackTrace); + } + } +} + +WebMcpRegistrationAttempt _defaultStarter( WebMcpTool tool, { - List exposedTo = const [], + required List exposedTo, }) => - WebMcp.registerTool(tool, exposedTo: exposedTo); + WebMcp.startToolRegistration(tool, exposedTo: exposedTo); diff --git a/lib/src/platform/platform.dart b/lib/src/platform/platform.dart index d86a52d..5c65448 100644 --- a/lib/src/platform/platform.dart +++ b/lib/src/platform/platform.dart @@ -1,4 +1,4 @@ -import '../webmcp_registration.dart'; +import '../webmcp_registration_attempt.dart'; import '../webmcp_support.dart'; import '../webmcp_tool.dart'; @@ -7,9 +7,9 @@ abstract interface class WebMcpPlatform { /// Current WebMCP support state. WebMcpSupport get support; - /// Registers [tool] for the requested origins. - Future registerTool( + /// Starts registering [tool] for the requested origins. + WebMcpRegistrationAttempt startToolRegistration( WebMcpTool tool, { - List exposedTo, + required List exposedTo, }); } diff --git a/lib/src/platform/platform_stub.dart b/lib/src/platform/platform_stub.dart index c38dd77..c900161 100644 --- a/lib/src/platform/platform_stub.dart +++ b/lib/src/platform/platform_stub.dart @@ -1,4 +1,4 @@ -import '../webmcp_registration.dart'; +import '../webmcp_registration_attempt.dart'; import '../webmcp_support.dart'; import '../webmcp_tool.dart'; import 'platform.dart'; @@ -16,9 +16,9 @@ final class _UnsupportedWebMcpPlatform implements WebMcpPlatform { ); @override - Future registerTool( + WebMcpRegistrationAttempt startToolRegistration( WebMcpTool tool, { - List exposedTo = const [], + required List exposedTo, }) { throw UnsupportedError( 'WebMCP is only available in a supported web browser.', diff --git a/lib/src/platform/platform_web.dart b/lib/src/platform/platform_web.dart index b678d29..29fd9c1 100644 --- a/lib/src/platform/platform_web.dart +++ b/lib/src/platform/platform_web.dart @@ -7,6 +7,7 @@ import 'dart:js_interop'; import '../webmcp_exception.dart'; import '../webmcp_logging.dart'; import '../webmcp_registration.dart'; +import '../webmcp_registration_attempt.dart'; import '../webmcp_result.dart'; import '../webmcp_support.dart'; import '../webmcp_tool.dart'; @@ -41,10 +42,22 @@ final class _BrowserWebMcpPlatform implements WebMcpPlatform { } @override - Future registerTool( + WebMcpRegistrationAttempt startToolRegistration( WebMcpTool tool, { - List exposedTo = const [], - }) async { + required List exposedTo, + }) { + final controller = _AbortController(); + return WebMcpRegistrationAttempt( + ready: _registerTool(tool, exposedTo, controller), + cancel: () => controller.abort(), + ); + } + + Future _registerTool( + WebMcpTool tool, + List exposedTo, + _AbortController controller, + ) async { final modelContext = _modelContext; if (modelContext == null) { throw const WebMcpException( @@ -52,7 +65,6 @@ final class _BrowserWebMcpPlatform implements WebMcpPlatform { ); } - final controller = _AbortController(); final annotations = tool.annotations; final jsTool = _ModelContextTool( name: tool.name, @@ -79,9 +91,21 @@ final class _BrowserWebMcpPlatform implements WebMcpPlatform { await (result as JSPromise).toDart; } } catch (error) { + if (controller.signal.aborted) { + throw WebMcpException( + 'Registration of tool `${tool.name}` was cancelled.', + error, + ); + } throw WebMcpException('Could not register tool `${tool.name}`.', error); } + if (controller.signal.aborted) { + throw WebMcpException( + 'Registration of tool `${tool.name}` was cancelled.', + ); + } + return WebMcpRegistration(tool.name, () => controller.abort()); } diff --git a/lib/src/webmcp.dart b/lib/src/webmcp.dart index ea80243..56bde25 100644 --- a/lib/src/webmcp.dart +++ b/lib/src/webmcp.dart @@ -4,6 +4,7 @@ import 'platform/platform.dart'; import 'platform/platform_stub.dart' if (dart.library.js_interop) 'platform/platform_web.dart'; import 'webmcp_registration.dart'; +import 'webmcp_registration_attempt.dart'; import 'webmcp_logging.dart'; import 'webmcp_support.dart'; import 'webmcp_tool.dart'; @@ -28,9 +29,17 @@ abstract final class WebMcp { static Future registerTool( WebMcpTool tool, { List exposedTo = const [], + }) { + return startToolRegistration(tool, exposedTo: exposedTo).ready; + } + + /// Starts registering [tool] and returns a handle that can cancel it. + static WebMcpRegistrationAttempt startToolRegistration( + WebMcpTool tool, { + List exposedTo = const [], }) { _validate(tool, exposedTo); - return _platform.registerTool(tool, exposedTo: exposedTo); + return _platform.startToolRegistration(tool, exposedTo: exposedTo); } static void _validate(WebMcpTool tool, List exposedTo) { diff --git a/lib/src/webmcp_registration_attempt.dart b/lib/src/webmcp_registration_attempt.dart new file mode 100644 index 0000000..2867137 --- /dev/null +++ b/lib/src/webmcp_registration_attempt.dart @@ -0,0 +1,42 @@ +import 'dart:async'; + +import 'webmcp_registration.dart'; + +/// A registration that can be cancelled before or after it becomes ready. +final class WebMcpRegistrationAttempt { + /// Creates an attempt backed by [cancel]. + WebMcpRegistrationAttempt({ + required Future ready, + required FutureOr Function() cancel, + }) : _cancel = cancel { + this.ready = ready.then((registration) { + _registration = registration; + return registration; + }); + } + + /// Completes when the registration attempt finishes. + late final Future ready; + + final FutureOr Function() _cancel; + WebMcpRegistration? _registration; + bool _isCancelled = false; + Future? _cancellation; + + /// Whether [cancel] has been called. + bool get isCancelled => _isCancelled; + + /// Cancels this attempt or removes its active registration. + /// + /// Cancellation starts immediately. The returned future reflects completion + /// of any adapter-specific asynchronous cleanup. + Future cancel() { + if (_isCancelled) return _cancellation ?? Future.value(); + _isCancelled = true; + final registration = _registration; + if (registration != null) { + return _cancellation = registration.unregister(); + } + return _cancellation = Future.sync(_cancel); + } +} diff --git a/lib/webmcp.dart b/lib/webmcp.dart index 5b07c94..92f53b4 100644 --- a/lib/webmcp.dart +++ b/lib/webmcp.dart @@ -6,6 +6,7 @@ export 'src/webmcp_annotations.dart'; export 'src/webmcp_exception.dart'; export 'src/webmcp_logging.dart'; export 'src/webmcp_registration.dart'; +export 'src/webmcp_registration_attempt.dart'; export 'src/webmcp_result.dart'; export 'src/webmcp_support.dart'; export 'src/webmcp_tool.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 88ffdb9..fb9501f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_webmcp description: A typed Dart API for exposing Flutter Web actions as WebMCP tools. -version: 0.2.1 +version: 0.3.0 repository: https://github.com/KickNext/flutter_webmcp issue_tracker: https://github.com/KickNext/flutter_webmcp/issues topics: diff --git a/test/webmcp_browser_test.dart b/test/webmcp_browser_test.dart index abcc97f..ca3528f 100644 --- a/test/webmcp_browser_test.dart +++ b/test/webmcp_browser_test.dart @@ -195,6 +195,38 @@ void main() { await registration.unregister(); }); + test('aborts a registration while its browser Promise is pending', () async { + final completer = Completer(); + JSObject? registrationOptions; + final fakeModelContext = _FakeModelContext( + registerTool: ((JSObject tool, JSObject options) { + registrationOptions = options; + return completer.future.toJS; + }).toJS, + ); + _document.setProperty('modelContext'.toJS, fakeModelContext); + + final attempt = WebMcp.startToolRegistration( + WebMcpTool( + name: 'cancel_pending', + description: 'Covers cancellation before registration is ready.', + execute: (input, context) => null, + ), + ); + final signal = registrationOptions!.getProperty('signal'.toJS); + expect(signal.getProperty('aborted'.toJS).toDart, isFalse); + + attempt.cancel(); + expect(signal.getProperty('aborted'.toJS).toDart, isTrue); + + final readyExpectation = expectLater( + attempt.ready, + throwsA(isA()), + ); + completer.complete(null); + await readyExpectation; + }); + test('hides unexpected local errors from the agent', () async { JSObject? registeredTool; final fakeModelContext = _FakeModelContext( diff --git a/test/webmcp_test.dart b/test/webmcp_test.dart index b17751d..19c3ce5 100644 --- a/test/webmcp_test.dart +++ b/test/webmcp_test.dart @@ -78,6 +78,23 @@ void main() { expect(registration.isRegistered, isFalse); }); + test('registration attempt cancels once', () async { + var calls = 0; + final registration = WebMcpRegistration('test', () => calls++); + final attempt = WebMcpRegistrationAttempt( + ready: Future.value(registration), + cancel: () => calls += 100, + ); + + await attempt.ready; + await attempt.cancel(); + await attempt.cancel(); + + expect(calls, 1); + expect(attempt.isCancelled, isTrue); + expect(registration.isRegistered, isFalse); + }); + test('typed tool decodes input before execution', () async { final typedTool = WebMcpTypedTool( name: 'double_value', diff --git a/test/webmcp_tool_scope_test.dart b/test/webmcp_tool_scope_test.dart index 5e87b73..c6bde84 100644 --- a/test/webmcp_tool_scope_test.dart +++ b/test/webmcp_tool_scope_test.dart @@ -1,37 +1,41 @@ @TestOn('vm') library; +import 'dart:async'; + import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_webmcp/flutter_webmcp.dart'; void main() { - testWidgets('registers tools while mounted and unregisters on dispose', ( + WebMcpTool tool(String name, {String? description}) => WebMcpTool( + name: name, + description: description ?? 'Tool $name.', + execute: (input, context) => null, + ); + + testWidgets('registers tools while mounted and cancels them on dispose', ( tester, ) async { - final registered = []; - final unregistered = []; - final tool = WebMcpTool( - name: 'test_tool', - description: 'A test tool.', - execute: (input, context) => null, - ); + final events = []; - Future registrar( + WebMcpRegistrationAttempt starter( WebMcpTool tool, { List exposedTo = const [], - }) async { - registered.add(tool.name); - return WebMcpRegistration( - tool.name, - () => unregistered.add(tool.name), + }) { + events.add('start:${tool.name}'); + void cancel() => events.add('cancel:${tool.name}'); + + return WebMcpRegistrationAttempt( + ready: Future.value(WebMcpRegistration(tool.name, cancel)), + cancel: cancel, ); } await tester.pumpWidget( WebMcpToolScope( - tools: [tool], - registrar: registrar, + tools: [tool('test_tool')], + registrationStarter: starter, supportCheck: () => true, child: const Directionality( textDirection: TextDirection.ltr, @@ -42,141 +46,296 @@ void main() { await tester.pump(); expect(find.text('Child'), findsOneWidget); - expect(registered, ['test_tool']); - expect(unregistered, isEmpty); + expect(events, ['start:test_tool']); await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(); - expect(unregistered, ['test_tool']); + expect(events, ['start:test_tool', 'cancel:test_tool']); }); - testWidgets('replaces tools when configuration changes', (tester) async { + testWidgets('cancels a pending version before starting its replacement', ( + tester, + ) async { final events = []; + final errors = []; + final pending = Completer(); + var starts = 0; - Future registrar( + WebMcpRegistrationAttempt starter( WebMcpTool tool, { List exposedTo = const [], - }) async { - events.add('register:${tool.name}'); - return WebMcpRegistration( - tool.name, - () => events.add('unregister:${tool.name}'), + }) { + starts++; + final version = starts; + events.add('start:$version'); + return WebMcpRegistrationAttempt( + ready: version == 1 + ? pending.future + : Future.value(WebMcpRegistration(tool.name, () { + events.add('cancel:$version'); + if (version == 2) throw StateError('active cleanup failed'); + })), + cancel: () { + events.add('cancel:$version'); + }, ); } - WebMcpTool tool(String name) => WebMcpTool( - name: name, - description: 'Tool $name.', - execute: (input, context) => null, - ); - await tester.pumpWidget( WebMcpToolScope( - tools: [tool('first')], - registrar: registrar, + tools: [tool('same_name', description: 'First version.')], + registrationStarter: starter, supportCheck: () => true, + onError: (error, stackTrace) => errors.add(error), child: const SizedBox(), ), ); - await tester.pump(); await tester.pumpWidget( WebMcpToolScope( - tools: [tool('second')], - registrar: registrar, + tools: [tool('same_name', description: 'Second version.')], + registrationStarter: starter, supportCheck: () => true, + onError: (error, stackTrace) => errors.add(error), child: const SizedBox(), ), ); + + expect(events, ['start:1', 'cancel:1', 'start:2']); + expect(errors, isEmpty); + + pending.completeError(const WebMcpException('Registration cancelled.')); await tester.pump(); + expect(events, ['start:1', 'cancel:1', 'start:2']); + expect(errors, isEmpty); - expect( - events, - ['register:first', 'unregister:first', 'register:second'], + await tester.pumpWidget( + WebMcpToolScope( + tools: [tool('same_name', description: 'Third version.')], + registrationStarter: starter, + supportCheck: () => true, + onError: (error, stackTrace) => errors.add(error), + child: const SizedBox(), + ), ); + await tester.pump(); + + expect(events, [ + 'start:1', + 'cancel:1', + 'start:2', + 'cancel:2', + 'start:3', + ]); + expect(errors.single, isA()); }); - testWidgets('does not register when disabled or unsupported', (tester) async { - var registrations = 0; - final tool = WebMcpTool( - name: 'test_tool', - description: 'A test tool.', - execute: (input, context) => null, - ); + testWidgets('keeps unchanged names active while other names change', ( + tester, + ) async { + final events = []; - Future registrar( + WebMcpRegistrationAttempt starter( WebMcpTool tool, { List exposedTo = const [], - }) async { - registrations++; - return WebMcpRegistration(tool.name, () {}); + }) { + events.add('start:${tool.name}'); + void cancel() => events.add('cancel:${tool.name}'); + + return WebMcpRegistrationAttempt( + ready: Future.value(WebMcpRegistration(tool.name, cancel)), + cancel: cancel, + ); } + final stable = tool('stable'); await tester.pumpWidget( WebMcpToolScope( - tools: [tool], - enabled: false, - registrar: registrar, + tools: [stable, tool('old')], + registrationStarter: starter, supportCheck: () => true, child: const SizedBox(), ), ); - await tester.pump(); - expect(registrations, 0); + await tester.pumpWidget( + WebMcpToolScope( + tools: [stable, tool('new')], + registrationStarter: starter, + supportCheck: () => true, + child: const SizedBox(), + ), + ); + + expect( + events, + ['start:stable', 'start:old', 'cancel:old', 'start:new'], + ); + }); + + testWidgets('reports registration errors without cancelling other names', ( + tester, + ) async { + final errors = []; + final events = []; + + WebMcpRegistrationAttempt starter( + WebMcpTool tool, { + List exposedTo = const [], + }) { + events.add('start:${tool.name}'); + return WebMcpRegistrationAttempt( + ready: tool.name == 'broken' + ? Future.error(StateError('registration failed')) + : Future.value(WebMcpRegistration(tool.name, () {})), + cancel: () => events.add('cancel:${tool.name}'), + ); + } await tester.pumpWidget( WebMcpToolScope( - tools: [tool], - registrar: registrar, - supportCheck: () => false, + tools: [tool('working'), tool('broken')], + registrationStarter: starter, + supportCheck: () => true, + onError: (error, stackTrace) => errors.add(error), child: const SizedBox(), ), ); await tester.pump(); - expect(registrations, 0); + + expect(events, ['start:working', 'start:broken']); + expect(errors.single, isA()); }); - testWidgets('cleans up partial registration and reports the error', ( + testWidgets('reports synchronous errors after the build phase', ( tester, ) async { - final unregistered = []; - Object? reportedError; - final tools = [ - WebMcpTool( - name: 'first', - description: 'First tool.', - execute: (input, context) => null, - ), - WebMcpTool( - name: 'second', - description: 'Second tool.', - execute: (input, context) => null, + var errorCount = 0; + late StateSetter setParentState; + final duplicate = tool('duplicate'); + final tools = [duplicate, duplicate]; + + WebMcpRegistrationAttempt starter( + WebMcpTool tool, { + List exposedTo = const [], + }) => + WebMcpRegistrationAttempt( + ready: Future.value(WebMcpRegistration(tool.name, () {})), + cancel: () {}, + ); + + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + setParentState = setState; + return WebMcpToolScope( + tools: tools, + registrationStarter: starter, + supportCheck: _supported, + onError: (error, stackTrace) { + errorCount++; + setParentState(() {}); + }, + child: const SizedBox(), + ); + }, ), - ]; + ); + await tester.pump(); - Future registrar( + expect(errorCount, 1); + expect(tester.takeException(), isNull); + }); + + testWidgets('disabling cancels without checking support', (tester) async { + var starts = 0; + var cancels = 0; + final testTool = tool('test_tool'); + + WebMcpRegistrationAttempt starter( WebMcpTool tool, { List exposedTo = const [], - }) async { - if (tool.name == 'second') throw StateError('registration failed'); - return WebMcpRegistration( - tool.name, - () => unregistered.add(tool.name), + }) { + starts++; + void cancel() => cancels++; + + return WebMcpRegistrationAttempt( + ready: Future.value(WebMcpRegistration(tool.name, cancel)), + cancel: cancel, ); } await tester.pumpWidget( WebMcpToolScope( - tools: tools, - registrar: registrar, + tools: [testTool], + registrationStarter: starter, supportCheck: () => true, - onError: (error, stackTrace) => reportedError = error, child: const SizedBox(), ), ); + await tester.pumpWidget( + WebMcpToolScope( + tools: [testTool], + enabled: false, + registrationStarter: starter, + supportCheck: () => throw StateError('must not be called'), + child: const SizedBox(), + ), + ); + + expect(starts, 1); + expect(cancels, 1); + }); + + testWidgets('dispose cancels every slot before reporting errors', ( + tester, + ) async { + var showScope = true; + var errors = 0; + late StateSetter setParentState; + final cancelled = []; + + WebMcpRegistrationAttempt starter( + WebMcpTool tool, { + List exposedTo = const [], + }) { + void cancel() { + cancelled.add(tool.name); + throw StateError('cancel failed'); + } + + return WebMcpRegistrationAttempt( + ready: Future.value(WebMcpRegistration(tool.name, cancel)), + cancel: cancel, + ); + } + + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + setParentState = setState; + return showScope + ? WebMcpToolScope( + tools: [tool('first'), tool('second')], + registrationStarter: starter, + supportCheck: _supported, + onError: (error, stackTrace) { + errors++; + setParentState(() {}); + }, + child: const SizedBox(), + ) + : const SizedBox(); + }, + ), + ); + + setParentState(() => showScope = false); + await tester.pump(); await tester.pump(); - expect(unregistered, ['first']); - expect(reportedError, isA()); + expect(cancelled, ['first', 'second']); + expect(errors, 2); + expect(tester.takeException(), isNull); }); } + +bool _supported() => true;