From 2eec7d7fa6b675dc199f992123dec00e0d4e96a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dar=C3=ADo=20Kondratiuk?= Date: Mon, 5 Oct 2026 20:37:19 -0300 Subject: [PATCH 1/3] Port WebMCP tests and add page.Webmcp()/frame.Webmcp() Port library/webmcp.spec.ts and webmcp-disabled.spec.ts. Add IWebMCP with ToolsAsync and CallToolAsync, backed by the upstream injected webMCP script evaluated in the frame utility world. Chromium checks the launch args for --enable-features=WebMCP; WebKit reports that it does not support WebMCP. Co-Authored-By: Claude Opus 5.5 --- .../LibraryWebmcpDisabledParityTests.cs | 50 ++ .../LibraryWebmcpParityTests.cs | 438 ++++++++++++++++++ .../Chromium/ChromiumBrowser.cs | 6 + .../Chromium/ChromiumFrame.cs | 16 + src/PlaywrightNative/Contracts/IWebMCP.cs | 54 +++ .../Contracts/Models/WebMCPTool.cs | 51 ++ .../Contracts/Models/WebMCPToolAnnotations.cs | 45 ++ src/PlaywrightNative/FrameCompatExtensions.cs | 6 + src/PlaywrightNative/Helpers/WebMCP.cs | 214 +++++++++ src/PlaywrightNative/PageCompatExtensions.cs | 6 + src/PlaywrightNative/Playwright.cs | 2 + 11 files changed, 888 insertions(+) create mode 100644 src/PlaywrightNative.Tests/LibraryWebmcpDisabledParityTests.cs create mode 100644 src/PlaywrightNative.Tests/LibraryWebmcpParityTests.cs create mode 100644 src/PlaywrightNative/Contracts/IWebMCP.cs create mode 100644 src/PlaywrightNative/Contracts/Models/WebMCPTool.cs create mode 100644 src/PlaywrightNative/Contracts/Models/WebMCPToolAnnotations.cs create mode 100644 src/PlaywrightNative/Helpers/WebMCP.cs diff --git a/src/PlaywrightNative.Tests/LibraryWebmcpDisabledParityTests.cs b/src/PlaywrightNative.Tests/LibraryWebmcpDisabledParityTests.cs new file mode 100644 index 00000000..39acd16a --- /dev/null +++ b/src/PlaywrightNative.Tests/LibraryWebmcpDisabledParityTests.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 LibraryWebmcpDisabledParityTests : 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.Tests/LibraryWebmcpParityTests.cs b/src/PlaywrightNative.Tests/LibraryWebmcpParityTests.cs new file mode 100644 index 00000000..3f384945 --- /dev/null +++ b/src/PlaywrightNative.Tests/LibraryWebmcpParityTests.cs @@ -0,0 +1,438 @@ +/* + * 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.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 LibraryWebmcpParityTests : 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"; + + private IBrowser _browser; + private IBrowserContext _context; + + 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."); + } + + _context = await _browser.NewContextAsync().ConfigureAwait(false); + Page = await _context.NewPageAsync().ConfigureAwait(false); + } + + [TearDown] + public async Task CloseContextAsync() + { + if (_context != null) + { + await _context.CloseAsync().ConfigureAwait(false); + _context = null; + } + } + + [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/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; From ce7c0eefcca90c40e554b87bba75adf1ad1bdc49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dar=C3=ADo=20Kondratiuk?= Date: Tue, 6 Oct 2026 03:00:22 -0300 Subject: [PATCH 2/3] Close each WebMCP test's own context in teardown A timed-out test still runs its TearDown later. With one shared context field it closed the next test's context, so one hang failed two tests. Key the contexts by test id. Co-Authored-By: Claude Opus 5.5 --- .../LibraryWebmcpParityTests.cs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/PlaywrightNative.Tests/LibraryWebmcpParityTests.cs b/src/PlaywrightNative.Tests/LibraryWebmcpParityTests.cs index 3f384945..d1abba73 100644 --- a/src/PlaywrightNative.Tests/LibraryWebmcpParityTests.cs +++ b/src/PlaywrightNative.Tests/LibraryWebmcpParityTests.cs @@ -15,6 +15,7 @@ * limitations under the License. */ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Text.Json; @@ -49,8 +50,11 @@ public class LibraryWebmcpParityTests : PlaywrightTestEx 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 IBrowserContext _context; private IPage Page { get; set; } @@ -97,17 +101,17 @@ public async Task NewPageAsync() Assert.Ignore("Test server is unavailable."); } - _context = await _browser.NewContextAsync().ConfigureAwait(false); - Page = await _context.NewPageAsync().ConfigureAwait(false); + 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 (_context != null) + if (_contexts.TryRemove(TestContext.CurrentContext.Test.ID, out IBrowserContext context)) { - await _context.CloseAsync().ConfigureAwait(false); - _context = null; + await context.CloseAsync().ConfigureAwait(false); } } From 289e17b722a283be5aa5fd567f63a9e160da9dea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dar=C3=ADo=20Kondratiuk?= Date: Tue, 6 Oct 2026 17:46:22 -0300 Subject: [PATCH 3/3] Move WebMCP tests to Library/Webmcp and Library/WebmcpDisabled Follow the per-spec folder layout: WebmcpTests and WebmcpDisabledTests. Co-Authored-By: Claude Opus 5.5 --- .../Webmcp/WebmcpTests.cs} | 2 +- .../WebmcpDisabled/WebmcpDisabledTests.cs} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/PlaywrightNative.Tests/{LibraryWebmcpParityTests.cs => Library/Webmcp/WebmcpTests.cs} (97%) rename src/PlaywrightNative.Tests/{LibraryWebmcpDisabledParityTests.cs => Library/WebmcpDisabled/WebmcpDisabledTests.cs} (93%) diff --git a/src/PlaywrightNative.Tests/LibraryWebmcpParityTests.cs b/src/PlaywrightNative.Tests/Library/Webmcp/WebmcpTests.cs similarity index 97% rename from src/PlaywrightNative.Tests/LibraryWebmcpParityTests.cs rename to src/PlaywrightNative.Tests/Library/Webmcp/WebmcpTests.cs index d1abba73..0cb97078 100644 --- a/src/PlaywrightNative.Tests/LibraryWebmcpParityTests.cs +++ b/src/PlaywrightNative.Tests/Library/Webmcp/WebmcpTests.cs @@ -34,7 +34,7 @@ namespace PlaywrightNative.Tests /// [TestFixture] [NonParallelizable] - public class LibraryWebmcpParityTests : PlaywrightTestEx + public class WebmcpTests : PlaywrightTestEx { private const string AddTool = @" modelContext.registerTool({ diff --git a/src/PlaywrightNative.Tests/LibraryWebmcpDisabledParityTests.cs b/src/PlaywrightNative.Tests/Library/WebmcpDisabled/WebmcpDisabledTests.cs similarity index 93% rename from src/PlaywrightNative.Tests/LibraryWebmcpDisabledParityTests.cs rename to src/PlaywrightNative.Tests/Library/WebmcpDisabled/WebmcpDisabledTests.cs index 39acd16a..4df1a705 100644 --- a/src/PlaywrightNative.Tests/LibraryWebmcpDisabledParityTests.cs +++ b/src/PlaywrightNative.Tests/Library/WebmcpDisabled/WebmcpDisabledTests.cs @@ -26,7 +26,7 @@ namespace PlaywrightNative.Tests /// [TestFixture] [NonParallelizable] - public class LibraryWebmcpDisabledParityTests : PageTestEx + public class WebmcpDisabledTests : PageTestEx { [SetUp] public void SkipWebKit()