Skip to content

Commit 30a7143

Browse files
kblokclaude
andcommitted
New Feature: Add WebMcpTool.ExecuteAsync() method (#14851)
Implements upstream PR #14851 which adds direct tool execution support to WebMcpTool. Includes the full WebMCP API infrastructure (from #14814) plus the execute capability. WebMcpTool.ExecuteAsync(input) invokes the tool via CDP and returns a Task<WebMcpToolCallResult> that completes when the toolresponded event fires with a matching invocation ID. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4c17dc4 commit 30a7143

12 files changed

Lines changed: 946 additions & 1 deletion

File tree

lib/PuppeteerSharp.Nunit/TestExpectations/TestExpectations.upstream.json

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4573,5 +4573,67 @@
45734573
"FAIL"
45744574
],
45754575
"comment": "TODO: investigate why we get the response for the last redirect"
4576+
},
4577+
{
4578+
"testIdPattern": "[webmcp.spec] *",
4579+
"platforms": [
4580+
"darwin",
4581+
"linux",
4582+
"win32"
4583+
],
4584+
"parameters": [
4585+
"firefox"
4586+
],
4587+
"expectations": [
4588+
"FAIL"
4589+
],
4590+
"comment": "Experimental support for WebMCP is not supported in Firefox"
4591+
},
4592+
{
4593+
"testIdPattern": "[webmcp.spec] *",
4594+
"platforms": [
4595+
"darwin",
4596+
"linux",
4597+
"win32"
4598+
],
4599+
"parameters": [
4600+
"webDriverBiDi"
4601+
],
4602+
"expectations": [
4603+
"FAIL"
4604+
],
4605+
"comment": "Experimental support for WebMCP is not supported in BiDi"
4606+
},
4607+
{
4608+
"testIdPattern": "[webmcp.spec] *",
4609+
"platforms": [
4610+
"darwin",
4611+
"linux",
4612+
"win32"
4613+
],
4614+
"parameters": [
4615+
"cdp",
4616+
"chrome"
4617+
],
4618+
"expectations": [
4619+
"FAIL",
4620+
"TIMEOUT"
4621+
],
4622+
"comment": "Experimental support for WebMCP requires Chrome 149+."
4623+
},
4624+
{
4625+
"testIdPattern": "[webmcp.spec] Page.webmcp should remove tools on frame navigation",
4626+
"platforms": [
4627+
"darwin",
4628+
"linux",
4629+
"win32"
4630+
],
4631+
"parameters": [
4632+
"cdp",
4633+
"chrome"
4634+
],
4635+
"expectations": [
4636+
"PASS"
4637+
]
45764638
}
45774639
]
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
using System;
2+
using System.Threading.Tasks;
3+
using NUnit.Framework;
4+
using PuppeteerSharp.Cdp;
5+
using PuppeteerSharp.Nunit;
6+
7+
namespace PuppeteerSharp.Tests.WebMcpTests
8+
{
9+
public class PageWebMcpTests : PuppeteerBaseTest
10+
{
11+
private static LaunchOptions WebMcpOptions() => new()
12+
{
13+
Args = new[] { "--enable-features=WebMCPTesting,DevToolsWebMCPSupport" },
14+
AcceptInsecureCerts = true,
15+
};
16+
17+
[Test, PuppeteerTest("webmcp.spec", "Page.webmcp", "should list tools")]
18+
public async Task ShouldListTools()
19+
{
20+
await using var browser = await Puppeteer.LaunchAsync(WebMcpOptions(), TestConstants.LoggerFactory);
21+
var page = (CdpPage)await browser.NewPageAsync();
22+
await page.GoToAsync(TestConstants.HttpsPrefix + "/empty.html");
23+
24+
Assert.That(page.WebMcp, Is.Not.Null);
25+
26+
var toolsAdded = new TaskCompletionSource<bool>();
27+
var count = 0;
28+
page.WebMcp.ToolsAdded += (_, _) =>
29+
{
30+
count++;
31+
if (count == 2)
32+
{
33+
toolsAdded.TrySetResult(true);
34+
}
35+
};
36+
37+
await page.EvaluateFunctionAsync(@"() => {
38+
window.navigator.modelContext.registerTool({
39+
name: 'test-tool-1',
40+
description: 'A test tool 1',
41+
inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
42+
execute: () => {},
43+
annotations: { readOnlyHint: true },
44+
});
45+
}");
46+
47+
await page.EvaluateFunctionAsync(@"() => {
48+
const form = document.createElement('form');
49+
form.setAttribute('toolname', 'declarative tool name');
50+
form.setAttribute('tooldescription', 'tool description');
51+
document.body.appendChild(form);
52+
}");
53+
54+
await toolsAdded.Task.WaitAsync(System.TimeSpan.FromSeconds(5));
55+
56+
var tools = page.WebMcp.Tools();
57+
Assert.That(tools.Length, Is.GreaterThanOrEqualTo(2));
58+
}
59+
60+
[Test, PuppeteerTest("webmcp.spec", "Page.webmcp", "should fire toolsadded events")]
61+
public async Task ShouldFireToolsAddedEvents()
62+
{
63+
await using var browser = await Puppeteer.LaunchAsync(WebMcpOptions(), TestConstants.LoggerFactory);
64+
var page = (CdpPage)await browser.NewPageAsync();
65+
await page.GoToAsync(TestConstants.HttpsPrefix + "/empty.html");
66+
67+
Assert.That(page.WebMcp, Is.Not.Null);
68+
69+
var tcs = new TaskCompletionSource<WebMcpTool[]>();
70+
page.WebMcp.ToolsAdded += (_, e) => tcs.TrySetResult(e.Tools);
71+
72+
await page.EvaluateFunctionAsync(@"() => {
73+
window.navigator.modelContext.registerTool({
74+
name: 'my-tool',
75+
description: 'A tool',
76+
execute: () => {},
77+
});
78+
}");
79+
80+
var tools = await tcs.Task.WaitAsync(System.TimeSpan.FromSeconds(5));
81+
Assert.That(tools, Has.Length.GreaterThanOrEqualTo(1));
82+
}
83+
84+
[Test, PuppeteerTest("webmcp.spec", "Page.webmcp", "should fire toolsremoved events")]
85+
public async Task ShouldFireToolsRemovedEvents()
86+
{
87+
await using var browser = await Puppeteer.LaunchAsync(WebMcpOptions(), TestConstants.LoggerFactory);
88+
var page = (CdpPage)await browser.NewPageAsync();
89+
await page.GoToAsync(TestConstants.HttpsPrefix + "/empty.html");
90+
91+
Assert.That(page.WebMcp, Is.Not.Null);
92+
93+
var addedTcs = new TaskCompletionSource<bool>();
94+
page.WebMcp.ToolsAdded += (_, _) => addedTcs.TrySetResult(true);
95+
96+
await page.EvaluateFunctionAsync(@"() => {
97+
window._tool = window.navigator.modelContext.registerTool({
98+
name: 'removable-tool',
99+
description: 'A removable tool',
100+
execute: () => {},
101+
});
102+
}");
103+
await addedTcs.Task.WaitAsync(System.TimeSpan.FromSeconds(5));
104+
105+
var removedTcs = new TaskCompletionSource<WebMcpTool[]>();
106+
page.WebMcp.ToolsRemoved += (_, e) => removedTcs.TrySetResult(e.Tools);
107+
108+
await page.EvaluateFunctionAsync("() => window._tool.unregister()");
109+
110+
var removed = await removedTcs.Task.WaitAsync(System.TimeSpan.FromSeconds(5));
111+
Assert.That(removed, Has.Length.GreaterThanOrEqualTo(1));
112+
}
113+
114+
[Test, PuppeteerTest("webmcp.spec", "Page.webmcp", "should invoke tool")]
115+
public async Task ShouldInvokeTool()
116+
{
117+
await using var browser = await Puppeteer.LaunchAsync(WebMcpOptions(), TestConstants.LoggerFactory);
118+
var page = (CdpPage)await browser.NewPageAsync();
119+
await page.GoToAsync(TestConstants.HttpsPrefix + "/empty.html");
120+
121+
Assert.That(page.WebMcp, Is.Not.Null);
122+
123+
var toolAddedTcs = new TaskCompletionSource<bool>();
124+
page.WebMcp.ToolsAdded += (_, _) => toolAddedTcs.TrySetResult(true);
125+
126+
await page.EvaluateFunctionAsync(@"() => {
127+
window.navigator.modelContext.registerTool({
128+
name: 'test-tool-1',
129+
description: 'A test tool 1',
130+
inputSchema: {
131+
type: 'object',
132+
properties: { text: { type: 'string', description: 'Some text' } },
133+
required: ['text'],
134+
},
135+
execute: (params) => {
136+
return `hello ${params.text}`;
137+
},
138+
});
139+
}");
140+
141+
await toolAddedTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
142+
143+
var tools = page.WebMcp.Tools();
144+
var tool = tools[0];
145+
146+
var toolCalledTcs = new TaskCompletionSource<WebMcpToolCall>();
147+
page.WebMcp.ToolInvoked += (_, call) => toolCalledTcs.TrySetResult(call);
148+
149+
var response = await tool.ExecuteAsync(new { text = "world" });
150+
var call = await toolCalledTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
151+
152+
Assert.That(response.Id, Is.EqualTo(call.Id));
153+
Assert.That(response.Call, Is.SameAs(call));
154+
Assert.That(response.Status, Is.EqualTo(WebMcpInvocationStatus.Completed));
155+
Assert.That(response.Output?.ToString(), Contains.Substring("hello world"));
156+
Assert.That(response.ErrorText, Is.Null);
157+
Assert.That(response.Exception, Is.Null);
158+
}
159+
160+
[Test, PuppeteerTest("webmcp.spec", "Page.webmcp", "should remove tools on frame navigation")]
161+
public async Task ShouldRemoveToolsOnFrameNavigation()
162+
{
163+
await using var browser = await Puppeteer.LaunchAsync(WebMcpOptions(), TestConstants.LoggerFactory);
164+
var page = (CdpPage)await browser.NewPageAsync();
165+
await page.GoToAsync(TestConstants.HttpsPrefix + "/empty.html");
166+
167+
var addedTcs = new TaskCompletionSource<bool>();
168+
page.WebMcp.ToolsAdded += (_, _) => addedTcs.TrySetResult(true);
169+
170+
await page.EvaluateFunctionAsync(@"() => {
171+
window.navigator.modelContext.registerTool({
172+
name: 'nav-tool',
173+
description: 'A tool',
174+
execute: () => {},
175+
});
176+
}");
177+
await addedTcs.Task.WaitAsync(System.TimeSpan.FromSeconds(5));
178+
179+
var removedTcs = new TaskCompletionSource<bool>();
180+
page.WebMcp.ToolsRemoved += (_, _) => removedTcs.TrySetResult(true);
181+
182+
await page.GoToAsync(TestConstants.HttpsPrefix + "/empty.html");
183+
await removedTcs.Task.WaitAsync(System.TimeSpan.FromSeconds(5));
184+
185+
Assert.That(page.WebMcp.Tools(), Is.Empty);
186+
}
187+
}
188+
}

