From 145f9c68f5d0defb4bb855dfbda030b70889e6dc Mon Sep 17 00:00:00 2001 From: ldm0 Date: Fri, 14 Aug 2026 00:49:14 +0800 Subject: [PATCH] Remove MCP server --- Cargo.lock | 1 - moli/Cargo.toml | 3 +- moli/src/app.rs | 10 +- moli/src/cli.rs | 11 +- moli/src/config.rs | 3 - moli/src/lib.rs | 1 - moli/src/mcp_server.rs | 1862 ---------------------------------------- moli/tests/cli.rs | 35 +- 8 files changed, 9 insertions(+), 1917 deletions(-) delete mode 100644 moli/src/mcp_server.rs diff --git a/Cargo.lock b/Cargo.lock index a466167ad..cf92851e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2013,7 +2013,6 @@ dependencies = [ "moli-protocol-server", "moli-test-support", "parking_lot", - "serde", "serde_json", "strip-ansi-escapes", "tikv-jemalloc-sys", diff --git a/moli/Cargo.toml b/moli/Cargo.toml index ae8978b75..bb64cdb73 100644 --- a/moli/Cargo.toml +++ b/moli/Cargo.toml @@ -26,9 +26,8 @@ moli-core = { path = "../moli-core" } moli-process-signal = { path = "../moli-process-signal" } moli-protocol-server = { path = "../moli-protocol-server" } parking_lot = "0.12" -serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" -tokio = { version = "1.51.0", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync"] } +tokio = { version = "1.51.0", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } tikv-jemalloc-sys = { version = "0.7.1", optional = true, default-features = false } tikv-jemallocator = { version = "0.7.0", optional = true, default-features = false, features = ["override_allocator_on_supported_platforms"] } tracing = "0.1.41" diff --git a/moli/src/app.rs b/moli/src/app.rs index 4eb2539cf..7df178cba 100644 --- a/moli/src/app.rs +++ b/moli/src/app.rs @@ -5,7 +5,7 @@ use std::{io::Write, sync::Arc}; use crate::{ cli::{Cli, Commands, FetchWaitUntil, normalize_args_for_compat}, config::AppConfig, - cookie_cache, fetch_dump, mcp_server, + cookie_cache, fetch_dump, }; use anyhow::Result; use anyhow::{Context, anyhow}; @@ -204,14 +204,6 @@ pub async fn run_cli_with_config( ); server.serve().await.context("protocol server failed")?; } - Commands::Mcp(_) => { - let browser = Browser::new(config.browser.clone()) - .context("failed to initialize browser runtime")?; - load_cookie_state(&browser, &config)?; - mcp_server::serve_stdio(browser.clone()) - .await - .context("MCP server failed")?; - } Commands::Help | Commands::Version => unreachable!(), } diff --git a/moli/src/cli.rs b/moli/src/cli.rs index 5342e403e..7b2bf94a1 100644 --- a/moli/src/cli.rs +++ b/moli/src/cli.rs @@ -34,7 +34,7 @@ const FETCH_INFER_FLAGS: &[&str] = &[ "--profile-dir", ]; const SERVE_INFER_FLAGS: &[&str] = &["--host", "--port", "--timeout", "--layout"]; -const EXPLICIT_COMMANDS: &[&str] = &["fetch", "serve", "mcp", "help", "version"]; +const EXPLICIT_COMMANDS: &[&str] = &["fetch", "serve", "help", "version"]; const DUMP_MODES: &[&str] = &[ "json", "html", @@ -62,8 +62,7 @@ pub struct Cli { #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum Commands { Fetch(Box), - Serve(ServeArgs), - Mcp(McpArgs), + Serve(Box), Help, Version, } @@ -227,12 +226,6 @@ pub struct ServeArgs { pub common: CommonArgs, } -#[derive(Debug, Clone, PartialEq, Eq, Args)] -pub struct McpArgs { - #[command(flatten)] - pub common: CommonArgs, -} - #[derive(Debug, Clone, PartialEq, Eq, Default, Args)] pub struct CommonArgs { #[arg(long)] diff --git a/moli/src/config.rs b/moli/src/config.rs index 1c8134929..7dda317ea 100644 --- a/moli/src/config.rs +++ b/moli/src/config.rs @@ -55,9 +55,6 @@ impl AppConfig { config.server.cdp_max_connections = args.cdp_max_connections; config.server.cdp_max_pending_connections = args.cdp_max_pending_connections; } - Commands::Mcp(args) => { - apply_common_args(&mut config, &args.common)?; - } Commands::Help | Commands::Version => {} } diff --git a/moli/src/lib.rs b/moli/src/lib.rs index 9bcbb8cb8..741f0a41f 100644 --- a/moli/src/lib.rs +++ b/moli/src/lib.rs @@ -8,7 +8,6 @@ pub mod cli; pub mod config; pub mod cookie_cache; pub mod fetch_dump; -pub mod mcp_server; mod network_trace; pub mod telemetry; diff --git a/moli/src/mcp_server.rs b/moli/src/mcp_server.rs deleted file mode 100644 index 01c1233a1..000000000 --- a/moli/src/mcp_server.rs +++ /dev/null @@ -1,1862 +0,0 @@ -use std::{ - collections::{HashMap, HashSet}, - time::Duration, -}; - -use anyhow::{Context, Result, anyhow}; -use moli_core::{ - RendererRuntimeInspectorResponseSender, - page::{ - DocumentNodeClientRectResolution, DocumentNodeRuntimeObjectResolution, - DocumentNodeSnapshot, Page, PageInputExt, RendererDocumentNodeAttributesResolution, - RendererDocumentNodeTextResolution, RendererDocumentQuerySelectorNode, - RendererDocumentQuerySelectorResolution, - }, - runtime::{Browser, RenderedDomWaitUntil}, -}; -use serde::Deserialize; -use serde_json::{Value, json}; -use tokio::io::{ - self, AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader, -}; - -use crate::{ - cli::{DumpFormat, StripOptions}, - fetch_dump::{render_page_dump_with_options_async, summarize_node_details_async}, -}; - -const MCP_PROTOCOL_VERSION: &str = "2024-11-05"; - -pub async fn serve_stdio(browser: Browser) -> Result<()> { - let mut server = McpServer::new(browser); - let stdin = io::stdin(); - let stdout = io::stdout(); - let mut reader = BufReader::new(stdin); - let mut writer = stdout; - - while let Some(message) = read_message(&mut reader).await? { - let response = match serde_json::from_slice::(&message) { - Ok(request) => server.handle_request(request).await, - Err(error) => Some(error_response( - Value::Null, - -32700, - format!("parse error: {error}"), - )), - }; - - if let Some(response) = response { - write_message(&mut writer, &response).await?; - } - } - - Ok(()) -} - -struct McpServer { - browser: Browser, - current_page: Option, -} - -impl McpServer { - fn new(browser: Browser) -> Self { - Self { - browser, - current_page: None, - } - } - - async fn handle_request(&mut self, request: Value) -> Option { - let id = request.get("id").cloned().unwrap_or(Value::Null); - let method = request.get("method").and_then(Value::as_str)?; - let params = request.get("params").cloned().unwrap_or_else(|| json!({})); - - match method { - "initialize" => Some(success_response( - id, - json!({ - "protocolVersion": MCP_PROTOCOL_VERSION, - "capabilities": { - "tools": {}, - "resources": {} - }, - "serverInfo": { - "name": "moli", - "version": env!("CARGO_PKG_VERSION") - } - }), - )), - "notifications/initialized" => None, - "ping" => Some(success_response(id, json!({}))), - "tools/list" => Some(success_response(id, json!({ "tools": tool_list() }))), - "resources/list" => Some(success_response( - id, - json!({ "resources": resource_list() }), - )), - "resources/read" => Some(match self.handle_resource_read(params).await { - Ok(result) => success_response(id, result), - Err(error) => error_response(id, -32000, error.to_string()), - }), - "tools/call" => Some(match self.handle_tool_call(params).await { - Ok(result) => success_response(id, result), - Err(error) => success_response(id, tool_error(error.to_string())), - }), - _ => Some(error_response( - id, - -32601, - format!("method `{method}` not found"), - )), - } - } - - async fn handle_resource_read(&mut self, params: Value) -> Result { - #[derive(Deserialize)] - struct ResourceReadParams { - uri: String, - } - - let params: ResourceReadParams = - serde_json::from_value(params).context("invalid resources/read params")?; - let page = self - .current_page - .as_mut() - .ok_or_else(|| anyhow!("page not loaded"))?; - - let (mime_type, text) = match params.uri.as_str() { - "mcp://page/html" => ( - "text/html", - render_page_dump_with_options_async( - page, - DumpFormat::Html, - StripOptions::default(), - false, - false, - false, - None, - ) - .await?, - ), - "mcp://page/markdown" => ( - "text/markdown", - render_page_dump_with_options_async( - page, - DumpFormat::Markdown, - StripOptions::default(), - false, - false, - false, - None, - ) - .await?, - ), - _ => return Err(anyhow!("resource `{}` not found", params.uri)), - }; - - Ok(json!({ - "contents": [{ - "uri": params.uri, - "mimeType": mime_type, - "text": text - }] - })) - } - - async fn handle_tool_call(&mut self, params: Value) -> Result { - #[derive(Deserialize)] - struct ToolCallParams { - name: String, - #[serde(default)] - arguments: Value, - } - - let params: ToolCallParams = - serde_json::from_value(params).context("invalid tools/call params")?; - match params.name.as_str() { - "goto" | "navigate" => self.tool_goto(params.arguments).await, - "markdown" => self.tool_markdown(params.arguments).await, - "links" => self.tool_links(params.arguments).await, - "evaluate" | "eval" => self.tool_evaluate(params.arguments).await, - "semantic_tree" => self.tool_semantic_tree(params.arguments).await, - "nodeDetails" => self.tool_node_details(params.arguments).await, - "interactiveElements" => self.tool_interactive_elements(params.arguments).await, - "findElement" => self.tool_find_element(params.arguments).await, - "waitForSelector" => self.tool_wait_for_selector(params.arguments).await, - "click" => self.tool_click(params.arguments).await, - "fill" => self.tool_fill(params.arguments).await, - "press" => self.tool_press(params.arguments).await, - "hover" => self.tool_hover(params.arguments).await, - "scroll" => self.tool_scroll(params.arguments).await, - "selectOption" => self.tool_select_option(params.arguments).await, - "setChecked" => self.tool_set_checked(params.arguments).await, - other => Err(anyhow!("tool `{other}` not found")), - } - } - - async fn tool_goto(&mut self, arguments: Value) -> Result { - let args: NavigateParams = - serde_json::from_value(arguments).context("invalid goto arguments")?; - let page = self - .navigate_to( - &args.url, - args.timeout.unwrap_or(10_000), - parse_wait_until(args.wait_until.as_deref())?, - ) - .await?; - tool_text(format!( - "Navigated to {} ({})", - page.final_url(), - page.document_title() - )) - } - - async fn tool_markdown(&mut self, arguments: Value) -> Result { - let args: OptionalNavigateParams = - serde_json::from_value(arguments).context("invalid markdown arguments")?; - let page = self - .ensure_page( - args.url.as_deref(), - args.timeout, - args.wait_until.as_deref(), - ) - .await?; - let markdown = render_page_dump_with_options_async( - page, - DumpFormat::Markdown, - StripOptions::default(), - false, - false, - false, - None, - ) - .await?; - tool_text(markdown) - } - - async fn tool_links(&mut self, arguments: Value) -> Result { - let args: OptionalNavigateParams = - serde_json::from_value(arguments).context("invalid links arguments")?; - let page = self - .ensure_page( - args.url.as_deref(), - args.timeout, - args.wait_until.as_deref(), - ) - .await?; - let nodes = query_selector_all_live(page, "a[href]").await?; - let mut links = Vec::new(); - for node in nodes { - let backend_node_id = node.backend_node_id; - let Some(attributes) = node_attributes_live(page, backend_node_id).await? else { - continue; - }; - let href = attribute_value_from_pairs(&attributes, "href").unwrap_or_default(); - if href.is_empty() { - continue; - } - let text = node_text_live(page, backend_node_id) - .await? - .unwrap_or_default() - .trim() - .to_owned(); - links.push(json!({ - "backendNodeId": backend_node_id, - "href": href, - "text": text, - })); - } - tool_json(json!(links)) - } - - async fn tool_evaluate(&mut self, arguments: Value) -> Result { - let args: EvaluateParams = - serde_json::from_value(arguments).context("invalid evaluate arguments")?; - let page = self - .ensure_page( - args.url.as_deref(), - args.timeout, - args.wait_until.as_deref(), - ) - .await?; - let result = page - .evaluate_runtime_expression_with_await_async(&args.script, true) - .await - .context("script evaluation failed")?; - tool_json(result) - } - - async fn tool_semantic_tree(&mut self, arguments: Value) -> Result { - let args: SemanticTreeParams = - serde_json::from_value(arguments).context("invalid semantic_tree arguments")?; - let page = self - .ensure_page( - args.url.as_deref(), - args.timeout, - args.wait_until.as_deref(), - ) - .await?; - let payloads = match args.backend_node_id { - Some(backend_node_id) => { - accessibility_tree_payloads_for_backend_node_id_live( - page, - backend_node_id, - args.max_depth, - ) - .await? - } - None => { - page.accessibility_tree_payloads_for_document_async(args.max_depth) - .await? - } - }; - let rendered = render_semantic_tree_text(&payloads); - Ok(json!({ - "content": [{ - "type": "text", - "text": rendered - }], - "structuredContent": payloads - })) - } - - async fn tool_node_details(&mut self, arguments: Value) -> Result { - #[derive(Deserialize)] - struct NodeDetailsParams { - #[serde(rename = "backendNodeId")] - backend_node_id: u32, - } - - let args: NodeDetailsParams = - serde_json::from_value(arguments).context("invalid nodeDetails arguments")?; - let page = self - .current_page - .as_mut() - .ok_or_else(|| anyhow!("page not loaded"))?; - let details = summarize_node_details_async(page, args.backend_node_id).await?; - tool_json(details) - } - - async fn tool_interactive_elements(&mut self, arguments: Value) -> Result { - let args: OptionalNavigateParams = - serde_json::from_value(arguments).context("invalid interactiveElements arguments")?; - let page = self - .ensure_page( - args.url.as_deref(), - args.timeout, - args.wait_until.as_deref(), - ) - .await?; - tool_json(json!(collect_interactive_elements(page).await?)) - } - - async fn tool_find_element(&mut self, arguments: Value) -> Result { - #[derive(Deserialize)] - struct FindElementParams { - role: Option, - name: Option, - } - - let args: FindElementParams = - serde_json::from_value(arguments).context("invalid findElement arguments")?; - let page = self - .current_page - .as_mut() - .ok_or_else(|| anyhow!("page not loaded"))?; - let role_filter = args.role.as_deref().map(str::to_ascii_lowercase); - let name_filter = args.name.as_deref().map(str::to_ascii_lowercase); - let matches = collect_interactive_elements(page) - .await? - .into_iter() - .filter(|item| { - let role_ok = role_filter.as_deref().is_none_or(|role| { - item.get("role") - .and_then(Value::as_str) - .is_some_and(|candidate| candidate.eq_ignore_ascii_case(role)) - }); - let name_ok = name_filter.as_deref().is_none_or(|needle| { - item.get("name") - .and_then(Value::as_str) - .unwrap_or_default() - .to_ascii_lowercase() - .contains(needle) - }); - role_ok && name_ok - }) - .collect::>(); - tool_json(json!(matches)) - } - - async fn tool_wait_for_selector(&mut self, arguments: Value) -> Result { - #[derive(Deserialize)] - struct WaitForSelectorParams { - selector: String, - timeout: Option, - } - - let args: WaitForSelectorParams = - serde_json::from_value(arguments).context("invalid waitForSelector arguments")?; - let timeout = Duration::from_millis(args.timeout.unwrap_or(5_000)); - let page = self - .current_page - .as_mut() - .ok_or_else(|| anyhow!("page not loaded"))?; - let node = self - .browser - .wait_for_selector(page, &args.selector, timeout) - .await?; - tool_json(json!({ - "backendNodeId": node.backend_node_id, - "selector": args.selector - })) - } - - async fn tool_click(&mut self, arguments: Value) -> Result { - #[derive(Deserialize)] - struct ClickParams { - #[serde(rename = "backendNodeId")] - backend_node_id: u32, - } - - let args: ClickParams = - serde_json::from_value(arguments).context("invalid click arguments")?; - let page = self - .current_page - .as_mut() - .ok_or_else(|| anyhow!("page not loaded"))?; - click_backend_node_center_async(page, args.backend_node_id).await?; - tool_json(json!({ - "backendNodeId": args.backend_node_id, - "url": page.final_url(), - "title": page.document_title() - })) - } - - async fn tool_fill(&mut self, arguments: Value) -> Result { - #[derive(Deserialize)] - struct FillParams { - #[serde(rename = "backendNodeId")] - backend_node_id: u32, - text: String, - } - - let args: FillParams = - serde_json::from_value(arguments).context("invalid fill arguments")?; - let page = self - .current_page - .as_mut() - .ok_or_else(|| anyhow!("page not loaded"))?; - click_backend_node_center_async(page, args.backend_node_id).await?; - page.evaluate_runtime_expression_async( - "(() => { const el = document.activeElement; if (!el) return false; if ('value' in el) { el.value = ''; } return true; })()", - ) - .await?; - if !page - .insert_text_into_active_control_async(&args.text) - .await? - { - return Err(anyhow!("failed to insert text into the active control")); - } - let value = page - .evaluate_runtime_expression_async( - "(() => { const el = document.activeElement; return el && 'value' in el ? String(el.value) : ''; })()", - ) - .await? - .get("value") - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned(); - tool_json(json!({ - "backendNodeId": args.backend_node_id, - "value": value, - "url": page.final_url(), - "title": page.document_title() - })) - } - - async fn tool_press(&mut self, arguments: Value) -> Result { - #[derive(Deserialize)] - struct PressParams { - key: String, - #[serde(rename = "backendNodeId")] - backend_node_id: Option, - } - - let args: PressParams = - serde_json::from_value(arguments).context("invalid press arguments")?; - let page = self - .current_page - .as_mut() - .ok_or_else(|| anyhow!("page not loaded"))?; - if let Some(backend_node_id) = args.backend_node_id { - click_backend_node_center_async(page, backend_node_id).await?; - } - let key_spec = key_spec(&args.key); - page.dispatch_key_event_async( - "keydown", - key_spec.key, - key_spec.code, - key_spec.text, - 0, - false, - !key_spec.text.is_empty(), - ) - .await?; - page.dispatch_key_event_async("keyup", key_spec.key, key_spec.code, "", 0, false, false) - .await?; - tool_json(json!({ - "key": args.key, - "url": page.final_url(), - "title": page.document_title() - })) - } - - async fn tool_hover(&mut self, arguments: Value) -> Result { - #[derive(Deserialize)] - struct HoverParams { - #[serde(rename = "backendNodeId")] - backend_node_id: u32, - } - - let args: HoverParams = - serde_json::from_value(arguments).context("invalid hover arguments")?; - let page = self - .current_page - .as_mut() - .ok_or_else(|| anyhow!("page not loaded"))?; - call_function_on_backend_node_async( - page, - args.backend_node_id, - r#"function() { - const rect = this.getBoundingClientRect(); - const init = { - bubbles: true, - cancelable: true, - clientX: rect.left + rect.width / 2, - clientY: rect.top + rect.height / 2 - }; - this.dispatchEvent(new MouseEvent('mouseover', init)); - this.dispatchEvent(new MouseEvent('mouseenter', init)); - this.dispatchEvent(new MouseEvent('mousemove', init)); - return true; - }"#, - vec![], - ) - .await?; - tool_json(json!({ - "backendNodeId": args.backend_node_id, - "url": page.final_url(), - "title": page.document_title() - })) - } - - async fn tool_scroll(&mut self, arguments: Value) -> Result { - #[derive(Deserialize)] - struct ScrollParams { - #[serde(rename = "backendNodeId")] - backend_node_id: Option, - x: Option, - y: Option, - } - - let args: ScrollParams = - serde_json::from_value(arguments).context("invalid scroll arguments")?; - let x = args.x.unwrap_or(0); - let y = args.y.unwrap_or(0); - let page = self - .current_page - .as_mut() - .ok_or_else(|| anyhow!("page not loaded"))?; - - let positions = if let Some(backend_node_id) = args.backend_node_id { - call_function_on_backend_node_async( - page, - backend_node_id, - r#"function(x, y) { - this.scrollLeft = x; - this.scrollTop = y; - this.dispatchEvent(new Event('scroll')); - return { x: this.scrollLeft, y: this.scrollTop }; - }"#, - vec![json!({ "value": x }), json!({ "value": y })], - ) - .await? - } else { - let payload = page - .evaluate_runtime_expression_async(&format!( - "(() => {{ window.scrollTo({x}, {y}); window.dispatchEvent(new Event('scroll')); return {{ x: window.scrollX, y: window.scrollY }}; }})()" - )) - .await?; - payload.get("value").cloned().unwrap_or(payload) - }; - - tool_json(json!({ - "position": positions, - "url": page.final_url(), - "title": page.document_title() - })) - } - - async fn tool_select_option(&mut self, arguments: Value) -> Result { - #[derive(Deserialize)] - struct SelectOptionParams { - #[serde(rename = "backendNodeId")] - backend_node_id: u32, - value: String, - } - - let args: SelectOptionParams = - serde_json::from_value(arguments).context("invalid selectOption arguments")?; - let page = self - .current_page - .as_mut() - .ok_or_else(|| anyhow!("page not loaded"))?; - let result = call_function_on_backend_node_async( - page, - args.backend_node_id, - r#"function(value) { - if (!(this instanceof HTMLSelectElement)) { - throw new TypeError('Node is not a element by value.", - "inputSchema": { - "type": "object", - "properties": { - "backendNodeId": { "type": "integer" }, - "value": { "type": "string" } - }, - "required": ["backendNodeId", "value"] - } - }), - json!({ - "name": "setChecked", - "description": "Set checked state on a checkbox or radio input.", - "inputSchema": { - "type": "object", - "properties": { - "backendNodeId": { "type": "integer" }, - "checked": { "type": "boolean" } - }, - "required": ["backendNodeId", "checked"] - } - }), - ] -} - -fn resource_list() -> Vec { - vec![ - json!({ - "uri": "mcp://page/html", - "name": "Page HTML", - "description": "Serialized HTML of the current page", - "mimeType": "text/html" - }), - json!({ - "uri": "mcp://page/markdown", - "name": "Page Markdown", - "description": "Markdown representation of the current page", - "mimeType": "text/markdown" - }), - ] -} - -fn url_tool_schema() -> Value { - json!({ - "type": "object", - "properties": { - "url": { "type": "string" }, - "timeout": { "type": "integer" }, - "waitUntil": { "type": "string", "enum": ["domcontentloaded", "load", "networkidle", "done"] } - } - }) -} - -fn success_response(id: Value, result: Value) -> Value { - json!({ - "jsonrpc": "2.0", - "id": id, - "result": result - }) -} - -fn error_response(id: Value, code: i64, message: String) -> Value { - json!({ - "jsonrpc": "2.0", - "id": id, - "error": { - "code": code, - "message": message - } - }) -} - -fn tool_text(text: String) -> Result { - Ok(json!({ - "content": [{ - "type": "text", - "text": text - }] - })) -} - -fn tool_json(value: Value) -> Result { - Ok(json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&value)? - }], - "structuredContent": value - })) -} - -fn tool_error(message: String) -> Value { - json!({ - "content": [{ - "type": "text", - "text": message - }], - "isError": true - }) -} - -fn parse_wait_until(raw: Option<&str>) -> Result { - match raw.unwrap_or("done").to_ascii_lowercase().as_str() { - "domcontentloaded" | "dom_content_loaded" => Ok(RenderedDomWaitUntil::DomContentLoaded), - "load" => Ok(RenderedDomWaitUntil::Load), - "networkidle" | "network_idle" => Ok(RenderedDomWaitUntil::NetworkIdle), - "domstable" | "dom_stable" => Ok(RenderedDomWaitUntil::DomStable), - "done" => Ok(RenderedDomWaitUntil::Done), - other => Err(anyhow!("unsupported waitUntil `{other}`")), - } -} - -async fn collect_interactive_elements(page: &mut Page) -> Result> { - let payloads = page - .accessibility_tree_payloads_for_document_async(None) - .await?; - let mut items = Vec::new(); - let mut seen = HashSet::new(); - - for payload in payloads { - let backend_node_id = payload - .get("backendDOMNodeId") - .and_then(Value::as_u64) - .and_then(|value| u32::try_from(value).ok()) - .unwrap_or_default(); - if backend_node_id == 0 || !seen.insert(backend_node_id) { - continue; - } - - let role = payload["role"]["value"] - .as_str() - .unwrap_or_default() - .to_owned(); - let name = payload["name"]["value"] - .as_str() - .unwrap_or_default() - .to_owned(); - let value = payload["value"]["value"] - .as_str() - .unwrap_or_default() - .to_owned(); - let snapshot = - document_node_snapshot_for_backend_node_id_live(page, backend_node_id).await?; - if !is_interactive_role(&role) && !snapshot.as_ref().is_some_and(snapshot_looks_interactive) - { - continue; - } - - items.push(json!({ - "backendNodeId": backend_node_id, - "role": role, - "name": name, - "value": value, - "tag": snapshot.as_ref().map(|snapshot| snapshot.local_name.clone()).unwrap_or_default(), - "href": snapshot.as_ref().and_then(|snapshot| snapshot_attribute(snapshot, "href")).unwrap_or_default(), - "inputType": snapshot.as_ref().and_then(|snapshot| snapshot_attribute(snapshot, "type")).unwrap_or_default(), - "disabled": snapshot.as_ref().is_some_and(|snapshot| snapshot_attribute(snapshot, "disabled").is_some()), - })); - } - - Ok(items) -} - -fn is_interactive_role(role: &str) -> bool { - matches!( - role, - "button" - | "link" - | "textbox" - | "searchbox" - | "checkbox" - | "radio" - | "combobox" - | "listbox" - | "option" - | "menuitem" - | "tab" - | "switch" - | "slider" - | "spinbutton" - ) -} - -fn snapshot_looks_interactive(snapshot: &DocumentNodeSnapshot) -> bool { - match snapshot.local_name.as_str() { - "a" => snapshot_attribute(snapshot, "href").is_some(), - "button" | "select" | "textarea" => true, - "input" => !matches!( - snapshot_attribute(snapshot, "type") - .unwrap_or_else(|| "text".to_owned()) - .as_str(), - "hidden" - ), - _ => false, - } -} - -fn snapshot_attribute(snapshot: &DocumentNodeSnapshot, name: &str) -> Option { - snapshot - .attributes - .iter() - .find(|attribute| attribute.local_name == name) - .map(|attribute| attribute.value.clone()) -} - -fn attribute_value_from_pairs(attributes: &[(String, String)], name: &str) -> Option { - attributes - .iter() - .find(|(local_name, _)| local_name == name) - .map(|(_, value)| value.clone()) -} - -async fn query_selector_all_live( - page: &mut Page, - selector: &str, -) -> Result> { - let pending = page.start_document_query_selector_for_document(selector.to_owned(), true)?; - let completion = pending.wait().await?; - match page.finish_document_query_selector(completion)? { - RendererDocumentQuerySelectorResolution::Found(node_ids) => Ok(node_ids), - RendererDocumentQuerySelectorResolution::MissingRoot => { - Err(anyhow!("document root missing for selector `{selector}`")) - } - RendererDocumentQuerySelectorResolution::InvalidSelector(message) => { - Err(anyhow!("invalid selector `{selector}`: {message}")) - } - } -} - -async fn node_attributes_live( - page: &mut Page, - backend_node_id: u32, -) -> Result>> { - let pending = page.start_document_node_attributes_for_backend_node_id(backend_node_id)?; - let completion = pending.wait().await?; - match page.finish_document_node_attributes(completion)? { - RendererDocumentNodeAttributesResolution::Found(attributes) => Ok(Some(attributes)), - RendererDocumentNodeAttributesResolution::NotElement - | RendererDocumentNodeAttributesResolution::MissingNode => Ok(None), - } -} - -async fn node_text_live(page: &mut Page, backend_node_id: u32) -> Result> { - let pending = page.start_document_node_text_for_backend_node_id(backend_node_id)?; - let completion = pending.wait().await?; - match page.finish_document_node_text(completion)? { - RendererDocumentNodeTextResolution::Found(text) => Ok(Some(text)), - RendererDocumentNodeTextResolution::MissingNode => Ok(None), - } -} - -async fn document_node_snapshot_for_backend_node_id_live( - page: &mut Page, - backend_node_id: u32, -) -> Result> { - let pending = - page.start_document_node_snapshot_for_backend_node_id(backend_node_id, 0, false)?; - let completion = pending.wait().await?; - Ok(page - .finish_document_node_snapshot_for_backend_node_id(completion)? - .map(|object_snapshot| object_snapshot.snapshot)) -} - -async fn accessibility_tree_payloads_for_backend_node_id_live( - page: &mut Page, - backend_node_id: u32, - max_depth: Option, -) -> Result> { - let pending = - page.start_accessibility_tree_payloads_for_backend_node_id(backend_node_id, max_depth)?; - let completion = pending.wait().await?; - Ok(page - .finish_accessibility_payloads_for_backend_node_id(completion)? - .and_then(|payload| payload.payloads) - .unwrap_or_default()) -} - -async fn client_rect_for_backend_node_id_live( - page: &mut Page, - backend_node_id: u32, -) -> Result> { - let pending = page.start_client_rect_for_backend_node_id(backend_node_id)?; - let completion = pending.wait().await?; - Ok( - match page.finish_client_rect_for_backend_node_id(completion)? { - Some(DocumentNodeClientRectResolution::Found(rect)) => Some(rect), - Some( - DocumentNodeClientRectResolution::FoundNonElement(_) - | DocumentNodeClientRectResolution::NotElement, - ) - | None => None, - }, - ) -} - -async fn click_backend_node_center_async(page: &mut Page, backend_node_id: u32) -> Result<()> { - let rect = client_rect_for_backend_node_id_live(page, backend_node_id) - .await? - .ok_or_else(|| anyhow!("backendNodeId `{backend_node_id}` has no clickable geometry"))?; - let x = rect.left + (rect.width / 2.0); - let y = rect.top + (rect.height / 2.0); - if !page - .dispatch_mouse_event_at_point_async(x, y, "mousedown", 0, None, 0.0, 0.0) - .await? - { - return Err(anyhow!("failed to dispatch click at node center")); - } - if !page - .dispatch_mouse_event_at_point_async(x, y, "mouseup", 0, None, 0.0, 0.0) - .await? - { - return Err(anyhow!("failed to dispatch click at node center")); - } - Ok(()) -} - -async fn call_function_on_backend_node_async( - page: &mut Page, - backend_node_id: u32, - function_declaration: &str, - arguments: Vec, -) -> Result { - let pending = page.start_resolve_runtime_object_for_backend_node_id_in_inspector_session( - None, - backend_node_id, - None, - Some("mcp-actions"), - )?; - let completion = pending.wait().await?; - let resolved = match page.finish_resolve_runtime_object_for_backend_node_id(completion)? { - DocumentNodeRuntimeObjectResolution::Found(remote_object) => remote_object, - DocumentNodeRuntimeObjectResolution::MissingContext => { - return Err(anyhow!( - "backendNodeId `{backend_node_id}` could not be resolved in the current runtime context" - )); - } - DocumentNodeRuntimeObjectResolution::MissingNode => { - return Err(anyhow!("backendNodeId `{backend_node_id}` not found")); - } - }; - let object_id = resolved - .as_protocol_value() - .get("objectId") - .and_then(Value::as_str) - .ok_or_else(|| { - anyhow!("backendNodeId `{backend_node_id}` runtime object did not include an objectId") - })?; - - let request = json!({ - "id": 1, - "method": "Runtime.callFunctionOn", - "params": { - "objectId": object_id, - "functionDeclaration": function_declaration, - "arguments": arguments, - "returnByValue": true - } - }); - let raw = serde_json::to_string(&request)?; - let prepared = page - .prepare_runtime_protocol_message_async("callFunctionOn", &raw) - .await?; - let (response_tx, response_rx) = tokio::sync::oneshot::channel(); - let pending = page.start_runtime_protocol_message_with_deferred_response( - prepared, - RendererRuntimeInspectorResponseSender::new(1, response_tx), - )?; - let completion = pending.wait().await?; - let output = page.finish_runtime_protocol_message_command_turn(completion)?; - let response = match output - .runtime_inspector_output() - .and_then(|output| output.protocol_response(1)) - .cloned() - { - Some(response) => response, - None => response_rx - .await - .map_err(|_| anyhow!("runtime callFunctionOn response callback was canceled"))? - .output - .into_protocol_response(1) - .ok_or_else(|| anyhow!("runtime callFunctionOn returned no protocol response"))?, - }; - if let Some(error) = response.get("error") { - let message = error - .get("message") - .and_then(Value::as_str) - .unwrap_or("runtime callFunctionOn failed"); - return Err(anyhow!(message.to_owned())); - } - let result = response - .get("result") - .and_then(|result| result.get("result")) - .cloned() - .ok_or_else(|| anyhow!("runtime callFunctionOn returned no result payload"))?; - Ok(result.get("value").cloned().unwrap_or(result)) -} - -struct KeySpec<'a> { - key: &'a str, - code: &'a str, - text: &'a str, -} - -fn key_spec(key: &str) -> KeySpec<'_> { - match key { - "Enter" => KeySpec { - key, - code: "Enter", - text: "", - }, - "Tab" => KeySpec { - key, - code: "Tab", - text: "", - }, - "Escape" => KeySpec { - key, - code: "Escape", - text: "", - }, - "Backspace" => KeySpec { - key, - code: "Backspace", - text: "", - }, - "ArrowUp" => KeySpec { - key, - code: "ArrowUp", - text: "", - }, - "ArrowDown" => KeySpec { - key, - code: "ArrowDown", - text: "", - }, - "ArrowLeft" => KeySpec { - key, - code: "ArrowLeft", - text: "", - }, - "ArrowRight" => KeySpec { - key, - code: "ArrowRight", - text: "", - }, - _ if key.len() == 1 => { - let ch = key.chars().next().unwrap(); - if ch.is_ascii_alphabetic() { - let upper = if ch.is_ascii_lowercase() { - ch.to_ascii_uppercase() - } else { - ch - }; - let code = match upper { - 'A' => "KeyA", - 'B' => "KeyB", - 'C' => "KeyC", - 'D' => "KeyD", - 'E' => "KeyE", - 'F' => "KeyF", - 'G' => "KeyG", - 'H' => "KeyH", - 'I' => "KeyI", - 'J' => "KeyJ", - 'K' => "KeyK", - 'L' => "KeyL", - 'M' => "KeyM", - 'N' => "KeyN", - 'O' => "KeyO", - 'P' => "KeyP", - 'Q' => "KeyQ", - 'R' => "KeyR", - 'S' => "KeyS", - 'T' => "KeyT", - 'U' => "KeyU", - 'V' => "KeyV", - 'W' => "KeyW", - 'X' => "KeyX", - 'Y' => "KeyY", - _ => "KeyZ", - }; - KeySpec { - key, - code, - text: key, - } - } else if ch.is_ascii_digit() { - let code = match ch { - '0' => "Digit0", - '1' => "Digit1", - '2' => "Digit2", - '3' => "Digit3", - '4' => "Digit4", - '5' => "Digit5", - '6' => "Digit6", - '7' => "Digit7", - '8' => "Digit8", - _ => "Digit9", - }; - KeySpec { - key, - code, - text: key, - } - } else if ch == ' ' { - KeySpec { - key: " ", - code: "Space", - text: " ", - } - } else { - KeySpec { - key, - code: "Unidentified", - text: key, - } - } - } - _ => KeySpec { - key, - code: key, - text: "", - }, - } -} - -fn render_semantic_tree_text(payloads: &[Value]) -> String { - if payloads.is_empty() { - return String::new(); - } - - let mut by_id = HashMap::new(); - for payload in payloads { - if let Some(node_id) = payload.get("nodeId").and_then(Value::as_str) { - by_id.insert(node_id.to_owned(), payload); - } - } - - let Some(root_id) = payloads[0].get("nodeId").and_then(Value::as_str) else { - return String::new(); - }; - - let mut out = String::new(); - render_semantic_tree_line(root_id, &by_id, 0, &mut out); - out.trim_end().to_owned() -} - -fn render_semantic_tree_line( - node_id: &str, - by_id: &HashMap, - depth: usize, - out: &mut String, -) { - let Some(payload) = by_id.get(node_id) else { - return; - }; - - let role = payload["role"]["value"].as_str().unwrap_or("unknown"); - let name = payload["name"]["value"].as_str().unwrap_or_default(); - let value = payload["value"]["value"].as_str().unwrap_or_default(); - let backend = payload["backendDOMNodeId"].as_u64().unwrap_or(0); - - out.push_str(&" ".repeat(depth)); - out.push_str("- "); - out.push_str(role); - if !name.is_empty() { - out.push_str(": "); - out.push_str(name); - } - if !value.is_empty() { - out.push_str(" = "); - out.push_str(value); - } - if backend != 0 { - out.push_str(&format!(" [backendNodeId={backend}]")); - } - out.push('\n'); - - for child_id in payload["childIds"] - .as_array() - .into_iter() - .flatten() - .filter_map(Value::as_str) - { - render_semantic_tree_line(child_id, by_id, depth + 1, out); - } -} - -async fn read_message(reader: &mut R) -> Result>> -where - R: AsyncBufRead + Unpin, -{ - let mut content_length = None; - loop { - let mut line = Vec::new(); - let bytes_read = reader.read_until(b'\n', &mut line).await?; - if bytes_read == 0 { - return if content_length.is_none() { - Ok(None) - } else { - Err(anyhow!("unexpected EOF while reading MCP headers")) - }; - } - - if line == b"\n" || line == b"\r\n" { - break; - } - - let line = std::str::from_utf8(&line)?.trim(); - if let Some(value) = line.strip_prefix("Content-Length:") { - content_length = Some(value.trim().parse::()?); - } - } - - let content_length = content_length.ok_or_else(|| anyhow!("missing Content-Length header"))?; - let mut body = vec![0; content_length]; - reader.read_exact(&mut body).await?; - Ok(Some(body)) -} - -async fn write_message(writer: &mut W, response: &Value) -> Result<()> -where - W: AsyncWrite + Unpin, -{ - let body = serde_json::to_vec(response)?; - let header = format!("Content-Length: {}\r\n\r\n", body.len()); - writer.write_all(header.as_bytes()).await?; - writer.write_all(&body).await?; - writer.flush().await?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::{Router, routing::get}; - use moli_core::runtime::BrowserConfig; - use std::sync::Arc; - use tokio::{net::TcpListener, task::JoinHandle}; - - async fn server_with_page(html: &str) -> Result<(McpServer, JoinHandle<()>)> { - let browser = Browser::new(BrowserConfig::default())?; - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let body = Arc::new(html.to_owned()); - let server_body = Arc::clone(&body); - let http_server = tokio::spawn(async move { - let app = Router::new().route( - "/", - get(move || { - let body = Arc::clone(&server_body); - async move { (*body).clone() } - }), - ); - axum::serve(listener, app).await.unwrap(); - }); - let page = browser.fetch(&format!("http://{addr}/")).await?; - let mut server = McpServer::new(browser); - server.current_page = Some(page); - Ok((server, http_server)) - } - - async fn backend_node_id_for_selector(page: &mut Page, selector: &str) -> Result { - query_selector_all_live(page, selector) - .await? - .into_iter() - .next() - .map(|node| node.backend_node_id) - .ok_or_else(|| anyhow!("selector `{selector}` not found")) - } - - #[tokio::test(flavor = "multi_thread")] - async fn tool_links_uses_renderer_live_dom_commands() -> Result<()> { - let (mut server, http_server) = server_with_page( - "One Link", - ) - .await?; - - let result = server.tool_links(json!({})).await?; - let links = result["structuredContent"] - .as_array() - .ok_or_else(|| anyhow!("links result should be an array"))?; - - assert_eq!(links.len(), 1); - assert_eq!(links[0]["href"], json!("/one")); - assert_eq!(links[0]["text"], json!("One Link")); - assert!( - links[0]["backendNodeId"] - .as_u64() - .and_then(|backend_node_id| u32::try_from(backend_node_id).ok()) - .is_some_and(moli_core::page::is_renderer_backend_node_id) - ); - http_server.abort(); - Ok(()) - } - - #[tokio::test(flavor = "multi_thread")] - async fn tool_wait_for_selector_returns_renderer_backend_node_id() -> Result<()> { - let (mut server, http_server) = - server_with_page("
Ready
").await?; - - let result = server - .tool_wait_for_selector(json!({ - "selector": "#ready", - "timeout": 500 - })) - .await?; - let backend_node_id = result["structuredContent"]["backendNodeId"] - .as_u64() - .and_then(|backend_node_id| u32::try_from(backend_node_id).ok()) - .ok_or_else(|| anyhow!("waitForSelector should return backendNodeId"))?; - assert!(moli_core::page::is_renderer_backend_node_id( - backend_node_id - )); - - let details = server - .tool_node_details(json!({ - "backendNodeId": backend_node_id - })) - .await?; - assert_eq!(details["structuredContent"]["tag"], json!("div")); - http_server.abort(); - Ok(()) - } - - #[tokio::test(flavor = "multi_thread")] - async fn tool_interactive_elements_uses_renderer_live_snapshot_enrichment() -> Result<()> { - let (mut server, http_server) = - server_with_page("Go").await?; - - let result = server.tool_interactive_elements(json!({})).await?; - let items = result["structuredContent"] - .as_array() - .ok_or_else(|| anyhow!("interactive elements result should be an array"))?; - let link = items - .iter() - .find(|item| item["href"] == json!("/go")) - .ok_or_else(|| anyhow!("link should be present in interactive elements"))?; - assert_eq!(link["tag"], json!("a")); - assert_eq!(link["role"], json!("link")); - - let result = server - .tool_find_element(json!({ - "role": "link", - "name": "Go" - })) - .await?; - let matches = result["structuredContent"] - .as_array() - .ok_or_else(|| anyhow!("findElement result should be an array"))?; - assert!(matches.iter().any(|item| item["href"] == json!("/go"))); - http_server.abort(); - Ok(()) - } - - #[tokio::test(flavor = "multi_thread")] - async fn tool_semantic_tree_uses_renderer_live_accessibility_tree() -> Result<()> { - let (mut server, http_server) = - server_with_page(r#""#).await?; - - let completion = { - let page = server - .current_page - .as_mut() - .ok_or_else(|| anyhow!("page not loaded"))?; - let mutation = json!({ - "id": 21, - "method": "Runtime.evaluate", - "params": { - "expression": "document.getElementById('target').textContent = 'live'; 'done';", - "returnByValue": true - } - }); - let pending = page.start_runtime_protocol_message(serde_json::to_string(&mutation)?)?; - pending.wait().await? - }; - - let result = server.tool_semantic_tree(json!({})).await?; - let payloads = result["structuredContent"] - .as_array() - .ok_or_else(|| anyhow!("semantic_tree result should be an array"))?; - assert!( - payloads - .iter() - .any(|payload| payload["name"]["value"] == json!("live")) - ); - assert!( - !payloads - .iter() - .any(|payload| payload["name"]["value"] == json!("old")) - ); - - let page = server - .current_page - .as_mut() - .ok_or_else(|| anyhow!("page not loaded"))?; - let _ = page.finish_runtime_protocol_message(completion)?; - http_server.abort(); - Ok(()) - } - - #[tokio::test(flavor = "multi_thread")] - async fn tool_node_details_uses_renderer_live_snapshot() -> Result<()> { - let (mut server, http_server) = server_with_page( - "", - ) - .await?; - let backend_node_id = { - let page = server - .current_page - .as_mut() - .ok_or_else(|| anyhow!("page not loaded"))?; - backend_node_id_for_selector(page, "#sel").await? - }; - - let result = server - .tool_node_details(json!({ - "backendNodeId": backend_node_id - })) - .await?; - - let details = &result["structuredContent"]; - assert_eq!(details["backendNodeId"], json!(backend_node_id)); - assert_eq!(details["tag"], json!("select")); - assert_eq!(details["options"][0]["value"], json!("one")); - assert_eq!(details["options"][1]["text"], json!("Two")); - assert_eq!(details["options"][1]["selected"], json!(true)); - http_server.abort(); - Ok(()) - } - - #[tokio::test(flavor = "multi_thread")] - async fn tool_node_details_reads_accessibility_by_backend_node_id() -> Result<()> { - let (mut server, http_server) = - server_with_page("").await?; - let backend_node_id = { - let page = server - .current_page - .as_mut() - .ok_or_else(|| anyhow!("page not loaded"))?; - backend_node_id_for_selector(page, "#submit").await? - }; - - let result = server - .tool_node_details(json!({ - "backendNodeId": backend_node_id - })) - .await?; - - let details = &result["structuredContent"]; - assert_eq!(details["backendNodeId"], json!(backend_node_id)); - assert_eq!(details["tag"], json!("button")); - assert_eq!(details["role"], json!("button")); - assert_eq!(details["name"], json!("Submit order")); - http_server.abort(); - Ok(()) - } - - #[tokio::test(flavor = "multi_thread")] - async fn tool_fill_uses_async_page_commands() -> Result<()> { - let (mut server, http_server) = - server_with_page("
").await?; - let backend_node_id = { - let page = server - .current_page - .as_mut() - .ok_or_else(|| anyhow!("page not loaded"))?; - backend_node_id_for_selector(page, "#field").await? - }; - - let result = server - .tool_fill(json!({ - "backendNodeId": backend_node_id, - "text": "async fill" - })) - .await?; - - assert_eq!(result["structuredContent"]["value"], json!("async fill")); - http_server.abort(); - Ok(()) - } - - #[tokio::test(flavor = "multi_thread")] - async fn tool_evaluate_awaits_timer_promise_through_owner_continuation() -> Result<()> { - let (mut server, http_server) = - server_with_page("
evaluate await
").await?; - - let result = server - .tool_evaluate(json!({ - "script": r#"new Promise(resolve => { - setTimeout(() => resolve("mcp-owner-await"), 25); -})"# - })) - .await?; - - assert_eq!( - result["structuredContent"]["value"], - json!("mcp-owner-await") - ); - http_server.abort(); - Ok(()) - } - - #[tokio::test(flavor = "multi_thread")] - async fn tool_select_option_uses_async_runtime_protocol_commands() -> Result<()> { - let (mut server, http_server) = server_with_page( - "", - ) - .await?; - let backend_node_id = { - let page = server - .current_page - .as_mut() - .ok_or_else(|| anyhow!("page not loaded"))?; - backend_node_id_for_selector(page, "#sel").await? - }; - - let result = server - .tool_select_option(json!({ - "backendNodeId": backend_node_id, - "value": "two" - })) - .await?; - - assert_eq!( - result["structuredContent"]["backendNodeId"], - json!(backend_node_id) - ); - let selected_value = server - .current_page - .as_mut() - .ok_or_else(|| anyhow!("page not loaded"))? - .evaluate_runtime_expression_async("document.getElementById('sel').value") - .await?; - assert_eq!(selected_value["value"], json!("two")); - http_server.abort(); - Ok(()) - } -} diff --git a/moli/tests/cli.rs b/moli/tests/cli.rs index 25c9bbedd..e9830b590 100644 --- a/moli/tests/cli.rs +++ b/moli/tests/cli.rs @@ -2,7 +2,7 @@ use clap::Parser; use std::{num::NonZeroU32, process::Command}; use moli::cli::{ - Cli, Commands, CommonArgs, DumpFormat, FetchArgs, FetchWaitUntil, LogFormat, LogLevel, McpArgs, + Cli, Commands, CommonArgs, DumpFormat, FetchArgs, FetchWaitUntil, LogFormat, LogLevel, RequestHeaderArg, ResponseJsonPathArg, ServeArgs, StripModeChoice, StripOptions, normalize_args_for_compat, }; @@ -470,14 +470,14 @@ fn infers_serve_mode_when_called_without_args() { assert_eq!( cli.command, - Commands::Serve(ServeArgs { + Commands::Serve(Box::new(ServeArgs { host: "127.0.0.1".to_owned(), port: 9222, timeout: 10, cdp_max_connections: 16, cdp_max_pending_connections: 128, common: CommonArgs::default(), - }) + })) ); } @@ -496,39 +496,14 @@ fn infers_serve_mode_from_legacy_serve_flags() { assert_eq!( cli.command, - Commands::Serve(ServeArgs { + Commands::Serve(Box::new(ServeArgs { host: "0.0.0.0".to_owned(), port: 9333, timeout: 42, cdp_max_connections: 16, cdp_max_pending_connections: 128, common: CommonArgs::default(), - }) - ); -} - -#[test] -fn parses_mcp_command_with_common_flags() { - let cli = Cli::try_parse_from(normalize_args_for_compat([ - "moli", - "mcp", - "--log-level", - "warn", - "--user-agent-suffix", - "bot", - ])) - .unwrap(); - - assert_eq!( - cli.command, - Commands::Mcp(McpArgs { - common: CommonArgs { - log_level: Some(LogLevel::Warn), - user_agent: None, - user_agent_suffix: Some("bot".to_owned()), - ..CommonArgs::default() - }, - }) + })) ); }