Skip to content
Open
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
19 changes: 18 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,6 +36,23 @@ jobs:
- name: Run browser tests
run: dart test -p chrome test/webmcp_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"
# 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
run: dart run tool/check_native_browser.dart

- name: Analyze example
run: flutter analyze example

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ pubspec.lock
*.iml
.idea/
.vscode/
.playwright-cli/
output/playwright/
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
## 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.
- 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.

## 0.3.0 - 2026-09-04

- Added cancellable registration attempts so Flutter scopes can abort stale
Expand Down
23 changes: 22 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +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
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)
Expand All @@ -31,6 +32,26 @@ 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. 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.
Expand Down
118 changes: 107 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -35,7 +37,7 @@ instead of the draft's `Promise<void>`. Both forms are supported.

```yaml
dependencies:
flutter_webmcp: ^0.3.0
flutter_webmcp: ^0.3.1
```

For a local checkout:
Expand All @@ -54,12 +56,13 @@ mounted:
```dart
class TasksPageState extends State<TasksPage> {
late final WebMcpTool addTaskTool;
final tasks = <String>[];

@override
void initState() {
super.initState();
addTaskTool = WebMcpTypedTool<AddTaskInput>(
name: 'add_task',
name: 'tasks_add_task',
title: 'Add task',
description: 'Adds a task to the list currently open in the app.',
inputSchema: const {
Expand All @@ -70,11 +73,16 @@ class TasksPageState extends State<TasksPage> {
'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.',
);
},
);
Expand All @@ -84,7 +92,7 @@ class TasksPageState extends State<TasksPage> {
Widget build(BuildContext context) {
return WebMcpToolScope(
tools: [addTaskTool],
child: const TasksView(),
child: TasksView(tasks: tasks),
);
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
21 changes: 19 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion example/lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_webmcp/flutter_webmcp.dart';

Expand Down Expand Up @@ -31,14 +33,25 @@ class _TaskDemoPageState extends State<TaskDemoPage> {
final _tasks = <String>['Open this page in a WebMCP browser'];
late final WebMcpTool _addTaskTool;
Object? _registrationError;
StreamSubscription<WebMcpLifecycleEvent>? _lifecycleSubscription;

@override
void initState() {
super.initState();
if (WebMcp.isLifecycleEventsSupported) {
_lifecycleSubscription = WebMcp.lifecycleEvents.listen((event) {
debugPrint('${event.toolName}: ${event.type.name}');
});
}
_addTaskTool = WebMcpTypedTool<AddTaskInput>(
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': {
Expand All @@ -57,7 +70,13 @@ class _TaskDemoPageState extends State<TaskDemoPage> {
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.');
},
);
Expand All @@ -80,6 +99,8 @@ class _TaskDemoPageState extends State<TaskDemoPage> {

@override
void dispose() {
final subscription = _lifecycleSubscription;
if (subscription != null) unawaited(subscription.cancel());
_controller.dispose();
super.dispose();
}
Expand Down
3 changes: 3 additions & 0 deletions lib/src/flutter/webmcp_tool_scope.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading