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/// Forward-looking seam: not every engine implements `WebMCP.callTool` yet.
92/// Depending on the engine's unknown-method policy, an unimplemented call
93/// surfaces either a protocol error or an empty success result — treat an
94/// empty object as "not supported yet" and discover live tools via
95/// `WebMCP.listTools`. The response is returned as opaque JSON
96/// ([`serde_json::Value`]) so the client does not constrain the result shape.
97#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
98pub struct CallToolParams {
99    /// The name of the tool to invoke, as reported by `WebMCP.listTools`.
100    pub name: String,
101    /// Arguments for the tool, matching its `inputSchema`.
102    #[serde(default)]
103    pub arguments: serde_json::Value,
104}
105
106impl CallToolParams {
107    pub const IDENTIFIER: &'static str = "WebMCP.callTool";
108
109    /// Create params for invoking `name` with the given `arguments`.
110    pub fn new(name: impl Into<String>, arguments: serde_json::Value) -> Self {
111        Self {
112            name: name.into(),
113            arguments,
114        }
115    }
116}
117
118impl chromiumoxide_types::Method for CallToolParams {
119    fn identifier(&self) -> chromiumoxide_types::MethodId {
120        Self::IDENTIFIER.into()
121    }
122}
123
124impl chromiumoxide_types::MethodType for CallToolParams {
125    fn method_id() -> chromiumoxide_types::MethodId
126    where
127        Self: Sized,
128    {
129        Self::IDENTIFIER.into()
130    }
131}
132
133impl chromiumoxide_types::Command for CallToolParams {
134    type Response = serde_json::Value;
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use serde_json::json;
141
142    const SAMPLE: &str = r#"{ "tools": [ {
143        "name": "createSupportRequest",
144        "description": "File a support ticket",
145        "inputSchema": { "type": "object",
146          "properties": { "subject": { "type": "string", "description": "Short summary" },
147                          "urgent": { "type": "boolean" },
148                          "category": { "type": "string", "enum": ["billing","tech"] } },
149          "required": ["subject"] },
150        "source": "declarative",
151        "action": "/support",
152        "autosubmit": true,
153        "integrity": { "trusted": false, "confidence": 0.7,
154                       "hints": { "readOnly": false, "untrustedContent": true } }
155    } ] }"#;
156
157    #[test]
158    fn deserializes_list_tools_sample_payload() {
159        let returns: ListToolsReturns =
160            serde_json::from_str(SAMPLE).expect("sample must deserialize");
161        assert_eq!(returns.tools.len(), 1);
162        let tool = &returns.tools[0];
163
164        // Standard descriptor fields.
165        assert_eq!(tool.name, "createSupportRequest");
166        assert_eq!(tool.description.as_deref(), Some("File a support ticket"));
167        assert_eq!(tool.action.as_deref(), Some("/support"));
168        assert!(tool.autosubmit);
169
170        // inputSchema stays an arbitrary JSON value and round-trips intact.
171        let expected_schema = json!({
172            "type": "object",
173            "properties": {
174                "subject": { "type": "string", "description": "Short summary" },
175                "urgent": { "type": "boolean" },
176                "category": { "type": "string", "enum": ["billing", "tech"] }
177            },
178            "required": ["subject"]
179        });
180        assert_eq!(tool.input_schema, expected_schema);
181
182        // Server-specific metadata is not modeled: it lands opaquely in
183        // `extra`, byte-for-byte.
184        assert!(tool.extra.contains_key("integrity"));
185        assert!(tool.extra.contains_key("source"));
186        assert_eq!(tool.extra["source"], json!("declarative"));
187        assert_eq!(
188            tool.extra["integrity"],
189            json!({ "trusted": false, "confidence": 0.7,
190                    "hints": { "readOnly": false, "untrustedContent": true } })
191        );
192
193        // Lossless passthrough: re-serializing the tool reproduces the exact
194        // wire object, extras included.
195        let wire: serde_json::Value = serde_json::from_str(SAMPLE).expect("sample is valid json");
196        let reserialized = serde_json::to_value(tool).expect("tool must serialize");
197        assert_eq!(reserialized, wire["tools"][0]);
198    }
199
200    #[test]
201    fn action_absent_deserializes_to_none() {
202        let returns: ListToolsReturns = serde_json::from_value(json!({
203            "tools": [{
204                "name": "noAction",
205                "description": "imperative tool",
206                "inputSchema": { "type": "object" },
207                "autosubmit": false
208            }]
209        }))
210        .expect("tool without action must deserialize");
211        let tool = &returns.tools[0];
212        assert_eq!(tool.action, None);
213        // An omitted action must also stay omitted when serialized back out.
214        let reserialized = serde_json::to_value(tool).expect("tool must serialize");
215        assert!(reserialized.get("action").is_none());
216    }
217
218    #[test]
219    fn method_identifiers_match_the_domain() {
220        use chromiumoxide_types::Method;
221
222        assert_eq!(ListToolsParams::IDENTIFIER, "WebMCP.listTools");
223        assert_eq!(CallToolParams::IDENTIFIER, "WebMCP.callTool");
224        assert_eq!(ListToolsParams::default().identifier(), "WebMCP.listTools");
225        assert_eq!(
226            CallToolParams::new("t", json!({})).identifier(),
227            "WebMCP.callTool"
228        );
229        // `listTools` takes no parameters — the params must serialize to `{}`.
230        assert_eq!(
231            serde_json::to_value(ListToolsParams::default()).expect("params must serialize"),
232            json!({})
233        );
234    }
235
236    #[test]
237    fn tolerates_unknown_fields() {
238        let returns: ListToolsReturns = serde_json::from_value(json!({
239            "tools": [{
240                "name": "future",
241                "description": "from a newer server phase",
242                "inputSchema": {},
243                "futureField": 1
244            }],
245            "futureTopLevel": true
246        }))
247        .expect("unknown fields must be tolerated");
248        let tool = &returns.tools[0];
249        assert_eq!(tool.name, "future");
250        assert_eq!(tool.extra["futureField"], json!(1));
251    }
252}