Skip to main content

chromiumoxide/
webmcp.rs

1//! Client bindings for the `WebMCP` CDP domain.
2//!
3//! Some remote engines expose a non-standard, capability-noun `WebMCP`
4//! domain (alongside standard protocol domains) that surfaces agent-callable
5//! "tools" a page has declared, following the WebMCP tool model (`name`,
6//! `description`, JSON-Schema `inputSchema`). These bindings are hand-written
7//! [`Command`](chromiumoxide_types::Command) implementations issued as raw
8//! method strings — no PDL definitions or code generation involved — mirroring
9//! how other vendor methods (e.g. `Interception.setPolicy`) are sent.
10//!
11//! Only the standard tool descriptor fields are modeled as typed fields; any
12//! additional server-specific metadata is preserved losslessly in
13//! [`Tool::extra`] so the client stays neutral and forward-compatible.
14//!
15//! Browsers that do not implement the `WebMCP` domain respond with a regular
16//! protocol error, surfaced through the crate's usual [`Result`](crate::error::Result).
17
18use std::collections::BTreeMap;
19
20use serde::{Deserialize, Serialize};
21
22/// An agent-callable tool a page declared, as reported by `WebMCP.listTools`.
23///
24/// The typed fields cover the standard WebMCP tool descriptor. Anything else
25/// the server includes is kept verbatim in [`Tool::extra`], so no data is
26/// lost and future server additions do not break older clients.
27#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
28pub struct Tool {
29    /// The tool's unique name, used to invoke it.
30    pub name: String,
31    /// Human/agent readable description of what the tool does.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub description: Option<String>,
34    /// JSON Schema describing the tool's arguments. Kept as raw JSON since it
35    /// is an arbitrary schema object.
36    #[serde(rename = "inputSchema", default)]
37    pub input_schema: serde_json::Value,
38    /// Target action associated with a declaratively-declared tool (e.g. a
39    /// form action). Omitted on the wire when absent.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub action: Option<String>,
42    /// Whether the underlying action may be auto-submitted when the tool is
43    /// invoked (declarative tools).
44    #[serde(default)]
45    pub autosubmit: bool,
46    /// Any additional fields the server reported, preserved losslessly and
47    /// opaquely (round-trips on re-serialization).
48    #[serde(flatten)]
49    pub extra: BTreeMap<String, serde_json::Value>,
50}
51
52/// List the agent-callable tools the current page declared.
53/// [listTools]: method `WebMCP.listTools`, takes no parameters.
54#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
55pub struct ListToolsParams {}
56
57impl ListToolsParams {
58    pub const IDENTIFIER: &'static str = "WebMCP.listTools";
59}
60
61impl chromiumoxide_types::Method for ListToolsParams {
62    fn identifier(&self) -> chromiumoxide_types::MethodId {
63        Self::IDENTIFIER.into()
64    }
65}
66
67impl chromiumoxide_types::MethodType for ListToolsParams {
68    fn method_id() -> chromiumoxide_types::MethodId
69    where
70        Self: Sized,
71    {
72        Self::IDENTIFIER.into()
73    }
74}
75
76/// The catalog of tools returned by `WebMCP.listTools`.
77#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
78pub struct ListToolsReturns {
79    /// The tools the page declared.
80    #[serde(default)]
81    pub tools: Vec<Tool>,
82}
83
84impl chromiumoxide_types::Command for ListToolsParams {
85    type Response = ListToolsReturns;
86}
87
88/// Invoke a page-declared tool by name.
89/// [callTool]: method `WebMCP.callTool`.
90///
91/// An engine that implements `WebMCP.callTool` runs the tool and returns its
92/// result as opaque JSON — commonly a WebMCP tool-result object with a
93/// `content` array and an `isError` flag (a failing tool is reported in-band as
94/// `isError: true`, not as a protocol error).
95///
96/// Not every engine implements `WebMCP.callTool`. Depending on the engine's
97/// unknown-method policy, an unimplemented call surfaces either a protocol
98/// error or an empty success result — treat an empty object as "not supported
99/// yet" and discover live tools via `WebMCP.listTools`. The response is
100/// returned as opaque JSON ([`serde_json::Value`]) so the client does not
101/// constrain the result shape.
102#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
103pub struct CallToolParams {
104    /// The name of the tool to invoke, as reported by `WebMCP.listTools`.
105    pub name: String,
106    /// Arguments for the tool, matching its `inputSchema`.
107    #[serde(default)]
108    pub arguments: serde_json::Value,
109}
110
111impl CallToolParams {
112    pub const IDENTIFIER: &'static str = "WebMCP.callTool";
113
114    /// Create params for invoking `name` with the given `arguments`.
115    pub fn new(name: impl Into<String>, arguments: serde_json::Value) -> Self {
116        Self {
117            name: name.into(),
118            arguments,
119        }
120    }
121}
122
123impl chromiumoxide_types::Method for CallToolParams {
124    fn identifier(&self) -> chromiumoxide_types::MethodId {
125        Self::IDENTIFIER.into()
126    }
127}
128
129impl chromiumoxide_types::MethodType for CallToolParams {
130    fn method_id() -> chromiumoxide_types::MethodId
131    where
132        Self: Sized,
133    {
134        Self::IDENTIFIER.into()
135    }
136}
137
138impl chromiumoxide_types::Command for CallToolParams {
139    type Response = serde_json::Value;
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use serde_json::json;
146
147    const SAMPLE: &str = r#"{ "tools": [ {
148        "name": "createSupportRequest",
149        "description": "File a support ticket",
150        "inputSchema": { "type": "object",
151          "properties": { "subject": { "type": "string", "description": "Short summary" },
152                          "urgent": { "type": "boolean" },
153                          "category": { "type": "string", "enum": ["billing","tech"] } },
154          "required": ["subject"] },
155        "source": "declarative",
156        "action": "/support",
157        "autosubmit": true,
158        "integrity": { "trusted": false, "confidence": 0.7,
159                       "hints": { "readOnly": false, "untrustedContent": true } }
160    } ] }"#;
161
162    #[test]
163    fn deserializes_list_tools_sample_payload() {
164        let returns: ListToolsReturns =
165            serde_json::from_str(SAMPLE).expect("sample must deserialize");
166        assert_eq!(returns.tools.len(), 1);
167        let tool = &returns.tools[0];
168
169        // Standard descriptor fields.
170        assert_eq!(tool.name, "createSupportRequest");
171        assert_eq!(tool.description.as_deref(), Some("File a support ticket"));
172        assert_eq!(tool.action.as_deref(), Some("/support"));
173        assert!(tool.autosubmit);
174
175        // inputSchema stays an arbitrary JSON value and round-trips intact.
176        let expected_schema = json!({
177            "type": "object",
178            "properties": {
179                "subject": { "type": "string", "description": "Short summary" },
180                "urgent": { "type": "boolean" },
181                "category": { "type": "string", "enum": ["billing", "tech"] }
182            },
183            "required": ["subject"]
184        });
185        assert_eq!(tool.input_schema, expected_schema);
186
187        // Server-specific metadata is not modeled: it lands opaquely in
188        // `extra`, byte-for-byte.
189        assert!(tool.extra.contains_key("integrity"));
190        assert!(tool.extra.contains_key("source"));
191        assert_eq!(tool.extra["source"], json!("declarative"));
192        assert_eq!(
193            tool.extra["integrity"],
194            json!({ "trusted": false, "confidence": 0.7,
195                    "hints": { "readOnly": false, "untrustedContent": true } })
196        );
197
198        // Lossless passthrough: re-serializing the tool reproduces the exact
199        // wire object, extras included.
200        let wire: serde_json::Value = serde_json::from_str(SAMPLE).expect("sample is valid json");
201        let reserialized = serde_json::to_value(tool).expect("tool must serialize");
202        assert_eq!(reserialized, wire["tools"][0]);
203    }
204
205    #[test]
206    fn action_absent_deserializes_to_none() {
207        let returns: ListToolsReturns = serde_json::from_value(json!({
208            "tools": [{
209                "name": "noAction",
210                "description": "imperative tool",
211                "inputSchema": { "type": "object" },
212                "autosubmit": false
213            }]
214        }))
215        .expect("tool without action must deserialize");
216        let tool = &returns.tools[0];
217        assert_eq!(tool.action, None);
218        // An omitted action must also stay omitted when serialized back out.
219        let reserialized = serde_json::to_value(tool).expect("tool must serialize");
220        assert!(reserialized.get("action").is_none());
221    }
222
223    #[test]
224    fn method_identifiers_match_the_domain() {
225        use chromiumoxide_types::Method;
226
227        assert_eq!(ListToolsParams::IDENTIFIER, "WebMCP.listTools");
228        assert_eq!(CallToolParams::IDENTIFIER, "WebMCP.callTool");
229        assert_eq!(ListToolsParams::default().identifier(), "WebMCP.listTools");
230        assert_eq!(
231            CallToolParams::new("t", json!({})).identifier(),
232            "WebMCP.callTool"
233        );
234        // `listTools` takes no parameters — the params must serialize to `{}`.
235        assert_eq!(
236            serde_json::to_value(ListToolsParams::default()).expect("params must serialize"),
237            json!({})
238        );
239    }
240
241    #[test]
242    fn tolerates_unknown_fields() {
243        let returns: ListToolsReturns = serde_json::from_value(json!({
244            "tools": [{
245                "name": "future",
246                "description": "from a newer server phase",
247                "inputSchema": {},
248                "futureField": 1
249            }],
250            "futureTopLevel": true
251        }))
252        .expect("unknown fields must be tolerated");
253        let tool = &returns.tools[0];
254        assert_eq!(tool.name, "future");
255        assert_eq!(tool.extra["futureField"], json!(1));
256    }
257}