From ad953a8599e048366772685a0f772ccfd730d462 Mon Sep 17 00:00:00 2001 From: KickNext Date: Wed, 30 Sep 2026 18:24:56 +0500 Subject: [PATCH 1/4] Sync WebMCP annotations and lifecycle events for 0.3.1 --- .github/workflows/ci.yml | 5 + CHANGELOG.md | 12 ++ CONTRIBUTING.md | 12 ++ README.md | 118 +++++++++-- SECURITY.md | 21 +- example/lib/main.dart | 23 ++- lib/src/flutter/webmcp_tool_scope.dart | 3 + lib/src/platform/platform.dart | 7 + lib/src/platform/platform_stub.dart | 7 + lib/src/platform/platform_web.dart | 85 ++++++-- lib/src/webmcp.dart | 19 ++ lib/src/webmcp_annotations.dart | 10 + lib/src/webmcp_lifecycle_event.dart | 23 +++ lib/src/webmcp_registration.dart | 2 + lib/src/webmcp_registration_attempt.dart | 2 + lib/src/webmcp_tool.dart | 2 + lib/webmcp.dart | 1 + pubspec.yaml | 2 +- test/native_browser_config.yaml | 4 + test/webmcp_browser_test.dart | 239 +++++++++++++++++++++++ test/webmcp_native_browser_test.dart | 119 +++++++++++ test/webmcp_test.dart | 5 + 22 files changed, 690 insertions(+), 31 deletions(-) create mode 100644 lib/src/webmcp_lifecycle_event.dart create mode 100644 test/native_browser_config.yaml create mode 100644 test/webmcp_native_browser_test.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ff1677..4b0136d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,11 @@ jobs: - name: Run browser tests run: dart test -p chrome test/webmcp_browser_test.dart + - name: Run native WebMCP tests when available + env: + DART_TEST_CONFIG: test/native_browser_config.yaml + run: dart test -p chrome test/webmcp_native_browser_test.dart + - name: Analyze example run: flutter analyze example diff --git a/CHANGELOG.md b/CHANGELOG.md index 05130c3..9d6e72a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## 0.3.1 - 2026-09-30 + +- Added `consequential` and `debugging` annotations, preserving explicit false + values and omitting null hints. +- Added typed ModelContext lifecycle events through `WebMcp.lifecycleEvents` + and separate feature detection with `WebMcp.isLifecycleEventsSupported`. + Native listeners follow stream subscription lifetimes. +- Added browser regressions for annotations, event ownership and subscription + cleanup, and independent registration/execution cancellation. +- Updated guidance for the 29 September 2026 draft: tool replacement, document + naming, UI state, sensitive inputs, and deployment headers. + ## 0.3.0 - 2026-09-04 - Added cancellable registration attempts so Flutter scopes can abort stale diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aa3bdc4..b582db8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,7 @@ dart format --output=none --set-exit-if-changed lib test example/lib flutter analyze flutter test dart test -p chrome test/webmcp_browser_test.dart +DART_TEST_CONFIG=test/native_browser_config.yaml dart test -p chrome test/webmcp_native_browser_test.dart flutter analyze example (cd example && flutter build web --release) (cd example && flutter build web --wasm --release) @@ -31,6 +32,17 @@ flutter pub publish --dry-run Chrome browser tests require a Chrome installation. Manual WebMCP testing may also require `chrome://flags/#enable-webmcp-testing`. +`webmcp_browser_test.dart` verifies the Dart bindings with controlled browser +fixtures. `webmcp_native_browser_test.dart` additionally checks actual browser +activation, cancellation ordering, and iframe ownership when the native API +is present. It explicitly skips on browsers without the lifecycle API; a skip +does not establish native WebMCP conformance. +The separate native browser config enables experimental Chromium features in +the test browser only. In PowerShell, set `$env:DART_TEST_CONFIG` to the config +path for this command, then remove it with `Remove-Item Env:DART_TEST_CONFIG`. +Run binding fixtures without this config, so native read-only +properties do not interfere with their controlled ModelContext fixtures. + ## Pull requests - Explain the user-visible behavior and compatibility impact. diff --git a/README.md b/README.md index e417c45..1c1447d 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,12 @@ Try the [live Flutter Web demo](https://kicknext.github.io/flutter_webmcp/). | Flutter Web (WebAssembly) | Supported when the browser exposes WebMCP | | Android, iOS, desktop, and Dart VM | Safe no-op detection; registration is unsupported | -The package is tested against the WebMCP Draft Community Group Report dated -26 August 2026. It implements the current `document.modelContext.registerTool` +The browser bindings follow the WebMCP Draft Community Group Report dated +29 September 2026, with browser regression tests for the interop contract. +The package implements the current `document.modelContext.registerTool` shape, execution cancellation through `AbortSignal`, signal-based -unregistration, tool annotations, and `exposedTo` origins. Because WebMCP is +unregistration, all four tool annotations, ModelContext lifecycle events, +and `exposedTo` origins. Because WebMCP is not yet a web standard, minor package releases may add compatibility shims for browser changes. @@ -35,7 +37,7 @@ instead of the draft's `Promise`. Both forms are supported. ```yaml dependencies: - flutter_webmcp: ^0.3.0 + flutter_webmcp: ^0.3.1 ``` For a local checkout: @@ -54,12 +56,13 @@ mounted: ```dart class TasksPageState extends State { late final WebMcpTool addTaskTool; + final tasks = []; @override void initState() { super.initState(); addTaskTool = WebMcpTypedTool( - name: 'add_task', + name: 'tasks_add_task', title: 'Add task', description: 'Adds a task to the list currently open in the app.', inputSchema: const { @@ -70,11 +73,16 @@ class TasksPageState extends State { 'required': ['title'], }, decodeInput: AddTaskInput.fromJson, - execute: (input, context) async { - final task = await taskRepository.add(input.title); - return WebMcpResult.structured( - {'id': task.id, 'title': task.title}, - text: 'Task "${task.title}" was added.', + execute: (input, context) { + if (!mounted || context.isCancelled) { + throw const WebMcpToolException( + code: 'page_unavailable', + message: 'The task list is no longer available.', + ); + } + setState(() => tasks.add(input.title)); + return WebMcpResult.text( + 'Task "${input.title}" was added to the visible list.', ); }, ); @@ -84,7 +92,7 @@ class TasksPageState extends State { Widget build(BuildContext context) { return WebMcpToolScope( tools: [addTaskTool], - child: const TasksView(), + child: TasksView(tasks: tasks), ); } } @@ -94,6 +102,25 @@ Keep tool instances stable, for example in `initState`, a ViewModel, or a dependency-injection container. Replacing a tool instance tells the scope to unregister the old configuration and register the new one. +Keep the same instance when only backing state or handler behavior changes. +Create a new instance when agent-facing metadata such as `name`, `description`, +or `inputSchema` changes. Do not mutate an already registered schema map. +Handlers must validate current input, authorization, and preconditions at +execution time; state may have changed since an agent discovered the tool. + +Names must be unique across the document, including nested or kept-alive +scopes. Prefer feature-specific names such as `tasks_add_task` or +`checkout_add_item`. A scope checks duplicates within its own list; the +browser registry rejects collisions across scopes. + +## WebMCP and remote MCP + +Use WebMCP for actions tied to the current page, route, UI, or session: the +visible task list, current selection, unsaved document, or open checkout. +Handlers should use the same application state and actions as the visible UI. +Browser-independent capabilities can be exposed through remote MCP. Simply +mirroring server tools into a page can leave the UI out of sync with results. + ## Typed input ```dart @@ -150,6 +177,52 @@ browser APIs. Permission-policy errors are reported when registration is attempted because current browsers do not expose a reliable read-only policy signal for WebMCP. +Detection reports API availability, not guaranteed permission to register. +An origin-keyed agent cluster is also required; registration failures preserve +the browser error as `WebMcpException.cause`. See Browser setup below. + +## Annotations + +```dart +annotations: const WebMcpAnnotations( + readOnly: false, + untrustedContent: false, + consequential: true, + debugging: false, +), +``` + +`consequential` maps to `consequentialHint`: use it for purchases, payments, +bookings, deletion, or other significant effects. `debugging` identifies tools +for testing, inspectors, or devtools. `readOnly` and `untrustedContent` map to +`readOnlyHint` and `untrustedContentHint`. Null hints are omitted; explicit +`false` is preserved. These hints do not enforce permissions or confirmation. + +## Lifecycle events + +```dart +if (WebMcp.isLifecycleEventsSupported) { + final subscription = WebMcp.lifecycleEvents.listen((event) { + debugPrint('${event.toolName}: ${event.type.name}'); + }); + // When the owner ends: + await subscription.cancel(); +} +``` + +The typed stream listens to `toolactivated` and `toolcancel` on +`document.modelContext`. Each access creates a broadcast stream; native +listeners attach on its first subscription and detach after its last one is +cancelled. No events are buffered without subscribers. Older implementations +can support tool registration without these events; unsupported runtimes +return an empty stream. + +Events belong to the tool owner's document, including when another frame +invokes the tool. They do not bubble to `window`. Caller cancellation may +reject `executeTool()` before `toolcancel` arrives asynchronously. Events +contain only a tool name, with no invocation ID: use `context.isCancelled` +inside each handler to distinguish concurrent calls of the same tool. + ## Logging ```dart @@ -173,6 +246,10 @@ input in `decodeInput`, enforce authorization inside the handler, and require normal user confirmation for destructive or sensitive operations. Annotations are agent hints, not security boundaries. +Keep passwords, OTPs, PINs, card security codes, authentication secrets, and +sensitive identifiers out of model-visible schemas and results. Collect them +through normal user-mediated UI when necessary. + Only use `exposedTo` with origins you trust. See [`SECURITY.md`](SECURITY.md) for reporting and deployment guidance. @@ -201,6 +278,11 @@ 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`. +`registration.unregister()`, `attempt.cancel()`, and scope disposal prevent +new calls. They do not cancel handlers already executing. Registration and +execution have separate signals. For widget-bound work, check `mounted` or an +application lifetime guard before applying results, including after `await`. + `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. @@ -211,6 +293,20 @@ WebMCP requires `document.modelContext`. It is available in ChatGPT's in-app browser. For local Chrome testing, enable `chrome://flags/#enable-webmcp-testing` and restart Chrome. +The draft requires an origin-keyed agent cluster. Where the browser does not +enable this by default, serve `Origin-Agent-Cluster: ?1` consistently across +the origin. A cluster restriction can reject registration with `SecurityError`. + +For pages that should never expose WebMCP tools, serve this HTTP header: + +```http +Permissions-Policy: tools=() +``` + +It disables WebMCP for the document and all descendant frames before page +scripts run. This deployment control supplements application authorization. +A policy restriction can reject registration with `NotAllowedError`. + - Flutter Web compiled to JavaScript or WebAssembly is supported when the browser implements WebMCP. - Android, iOS, macOS, Windows, Linux, and Dart VM report unsupported. diff --git a/SECURITY.md b/SECURITY.md index 5a2a52e..bd11bac 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,8 +17,25 @@ authorization, confirmation of destructive actions, input validation, and protecting sensitive results. Tool annotations are hints to agents, not access controls. Restrict cross-origin -discovery with `exposedTo`, use HTTPS, and apply an appropriate -`Permissions-Policy` header where WebMCP must be disabled. +discovery with `exposedTo` and use HTTPS. For pages that must never expose +WebMCP, serve `Permissions-Policy: tools=()`. It disables WebMCP for the +document and all descendant frames, both same-origin and cross-origin, before +page scripts run. This is defense in depth, not a replacement for authorization. + +WebMCP also requires an origin-keyed agent cluster. If the browser does not +enable this by default, serve `Origin-Agent-Cluster: ?1` consistently across +the origin. Registration errors retain the browser cause: `SecurityError` +can indicate a cluster restriction and `NotAllowedError` a policy restriction. + +Do not expose passwords, OTPs, PINs, card security codes, authentication +secrets, or sensitive identifiers in model-visible input schemas or results. +Use normal user-mediated UI to collect required secrets. Return only the +information needed for the task, and recheck input, authorization, and current +preconditions inside each handler. + +Unregistration and scope disposal do not cancel executing handlers. Guard +widget-bound work with application lifetime state and use the invocation's +execution context for caller cancellation. `WebMcp.logger` can receive tool inputs, outputs, errors, and stack traces. Do not send these diagnostics to telemetry without redacting secrets and personal diff --git a/example/lib/main.dart b/example/lib/main.dart index 70c3537..a44629c 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_webmcp/flutter_webmcp.dart'; @@ -31,14 +33,25 @@ class _TaskDemoPageState extends State { final _tasks = ['Open this page in a WebMCP browser']; late final WebMcpTool _addTaskTool; Object? _registrationError; + StreamSubscription? _lifecycleSubscription; @override void initState() { super.initState(); + if (WebMcp.isLifecycleEventsSupported) { + _lifecycleSubscription = WebMcp.lifecycleEvents.listen((event) { + debugPrint('${event.toolName}: ${event.type.name}'); + }); + } _addTaskTool = WebMcpTypedTool( name: 'add_task', title: 'Add task', description: 'Adds a task to the list visible on this page.', + annotations: const WebMcpAnnotations( + readOnly: false, + consequential: false, + debugging: false, + ), inputSchema: const { 'type': 'object', 'properties': { @@ -57,7 +70,13 @@ class _TaskDemoPageState extends State { message: 'Task creation was cancelled.', ); } - if (mounted) setState(() => _tasks.add(input.title)); + if (!mounted) { + throw const WebMcpToolException( + code: 'page_unavailable', + message: 'The task list is no longer available.', + ); + } + setState(() => _tasks.add(input.title)); return WebMcpResult.text('Task "${input.title}" was added.'); }, ); @@ -80,6 +99,8 @@ class _TaskDemoPageState extends State { @override void dispose() { + final subscription = _lifecycleSubscription; + if (subscription != null) unawaited(subscription.cancel()); _controller.dispose(); super.dispose(); } diff --git a/lib/src/flutter/webmcp_tool_scope.dart b/lib/src/flutter/webmcp_tool_scope.dart index f5b631d..45df12a 100644 --- a/lib/src/flutter/webmcp_tool_scope.dart +++ b/lib/src/flutter/webmcp_tool_scope.dart @@ -12,6 +12,9 @@ typedef WebMcpToolRegistrationStarter = WebMcpRegistrationAttempt Function( }); /// Registers [tools] while [child] is mounted. +/// +/// Disposal and replacement prevent new calls but do not cancel executing +/// handlers. Guard widget-bound work with the application's own lifetime state. class WebMcpToolScope extends StatefulWidget { /// Creates a lifecycle scope for [tools]. const WebMcpToolScope({ diff --git a/lib/src/platform/platform.dart b/lib/src/platform/platform.dart index 5c65448..1363f3b 100644 --- a/lib/src/platform/platform.dart +++ b/lib/src/platform/platform.dart @@ -1,12 +1,19 @@ import '../webmcp_registration_attempt.dart'; import '../webmcp_support.dart'; import '../webmcp_tool.dart'; +import '../webmcp_lifecycle_event.dart'; /// Internal adapter implemented by browser and non-browser runtimes. abstract interface class WebMcpPlatform { /// Current WebMCP support state. WebMcpSupport get support; + /// Whether both lifecycle event handlers are exposed by ModelContext. + bool get isLifecycleEventsSupported; + + /// Browser lifecycle notifications; listeners own their subscriptions. + Stream get lifecycleEvents; + /// Starts registering [tool] for the requested origins. WebMcpRegistrationAttempt startToolRegistration( WebMcpTool tool, { diff --git a/lib/src/platform/platform_stub.dart b/lib/src/platform/platform_stub.dart index c900161..6e34e8e 100644 --- a/lib/src/platform/platform_stub.dart +++ b/lib/src/platform/platform_stub.dart @@ -1,6 +1,7 @@ import '../webmcp_registration_attempt.dart'; import '../webmcp_support.dart'; import '../webmcp_tool.dart'; +import '../webmcp_lifecycle_event.dart'; import 'platform.dart'; /// Creates the non-browser platform adapter. @@ -9,6 +10,12 @@ WebMcpPlatform createWebMcpPlatform() => const _UnsupportedWebMcpPlatform(); final class _UnsupportedWebMcpPlatform implements WebMcpPlatform { const _UnsupportedWebMcpPlatform(); + @override + bool get isLifecycleEventsSupported => false; + + @override + Stream get lifecycleEvents => const Stream.empty(); + @override WebMcpSupport get support => const WebMcpSupport( WebMcpSupportStatus.unsupportedPlatform, diff --git a/lib/src/platform/platform_web.dart b/lib/src/platform/platform_web.dart index 29fd9c1..d2855aa 100644 --- a/lib/src/platform/platform_web.dart +++ b/lib/src/platform/platform_web.dart @@ -1,10 +1,13 @@ @JS() library; +import 'dart:async'; import 'dart:convert'; import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; import '../webmcp_exception.dart'; +import '../webmcp_lifecycle_event.dart'; import '../webmcp_logging.dart'; import '../webmcp_registration.dart'; import '../webmcp_registration_attempt.dart'; @@ -21,6 +24,49 @@ final class _BrowserWebMcpPlatform implements WebMcpPlatform { _ModelContext? get _modelContext => _document.modelContext; + bool _supportsLifecycleEvents(_ModelContext? context) => + support.isSupported && + context != null && + context.hasProperty('ontoolactivated'.toJS).toDart && + context.hasProperty('ontoolcancel'.toJS).toDart && + context.getProperty('addEventListener'.toJS).isA() && + context.getProperty('removeEventListener'.toJS).isA(); + + @override + bool get isLifecycleEventsSupported => + _supportsLifecycleEvents(_modelContext); + + @override + Stream get lifecycleEvents { + final context = _modelContext; + if (!_supportsLifecycleEvents(context)) return const Stream.empty(); + final modelContext = context!; + late final StreamController controller; + final activated = ((_ToolEvent event) { + controller.add(WebMcpLifecycleEvent( + type: WebMcpLifecycleEventType.activated, + toolName: event.toolName, + )); + }).toJS; + final cancelled = ((_ToolEvent event) { + controller.add(WebMcpLifecycleEvent( + type: WebMcpLifecycleEventType.cancelled, + toolName: event.toolName, + )); + }).toJS; + controller = StreamController.broadcast( + onListen: () { + modelContext.addEventListener('toolactivated', activated); + modelContext.addEventListener('toolcancel', cancelled); + }, + onCancel: () { + modelContext.removeEventListener('toolactivated', activated); + modelContext.removeEventListener('toolcancel', cancelled); + }, + ); + return controller.stream; + } + @override WebMcpSupport get support { if (!_isSecureContext) { @@ -66,17 +112,27 @@ final class _BrowserWebMcpPlatform implements WebMcpPlatform { } final annotations = tool.annotations; + final jsAnnotations = annotations == null ? null : JSObject(); + if (annotations != null) { + final hints = { + 'readOnlyHint': annotations.readOnly, + 'untrustedContentHint': annotations.untrustedContent, + 'consequentialHint': annotations.consequential, + 'debugging': annotations.debugging, + }; + for (final entry in hints.entries) { + final value = entry.value; + if (value != null) { + jsAnnotations!.setProperty(entry.key.toJS, value.toJS); + } + } + } final jsTool = _ModelContextTool( name: tool.name, title: tool.title, description: tool.description, inputSchema: tool.inputSchema.jsify() as JSObject, - annotations: annotations == null - ? null - : _ToolAnnotations( - readOnlyHint: annotations.readOnly, - untrustedContentHint: annotations.untrustedContent, - ), + annotations: jsAnnotations, execute: ((JSObject input, [_ExecuteOptions? options]) => _execute(tool, input, options).toJS).toJS, ); @@ -184,12 +240,18 @@ extension type _Document._(JSObject _) implements JSObject { } extension type _ModelContext._(JSObject _) implements JSObject { + external void addEventListener(String type, JSFunction listener); + external void removeEventListener(String type, JSFunction listener); external JSAny? registerTool( _ModelContextTool tool, [ _RegisterOptions options, ]); } +extension type _ToolEvent._(JSObject _) implements JSObject { + external String get toolName; +} + @JS() @anonymous extension type _ModelContextTool._(JSObject _) implements JSObject { @@ -199,16 +261,7 @@ extension type _ModelContextTool._(JSObject _) implements JSObject { required String description, required JSObject inputSchema, required JSFunction execute, - _ToolAnnotations? annotations, - }); -} - -@JS() -@anonymous -extension type _ToolAnnotations._(JSObject _) implements JSObject { - external factory _ToolAnnotations({ - bool? readOnlyHint, - bool? untrustedContentHint, + JSObject? annotations, }); } diff --git a/lib/src/webmcp.dart b/lib/src/webmcp.dart index 56bde25..6f2f883 100644 --- a/lib/src/webmcp.dart +++ b/lib/src/webmcp.dart @@ -8,6 +8,7 @@ import 'webmcp_registration_attempt.dart'; import 'webmcp_logging.dart'; import 'webmcp_support.dart'; import 'webmcp_tool.dart'; +import 'webmcp_lifecycle_event.dart'; /// Entry point for WebMCP feature detection and tool registration. abstract final class WebMcp { @@ -19,6 +20,24 @@ abstract final class WebMcp { /// Detailed feature-detection result for the current page. static WebMcpSupport get support => _platform.support; + /// Whether the browser exposes both ModelContext lifecycle event handlers. + /// + /// Older browsers may support registration without these events. + static bool get isLifecycleEventsSupported => + _platform.isLifecycleEventsSupported; + + /// Activation and cancellation notifications for this document's tools. + /// + /// Each access creates a broadcast stream bound to the current ModelContext. + /// Native listeners attach on the first subscription and detach when the last + /// subscription is cancelled. Events are not buffered without listeners. + /// Unsupported runtimes return an empty stream. + /// + /// Cancel subscriptions when their owner ends. Events contain no invocation + /// ID; use [WebMcpExecutionContext.isCancelled] inside individual handlers. + static Stream get lifecycleEvents => + _platform.lifecycleEvents; + /// Receives completed tool calls for debugging and observability. static set logger(WebMcpLogger? value) => webMcpLogger = value; diff --git a/lib/src/webmcp_annotations.dart b/lib/src/webmcp_annotations.dart index 78c06a2..e2934cf 100644 --- a/lib/src/webmcp_annotations.dart +++ b/lib/src/webmcp_annotations.dart @@ -4,6 +4,8 @@ final class WebMcpAnnotations { const WebMcpAnnotations({ this.readOnly, this.untrustedContent, + this.consequential, + this.debugging, }); /// Whether the tool only reads state and has no side effects. @@ -11,4 +13,12 @@ final class WebMcpAnnotations { /// Whether the tool result can contain content from an untrusted source. final bool? untrustedContent; + + /// Whether execution can have significant or difficult-to-reverse effects. + /// + /// This is an agent hint, not a substitute for authorization or confirmation. + final bool? consequential; + + /// Whether the tool is intended for debugging, testing, or developer tools. + final bool? debugging; } diff --git a/lib/src/webmcp_lifecycle_event.dart b/lib/src/webmcp_lifecycle_event.dart new file mode 100644 index 0000000..0f2ab9f --- /dev/null +++ b/lib/src/webmcp_lifecycle_event.dart @@ -0,0 +1,23 @@ +/// Browser notifications about tools owned by the current document. +enum WebMcpLifecycleEventType { + /// A tool invocation has started. + activated, + + /// The caller has cancelled a tool invocation. + cancelled, +} + +/// A notification from `document.modelContext`. +/// +/// Events identify a tool, not an individual invocation. For concurrent calls +/// of the same tool, use each handler's execution context for cancellation. +final class WebMcpLifecycleEvent { + /// Creates a notification for [toolName]. + const WebMcpLifecycleEvent({required this.type, required this.toolName}); + + /// The kind of browser notification. + final WebMcpLifecycleEventType type; + + /// Name of the tool owned by this document. + final String toolName; +} diff --git a/lib/src/webmcp_registration.dart b/lib/src/webmcp_registration.dart index 6f23e66..eefb4a6 100644 --- a/lib/src/webmcp_registration.dart +++ b/lib/src/webmcp_registration.dart @@ -15,6 +15,8 @@ final class WebMcpRegistration { bool get isRegistered => _isRegistered; /// Removes the tool. Calling this more than once has no effect. + /// + /// Prevents new calls; it does not cancel handlers already executing. Future unregister() async { if (!_isRegistered) return; _isRegistered = false; diff --git a/lib/src/webmcp_registration_attempt.dart b/lib/src/webmcp_registration_attempt.dart index 2867137..6976b33 100644 --- a/lib/src/webmcp_registration_attempt.dart +++ b/lib/src/webmcp_registration_attempt.dart @@ -28,6 +28,8 @@ final class WebMcpRegistrationAttempt { /// Cancels this attempt or removes its active registration. /// + /// This controls tool availability, not cancellation of executing handlers. + /// /// Cancellation starts immediately. The returned future reflects completion /// of any adapter-specific asynchronous cleanup. Future cancel() { diff --git a/lib/src/webmcp_tool.dart b/lib/src/webmcp_tool.dart index 000e025..e5a3079 100644 --- a/lib/src/webmcp_tool.dart +++ b/lib/src/webmcp_tool.dart @@ -26,6 +26,8 @@ final class WebMcpExecutionContext { final bool Function() _isCancelled; /// Whether the browser agent cancelled this invocation. + /// + /// Unregistering the tool or disposing its scope does not cancel invocation. bool get isCancelled => _isCancelled(); } diff --git a/lib/webmcp.dart b/lib/webmcp.dart index 92f53b4..11f323c 100644 --- a/lib/webmcp.dart +++ b/lib/webmcp.dart @@ -5,6 +5,7 @@ export 'src/webmcp.dart'; export 'src/webmcp_annotations.dart'; export 'src/webmcp_exception.dart'; export 'src/webmcp_logging.dart'; +export 'src/webmcp_lifecycle_event.dart'; export 'src/webmcp_registration.dart'; export 'src/webmcp_registration_attempt.dart'; export 'src/webmcp_result.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index fb9501f..ed5206b 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.3.0 +version: 0.3.1 repository: https://github.com/KickNext/flutter_webmcp issue_tracker: https://github.com/KickNext/flutter_webmcp/issues topics: diff --git a/test/native_browser_config.yaml b/test/native_browser_config.yaml new file mode 100644 index 0000000..dbb4044 --- /dev/null +++ b/test/native_browser_config.yaml @@ -0,0 +1,4 @@ +override_platforms: + chrome: + settings: + arguments: --enable-blink-features=WebMCP,WebMCPTesting diff --git a/test/webmcp_browser_test.dart b/test/webmcp_browser_test.dart index ca3528f..797327a 100644 --- a/test/webmcp_browser_test.dart +++ b/test/webmcp_browser_test.dart @@ -10,6 +10,216 @@ import 'package:flutter_webmcp/webmcp.dart'; import 'package:test/test.dart'; void main() { + setUp(() { + final original = _document.getProperty('modelContext'.toJS); + addTearDown(() { + _document.setProperty('modelContext'.toJS, original); + WebMcp.logger = null; + }); + }); + + for (final value in [true, false, null]) { + test('serializes all annotations with value $value', () async { + JSObject? registeredTool; + _document.setProperty( + 'modelContext'.toJS, + _FakeModelContext( + registerTool: ((JSObject tool, JSObject options) { + registeredTool = tool; + }).toJS, + )); + final registration = await WebMcp.registerTool(WebMcpTool( + name: 'annotations', + description: 'Tests annotation serialization.', + annotations: WebMcpAnnotations( + readOnly: value, + untrustedContent: value, + consequential: value, + debugging: value, + ), + execute: (input, context) => null, + )); + final annotations = + registeredTool!.getProperty('annotations'.toJS); + for (final key in [ + 'readOnlyHint', + 'untrustedContentHint', + 'consequentialHint', + 'debugging', + ]) { + if (value == null) { + expect(annotations.hasProperty(key.toJS).toDart, isFalse); + } else { + expect(annotations.getProperty(key.toJS).toDart, value); + } + } + await registration.unregister(); + }); + } + + test('older ModelContext can register without lifecycle events', () async { + _document.setProperty( + 'modelContext'.toJS, + _FakeModelContext( + registerTool: ((JSObject tool, JSObject options) {}).toJS, + )); + expect(WebMcp.isSupported, isTrue); + expect(WebMcp.isLifecycleEventsSupported, isFalse); + expect(await WebMcp.lifecycleEvents.toList(), isEmpty); + final registration = await WebMcp.registerTool(WebMcpTool( + name: 'older_browser', + description: 'Registration still works.', + execute: (input, context) => null, + )); + await registration.unregister(); + }); + + test('unavailable ModelContext has an empty event stream', () async { + _document.setProperty('modelContext'.toJS, null); + expect(WebMcp.isLifecycleEventsSupported, isFalse); + expect(await WebMcp.lifecycleEvents.toList(), isEmpty); + }); + + test('lifecycle detection requires both events and EventTarget methods', () { + final target = _lifecycleTarget(); + _document.setProperty('modelContext'.toJS, target); + expect(WebMcp.isLifecycleEventsSupported, isTrue); + target.delete('ontoolcancel'.toJS); + expect(WebMcp.isLifecycleEventsSupported, isFalse); + target.setProperty('ontoolcancel'.toJS, null); + target.setProperty('addEventListener'.toJS, null); + expect(WebMcp.isLifecycleEventsSupported, isFalse); + }); + + test('streams owner events without replacing JS callbacks; cleans up', + () async { + final owner = _lifecycleTarget(); + final otherDocument = _lifecycleTarget(); + _document.setProperty('modelContext'.toJS, owner); + var jsCalls = 0; + final jsListener = ((JSObject event) => jsCalls++).toJS; + owner.addEventListener('toolactivated', jsListener); + addTearDown(() => owner.removeEventListener('toolactivated', jsListener)); + final handler = ((JSObject event) {}).toJS; + owner.setProperty('ontoolactivated'.toJS, handler); + + var attached = 0; + var detached = 0; + final nativeAdd = owner.getProperty('addEventListener'.toJS); + final nativeRemove = + owner.getProperty('removeEventListener'.toJS); + owner.setProperty( + 'addEventListener'.toJS, + ((JSString type, JSFunction listener) { + attached++; + nativeAdd.callAsFunction(owner, type, listener); + }).toJS); + owner.setProperty( + 'removeEventListener'.toJS, + ((JSString type, JSFunction listener) { + detached++; + nativeRemove.callAsFunction(owner, type, listener); + }).toJS); + final stream = WebMcp.lifecycleEvents; + expect(attached, 0); + final first = []; + final second = []; + final a = stream.listen(first.add); + final b = stream.listen(second.add); + addTearDown(a.cancel); + addTearDown(b.cancel); + expect(attached, 2); + + _dispatchToolEvent(otherDocument, 'toolactivated', 'other_tool'); + _dispatchToolEvent(owner, 'toolactivated', 'owner_tool'); + _dispatchToolEvent(owner, 'toolcancel', 'owner_tool'); + await Future.delayed(Duration.zero); + expect(first.map((event) => event.type), [ + WebMcpLifecycleEventType.activated, + WebMcpLifecycleEventType.cancelled, + ]); + expect(first.map((event) => event.toolName), ['owner_tool', 'owner_tool']); + expect(second.length, 2); + expect(jsCalls, 1); + expect( + identical( + owner.getProperty('ontoolactivated'.toJS), handler), + isTrue); + + await a.cancel(); + expect(detached, 0); + _dispatchToolEvent(owner, 'toolcancel', 'owner_tool'); + await Future.delayed(Duration.zero); + expect(first.length, 2); + expect(second.length, 3); + + // Cleanup stays bound to the original target even if the document changes. + _document.setProperty('modelContext'.toJS, otherDocument); + await b.cancel(); + expect(detached, 2); + _dispatchToolEvent(owner, 'toolactivated', 'owner_tool'); + expect(jsCalls, 2); + final c = stream.listen(first.add); + addTearDown(c.cancel); + expect(attached, 4); + _dispatchToolEvent(owner, 'toolactivated', 'owner_tool'); + await Future.delayed(Duration.zero); + expect(first.length, 3); + await c.cancel(); + expect(detached, 4); + }); + + for (final cancelAttempt in [false, true]) { + test('registration cleanup preserves an executing handler ($cancelAttempt)', + () async { + JSObject? registeredTool; + JSObject? registrationOptions; + _document.setProperty( + 'modelContext'.toJS, + _FakeModelContext( + registerTool: ((JSObject tool, JSObject options) { + registeredTool = tool; + registrationOptions = options; + }).toJS, + )); + final finish = Completer(); + final started = Completer(); + final attempt = WebMcp.startToolRegistration(WebMcpTool( + name: 'long_running', + description: 'Keeps executing after registration cleanup.', + execute: (input, context) async { + started.complete(context); + await finish.future; + return {'cancelled': context.isCancelled}; + }, + )); + final registration = await attempt.ready; + final executionController = _TestAbortController(); + final promise = registeredTool! + .getProperty('execute'.toJS) + .callAsFunction(registeredTool, {}.jsify(), + {'signal': executionController.signal}.jsify()) + as JSPromise; + final context = await started.future; + if (cancelAttempt) { + await attempt.cancel(); + } else { + await registration.unregister(); + } + expect( + registrationOptions! + .getProperty('signal'.toJS) + .getProperty('aborted'.toJS) + .toDart, + isTrue); + expect(context.isCancelled, isFalse); + finish.complete(); + expect((await promise.toDart).dartify(), {'cancelled': false}); + executionController.abort(); + expect(context.isCancelled, isTrue); + }); + } + test('registers, executes, and unregisters through document.modelContext', () async { JSObject? registeredTool; @@ -264,6 +474,35 @@ void main() { @JS('document') external JSObject get _document; +@JS('EventTarget') +extension type _TestEventTarget._(JSObject _) implements JSObject { + external factory _TestEventTarget(); + external void addEventListener(String type, JSFunction listener); + external void removeEventListener(String type, JSFunction listener); + external bool dispatchEvent(_TestEvent event); +} + +@JS('Event') +extension type _TestEvent._(JSObject _) implements JSObject { + external factory _TestEvent(String type); +} + +@JS('AbortController') +extension type _TestAbortController._(JSObject _) implements JSObject { + external factory _TestAbortController(); + external JSObject get signal; + external void abort(); +} + +_TestEventTarget _lifecycleTarget() => _TestEventTarget() + ..setProperty('ontoolactivated'.toJS, null) + ..setProperty('ontoolcancel'.toJS, null); + +void _dispatchToolEvent(_TestEventTarget target, String type, String name) { + final event = _TestEvent(type)..setProperty('toolName'.toJS, name.toJS); + target.dispatchEvent(event); +} + @JS() @anonymous extension type _FakeModelContext._(JSObject _) implements JSObject { diff --git a/test/webmcp_native_browser_test.dart b/test/webmcp_native_browser_test.dart new file mode 100644 index 0000000..bb10368 --- /dev/null +++ b/test/webmcp_native_browser_test.dart @@ -0,0 +1,119 @@ +@TestOn('browser') +@JS() +library; + +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; + +import 'package:flutter_webmcp/webmcp.dart'; +import 'package:test/test.dart'; + +void main() { + final context = _document.getProperty('modelContext'.toJS); + final available = WebMcp.isLifecycleEventsSupported && + context != null && + context.getProperty('getTools'.toJS).isA() && + context.getProperty('executeTool'.toJS).isA(); + + test('native iframe invocation notifies the owner and cancels independently', + () async { + final frame = + _document.callMethod('createElement'.toJS, 'iframe'.toJS); + final body = _document.getProperty('body'.toJS); + body.callMethod('appendChild'.toJS, frame); + addTearDown(() => frame.callMethod('remove'.toJS)); + final childDocument = frame.getProperty('contentDocument'.toJS); + final caller = _NativeModelContext( + childDocument.getProperty('modelContext'.toJS)); + var callerEvents = 0; + final callerListener = ((JSObject event) => callerEvents++).toJS; + caller.addEventListener('toolactivated', callerListener); + caller.addEventListener('toolcancel', callerListener); + addTearDown(() { + caller.removeEventListener('toolactivated', callerListener); + caller.removeEventListener('toolcancel', callerListener); + }); + + const name = 'flutter_webmcp_native_lifecycle'; + final activated = Completer(); + final cancelled = Completer(); + final order = []; + final subscription = WebMcp.lifecycleEvents.listen((event) { + if (event.toolName != name) return; + switch (event.type) { + case WebMcpLifecycleEventType.activated: + activated.complete(); + case WebMcpLifecycleEventType.cancelled: + order.add('cancel_event'); + cancelled.complete(); + } + }); + addTearDown(subscription.cancel); + final finish = Completer(); + addTearDown(() { + if (!finish.isCompleted) finish.complete(); + }); + final started = Completer(); + final registration = await WebMcp.registerTool(WebMcpTool( + name: name, + description: 'Checks native owner events and separate cancellation.', + execute: (input, execution) async { + started.complete(execution); + await finish.future; + return WebMcpResult.text('Finished'); + }, + )); + addTearDown(registration.unregister); + final tools = + (await _NativeModelContext(context!).getTools().toDart).toDart; + final tool = tools.singleWhere( + (tool) => tool.getProperty('name'.toJS).toDart == name); + final executionController = _NativeAbortController(); + addTearDown(() => executionController.abort()); + final result = caller + .executeTool( + tool, + JSObject(), + {'signal': executionController.signal}.jsify() + as JSObject) + .toDart; + final rejection = expectLater(result.catchError((Object error) { + order.add('rejected'); + throw error; + }), + throwsA(predicate( + (Object error) => error.toString().contains('AbortError')))); + + final execution = await started.future; + await activated.future; + expect(callerEvents, 0); + await registration.unregister(); + expect(execution.isCancelled, isFalse); + executionController.abort(); + await rejection; + await cancelled.future; + expect(execution.isCancelled, isTrue); + expect(callerEvents, 0); + expect(order, ['rejected', 'cancel_event']); + finish.complete(); + }, skip: available ? false : 'Native WebMCP lifecycle API is unavailable.'); +} + +@JS('document') +external JSObject get _document; + +extension type _NativeModelContext(JSObject _) implements JSObject { + external JSPromise> getTools(); + external JSPromise executeTool( + JSObject tool, JSObject input, JSObject options); + external void addEventListener(String type, JSFunction listener); + external void removeEventListener(String type, JSFunction listener); +} + +@JS('AbortController') +extension type _NativeAbortController._(JSObject _) implements JSObject { + external factory _NativeAbortController(); + external JSObject get signal; + external void abort(); +} diff --git a/test/webmcp_test.dart b/test/webmcp_test.dart index 19c3ce5..7204707 100644 --- a/test/webmcp_test.dart +++ b/test/webmcp_test.dart @@ -19,6 +19,11 @@ void main() { test('reports unsupported outside a browser', () { expect(WebMcp.isSupported, isFalse); + expect(WebMcp.isLifecycleEventsSupported, isFalse); + }); + + test('lifecycle events are empty outside a browser', () async { + expect(await WebMcp.lifecycleEvents.toList(), isEmpty); }); test('rejects an invalid tool name before platform access', () { From ac6cf0ab8e5424de62d7b1dffb781799dd76f525 Mon Sep 17 00:00:00 2001 From: KickNext Date: Wed, 30 Sep 2026 19:43:08 +0500 Subject: [PATCH 2/4] Require native lifecycle and HTTP policy browser checks --- .github/workflows/ci.yml | 16 +++- .gitignore | 2 + CHANGELOG.md | 3 + CONTRIBUTING.md | 23 +++-- test/fixtures/native_policy_probe.dart | 112 ++++++++++++++++++++++ test/native_browser_config.yaml | 4 - test/webmcp_native_browser_test.dart | 15 ++- tool/check_native_browser.dart | 123 +++++++++++++++++++++++++ 8 files changed, 279 insertions(+), 19 deletions(-) create mode 100644 test/fixtures/native_policy_probe.dart delete mode 100644 test/native_browser_config.yaml create mode 100644 tool/check_native_browser.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b0136d..8035d03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: run: flutter pub get - name: Check formatting - run: dart format --output=none --set-exit-if-changed lib test example/lib + run: dart format --output=none --set-exit-if-changed lib test tool example/lib - name: Analyze package run: flutter analyze @@ -36,10 +36,16 @@ jobs: - name: Run browser tests run: dart test -p chrome test/webmcp_browser_test.dart - - name: Run native WebMCP tests when available - env: - DART_TEST_CONFIG: test/native_browser_config.yaml - run: dart test -p chrome test/webmcp_native_browser_test.dart + - name: Install Chrome with native WebMCP lifecycle events + run: | + curl --fail --location --retry 3 \ + https://storage.googleapis.com/chrome-for-testing-public/157.0.8079.0/linux64/chrome-linux64.zip \ + --output "$RUNNER_TEMP/webmcp-chrome.zip" + unzip -q "$RUNNER_TEMP/webmcp-chrome.zip" -d "$RUNNER_TEMP/webmcp-chrome" + echo "CHROME_EXECUTABLE=$RUNNER_TEMP/webmcp-chrome/chrome-linux64/chrome" >> "$GITHUB_ENV" + + - name: Check native lifecycle and HTTP Permissions Policy + run: dart run tool/check_native_browser.dart - name: Analyze example run: flutter analyze example diff --git a/.gitignore b/.gitignore index e1d947b..e86eb97 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ pubspec.lock *.iml .idea/ .vscode/ +.playwright-cli/ +output/playwright/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d6e72a..139f40f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ Native listeners follow stream subscription lifetimes. - Added browser regressions for annotations, event ownership and subscription cleanup, and independent registration/execution cancellation. +- Verified native activation/cancellation in Chrome for Testing 157.0.8079.0. + CI requires these events and checks real HTTP Permissions Policy enforcement + in documents and same-origin descendants. - Updated guidance for the 29 September 2026 draft: tool replacement, document naming, UI state, sensitive inputs, and deployment headers. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b582db8..c03286d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,11 +18,11 @@ Participation is governed by the project's Run these commands from the package root: ```sh -dart format --output=none --set-exit-if-changed lib test example/lib +dart format --output=none --set-exit-if-changed lib test tool example/lib flutter analyze flutter test dart test -p chrome test/webmcp_browser_test.dart -DART_TEST_CONFIG=test/native_browser_config.yaml dart test -p chrome test/webmcp_native_browser_test.dart +CHROME_EXECUTABLE=/path/to/canary/chrome dart run tool/check_native_browser.dart flutter analyze example (cd example && flutter build web --release) (cd example && flutter build web --wasm --release) @@ -35,14 +35,23 @@ also require `chrome://flags/#enable-webmcp-testing`. `webmcp_browser_test.dart` verifies the Dart bindings with controlled browser fixtures. `webmcp_native_browser_test.dart` additionally checks actual browser activation, cancellation ordering, and iframe ownership when the native API -is present. It explicitly skips on browsers without the lifecycle API; a skip -does not establish native WebMCP conformance. -The separate native browser config enables experimental Chromium features in -the test browser only. In PowerShell, set `$env:DART_TEST_CONFIG` to the config -path for this command, then remove it with `Remove-Item Env:DART_TEST_CONFIG`. +is present. This suite fails when the native lifecycle API is absent. Use a +recent Chrome Canary (CI pins Chrome for Testing 157.0.8079.0), setting +`CHROME_EXECUTABLE` to its executable path. Stable Chrome may expose registration +without the newer lifecycle events. +The native runner enables experimental Chromium features in the test browser +only. In PowerShell, set `$env:CHROME_EXECUTABLE` to the executable path for +this command, then remove it with `Remove-Item Env:CHROME_EXECUTABLE`. Run binding fixtures without this config, so native read-only properties do not interfere with their controlled ModelContext fixtures. +`check_native_browser.dart` also serves the compiled Dart bindings with actual HTTP +headers and verifies both allowed registration and `NotAllowedError` under +`Permissions-Policy: tools=()`, including inherited denial in same-origin +descendants. It uses isolated temporary Chrome profiles and requires native +WebMCP support. Pass `--serve` to inspect the printed fixture URLs in the in-app +browser or another browser manually. + ## Pull requests - Explain the user-visible behavior and compatibility impact. diff --git a/test/fixtures/native_policy_probe.dart b/test/fixtures/native_policy_probe.dart new file mode 100644 index 0000000..032a7c4 --- /dev/null +++ b/test/fixtures/native_policy_probe.dart @@ -0,0 +1,112 @@ +@JS() +library; + +import 'dart:convert'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; + +import 'package:flutter_webmcp/webmcp.dart'; + +// Served by tool/check_native_browser.dart with real HTTP response headers. +Future main() async { + final path = _location.getProperty('pathname'.toJS).toDart; + final blocked = path == '/blocked'; + final checks = {'path': path}; + try { + if (!WebMcp.isSupported) throw StateError('Native WebMCP is unavailable'); + // API presence is distinct from permission to register. + checks['apiPresent'] = true; + checks['parentAllowed'] = await _registrationAllowed(); + + final frame = + _document.callMethod('createElement'.toJS, 'iframe'.toJS); + _document + .getProperty('body'.toJS) + .callMethod('appendChild'.toJS, frame); + try { + final child = frame.getProperty('contentDocument'.toJS); + checks['descendantAllowed'] = await _childRegistrationAllowed(child); + } finally { + frame.callMethod('remove'.toJS); + } + checks['passed'] = checks['parentAllowed'] == !blocked && + checks['descendantAllowed'] == !blocked; + } catch (error) { + checks['passed'] = false; + checks['error'] = error.toString(); + } + final json = jsonEncode(checks); + _document + .callMethod('getElementById'.toJS, 'result'.toJS) + .setProperty('textContent'.toJS, json.toJS); + await _fetch( + '/result', + { + 'method': 'POST', + 'body': json, + }.jsify() as JSObject) + .toDart; +} + +Future _registrationAllowed() async { + try { + final registration = await WebMcp.registerTool(WebMcpTool( + name: 'native_policy_parent', + description: 'Tests HTTP Permissions Policy enforcement.', + execute: (_, __) => WebMcpResult.text('OK'), + )); + await registration.unregister(); + return true; + } on WebMcpException catch (error) { + final cause = error.cause as JSAny?; + if (cause != null && + cause.isA() && + (cause as JSObject).getProperty('name'.toJS).toDart == + 'NotAllowedError') { + return false; + } + rethrow; + } +} + +Future _childRegistrationAllowed(JSObject document) async { + final context = document.getProperty('modelContext'.toJS); + final controller = _AbortController(); + final tool = { + 'name': 'native_policy_child', + 'description': 'Tests inherited HTTP Permissions Policy.', + 'inputSchema': {'type': 'object'}, + }.jsify() as JSObject; + tool.setProperty('execute'.toJS, (() => 'OK'.toJS).toJS); + try { + await context + .callMethod>('registerTool'.toJS, tool, + {'signal': controller.signal}.jsify()) + .toDart; + return true; + } catch (error) { + final cause = error as JSAny?; + if (cause != null && + cause.isA() && + (cause as JSObject).getProperty('name'.toJS).toDart == + 'NotAllowedError') { + return false; + } + rethrow; + } finally { + controller.abort(); + } +} + +@JS('document') +external JSObject get _document; +@JS('location') +external JSObject get _location; +@JS('fetch') +external JSPromise _fetch(String url, JSObject options); +@JS('AbortController') +extension type _AbortController._(JSObject _) implements JSObject { + external factory _AbortController(); + external JSObject get signal; + external void abort(); +} diff --git a/test/native_browser_config.yaml b/test/native_browser_config.yaml deleted file mode 100644 index dbb4044..0000000 --- a/test/native_browser_config.yaml +++ /dev/null @@ -1,4 +0,0 @@ -override_platforms: - chrome: - settings: - arguments: --enable-blink-features=WebMCP,WebMCPTesting diff --git a/test/webmcp_native_browser_test.dart b/test/webmcp_native_browser_test.dart index bb10368..ec7a5cc 100644 --- a/test/webmcp_native_browser_test.dart +++ b/test/webmcp_native_browser_test.dart @@ -16,6 +16,13 @@ void main() { context.getProperty('getTools'.toJS).isA() && context.getProperty('executeTool'.toJS).isA(); + test('native lifecycle API is available', () { + expect(available, isTrue, + reason: 'Use a recent Chrome Canary with WebMCP testing enabled. ' + '${_navigator.getProperty("userAgent".toJS).toDart}'); + }); + if (!available) return; + test('native iframe invocation notifies the owner and cancels independently', () async { final frame = @@ -65,8 +72,7 @@ void main() { }, )); addTearDown(registration.unregister); - final tools = - (await _NativeModelContext(context!).getTools().toDart).toDart; + final tools = (await _NativeModelContext(context).getTools().toDart).toDart; final tool = tools.singleWhere( (tool) => tool.getProperty('name'.toJS).toDart == name); final executionController = _NativeAbortController(); @@ -97,12 +103,15 @@ void main() { expect(callerEvents, 0); expect(order, ['rejected', 'cancel_event']); finish.complete(); - }, skip: available ? false : 'Native WebMCP lifecycle API is unavailable.'); + }); } @JS('document') external JSObject get _document; +@JS('navigator') +external JSObject get _navigator; + extension type _NativeModelContext(JSObject _) implements JSObject { external JSPromise> getTools(); external JSPromise executeTool( diff --git a/tool/check_native_browser.dart b/tool/check_native_browser.dart new file mode 100644 index 0000000..ecc93e5 --- /dev/null +++ b/tool/check_native_browser.dart @@ -0,0 +1,123 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +// No browser automation dependency: Chrome reports results over loopback HTTP. +Future main(List arguments) async { + final executable = Platform.environment['CHROME_EXECUTABLE'] ?? + (Platform.isWindows + ? r'C:\Program Files\Google\Chrome\Application\chrome.exe' + : 'google-chrome'); + if (!arguments.contains('--serve')) { + // package:test's settings merge drops CHROME_EXECUTABLE when adding flags. + // Write the selected executable explicitly to the isolated native config. + final config = File('.dart_tool/native_browser.yaml'); + await config.writeAsString('override_platforms:\n' + ' chrome:\n' + ' settings:\n' + ' executable: ${jsonEncode(executable)}\n' + ' arguments: --enable-features=WebMCPTesting ' + '--enable-blink-features=WebMCP,WebMCPTesting\n'); + final tests = await Process.start(Platform.resolvedExecutable, [ + 'test', + '-p', + 'chrome', + 'test/webmcp_native_browser_test.dart', + ], environment: { + 'DART_TEST_CONFIG': config.absolute.path + }); + await Future.wait( + [stdout.addStream(tests.stdout), stderr.addStream(tests.stderr)]); + final testExit = await tests.exitCode; + if (testExit != 0) { + exitCode = testExit; + return; + } + } + final compile = await Process.run(Platform.resolvedExecutable, [ + 'compile', + 'js', + 'test/fixtures/native_policy_probe.dart', + '-o', + 'build/native_policy/probe.js', + ]); + if (compile.exitCode != 0) { + stderr.write(compile.stderr); + stderr.write(compile.stdout); + exitCode = compile.exitCode; + return; + } + final script = await File('build/native_policy/probe.js').readAsBytes(); + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final origin = 'http://127.0.0.1:${server.port}'; + final results = >>{ + for (final path in ['/allowed', '/blocked']) path: Completer(), + }; + server.listen((request) async { + request.response.headers.set('Origin-Agent-Cluster', '?1'); + final path = request.uri.path; + if (path == '/result' && request.method == 'POST') { + final report = jsonDecode(await utf8.decoder.bind(request).join()) + as Map; + final result = results[report['path']]; + if (result != null && !result.isCompleted) result.complete(report); + } else if (path == '/probe.js') { + request.response.headers.contentType = ContentType('text', 'javascript'); + request.response.add(script); + } else if (results.containsKey(path)) { + if (path == '/blocked') { + request.response.headers.set('Permissions-Policy', 'tools=()'); + } + request.response.headers.contentType = ContentType.html; + request.response + .write('Native WebMCP policy' + '
Checking $path...
' + ''); + } else { + request.response.statusCode = HttpStatus.notFound; + } + await request.response.close(); + }); + stdout.writeln('Native policy fixtures: $origin/allowed and $origin/blocked'); + if (arguments.contains('--serve')) return; // Keep server alive for UI checks. + + try { + for (final entry in results.entries) { + final profile = await Directory.systemTemp.createTemp('webmcp_policy_'); + Process? browser; + try { + browser = await Process.start(executable, [ + '--headless', + '--no-first-run', + '--no-default-browser-check', + '--user-data-dir=${profile.path}', + '--enable-features=WebMCPTesting', + '--enable-blink-features=WebMCP,WebMCPTesting', + '$origin${entry.key}', + ]); + browser.stdout.drain(); + browser.stderr.drain(); + final report = + await entry.value.future.timeout(const Duration(seconds: 45)); + stdout.writeln(jsonEncode(report)); + if (report['passed'] != true) exitCode = 1; + } finally { + if (browser != null) { + browser.kill(); + await browser.exitCode; + } + // Chrome may take a moment to release profile files on Windows. + try { + await profile.delete(recursive: true); + } on FileSystemException { + stderr.writeln('Temporary Chrome profile remains: ${profile.path}'); + } + } + } + } catch (error) { + stderr.writeln(error); + exitCode = 1; + } finally { + await server.close(force: true); + } +} From c2eccc0c3b21d4eda2783c0912a8a7398ccbea32 Mon Sep 17 00:00:00 2001 From: KickNext Date: Wed, 30 Sep 2026 19:45:51 +0500 Subject: [PATCH 3/4] Use installed Chrome sandbox helper on Ubuntu CI --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8035d03..3810174 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,10 @@ jobs: --output "$RUNNER_TEMP/webmcp-chrome.zip" unzip -q "$RUNNER_TEMP/webmcp-chrome.zip" -d "$RUNNER_TEMP/webmcp-chrome" echo "CHROME_EXECUTABLE=$RUNNER_TEMP/webmcp-chrome/chrome-linux64/chrome" >> "$GITHUB_ENV" + # Ubuntu restricts user namespaces for downloaded Chrome binaries. + # Use the installed Chrome sandbox helper as Chromium recommends. + test -x /opt/google/chrome/chrome-sandbox + echo "CHROME_DEVEL_SANDBOX=/opt/google/chrome/chrome-sandbox" >> "$GITHUB_ENV" - name: Check native lifecycle and HTTP Permissions Policy run: dart run tool/check_native_browser.dart From fc558ec994b718968fcadd0a2fc16390f228c583 Mon Sep 17 00:00:00 2001 From: KickNext Date: Wed, 30 Sep 2026 19:47:46 +0500 Subject: [PATCH 4/4] Configure CI sandbox helper ownership and mode --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3810174..34e934a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,8 @@ jobs: # Ubuntu restricts user namespaces for downloaded Chrome binaries. # Use the installed Chrome sandbox helper as Chromium recommends. test -x /opt/google/chrome/chrome-sandbox + sudo chown root:root /opt/google/chrome/chrome-sandbox + sudo chmod 4755 /opt/google/chrome/chrome-sandbox echo "CHROME_DEVEL_SANDBOX=/opt/google/chrome/chrome-sandbox" >> "$GITHUB_ENV" - name: Check native lifecycle and HTTP Permissions Policy