playwright_rs/protocol/webmcp.rs
1// Copyright 2026 Paul Adamson
2// Licensed under the Apache License, Version 2.0
3//
4// WebMCP: the tools a page registers through the browser's experimental
5// model-context API, listed and called the way an agent would.
6//
7// See: https://playwright.dev/docs/api/class-webmcp
8
9use crate::error::{Error, Result};
10use crate::protocol::Frame;
11use crate::protocol::evaluate_conversion::{parse_result, serialize_argument};
12use crate::server::channel_owner::ChannelOwner;
13use serde::Deserialize;
14use serde::de::DeserializeOwned;
15use serde_json::{Value, json};
16
17/// The tools a page registers through the experimental
18/// [WebMCP](https://github.com/webmachinelearning/webmcp) browser API, so a
19/// test can list and call them as an agent would.
20///
21/// Reached through [`Page::webmcp`](crate::protocol::Page::webmcp) (the main
22/// frame's tools) or [`Frame::webmcp`].
23///
24/// The API is behind a flag in every browser that has it. Launch Chromium
25/// with `--enable-features=WebMCP` in `args`, or Firefox with the
26/// `dom.modelcontext.enabled` and `dom.modelcontext.testing.enabled`
27/// preferences set in `firefox_user_prefs`. Without them, [`tools`](Self::tools)
28/// fails with `WebMCP is not enabled`, followed by how to enable it; WebKit
29/// does not support it at all and fails with `WebMCP is not supported in
30/// WebKit`.
31///
32/// # Example
33///
34/// ```no_run
35/// use playwright_rs::LaunchOptions;
36/// use playwright_rs::protocol::Playwright;
37///
38/// # #[tokio::main]
39/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
40/// let playwright = Playwright::launch().await?;
41/// let browser = playwright
42/// .chromium()
43/// .launch_with_options(LaunchOptions::new().args(vec!["--enable-features=WebMCP".into()]))
44/// .await?;
45/// let page = browser.new_page().await?;
46/// page.goto("https://example.com/app", None).await?;
47///
48/// for tool in page.webmcp().tools(None).await? {
49/// println!("{}: {}", tool.name, tool.description);
50/// }
51/// let sum: serde_json::Value = page
52/// .webmcp()
53/// .call_tool("add", &serde_json::json!({ "a": 2, "b": 40 }), None)
54/// .await?;
55/// # Ok(())
56/// # }
57/// ```
58///
59/// See: <https://playwright.dev/docs/api/class-webmcp>
60#[derive(Clone, Debug)]
61pub struct WebMCP {
62 frame: Frame,
63}
64
65/// One tool a page registered, as [`WebMCP::tools`] reports it.
66///
67/// See: <https://playwright.dev/docs/api/class-webmcp#webmcp-tools>
68#[derive(Clone, Debug, PartialEq, Deserialize)]
69#[serde(rename_all = "camelCase")]
70#[non_exhaustive]
71pub struct WebMCPTool {
72 /// The name [`WebMCP::call_tool`] takes.
73 pub name: String,
74 /// What the page says the tool does.
75 pub description: String,
76 /// The JSON Schema of the tool's input, when the page gave one.
77 pub input_schema: Option<Value>,
78 /// The hints the page gave about the tool, when it gave any.
79 pub annotations: Option<WebMCPToolAnnotations>,
80}
81
82/// Hints a page gives about a tool. Each is reported only when the page set
83/// it to true.
84///
85/// See: <https://playwright.dev/docs/api/class-webmcp#webmcp-tools>
86#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
87#[serde(rename_all = "camelCase")]
88#[non_exhaustive]
89pub struct WebMCPToolAnnotations {
90 /// The tool does not change anything (the page's `readOnlyHint`).
91 pub read_only: Option<bool>,
92 /// The tool's output may hold content the page does not vouch for.
93 pub untrusted_content: Option<bool>,
94 /// The tool has effects beyond the page.
95 pub consequential: Option<bool>,
96}
97
98/// Options for [`WebMCP::tools`] and [`WebMCP::call_tool`].
99///
100/// See: <https://playwright.dev/docs/api/class-webmcp#webmcp-call-tool>
101#[derive(Clone, Debug, Default)]
102#[non_exhaustive]
103pub struct WebMCPOptions {
104 /// Maximum time in milliseconds. Defaults to the page's default timeout
105 /// (see `Page::set_default_timeout`), and to
106 /// [`DEFAULT_TIMEOUT_MS`](crate::DEFAULT_TIMEOUT_MS) without one.
107 pub timeout: Option<f64>,
108}
109
110impl WebMCPOptions {
111 /// Maximum time in milliseconds.
112 pub fn timeout(mut self, timeout: f64) -> Self {
113 self.timeout = Some(timeout);
114 self
115 }
116}
117
118/// The timeout a call sends: the option, else the page's default, else the
119/// crate's. The driver treats an absent timeout as no deadline at all.
120fn timeout_of(options: Option<WebMCPOptions>, page_default: Option<f64>) -> f64 {
121 options
122 .and_then(|o| o.timeout)
123 .or(page_default)
124 .unwrap_or(crate::DEFAULT_TIMEOUT_MS)
125}
126
127impl WebMCP {
128 pub(crate) fn new(frame: Frame) -> Self {
129 Self { frame }
130 }
131
132 fn timeout(&self, options: Option<WebMCPOptions>) -> f64 {
133 timeout_of(options, self.frame.page().map(|p| p.default_timeout_ms()))
134 }
135
136 /// Lists the tools the frame's page has registered, in the browser's
137 /// order.
138 ///
139 /// # Errors
140 ///
141 /// Returns error if WebMCP is not enabled in this browser (see the type
142 /// docs), or the frame has been detached.
143 ///
144 /// See: <https://playwright.dev/docs/api/class-webmcp#webmcp-tools>
145 #[tracing::instrument(level = "debug", skip_all, fields(count = tracing::field::Empty))]
146 pub async fn tools(
147 &self,
148 options: impl Into<Option<WebMCPOptions>>,
149 ) -> Result<Vec<WebMCPTool>> {
150 #[derive(Deserialize)]
151 struct ToolsResponse {
152 tools: Vec<WebMCPTool>,
153 }
154 let response: ToolsResponse = self
155 .frame
156 .channel()
157 .send(
158 "webmcpTools",
159 json!({ "timeout": self.timeout(options.into()) }),
160 )
161 .await?;
162 tracing::Span::current().record("count", response.tools.len());
163 Ok(response.tools)
164 }
165
166 /// Calls the tool registered as `name` with `input`, and returns what it
167 /// returned, deserialized as `R` (use [`serde_json::Value`] to take it
168 /// as it is). A tool reports its own failure in its result, with
169 /// `isError` set, rather than raising one, so such a result comes back
170 /// like any other.
171 ///
172 /// `input` is serialized the way an `evaluate` argument is; pass `&()`
173 /// for a tool that takes none.
174 ///
175 /// # Errors
176 ///
177 /// Returns error if no tool is registered as `name`
178 /// (`No WebMCP tool named "..."`), WebMCP is not enabled, or the result
179 /// does not deserialize as `R`.
180 ///
181 /// See: <https://playwright.dev/docs/api/class-webmcp#webmcp-call-tool>
182 #[tracing::instrument(level = "debug", skip_all, fields(name = %name))]
183 pub async fn call_tool<I, R>(
184 &self,
185 name: &str,
186 input: &I,
187 options: impl Into<Option<WebMCPOptions>>,
188 ) -> Result<R>
189 where
190 I: serde::Serialize + ?Sized,
191 R: DeserializeOwned,
192 {
193 // Serialize up front: the shared argument encoder turns a value
194 // serde rejects into null, which would call the tool with no input.
195 let input = serde_json::to_value(input).map_err(|e| {
196 Error::InvalidArgument(format!("WebMCP tool input does not serialize: {e}"))
197 })?;
198 #[derive(Deserialize)]
199 struct CallToolResponse {
200 result: Value,
201 }
202 let response: CallToolResponse = self
203 .frame
204 .channel()
205 .send(
206 "webmcpCallTool",
207 json!({
208 "name": name,
209 "input": serialize_argument(&input),
210 "timeout": self.timeout(options.into()),
211 }),
212 )
213 .await?;
214 serde_json::from_value(parse_result(&response.result)).map_err(|e| {
215 Error::ProtocolError(format!("WebMCP tool result deserialization failed: {e}"))
216 })
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn a_tool_reads_back_whatever_the_page_gave_it() {
226 let tools: Vec<WebMCPTool> = serde_json::from_value(json!([
227 {
228 "name": "add",
229 "description": "Adds two numbers",
230 "inputSchema": {"type": "object"},
231 "annotations": {"readOnly": true}
232 },
233 {"name": "noop", "description": "Does nothing"}
234 ]))
235 .unwrap();
236
237 assert_eq!(tools[0].input_schema, Some(json!({"type": "object"})));
238 assert_eq!(
239 tools[0].annotations,
240 Some(WebMCPToolAnnotations {
241 read_only: Some(true),
242 ..Default::default()
243 })
244 );
245 assert_eq!(
246 (tools[1].input_schema.clone(), tools[1].annotations.clone()),
247 (None, None)
248 );
249 }
250
251 #[test]
252 fn the_timeout_is_the_option_else_the_page_default_else_the_crate_default() {
253 let given = Some(WebMCPOptions::default().timeout(5.0));
254 assert_eq!(timeout_of(given, Some(2.0)), 5.0);
255 assert_eq!(timeout_of(Some(WebMCPOptions::default()), Some(2.0)), 2.0);
256 assert_eq!(timeout_of(None, None), crate::DEFAULT_TIMEOUT_MS);
257 }
258}