use rmcp::ErrorData;
use serde_json::json;
use zendriver::ZendriverError;
#[derive(Debug, thiserror::Error)]
pub enum McpServerError {
#[error("Browser not open. Call `browser_open` first.")]
BrowserNotOpen,
#[error("Browser already open. Call `browser_close` first.")]
BrowserAlreadyOpen,
#[error("No current tab. Open a tab via `browser_tab_new` or `browser_open`.")]
NoCurrentTab,
#[error("Expectation `{0}` not found. Did you call `browser_expect_register` first?")]
ExpectationNotFound(String),
#[error("Intercept rule `{0}` not found.")]
RuleNotFound(String),
#[error(transparent)]
Zendriver(#[from] ZendriverError),
}
pub fn map_error(err: impl Into<McpServerError>) -> ErrorData {
let err: McpServerError = err.into();
let (msg, suggested_next) = match &err {
McpServerError::BrowserNotOpen => (err.to_string(), Some("browser_open")),
McpServerError::BrowserAlreadyOpen => (err.to_string(), Some("browser_close")),
McpServerError::NoCurrentTab => (err.to_string(), Some("browser_tab_new")),
McpServerError::ExpectationNotFound(_) => {
(err.to_string(), Some("browser_expect_register"))
}
McpServerError::RuleNotFound(_) => (err.to_string(), Some("browser_intercept_add_rule")),
McpServerError::Zendriver(ze) => map_zendriver(ze),
};
let data = suggested_next.map(|hint| json!({ "suggested_next": hint }));
ErrorData::invalid_request(msg, data)
}
fn map_zendriver(err: &ZendriverError) -> (String, Option<&'static str>) {
match err {
ZendriverError::ElementNotFound { selector } => (
format!(
"No element matched `{selector}`. Try `browser_html` to inspect current page."
),
Some("browser_html"),
),
ZendriverError::Timeout(d) => (
format!(
"Operation timed out after {d:?}. Retry with a larger `timeout_ms` or inspect with `browser_html`."
),
Some("browser_html"),
),
ZendriverError::NotActionable(_, reason) => (
format!(
"Element not actionable: {reason}. Inspect with `browser_html` or wait for the page to settle."
),
Some("browser_html"),
),
ZendriverError::TabNotFound(id) => (
format!("Tab `{id}` not found. Use `browser_tab_list` to enumerate live tabs."),
Some("browser_tab_list"),
),
ZendriverError::FrameNotFound(id) => (
format!("Frame `{id}` not found. Use `browser_frame_list` to enumerate frames."),
Some("browser_frame_list"),
),
ZendriverError::Navigation(msg) => (format!("Navigation failed: {msg}"), None),
ZendriverError::JsException(msg) => (
format!("JavaScript exception during evaluation: {msg}"),
None,
),
ZendriverError::ElementStale => (
"Element handle is stale. Re-run the find before retrying.".into(),
Some("browser_find"),
),
ZendriverError::NotRefreshable => (
"Element handle is not refreshable (came from raw JS eval). Re-find it with a selector.".into(),
Some("browser_find"),
),
_ => (err.to_string(), None),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn browser_not_open_suggests_browser_open() {
let e = map_error(McpServerError::BrowserNotOpen);
assert!(e.message.contains("browser_open"));
let data = e.data.as_ref().expect("data populated");
assert_eq!(data["suggested_next"], "browser_open");
}
#[test]
fn browser_already_open_suggests_browser_close() {
let e = map_error(McpServerError::BrowserAlreadyOpen);
assert!(e.message.contains("browser_close"));
let data = e.data.as_ref().expect("data populated");
assert_eq!(data["suggested_next"], "browser_close");
}
#[test]
fn element_not_found_suggests_html() {
let inner = ZendriverError::ElementNotFound {
selector: "css(button.primary)".into(),
};
let e = map_error(McpServerError::from(inner));
assert!(e.message.contains("`css(button.primary)`"));
let data = e.data.as_ref().expect("data populated");
assert_eq!(data["suggested_next"], "browser_html");
}
#[test]
fn from_zendriver_error_lets_question_mark_work() {
fn inner() -> Result<(), McpServerError> {
let z: zendriver::Result<()> = Err(ZendriverError::TabNotFound("T9".into()));
z?;
Ok(())
}
let e = map_error(inner().unwrap_err());
assert_eq!(
e.data.as_ref().unwrap()["suggested_next"],
"browser_tab_list"
);
}
}