diff --git a/src/PlaywrightNative.Tests/Library/Webmcp/WebmcpTests.cs b/src/PlaywrightNative.Tests/Library/Webmcp/WebmcpTests.cs
new file mode 100644
index 00000000..0cb97078
--- /dev/null
+++ b/src/PlaywrightNative.Tests/Library/Webmcp/WebmcpTests.cs
@@ -0,0 +1,442 @@
+/*
+ * Copyright (c) Microsoft Corporation.
+ * Modifications copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using NUnit.Framework;
+using PlaywrightNative.NUnit;
+
+namespace PlaywrightNative.Tests
+{
+ ///
+ /// Official library/webmcp.spec.ts parity. The browser is launched
+ /// with WebMCP enabled (--enable-features=WebMCP, or the
+ /// dom.modelcontext.* preferences on Firefox).
+ ///
+ [TestFixture]
+ [NonParallelizable]
+ public class WebmcpTests : PlaywrightTestEx
+ {
+ private const string AddTool = @"
+ modelContext.registerTool({
+ name: 'add',
+ description: 'Adds two numbers',
+ inputSchema: { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } }, required: ['a', 'b'] },
+ annotations: { readOnlyHint: true },
+ async execute(input) { return { content: [{ type: 'text', text: String(input.a + input.b) }] }; },
+ });
+";
+
+ private const string AddSchema = "{ \"type\": \"object\", \"properties\": { \"a\": { \"type\": \"number\" }, \"b\": { \"type\": \"number\" } }, \"required\": [\"a\", \"b\"] }";
+
+ private const string IframeSkipReason = "Firefox does not support registering WebMCP tools in iframes yet, https://bugzilla.mozilla.org/show_bug.cgi?id=2019743";
+
+ // Keyed by test id: a timed-out test can still run its TearDown later,
+ // and it must close its own context, not the one of the next test.
+ private readonly ConcurrentDictionary _contexts = new();
+
+ private IBrowser _browser;
+
+ private IPage Page { get; set; }
+
+ [OneTimeSetUp]
+ public async Task LaunchWebMCPBrowserAsync()
+ {
+ if (TestConstants.IsWebKit)
+ {
+ return;
+ }
+
+ BrowserTypeLaunchOptions options = TestConstants.IsFirefox
+ ? new BrowserTypeLaunchOptions
+ {
+ FirefoxUserPrefs = new Dictionary
+ {
+ ["dom.modelcontext.enabled"] = true,
+ ["dom.modelcontext.testing.enabled"] = true,
+ },
+ }
+ : new BrowserTypeLaunchOptions { Args = ["--enable-features=WebMCP"] };
+ _browser = await BrowserLauncher.LaunchAsync(options).ConfigureAwait(false);
+ }
+
+ [OneTimeTearDown]
+ public async Task CloseWebMCPBrowserAsync()
+ {
+ if (_browser != null)
+ {
+ await _browser.CloseAsync().ConfigureAwait(false);
+ }
+ }
+
+ [SetUp]
+ public async Task NewPageAsync()
+ {
+ if (TestConstants.IsWebKit)
+ {
+ Assert.Ignore("WebKit does not implement WebMCP");
+ }
+
+ if (Server == null)
+ {
+ Assert.Ignore("Test server is unavailable.");
+ }
+
+ IBrowserContext context = await _browser.NewContextAsync().ConfigureAwait(false);
+ _contexts[TestContext.CurrentContext.Test.ID] = context;
+ Page = await context.NewPageAsync().ConfigureAwait(false);
+ }
+
+ [TearDown]
+ public async Task CloseContextAsync()
+ {
+ if (_contexts.TryRemove(TestContext.CurrentContext.Test.ID, out IBrowserContext context))
+ {
+ await context.CloseAsync().ConfigureAwait(false);
+ }
+ }
+
+ [PlaywrightTest("webmcp.spec.ts", "should list tools registered by the page")]
+ [Test]
+ [Timeout(TestConstants.DefaultTestTimeout)]
+ public async Task ShouldListToolsRegisteredByThePage()
+ {
+ Serve("/", RegisterScript(AddTool + @"
+ modelContext.registerTool({
+ name: 'noop',
+ description: 'Does nothing',
+ async execute() {},
+ });
+ "));
+ await Page.GoToAsync(TestConstants.ServerUrl + "/").ConfigureAwait(false);
+
+ // Browsers list tools in their own order.
+ List tools = (await Page.Webmcp().ToolsAsync().ConfigureAwait(false)).OrderBy(tool => tool.Name, StringComparer.Ordinal).ToList();
+ Assert.That(tools, Has.Count.EqualTo(2));
+ Assert.That(tools[0].Name, Is.EqualTo("add"));
+ Assert.That(tools[0].Description, Is.EqualTo("Adds two numbers"));
+ AssertJson(tools[0].InputSchema, AddSchema);
+ Assert.That(tools[0].Annotations, Is.Not.Null);
+ Assert.That(tools[0].Annotations.ReadOnly, Is.True);
+ Assert.That(tools[0].Annotations.UntrustedContent, Is.Null);
+ Assert.That(tools[0].Annotations.Consequential, Is.Null);
+ AssertNameAndDescriptionOnly(tools[1], "noop", "Does nothing");
+ }
+
+ [PlaywrightTest("webmcp.spec.ts", "should report no tools when the page registers none")]
+ [Test]
+ [Timeout(TestConstants.DefaultTestTimeout)]
+ public async Task ShouldReportNoToolsWhenThePageRegistersNone()
+ {
+ await Page.GoToAsync(TestConstants.EmptyPage).ConfigureAwait(false);
+ Assert.That(await Page.Webmcp().ToolsAsync().ConfigureAwait(false), Is.Empty);
+ }
+
+ [PlaywrightTest("webmcp.spec.ts", "should call a tool")]
+ [Test]
+ [Timeout(TestConstants.DefaultTestTimeout)]
+ public async Task ShouldCallATool()
+ {
+ Serve("/", RegisterScript(AddTool));
+ await Page.GoToAsync(TestConstants.ServerUrl + "/").ConfigureAwait(false);
+
+ AssertJson(
+ await Page.Webmcp().CallToolAsync("add", new { a = 2, b = 40 }).ConfigureAwait(false),
+ "{ \"content\": [{ \"type\": \"text\", \"text\": \"42\" }] }");
+ }
+
+ [PlaywrightTest("webmcp.spec.ts", "should return isError results as is")]
+ [Test]
+ [Timeout(TestConstants.DefaultTestTimeout)]
+ public async Task ShouldReturnIsErrorResultsAsIs()
+ {
+ Serve("/", RegisterScript(@"
+ modelContext.registerTool({
+ name: 'broken',
+ description: 'Always fails',
+ async execute() { return { content: [{ type: 'text', text: 'nope' }], isError: true }; },
+ });
+ "));
+ await Page.GoToAsync(TestConstants.ServerUrl + "/").ConfigureAwait(false);
+
+ AssertJson(
+ await Page.Webmcp().CallToolAsync("broken").ConfigureAwait(false),
+ "{ \"content\": [{ \"type\": \"text\", \"text\": \"nope\" }], \"isError\": true }");
+ }
+
+ [PlaywrightTest("webmcp.spec.ts", "should throw when the tool throws")]
+ [Test]
+ [Timeout(TestConstants.DefaultTestTimeout)]
+ public async Task ShouldThrowWhenTheToolThrows()
+ {
+ Serve("/", RegisterScript(@"
+ modelContext.registerTool({
+ name: 'throwing',
+ description: 'Throws',
+ async execute() { throw new Error('boom'); },
+ });
+ "));
+ await Page.GoToAsync(TestConstants.ServerUrl + "/").ConfigureAwait(false);
+
+ Exception error = Assert.CatchAsync(() => Page.Webmcp().CallToolAsync("throwing"));
+
+ // Chromium does not report the error that the tool threw.
+ Assert.That(error.Message, Does.Contain(TestConstants.IsChromium ? "the invocation failed" : "boom"));
+ }
+
+ [PlaywrightTest("webmcp.spec.ts", "should throw for an unknown tool")]
+ [Test]
+ [Timeout(TestConstants.DefaultTestTimeout)]
+ public async Task ShouldThrowForAnUnknownTool()
+ {
+ Serve("/", RegisterScript(AddTool));
+ await Page.GoToAsync(TestConstants.ServerUrl + "/").ConfigureAwait(false);
+
+ Exception error = Assert.CatchAsync(() => Page.Webmcp().CallToolAsync("missing"));
+ Assert.That(error.Message, Does.Contain("No WebMCP tool named \"missing\""));
+ Assert.That(error.Message, Does.Contain("Available tools: add"));
+ }
+
+ [PlaywrightTest("webmcp.spec.ts", "should time out when the tool does not settle")]
+ [Test]
+ [Timeout(TestConstants.DefaultTestTimeout)]
+ public async Task ShouldTimeOutWhenTheToolDoesNotSettle()
+ {
+ Serve("/", RegisterScript(@"
+ modelContext.registerTool({
+ name: 'stuck',
+ description: 'Never resolves',
+ execute() { return new Promise(() => {}); },
+ });
+ "));
+ await Page.GoToAsync(TestConstants.ServerUrl + "/").ConfigureAwait(false);
+
+ Exception error = Assert.CatchAsync(() => Page.Webmcp().CallToolAsync("stuck", new { }, timeout: 500));
+ Assert.That(error.Message, Does.Contain("Timeout 500ms exceeded"));
+ }
+
+ [PlaywrightTest("webmcp.spec.ts", "should drop tools after navigation")]
+ [Test]
+ [Timeout(TestConstants.DefaultTestTimeout)]
+ public async Task ShouldDropToolsAfterNavigation()
+ {
+ Serve("/", RegisterScript(AddTool));
+ await Page.GoToAsync(TestConstants.ServerUrl + "/").ConfigureAwait(false);
+ Assert.That(await Page.Webmcp().ToolsAsync().ConfigureAwait(false), Has.Count.EqualTo(1));
+
+ await Page.GoToAsync(TestConstants.EmptyPage).ConfigureAwait(false);
+ Assert.That(await Page.Webmcp().ToolsAsync().ConfigureAwait(false), Is.Empty);
+ Exception error = Assert.CatchAsync(() => Page.Webmcp().CallToolAsync("add", new { a = 1, b = 1 }));
+ Assert.That(error.Message, Does.Contain("The frame does not register any WebMCP tools"));
+ }
+
+ [PlaywrightTest("webmcp.spec.ts", "should list a tool registered after load")]
+ [Test]
+ [Timeout(TestConstants.DefaultTestTimeout)]
+ public async Task ShouldListAToolRegisteredAfterLoad()
+ {
+ Serve("/", RegisterScript(AddTool));
+ await Page.GoToAsync(TestConstants.ServerUrl + "/").ConfigureAwait(false);
+ Assert.That(await Page.Webmcp().ToolsAsync().ConfigureAwait(false), Has.Count.EqualTo(1));
+
+ await Page.EvaluateAsync(@"() => {
+ const modelContext = document.modelContext || navigator.modelContext;
+ modelContext.registerTool({ name: 'late', description: 'Registered later', async execute() {} });
+ }").ConfigureAwait(false);
+ IEnumerable names = (await Page.Webmcp().ToolsAsync().ConfigureAwait(false)).Select(tool => tool.Name).Order(StringComparer.Ordinal);
+ Assert.That(names, Is.EqualTo(new[] { "add", "late" }));
+ }
+
+ [PlaywrightTest("webmcp.spec.ts", "should list tools while a tool is running")]
+ [Test]
+ [Timeout(TestConstants.DefaultTestTimeout)]
+ public async Task ShouldListToolsWhileAToolIsRunning()
+ {
+ Serve("/", RegisterScript(AddTool + @"
+ modelContext.registerTool({
+ name: 'gated',
+ description: 'Resolves when released',
+ execute() { return new Promise(resolve => window.release = () => resolve({ content: [{ type: 'text', text: 'released' }] })); },
+ });
+ "));
+ await Page.GoToAsync(TestConstants.ServerUrl + "/").ConfigureAwait(false);
+
+ Task result = Page.Webmcp().CallToolAsync("gated");
+ await Page.WaitForFunctionAsync("() => !!window.release").ConfigureAwait(false);
+ Assert.That(await Page.Webmcp().ToolsAsync().ConfigureAwait(false), Has.Count.EqualTo(2));
+ await Page.EvaluateAsync("() => window.release()").ConfigureAwait(false);
+ AssertJson(await result.ConfigureAwait(false), "{ \"content\": [{ \"type\": \"text\", \"text\": \"released\" }] }");
+ }
+
+ [PlaywrightTest("webmcp.spec.ts", "should list declarative tools")]
+ [Test]
+ [Timeout(TestConstants.DefaultTestTimeout)]
+ public async Task ShouldListDeclarativeTools()
+ {
+ if (!TestConstants.IsChromium)
+ {
+ Assert.Ignore("Only Chromium implements declarative tools");
+ }
+
+ Serve("/", @"
+
+ ");
+ await Page.GoToAsync(TestConstants.ServerUrl + "/").ConfigureAwait(false);
+
+ IReadOnlyList tools = await Page.Webmcp().ToolsAsync().ConfigureAwait(false);
+ Assert.That(tools, Has.Count.EqualTo(1));
+ Assert.That(tools[0].Name, Is.EqualTo("subscribe"));
+ Assert.That(tools[0].Description, Is.EqualTo("Subscribes to the newsletter"));
+ Assert.That(tools[0].InputSchema, Is.Not.Null);
+ Assert.That(tools[0].InputSchema.Value.GetProperty("type").GetString(), Is.EqualTo("object"));
+ AssertJson(tools[0].InputSchema.Value.GetProperty("properties"), "{ \"email\": { \"type\": \"string\" } }");
+
+ // A declarative tool waits for the user to submit the form.
+ Exception error = Assert.CatchAsync(() => Page.Webmcp().CallToolAsync("subscribe", new { email = "me@example.com" }, timeout: 500));
+ Assert.That(error.Message, Does.Contain("Timeout 500ms exceeded"));
+ }
+
+ [PlaywrightTest("webmcp.spec.ts", "should scope tools to their frame")]
+ [Test]
+ [Timeout(TestConstants.DefaultTestTimeout)]
+ public async Task ShouldScopeToolsToTheirFrame()
+ {
+ if (TestConstants.IsFirefox)
+ {
+ Assert.Ignore(IframeSkipReason);
+ }
+
+ Serve("/", RegisterScript(AddTool) + "");
+ Serve("/frame.html", RegisterScript(@"
+ modelContext.registerTool({
+ name: 'subscribe',
+ description: 'Subscribes to the newsletter',
+ async execute() { return { content: [{ type: 'text', text: 'subscribed' }] }; },
+ });
+ "));
+ await Page.GoToAsync(TestConstants.ServerUrl + "/").ConfigureAwait(false);
+
+ IFrame childFrame = Page.Frames[1];
+ Assert.That((await Page.Webmcp().ToolsAsync().ConfigureAwait(false)).Select(tool => tool.Name), Is.EqualTo(new[] { "add" }));
+ Assert.That((await childFrame.Webmcp().ToolsAsync().ConfigureAwait(false)).Select(tool => tool.Name), Is.EqualTo(new[] { "subscribe" }));
+ AssertJson(
+ await childFrame.Webmcp().CallToolAsync("subscribe").ConfigureAwait(false),
+ "{ \"content\": [{ \"type\": \"text\", \"text\": \"subscribed\" }] }");
+
+ Exception error = Assert.CatchAsync(() => Page.Webmcp().CallToolAsync("subscribe"));
+ Assert.That(error.Message, Does.Contain("No WebMCP tool named \"subscribe\""));
+ Assert.That(error.Message, Does.Contain("Available tools: add"));
+ }
+
+ [PlaywrightTest("webmcp.spec.ts", "should call same-name tools in their own frame")]
+ [Test]
+ [Timeout(TestConstants.DefaultTestTimeout)]
+ public async Task ShouldCallSameNameToolsInTheirOwnFrame()
+ {
+ if (TestConstants.IsFirefox)
+ {
+ Assert.Ignore(IframeSkipReason);
+ }
+
+ static string RegisterEcho(string text) => RegisterScript($@"
+ modelContext.registerTool({{
+ name: 'echo',
+ description: 'Echoes',
+ async execute() {{ return {{ content: [{{ type: 'text', text: '{text}' }}] }}; }},
+ }});
+ ");
+ Serve("/", RegisterEcho("main") + "");
+ Serve("/frame.html", RegisterEcho("frame"));
+ await Page.GoToAsync(TestConstants.ServerUrl + "/").ConfigureAwait(false);
+ IFrame childFrame = Page.Frames[1];
+
+ AssertJson(await Page.Webmcp().CallToolAsync("echo").ConfigureAwait(false), "{ \"content\": [{ \"type\": \"text\", \"text\": \"main\" }] }");
+ AssertJson(await childFrame.Webmcp().CallToolAsync("echo").ConfigureAwait(false), "{ \"content\": [{ \"type\": \"text\", \"text\": \"frame\" }] }");
+ }
+
+ [PlaywrightTest("webmcp.spec.ts", "should list and call tools in cross-origin and nested frames")]
+ [Test]
+ [Timeout(TestConstants.DefaultTestTimeout)]
+ public async Task ShouldListAndCallToolsInCrossOriginAndNestedFrames()
+ {
+ if (TestConstants.IsFirefox)
+ {
+ Assert.Ignore(IframeSkipReason);
+ }
+
+ static string RegisterEcho(string text) => RegisterScript($@"
+ modelContext.registerTool({{
+ name: 'echo-{text}',
+ description: 'Echoes {text}',
+ async execute() {{ return {{ content: [{{ type: 'text', text: '{text}' }}] }}; }},
+ }});
+ ");
+ Serve("/", RegisterEcho("main") + $"");
+ Serve("/frame.html", RegisterEcho("frame") + "");
+ Serve("/nested.html", RegisterEcho("nested"));
+ await Page.GoToAsync(TestConstants.ServerUrl + "/").ConfigureAwait(false);
+
+ IFrame mainFrame = Page.Frames[0];
+ IFrame frame = Page.Frames[1];
+ IFrame nested = Page.Frames[2];
+ AssertNameAndDescriptionOnly((await mainFrame.Webmcp().ToolsAsync().ConfigureAwait(false)).Single(), "echo-main", "Echoes main");
+ AssertNameAndDescriptionOnly((await frame.Webmcp().ToolsAsync().ConfigureAwait(false)).Single(), "echo-frame", "Echoes frame");
+ AssertNameAndDescriptionOnly((await nested.Webmcp().ToolsAsync().ConfigureAwait(false)).Single(), "echo-nested", "Echoes nested");
+ AssertJson(await frame.Webmcp().CallToolAsync("echo-frame").ConfigureAwait(false), "{ \"content\": [{ \"type\": \"text\", \"text\": \"frame\" }] }");
+ AssertJson(await nested.Webmcp().CallToolAsync("echo-nested").ConfigureAwait(false), "{ \"content\": [{ \"type\": \"text\", \"text\": \"nested\" }] }");
+ }
+
+ private static string RegisterScript(string tools)
+ => $@"";
+
+ private static void AssertJson(JsonElement? actual, string expected)
+ {
+ Assert.That(actual, Is.Not.Null);
+ Assert.That(
+ JsonNode.DeepEquals(JsonNode.Parse(actual.Value.GetRawText()), JsonNode.Parse(expected)),
+ Is.True,
+ $"Expected {expected} but was {actual.Value.GetRawText()}");
+ }
+
+ private static void AssertNameAndDescriptionOnly(WebMCPTool tool, string name, string description)
+ {
+ Assert.That(tool.Name, Is.EqualTo(name));
+ Assert.That(tool.Description, Is.EqualTo(description));
+ Assert.That(tool.InputSchema, Is.Null);
+ Assert.That(tool.Annotations, Is.Null);
+ }
+
+ private void Serve(string path, string body)
+ => Server.SetRoute(path, http =>
+ {
+ http.Response.StatusCode = 200;
+ http.Response.ContentType = "text/html";
+ return http.Response.WriteAsync("WebMCP" + body);
+ });
+ }
+}
diff --git a/src/PlaywrightNative.Tests/Library/WebmcpDisabled/WebmcpDisabledTests.cs b/src/PlaywrightNative.Tests/Library/WebmcpDisabled/WebmcpDisabledTests.cs
new file mode 100644
index 00000000..4df1a705
--- /dev/null
+++ b/src/PlaywrightNative.Tests/Library/WebmcpDisabled/WebmcpDisabledTests.cs
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) Microsoft Corporation.
+ * Modifications copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+using System;
+using NUnit.Framework;
+using PlaywrightNative.NUnit;
+
+namespace PlaywrightNative.Tests
+{
+ ///
+ /// Official library/webmcp-disabled.spec.ts parity. Uses the shared
+ /// browser, which is launched without WebMCP.
+ ///
+ [TestFixture]
+ [NonParallelizable]
+ public class WebmcpDisabledTests : PageTestEx
+ {
+ [SetUp]
+ public void SkipWebKit()
+ {
+ if (TestConstants.IsWebKit)
+ {
+ Assert.Ignore("WebKit does not implement WebMCP");
+ }
+ }
+
+ [PlaywrightTest("webmcp-disabled.spec.ts", "should throw when the browser was launched without WebMCP")]
+ [Test]
+ [Timeout(TestConstants.DefaultTestTimeout)]
+ public void ShouldThrowWhenTheBrowserWasLaunchedWithoutWebMCP()
+ {
+ Exception error = Assert.CatchAsync(() => Page.Webmcp().ToolsAsync());
+ Assert.That(error.Message, Does.Contain("WebMCP is not enabled."));
+ Assert.That(error.Message, Does.Contain(TestConstants.IsFirefox ? "dom.modelcontext.enabled" : "--enable-features=WebMCP"));
+ }
+ }
+}
diff --git a/src/PlaywrightNative/Chromium/ChromiumBrowser.cs b/src/PlaywrightNative/Chromium/ChromiumBrowser.cs
index 5983f25e..2663bacd 100644
--- a/src/PlaywrightNative/Chromium/ChromiumBrowser.cs
+++ b/src/PlaywrightNative/Chromium/ChromiumBrowser.cs
@@ -110,6 +110,12 @@ public IReadOnlyList Contexts
///
internal Proxy LaunchProxy { get; set; }
+ ///
+ /// Gets the args the browser was launched with, or
+ /// when Playwright did not launch it (for example connectOverCDP).
+ ///
+ internal IReadOnlyList LaunchArgs { get; init; }
+
///
public async Task CloseAsync(string reason = default)
{
diff --git a/src/PlaywrightNative/Chromium/ChromiumFrame.cs b/src/PlaywrightNative/Chromium/ChromiumFrame.cs
index e633b0fd..0b2098c6 100644
--- a/src/PlaywrightNative/Chromium/ChromiumFrame.cs
+++ b/src/PlaywrightNative/Chromium/ChromiumFrame.cs
@@ -483,6 +483,22 @@ public Task WaitForFunctionAsync(string expression, object arg = defa
public Task WaitForTimeoutAsync(float timeout)
=> Task.Delay((int)timeout);
+ ///
+ /// Calls in the frame utility world and
+ /// returns its result by value.
+ ///
+ /// The type to deserialize the result to.
+ /// The JavaScript function to call.
+ /// Arguments passed by value.
+ /// The deserialized result.
+ internal async Task EvaluateInUtilityWorldAsync(string functionDeclaration, params object[] args)
+ {
+ EvaluateWithArg.ThrowIfDetached(this);
+ CRExecutionContext context = await ((Page)_page).CrPage.GetUtilityWorldAsync(_crFrame).ConfigureAwait(false)
+ ?? throw new PlaywrightException("Frame utility world is not available.");
+ return await context.EvaluateFunctionAsync(functionDeclaration, args).ConfigureAwait(false);
+ }
+
private async Task EvaluatePreparedInFrameAsync(string handleFn, object[] handleArgs)
{
object[] args = EvaluateHandleArg.AsCallFunctionArguments(handleArgs);
diff --git a/src/PlaywrightNative/Contracts/IWebMCP.cs b/src/PlaywrightNative/Contracts/IWebMCP.cs
new file mode 100644
index 00000000..5fbbedea
--- /dev/null
+++ b/src/PlaywrightNative/Contracts/IWebMCP.cs
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) Microsoft Corporation.
+ * Modifications copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Threading.Tasks;
+
+namespace PlaywrightNative
+{
+ ///
+ /// Lists and calls the WebMCP tools a frame registers through
+ /// document.modelContext.registerTool. Chromium needs the
+ /// --enable-features=WebMCP launch argument. WebKit does not
+ /// implement WebMCP.
+ ///
+ public interface IWebMCP
+ {
+ ///
+ /// Returns the tools currently registered by the frame. Throws if the
+ /// browser was launched without WebMCP support. The page object covers
+ /// the main frame only; child frames list their tools through their own
+ /// frame.Webmcp().
+ ///
+ /// Maximum time in milliseconds. Defaults to the page default timeout. Pass 0 to disable it.
+ /// The registered tools.
+ Task> ToolsAsync(float? timeout = default);
+
+ ///
+ /// Calls a tool registered by the frame and returns its result. The
+ /// result is whatever the tool's execute function resolved to,
+ /// typically an object with a content array. A result with
+ /// isError: true is returned as is. Throws when the tool is not
+ /// registered or its execute function throws.
+ ///
+ /// Name of the tool, as reported by .
+ /// Input for the tool, matching its inputSchema. Defaults to an empty object.
+ /// Maximum time in milliseconds. Defaults to the page default timeout. Pass 0 to disable it.
+ /// The tool result, or when the tool returned nothing.
+ Task CallToolAsync(string name, object input = default, float? timeout = default);
+ }
+}
diff --git a/src/PlaywrightNative/Contracts/Models/WebMCPTool.cs b/src/PlaywrightNative/Contracts/Models/WebMCPTool.cs
new file mode 100644
index 00000000..46023f15
--- /dev/null
+++ b/src/PlaywrightNative/Contracts/Models/WebMCPTool.cs
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) Microsoft Corporation.
+ * Modifications copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace PlaywrightNative
+{
+ ///
+ /// A WebMCP tool registered by a frame.
+ ///
+ public sealed class WebMCPTool
+ {
+ ///
+ /// Tool name, unique within the frame.
+ ///
+ [JsonPropertyName("name")]
+ public string Name { get; set; }
+
+ ///
+ /// Tool description.
+ ///
+ [JsonPropertyName("description")]
+ public string Description { get; set; }
+
+ ///
+ /// JSON Schema of the tool input, when the page provides one.
+ ///
+ [JsonPropertyName("inputSchema")]
+ public JsonElement? InputSchema { get; set; }
+
+ ///
+ /// Hints the page provides about the tool, or when it provides none.
+ ///
+ [JsonPropertyName("annotations")]
+ public WebMCPToolAnnotations Annotations { get; set; }
+ }
+}
diff --git a/src/PlaywrightNative/Contracts/Models/WebMCPToolAnnotations.cs b/src/PlaywrightNative/Contracts/Models/WebMCPToolAnnotations.cs
new file mode 100644
index 00000000..8b190951
--- /dev/null
+++ b/src/PlaywrightNative/Contracts/Models/WebMCPToolAnnotations.cs
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) Microsoft Corporation.
+ * Modifications copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+using System.Text.Json.Serialization;
+
+namespace PlaywrightNative
+{
+ ///
+ /// Hints a page provides about a . Only hints the
+ /// page set to are reported.
+ ///
+ public sealed class WebMCPToolAnnotations
+ {
+ ///
+ /// The tool does not modify any state.
+ ///
+ [JsonPropertyName("readOnly")]
+ public bool? ReadOnly { get; set; }
+
+ ///
+ /// The tool output may contain third-party content.
+ ///
+ [JsonPropertyName("untrustedContent")]
+ public bool? UntrustedContent { get; set; }
+
+ ///
+ /// The tool takes a consequential action, such as placing an order.
+ ///
+ [JsonPropertyName("consequential")]
+ public bool? Consequential { get; set; }
+ }
+}
diff --git a/src/PlaywrightNative/FrameCompatExtensions.cs b/src/PlaywrightNative/FrameCompatExtensions.cs
index 99a01a53..86ac1ffb 100644
--- a/src/PlaywrightNative/FrameCompatExtensions.cs
+++ b/src/PlaywrightNative/FrameCompatExtensions.cs
@@ -606,6 +606,12 @@ public static Task WaitForNavigationAsync(
WaitUntil = options?.WaitUntil,
});
+ /// WebMCP tools registered by this frame.
+ /// The frame.
+ /// The frame WebMCP API.
+ public static IWebMCP Webmcp(this IFrame frame)
+ => new WebMCP(frame);
+
/// Any-frame search from this frame.
public static IFrameLocator FrameLocator(this IFrame frame)
=> new FrameLocator(frame);
diff --git a/src/PlaywrightNative/Helpers/WebMCP.cs b/src/PlaywrightNative/Helpers/WebMCP.cs
new file mode 100644
index 00000000..b69e6e5e
--- /dev/null
+++ b/src/PlaywrightNative/Helpers/WebMCP.cs
@@ -0,0 +1,214 @@
+/*
+ * Copyright (c) Microsoft Corporation.
+ * Modifications copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.Playwright;
+using PlaywrightNative.Chromium;
+using PlaywrightNative.WebKit;
+
+namespace PlaywrightNative.Helpers
+{
+ ///
+ /// Official server/webmcp.ts: lists and calls the WebMCP tools of
+ /// one frame by evaluating the injected webMCP.ts script in the
+ /// frame utility world.
+ ///
+ internal sealed class WebMCP : IWebMCP
+ {
+ /// Official injected packages/injected/src/webMCP.ts.
+ private const string ScriptSource = @"(() => {
+ class WebMCPScript {
+ constructor(global) {
+ this._global = global;
+ // Chromium exposes the entry point on `document`, Firefox on `navigator`.
+ this._modelContext = global.document?.modelContext ?? global.navigator?.modelContext;
+ }
+
+ async tools() {
+ return (await this._ownTools()).map(tool => this._describe(tool));
+ }
+
+ async callTool(name, input) {
+ const modelContext = this._modelContext;
+ if (!modelContext)
+ throw new Error('WebMCP is not available on this page');
+ if (modelContext.invokeTool)
+ return await modelContext.invokeTool(name, input);
+ const tool = (await this._ownTools()).find(tool => tool.name === name);
+ if (!tool || !modelContext.executeTool)
+ throw new Error(`WebMCP tool ""${name}"" is not registered in this frame`);
+ return this._parseResult(await this._executeTool(modelContext, tool, input));
+ }
+
+ async _executeTool(modelContext, tool, input) {
+ try {
+ return await modelContext.executeTool(tool, input);
+ } catch (e) {
+ // Chromium before 155 takes the input as a JSON string. Chromium 151 (the
+ // build this port ships) reports it as 'Failed to parse input string as JSON'.
+ const message = String(e?.message);
+ if (!message.includes('Failed to parse input arguments') && !message.includes('Failed to parse input string as JSON'))
+ throw e;
+ return await modelContext.executeTool(tool, JSON.stringify(input));
+ }
+ }
+
+ _parseResult(result) {
+ // Chromium hands the result back as a string.
+ if (typeof result !== 'string')
+ return result;
+ if (result === 'undefined')
+ return undefined;
+ try {
+ return JSON.parse(result);
+ } catch {
+ return result;
+ }
+ }
+
+ async _ownTools() {
+ const tools = await this._modelContext?.getTools?.() ?? [];
+ // Chromium's getTools() aggregates same-origin descendant frames, Firefox's does not.
+ return tools.filter(tool => !('window' in tool) || tool.window === this._global.window);
+ }
+
+ _describe(tool) {
+ const annotations = tool.annotations;
+ return {
+ name: tool.name,
+ description: tool.description ?? '',
+ inputSchema: this._parseInputSchema(tool.inputSchema),
+ annotations: annotations ? {
+ readOnly: annotations.readOnlyHint,
+ untrustedContent: annotations.untrustedContentHint,
+ consequential: annotations.consequentialHint,
+ } : undefined,
+ };
+ }
+
+ _parseInputSchema(inputSchema) {
+ // Chromium before 155 hands the schema back as a JSON string.
+ if (typeof inputSchema !== 'string')
+ return inputSchema;
+ try {
+ return JSON.parse(inputSchema);
+ } catch {
+ return undefined;
+ }
+ }
+ }
+ return new WebMCPScript(globalThis);
+})()";
+
+ private readonly IFrame _frame;
+
+ internal WebMCP(IFrame frame)
+ {
+ _frame = frame ?? throw new ArgumentNullException(nameof(frame));
+ }
+
+ ///
+ public Task> ToolsAsync(float? timeout = default)
+ => WithTimeoutAsync(ListToolsAsync(), timeout, "webMCP.tools");
+
+ ///
+ public Task CallToolAsync(string name, object input = default, float? timeout = default)
+ => WithTimeoutAsync(InvokeToolAsync(name, input), timeout, "webMCP.callTool");
+
+ private static WebMCPTool Normalize(WebMCPTool tool)
+ {
+ WebMCPToolAnnotations annotations = new()
+ {
+ ReadOnly = tool.Annotations?.ReadOnly == true ? true : null,
+ UntrustedContent = tool.Annotations?.UntrustedContent == true ? true : null,
+ Consequential = tool.Annotations?.Consequential == true ? true : null,
+ };
+ bool hasAnnotations = annotations.ReadOnly.HasValue || annotations.UntrustedContent.HasValue || annotations.Consequential.HasValue;
+ return new WebMCPTool
+ {
+ Name = tool.Name,
+ Description = tool.Description,
+ InputSchema = tool.InputSchema,
+ Annotations = hasAnnotations ? annotations : null,
+ };
+ }
+
+ private static IEnumerable EnabledFeatures(IEnumerable args)
+ => args
+ .Where(arg => arg.StartsWith("--enable-features=", StringComparison.Ordinal))
+ .SelectMany(arg => arg["--enable-features=".Length..].Split(','));
+
+ private async Task> ListToolsAsync()
+ {
+ ChromiumFrame frame = AssertBrowserSupport();
+ List tools = await frame
+ .EvaluateInUtilityWorldAsync>("() => " + ScriptSource + ".tools()")
+ .ConfigureAwait(false);
+ return (tools ?? []).Select(Normalize).ToList();
+ }
+
+ private async Task InvokeToolAsync(string name, object input)
+ {
+ IReadOnlyList tools = await ListToolsAsync().ConfigureAwait(false);
+ if (!tools.Any(tool => tool.Name == name))
+ {
+ throw new PlaywrightException($"No WebMCP tool named \"{name}\"." +
+ (tools.Count > 0
+ ? " Available tools: " + string.Join(", ", tools.Select(tool => tool.Name)) + "."
+ : " The frame does not register any WebMCP tools."));
+ }
+
+ return await ((ChromiumFrame)_frame)
+ .EvaluateInUtilityWorldAsync(
+ "params => " + ScriptSource + ".callTool(params.name, params.input)",
+ new { name, input = input ?? new Dictionary() })
+ .ConfigureAwait(false);
+ }
+
+ private ChromiumFrame AssertBrowserSupport()
+ {
+ switch (_frame)
+ {
+ case WebKitFrame:
+ throw new PlaywrightException("WebMCP is not supported in WebKit.");
+ case ChromiumFrame chromium:
+ // Only a browser launched by Playwright knows its launch options.
+ if (chromium.Page.Context.Browser is ChromiumBrowser { LaunchArgs: { } args }
+ && !EnabledFeatures(args).Contains("WebMCP"))
+ {
+ throw new PlaywrightException("WebMCP is not enabled. Launch the browser with the \"--enable-features=WebMCP\" argument.");
+ }
+
+ return chromium;
+ default:
+ throw new NotSupportedException("WebMCP is not supported in this browser.");
+ }
+ }
+
+ private Task WithTimeoutAsync(Task task, float? timeout, string apiName)
+ {
+ int timeoutMs = TimeoutSettings.TimeoutMs(timeout ?? _frame.Page.DefaultTimeout());
+ return task.WithTimeout(
+ timeoutMs,
+ _ => new TimeoutException(apiName + ": Timeout " + timeoutMs.ToString(CultureInfo.InvariantCulture) + "ms exceeded."));
+ }
+ }
+}
diff --git a/src/PlaywrightNative/PageCompatExtensions.cs b/src/PlaywrightNative/PageCompatExtensions.cs
index c6afb9d9..6aab45a0 100644
--- a/src/PlaywrightNative/PageCompatExtensions.cs
+++ b/src/PlaywrightNative/PageCompatExtensions.cs
@@ -834,6 +834,12 @@ public static ICoverage Coverage(this IPage page)
? extras.Coverage
: throw new NotSupportedException("This page does not expose PlaywrightNative coverage.");
+ /// WebMCP tools registered by the main frame. Child frames use frame.Webmcp().
+ /// The page.
+ /// The main frame WebMCP API.
+ public static IWebMCP Webmcp(this IPage page)
+ => page.MainFrame.Webmcp();
+
/// Legacy frame lookup by URL pattern.
public static IFrame FrameByUrl(this IPage page, string urlString, Regex urlRegex, Func urlFunc)
=> PageCompatDispatch.FrameByUrl(page, urlString, urlRegex, urlFunc);
diff --git a/src/PlaywrightNative/Playwright.cs b/src/PlaywrightNative/Playwright.cs
index 4ff0e5ba..90f8e393 100644
--- a/src/PlaywrightNative/Playwright.cs
+++ b/src/PlaywrightNative/Playwright.cs
@@ -115,6 +115,7 @@ public static async Task LaunchChromiumAsync(BrowserTypeLaunchOptions
PlaywrightNative.Chromium.ChromiumBrowser chromium = new PlaywrightNative.Chromium.ChromiumBrowser(crBrowser, ResolveDownloadsPath(options), options.Logger)
{
LaunchProxy = options.Proxy,
+ LaunchArgs = ToArgArray(options.Args) ?? [],
};
((PlaywrightNative.Helpers.IHasTracesDir)chromium).TracesDir = options.TracesDir;
((PlaywrightNative.Helpers.IHasArtifactsDir)chromium).ArtifactsDir = options.ArtifactsDir;
@@ -235,6 +236,7 @@ internal static async Task LaunchChromiumPersistentContextAsync
PlaywrightNative.Chromium.ChromiumBrowser instance = new(crBrowser, ResolveDownloadsPath(options))
{
LaunchProxy = options.Proxy,
+ LaunchArgs = ToArgArray(options.Args) ?? [],
};
((PlaywrightNative.Helpers.IHasTracesDir)instance).TracesDir = options.TracesDir;
((PlaywrightNative.Helpers.IHasArtifactsDir)instance).ArtifactsDir = options.ArtifactsDir;