Skip to main content

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}