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;