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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ instead of the draft's `Promise<void>`. Both forms are supported.

```yaml
dependencies:
flutter_webmcp: ^0.2.1
flutter_webmcp: ^0.3.0
```

For a local checkout:
Expand Down Expand Up @@ -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
Expand Down
202 changes: 148 additions & 54 deletions lib/src/flutter/webmcp_tool_scope.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import 'package:flutter/widgets.dart';

import '../../webmcp.dart';

/// Registers one tool and returns its lifecycle handle.
typedef WebMcpToolRegistrar = Future<WebMcpRegistration> Function(
/// Starts a registration that can be cancelled immediately.
typedef WebMcpToolRegistrationStarter = WebMcpRegistrationAttempt Function(
WebMcpTool tool, {
List<String> exposedTo,
required List<String> exposedTo,
});
Comment thread
KickNext marked this conversation as resolved.

/// 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({
Expand All @@ -21,7 +21,7 @@ class WebMcpToolScope extends StatefulWidget {
this.enabled = true,
this.exposedTo = const [],
this.onError,
this.registrar,
this.registrationStarter,
this.supportCheck,
});

Expand All @@ -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;
Expand All @@ -51,72 +51,87 @@ class WebMcpToolScope extends StatefulWidget {
}

class _WebMcpToolScopeState extends State<WebMcpToolScope> {
List<WebMcpRegistration> _registrations = [];
int _generation = 0;
final Map<String, _ToolSlot> _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<void> _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 = <WebMcpRegistration>[];
try {
void _reconcile() {
final supported =
widget.enabled && (widget.supportCheck?.call() ?? WebMcp.isSupported);
final desired = <String, _ToolConfiguration>{};
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<String>.of(widget.exposedTo),
starter,
);
}
}
}

Future<void> _unregisterCurrent() async {
final current = _registrations;
_registrations = [];
await _unregister(current);
}

Future<void> _unregister(List<WebMcpRegistration> 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);
Comment thread
KickNext marked this conversation as resolved.
}
}

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);
Expand All @@ -132,23 +147,102 @@ class _WebMcpToolScopeState extends State<WebMcpToolScope> {
);
}

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();
}

@override
Widget build(BuildContext context) => widget.child;
}

Future<WebMcpRegistration> _defaultRegistrar(
final class _ToolConfiguration {
const _ToolConfiguration(this.tool, this.exposedTo, this.starter);

final WebMcpTool tool;
final List<String> 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);

Comment thread
KickNext marked this conversation as resolved.
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<void>(
(_) {},
onError: (Object error, StackTrace stackTrace) {
if (identical(_attempt, attempt) && !attempt.isCancelled) {
reportError(error, stackTrace);
Comment thread
KickNext marked this conversation as resolved.
}
},
),
);
}

void cancel(void Function(Object, StackTrace) reportError) {
final attempt = _attempt;
_attempt = null;
if (attempt == null) return;
try {
unawaited(
attempt.cancel().then<void>(
(_) {},
onError: (Object error, StackTrace stackTrace) {
reportError(error, stackTrace);
},
),
);
} catch (error, stackTrace) {
reportError(error, stackTrace);
}
}
}

WebMcpRegistrationAttempt _defaultStarter(
WebMcpTool tool, {
List<String> exposedTo = const [],
required List<String> exposedTo,
}) =>
WebMcp.registerTool(tool, exposedTo: exposedTo);
WebMcp.startToolRegistration(tool, exposedTo: exposedTo);
8 changes: 4 additions & 4 deletions lib/src/platform/platform.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import '../webmcp_registration.dart';
import '../webmcp_registration_attempt.dart';
import '../webmcp_support.dart';
import '../webmcp_tool.dart';

Expand All @@ -7,9 +7,9 @@ abstract interface class WebMcpPlatform {
/// Current WebMCP support state.
WebMcpSupport get support;

/// Registers [tool] for the requested origins.
Future<WebMcpRegistration> registerTool(
/// Starts registering [tool] for the requested origins.
WebMcpRegistrationAttempt startToolRegistration(
WebMcpTool tool, {
List<String> exposedTo,
required List<String> exposedTo,
});
}
6 changes: 3 additions & 3 deletions lib/src/platform/platform_stub.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import '../webmcp_registration.dart';
import '../webmcp_registration_attempt.dart';
import '../webmcp_support.dart';
import '../webmcp_tool.dart';
import 'platform.dart';
Expand All @@ -16,9 +16,9 @@ final class _UnsupportedWebMcpPlatform implements WebMcpPlatform {
);

@override
Future<WebMcpRegistration> registerTool(
WebMcpRegistrationAttempt startToolRegistration(
WebMcpTool tool, {
List<String> exposedTo = const [],
required List<String> exposedTo,
}) {
throw UnsupportedError(
'WebMCP is only available in a supported web browser.',
Expand Down
Loading