use crate::error::{Error, Result};
use crate::protocol::Frame;
use crate::protocol::evaluate_conversion::{parse_result, serialize_argument};
use crate::server::channel_owner::ChannelOwner;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
#[derive(Clone, Debug)]
pub struct WebMCP {
frame: Frame,
}
#[derive(Clone, Debug, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct WebMCPTool {
pub name: String,
pub description: String,
pub input_schema: Option<Value>,
pub annotations: Option<WebMCPToolAnnotations>,
}
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct WebMCPToolAnnotations {
pub read_only: Option<bool>,
pub untrusted_content: Option<bool>,
pub consequential: Option<bool>,
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct WebMCPOptions {
pub timeout: Option<f64>,
}
impl WebMCPOptions {
pub fn timeout(mut self, timeout: f64) -> Self {
self.timeout = Some(timeout);
self
}
}
fn timeout_of(options: Option<WebMCPOptions>, page_default: Option<f64>) -> f64 {
options
.and_then(|o| o.timeout)
.or(page_default)
.unwrap_or(crate::DEFAULT_TIMEOUT_MS)
}
impl WebMCP {
pub(crate) fn new(frame: Frame) -> Self {
Self { frame }
}
fn timeout(&self, options: Option<WebMCPOptions>) -> f64 {
timeout_of(options, self.frame.page().map(|p| p.default_timeout_ms()))
}
#[tracing::instrument(level = "debug", skip_all, fields(count = tracing::field::Empty))]
pub async fn tools(
&self,
options: impl Into<Option<WebMCPOptions>>,
) -> Result<Vec<WebMCPTool>> {
#[derive(Deserialize)]
struct ToolsResponse {
tools: Vec<WebMCPTool>,
}
let response: ToolsResponse = self
.frame
.channel()
.send(
"webmcpTools",
json!({ "timeout": self.timeout(options.into()) }),
)
.await?;
tracing::Span::current().record("count", response.tools.len());
Ok(response.tools)
}
#[tracing::instrument(level = "debug", skip_all, fields(name = %name))]
pub async fn call_tool<I, R>(
&self,
name: &str,
input: &I,
options: impl Into<Option<WebMCPOptions>>,
) -> Result<R>
where
I: serde::Serialize + ?Sized,
R: DeserializeOwned,
{
let input = serde_json::to_value(input).map_err(|e| {
Error::InvalidArgument(format!("WebMCP tool input does not serialize: {e}"))
})?;
#[derive(Deserialize)]
struct CallToolResponse {
result: Value,
}
let response: CallToolResponse = self
.frame
.channel()
.send(
"webmcpCallTool",
json!({
"name": name,
"input": serialize_argument(&input),
"timeout": self.timeout(options.into()),
}),
)
.await?;
serde_json::from_value(parse_result(&response.result)).map_err(|e| {
Error::ProtocolError(format!("WebMCP tool result deserialization failed: {e}"))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_tool_reads_back_whatever_the_page_gave_it() {
let tools: Vec<WebMCPTool> = serde_json::from_value(json!([
{
"name": "add",
"description": "Adds two numbers",
"inputSchema": {"type": "object"},
"annotations": {"readOnly": true}
},
{"name": "noop", "description": "Does nothing"}
]))
.unwrap();
assert_eq!(tools[0].input_schema, Some(json!({"type": "object"})));
assert_eq!(
tools[0].annotations,
Some(WebMCPToolAnnotations {
read_only: Some(true),
..Default::default()
})
);
assert_eq!(
(tools[1].input_schema.clone(), tools[1].annotations.clone()),
(None, None)
);
}
#[test]
fn the_timeout_is_the_option_else_the_page_default_else_the_crate_default() {
let given = Some(WebMCPOptions::default().timeout(5.0));
assert_eq!(timeout_of(given, Some(2.0)), 5.0);
assert_eq!(timeout_of(Some(WebMCPOptions::default()), Some(2.0)), 2.0);
assert_eq!(timeout_of(None, None), crate::DEFAULT_TIMEOUT_MS);
}
}