lib/PuppeteerSharp/Cdp/CdpPage.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public class CdpPage : Page
4646
{
4747
private readonly ConcurrentDictionary<string, CdpWebWorker> _workers = new();
4848
private readonly ITargetManager _targetManager;
49+
private readonly CdpWebMcp _webMcp;
4950
private readonly CdpEmulationManager _emulationManager;
5051
private readonly ILogger _logger;
5152
private readonly Task _closedFinishedTask;
@@ -74,6 +75,7 @@ private CdpPage(
7475
_emulationManager = new CdpEmulationManager(client);
7576
_logger = Client.Connection.LoggerFactory.CreateLogger<Page>();
7677
FrameManager = new FrameManager(client, this, TimeoutSettings, target.Browser.IsNetworkEnabled());
78+
_webMcp = new CdpWebMcp(client, FrameManager);
7779
Accessibility = new Accessibility(client, () => MainFrame?.Id, () => (FrameManager.MainFrame as Frame)?.MainRealm);
7880

7981
// Use browser context's connection, as current Bluetooth emulation in Chromium is
@@ -134,6 +136,9 @@ private CdpPage(
134136
/// <inheritdoc/>
135137
public override bool IsJavaScriptEnabled => _emulationManager.JavascriptEnabled;
136138

139+
/// <inheritdoc/>
140+
public override CdpWebMcp WebMcp => _webMcp;
141+
137142
/// <inheritdoc />
138143
protected override Browser Browser => PrimaryTarget.Browser;
139144

@@ -1173,6 +1178,7 @@ private async Task OnActivationAsync(CdpCDPSession newSession)
11731178
_emulationManager.UpdateClient(Client);
11741179
Tracing.UpdateClient(Client);
11751180
Coverage.UpdateClient(Client);
1181+
_webMcp.UpdateClient(Client);
11761182
await FrameManager.SwapFrameTreeAsync(Client).ConfigureAwait(false);
11771183
SetupPrimaryTargetListeners();
11781184
}
@@ -1433,7 +1439,8 @@ private async Task InitializeAsync()
14331439

14341440
await Task.WhenAll(
14351441
PrimaryTargetClient.SendAsync("Performance.enable"),
1436-
PrimaryTargetClient.SendAsync("Log.enable")).ConfigureAwait(false);
1442+
PrimaryTargetClient.SendAsync("Log.enable"),
1443+
_webMcp.InitializeAsync()).ConfigureAwait(false);
14371444
}
14381445

14391446
private async Task<IResponse> GoAsync(int delta, NavigationOptions options)

0 commit comments

Comments
 (0)