MCP support (#65)
* add mcp support * add MCP docs to README * wire up mcp subcommand in cli * fix(stealth): propagate stealth feature to obscura-mcp
This commit is contained in:
1 parent
f1cbd58894
commit
bce055fd2a
9 files changed
+1215
-1
No files matched your search
Generated
+15
@@ -1635,6 +1635,7 @@ dependencies = [
|
||||
"obscura-cdp",
|
||||
"obscura-dom",
|
||||
"obscura-js",
|
||||
"obscura-mcp",
|
||||
"obscura-net",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1678,6 +1679,20 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "obscura-mcp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"obscura-browser",
|
||||
"obscura-dom",
|
||||
"obscura-net",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "obscura-net"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -6,6 +6,7 @@ members = [
|
||||
"crates/obscura-browser",
|
||||
"crates/obscura-cdp",
|
||||
"crates/obscura-js",
|
||||
"crates/obscura-mcp",
|
||||
"crates/obscura-cli",
|
||||
]
|
||||
|
||||
@@ -21,6 +22,7 @@ obscura-net = { path = "crates/obscura-net" }
|
||||
obscura-browser = { path = "crates/obscura-browser" }
|
||||
obscura-cdp = { path = "crates/obscura-cdp" }
|
||||
obscura-js = { path = "crates/obscura-js" }
|
||||
obscura-mcp = { path = "crates/obscura-mcp" }
|
||||
|
||||
html5ever = "0.29"
|
||||
markup5ever = "0.14"
|
||||
|
||||
@@ -277,6 +277,63 @@ Scrape multiple URLs in parallel with worker processes.
|
||||
| `--quiet` | off | Suppress scrape progress on stderr |
|
||||
| `--proxy` | — | Inherited global HTTP/SOCKS5 proxy URL for all workers |
|
||||
|
||||
## MCP (Model Context Protocol)
|
||||
|
||||
Obscura ships an MCP server that exposes browser automation tools to AI agents (Claude Desktop, Cursor, etc.).
|
||||
|
||||
### Start
|
||||
|
||||
**stdio** (default) — for Claude Desktop and MCP clients that launch a subprocess:
|
||||
|
||||
```bash
|
||||
obscura mcp
|
||||
```
|
||||
|
||||
**HTTP** — for clients that connect over the network:
|
||||
|
||||
```bash
|
||||
obscura mcp --http --port 8080
|
||||
# endpoint: http://127.0.0.1:8080/mcp
|
||||
```
|
||||
|
||||
Optional flags (both transports):
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--proxy <URL>` | HTTP/SOCKS5 proxy |
|
||||
| `--user-agent <UA>` | Custom User-Agent string |
|
||||
| `--stealth` | Enable anti-detection mode |
|
||||
|
||||
### Claude Desktop config
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"obscura": {
|
||||
"command": "obscura",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `browser_navigate` | Navigate to a URL (`url`, optional `waitUntil`: `load` / `domcontentloaded` / `networkidle0`) |
|
||||
| `browser_snapshot` | Return the current page URL, title, and body text |
|
||||
| `browser_click` | Click an element by CSS selector |
|
||||
| `browser_fill` | Set an input value (triggers `input` + `change` events) |
|
||||
| `browser_type` | Append text to an input |
|
||||
| `browser_press_key` | Dispatch a keyboard event (`key`, optional `selector`) |
|
||||
| `browser_select_option` | Select an `<option>` by value or text |
|
||||
| `browser_evaluate` | Evaluate a JavaScript expression and return the result |
|
||||
| `browser_wait_for` | Wait for a CSS selector to appear (`selector`, optional `timeout` in seconds) |
|
||||
| `browser_network_requests` | List network requests made by the current page |
|
||||
| `browser_console_messages` | Return console messages logged by the page |
|
||||
| `browser_close` | Close the page and reset browser state |
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0
|
||||
|
||||
@@ -13,10 +13,11 @@ path = "src/worker.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
stealth = ["obscura-browser/stealth", "obscura-net/stealth"]
|
||||
stealth = ["obscura-browser/stealth", "obscura-net/stealth", "obscura-mcp/stealth"]
|
||||
|
||||
[dependencies]
|
||||
obscura-browser = { workspace = true }
|
||||
obscura-mcp = { workspace = true }
|
||||
obscura-cdp = { workspace = true }
|
||||
obscura-dom = { workspace = true }
|
||||
obscura-net = { workspace = true }
|
||||
|
||||
@@ -101,6 +101,23 @@ enum Command {
|
||||
quiet: bool,
|
||||
},
|
||||
|
||||
Mcp {
|
||||
#[arg(long)]
|
||||
http: bool,
|
||||
|
||||
#[arg(long, default_value_t = 3000)]
|
||||
port: u16,
|
||||
|
||||
#[arg(long)]
|
||||
proxy: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
user_agent: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
stealth: bool,
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -195,6 +212,13 @@ async fn main() -> anyhow::Result<()> {
|
||||
Some(Command::Scrape { urls, eval, concurrency, format, timeout, quiet }) => {
|
||||
run_parallel_scrape(urls, eval, concurrency.get(), &format, timeout, quiet, global_proxy).await?;
|
||||
}
|
||||
Some(Command::Mcp { http, port, proxy, user_agent, stealth }) => {
|
||||
if http {
|
||||
obscura_mcp::http::run(port, proxy, user_agent, stealth).await?;
|
||||
} else {
|
||||
obscura_mcp::run(proxy, user_agent, stealth).await?;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
print_banner(args.port);
|
||||
if let Some(ref proxy) = args.proxy {
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
|
||||
const OBSCURA: &str = env!("CARGO_BIN_EXE_obscura");
|
||||
|
||||
// ── minimal MCP client ────────────────────────────────────────────────────────
|
||||
|
||||
struct McpClient {
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
reader: BufReader<std::process::ChildStdout>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl McpClient {
|
||||
fn spawn() -> Self {
|
||||
let mut child = Command::new(OBSCURA)
|
||||
.args(["mcp"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("failed to spawn obscura mcp");
|
||||
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
|
||||
let mut client = McpClient {
|
||||
child,
|
||||
stdin,
|
||||
reader: BufReader::new(stdout),
|
||||
next_id: 1,
|
||||
};
|
||||
|
||||
// Perform the initialize handshake automatically
|
||||
let id = client.next_id;
|
||||
client.next_id += 1;
|
||||
let _resp = client.call_raw(serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": { "name": "test-client", "version": "0.0.0" }
|
||||
}
|
||||
}));
|
||||
|
||||
// Send initialized notification (no id, no response expected)
|
||||
client.notify("notifications/initialized", serde_json::json!({}));
|
||||
|
||||
client
|
||||
}
|
||||
|
||||
fn notify(&mut self, method: &str, params: serde_json::Value) {
|
||||
let msg = serde_json::json!({ "jsonrpc": "2.0", "method": method, "params": params });
|
||||
self.write_msg(&msg);
|
||||
}
|
||||
|
||||
fn call(&mut self, method: &str, params: serde_json::Value) -> serde_json::Value {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let msg = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": method,
|
||||
"params": params
|
||||
});
|
||||
self.call_raw(msg)
|
||||
}
|
||||
|
||||
fn call_raw(&mut self, msg: serde_json::Value) -> serde_json::Value {
|
||||
self.write_msg(&msg);
|
||||
self.read_msg()
|
||||
}
|
||||
|
||||
fn tool(&mut self, name: &str, args: serde_json::Value) -> serde_json::Value {
|
||||
self.call("tools/call", serde_json::json!({ "name": name, "arguments": args }))
|
||||
}
|
||||
|
||||
fn write_msg(&mut self, msg: &serde_json::Value) {
|
||||
// MCP stdio transport: newline-delimited JSON
|
||||
let mut body = serde_json::to_string(msg).unwrap();
|
||||
body.push('\n');
|
||||
self.stdin.write_all(body.as_bytes()).unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
}
|
||||
|
||||
fn read_msg(&mut self) -> serde_json::Value {
|
||||
let mut line = String::new();
|
||||
self.reader.read_line(&mut line).expect("read line");
|
||||
serde_json::from_str(line.trim()).expect("parse JSON")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for McpClient {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
// helper: extract first content text from a tools/call response
|
||||
fn content_text(resp: &serde_json::Value) -> &str {
|
||||
resp["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_initialize() {
|
||||
let mut c = McpClient::spawn();
|
||||
// A second initialize should still return a valid response
|
||||
let resp = c.call(
|
||||
"initialize",
|
||||
serde_json::json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {}
|
||||
}),
|
||||
);
|
||||
assert_eq!(resp["result"]["protocolVersion"], "2024-11-05");
|
||||
assert_eq!(resp["result"]["serverInfo"]["name"], "obscura-mcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ping() {
|
||||
let mut c = McpClient::spawn();
|
||||
let resp = c.call("ping", serde_json::json!({}));
|
||||
assert!(resp.get("error").is_none(), "ping should not error");
|
||||
assert_eq!(resp["result"], serde_json::json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tools_list() {
|
||||
let mut c = McpClient::spawn();
|
||||
let resp = c.call("tools/list", serde_json::json!({}));
|
||||
let tools = resp["result"]["tools"].as_array().expect("tools array");
|
||||
assert!(!tools.is_empty());
|
||||
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||
for expected in &[
|
||||
"browser_navigate",
|
||||
"browser_snapshot",
|
||||
"browser_click",
|
||||
"browser_fill",
|
||||
"browser_type",
|
||||
"browser_press_key",
|
||||
"browser_select_option",
|
||||
"browser_evaluate",
|
||||
"browser_wait_for",
|
||||
"browser_network_requests",
|
||||
"browser_console_messages",
|
||||
"browser_close",
|
||||
] {
|
||||
assert!(names.contains(expected), "missing tool: {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resources_list() {
|
||||
let mut c = McpClient::spawn();
|
||||
let resp = c.call("resources/list", serde_json::json!({}));
|
||||
assert!(resp.get("error").is_none());
|
||||
assert_eq!(resp["result"]["resources"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prompts_list() {
|
||||
let mut c = McpClient::spawn();
|
||||
let resp = c.call("prompts/list", serde_json::json!({}));
|
||||
assert!(resp.get("error").is_none());
|
||||
assert_eq!(resp["result"]["prompts"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_method_returns_error() {
|
||||
let mut c = McpClient::spawn();
|
||||
let resp = c.call("nonexistent/method", serde_json::json!({}));
|
||||
assert_eq!(resp["error"]["code"], -32601);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_notifications_are_silent() {
|
||||
// Notifications must not produce a response; the next real call should succeed
|
||||
let mut c = McpClient::spawn();
|
||||
c.notify("notifications/initialized", serde_json::json!({}));
|
||||
c.notify("some/other_notification", serde_json::json!({"x": 1}));
|
||||
// If the server accidentally replied to a notification, the next read would
|
||||
// consume that stray response and the ping result would be mismatched.
|
||||
let resp = c.call("ping", serde_json::json!({}));
|
||||
assert!(resp.get("error").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigate_and_snapshot() {
|
||||
let mut c = McpClient::spawn();
|
||||
|
||||
let nav = c.tool("browser_navigate", serde_json::json!({"url": "https://example.com"}));
|
||||
assert!(nav["result"]["isError"].is_null(), "navigate failed: {nav}");
|
||||
let text = content_text(&nav);
|
||||
assert!(text.contains("example.com"), "unexpected nav text: {text}");
|
||||
|
||||
let snap = c.tool("browser_snapshot", serde_json::json!({}));
|
||||
assert!(snap["result"]["isError"].is_null(), "snapshot failed: {snap}");
|
||||
let text = content_text(&snap);
|
||||
assert!(text.contains("Example Domain"), "unexpected snapshot: {text}");
|
||||
assert!(text.contains("URL:"), "snapshot missing URL line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate() {
|
||||
let mut c = McpClient::spawn();
|
||||
c.tool("browser_navigate", serde_json::json!({"url": "https://example.com"}));
|
||||
|
||||
let resp = c.tool(
|
||||
"browser_evaluate",
|
||||
serde_json::json!({"expression": "document.title"}),
|
||||
);
|
||||
let text = content_text(&resp);
|
||||
assert_eq!(text, "Example Domain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_math() {
|
||||
let mut c = McpClient::spawn();
|
||||
c.tool("browser_navigate", serde_json::json!({"url": "https://example.com"}));
|
||||
|
||||
let resp = c.tool(
|
||||
"browser_evaluate",
|
||||
serde_json::json!({"expression": "1 + 2"}),
|
||||
);
|
||||
let text = content_text(&resp);
|
||||
// V8 serialises integer results as floats ("3" or "3.0" depending on context)
|
||||
assert!(text == "3" || text == "3.0", "unexpected result: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_for_selector() {
|
||||
let mut c = McpClient::spawn();
|
||||
c.tool("browser_navigate", serde_json::json!({"url": "https://example.com"}));
|
||||
|
||||
let resp = c.tool(
|
||||
"browser_wait_for",
|
||||
serde_json::json!({"selector": "h1", "timeout": 5}),
|
||||
);
|
||||
assert!(resp["result"]["isError"].is_null(), "wait_for failed: {resp}");
|
||||
assert!(content_text(&resp).contains("Found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_for_timeout() {
|
||||
let mut c = McpClient::spawn();
|
||||
c.tool("browser_navigate", serde_json::json!({"url": "https://example.com"}));
|
||||
|
||||
let resp = c.tool(
|
||||
"browser_wait_for",
|
||||
serde_json::json!({"selector": "#does-not-exist", "timeout": 1}),
|
||||
);
|
||||
assert_eq!(resp["result"]["isError"], true);
|
||||
assert!(content_text(&resp).contains("Timeout"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigate_missing_url_returns_error() {
|
||||
let mut c = McpClient::spawn();
|
||||
let resp = c.tool("browser_navigate", serde_json::json!({}));
|
||||
assert_eq!(resp["result"]["isError"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_tool_returns_error() {
|
||||
let mut c = McpClient::spawn();
|
||||
let resp = c.tool("browser_does_not_exist", serde_json::json!({}));
|
||||
assert_eq!(resp["result"]["isError"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_requests() {
|
||||
let mut c = McpClient::spawn();
|
||||
c.tool("browser_navigate", serde_json::json!({"url": "https://example.com"}));
|
||||
|
||||
let resp = c.tool("browser_network_requests", serde_json::json!({}));
|
||||
let text = content_text(&resp);
|
||||
assert!(
|
||||
text.contains("example.com") || text.contains("No network"),
|
||||
"unexpected: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_close_resets_state() {
|
||||
let mut c = McpClient::spawn();
|
||||
c.tool("browser_navigate", serde_json::json!({"url": "https://example.com"}));
|
||||
let close = c.tool("browser_close", serde_json::json!({}));
|
||||
assert!(close["result"]["isError"].is_null());
|
||||
|
||||
// After close, snapshot should return empty/default page (no panic)
|
||||
let snap = c.tool("browser_snapshot", serde_json::json!({}));
|
||||
assert!(snap["result"]["isError"].is_null());
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "obscura-mcp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
stealth = ["obscura-browser/stealth", "obscura-net/stealth"]
|
||||
|
||||
[dependencies]
|
||||
obscura-browser = { workspace = true }
|
||||
obscura-dom = { workspace = true }
|
||||
obscura-net = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -0,0 +1,206 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::{dispatch, BrowserState};
|
||||
|
||||
/// MCP Streamable HTTP transport (POST /mcp → JSON response).
|
||||
///
|
||||
/// Connections are handled sequentially on the current thread — the browser
|
||||
/// session (including the V8 runtime) is single-threaded and `!Send`, so we
|
||||
/// never need to move state across threads.
|
||||
pub async fn run(port: u16, proxy: Option<String>, user_agent: Option<String>, stealth: bool) -> Result<()> {
|
||||
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
|
||||
let listener = TcpListener::bind(&addr).await?;
|
||||
tracing::info!("MCP HTTP server on http://127.0.0.1:{}/mcp", port);
|
||||
|
||||
let mut state = BrowserState::new(proxy, user_agent, stealth);
|
||||
|
||||
loop {
|
||||
let (stream, peer) = listener.accept().await?;
|
||||
tracing::debug!("MCP HTTP connection from {}", peer);
|
||||
if let Err(e) = handle_connection(stream, &mut state).await {
|
||||
tracing::debug!("connection closed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
stream: tokio::net::TcpStream,
|
||||
state: &mut BrowserState,
|
||||
) -> Result<()> {
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
|
||||
loop {
|
||||
// ── request line ─────────────────────────────────────────────────────
|
||||
let mut request_line = String::new();
|
||||
if reader.read_line(&mut request_line).await? == 0 {
|
||||
break;
|
||||
}
|
||||
let request_line = request_line.trim().to_string();
|
||||
if request_line.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = request_line.splitn(3, ' ').collect();
|
||||
if parts.len() < 3 {
|
||||
break;
|
||||
}
|
||||
let method = parts[0];
|
||||
let path = parts[1];
|
||||
|
||||
// ── headers ──────────────────────────────────────────────────────────
|
||||
let mut content_length: Option<usize> = None;
|
||||
let mut accept_sse = false;
|
||||
let mut keep_alive = false;
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await?;
|
||||
let trimmed = line.trim_end_matches("\r\n").trim_end_matches('\n');
|
||||
if trimmed.is_empty() {
|
||||
break;
|
||||
}
|
||||
let lower = trimmed.to_lowercase();
|
||||
if let Some(v) = lower.strip_prefix("content-length: ") {
|
||||
content_length = v.trim().parse().ok();
|
||||
}
|
||||
if lower.contains("text/event-stream") {
|
||||
accept_sse = true;
|
||||
}
|
||||
if lower.starts_with("connection: ") && lower.contains("keep-alive") {
|
||||
keep_alive = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── routing ──────────────────────────────────────────────────────────
|
||||
if path != "/mcp" {
|
||||
respond(&mut writer, 404, b"{\"error\":\"not found\"}").await?;
|
||||
break;
|
||||
}
|
||||
|
||||
match method {
|
||||
"OPTIONS" => {
|
||||
let hdr = "HTTP/1.1 204 No Content\r\n\
|
||||
Access-Control-Allow-Origin: *\r\n\
|
||||
Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\
|
||||
Access-Control-Allow-Headers: Content-Type\r\n\
|
||||
\r\n";
|
||||
writer.write_all(hdr.as_bytes()).await?;
|
||||
}
|
||||
|
||||
"GET" if accept_sse => {
|
||||
// SSE stream: hold open and send periodic keep-alive comments
|
||||
let hdr = "HTTP/1.1 200 OK\r\n\
|
||||
Content-Type: text/event-stream\r\n\
|
||||
Cache-Control: no-cache\r\n\
|
||||
Connection: keep-alive\r\n\
|
||||
Access-Control-Allow-Origin: *\r\n\
|
||||
\r\n";
|
||||
writer.write_all(hdr.as_bytes()).await?;
|
||||
loop {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(15)).await;
|
||||
if writer.write_all(b": ping\n\n").await.is_err() {
|
||||
break;
|
||||
}
|
||||
let _ = writer.flush().await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
"POST" => {
|
||||
let len = match content_length {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
respond(&mut writer, 400, b"{\"error\":\"missing Content-Length\"}").await?;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let mut body = vec![0u8; len];
|
||||
reader.read_exact(&mut body).await?;
|
||||
|
||||
let response = process_body(&body, state).await;
|
||||
let bytes = serde_json::to_vec(&response)?;
|
||||
respond_json(&mut writer, &bytes).await?;
|
||||
|
||||
if !keep_alive {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_ => {
|
||||
respond(&mut writer, 405, b"{\"error\":\"method not allowed\"}").await?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn process_body(body: &[u8], state: &mut BrowserState) -> Value {
|
||||
let msg: Value = match serde_json::from_slice(body) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return json!({"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}),
|
||||
};
|
||||
|
||||
if let Some(batch) = msg.as_array() {
|
||||
let mut results = Vec::new();
|
||||
for item in batch {
|
||||
if let Some(r) = process_one(item, state).await {
|
||||
results.push(r);
|
||||
}
|
||||
}
|
||||
return Value::Array(results);
|
||||
}
|
||||
|
||||
process_one(&msg, state).await
|
||||
.unwrap_or_else(|| json!({"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid Request"}}))
|
||||
}
|
||||
|
||||
async fn process_one(msg: &Value, state: &mut BrowserState) -> Option<Value> {
|
||||
let id = msg.get("id").cloned()?; // notifications have no id — return None
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
|
||||
let params = msg.get("params").unwrap_or(&Value::Null);
|
||||
let resp = dispatch(method, id, params, state).await;
|
||||
Some(serde_json::to_value(resp).unwrap())
|
||||
}
|
||||
|
||||
async fn respond_json(writer: &mut (impl AsyncWriteExt + Unpin), body: &[u8]) -> Result<()> {
|
||||
let hdr = format!(
|
||||
"HTTP/1.1 200 OK\r\n\
|
||||
Content-Type: application/json\r\n\
|
||||
Content-Length: {}\r\n\
|
||||
Access-Control-Allow-Origin: *\r\n\
|
||||
Connection: keep-alive\r\n\
|
||||
\r\n",
|
||||
body.len()
|
||||
);
|
||||
writer.write_all(hdr.as_bytes()).await?;
|
||||
writer.write_all(body).await?;
|
||||
writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn respond(writer: &mut (impl AsyncWriteExt + Unpin), status: u16, body: &[u8]) -> Result<()> {
|
||||
let status_text = match status {
|
||||
400 => "Bad Request",
|
||||
404 => "Not Found",
|
||||
405 => "Method Not Allowed",
|
||||
_ => "OK",
|
||||
};
|
||||
let hdr = format!(
|
||||
"HTTP/1.1 {status} {status_text}\r\n\
|
||||
Content-Type: application/json\r\n\
|
||||
Content-Length: {}\r\n\
|
||||
\r\n",
|
||||
body.len()
|
||||
);
|
||||
writer.write_all(hdr.as_bytes()).await?;
|
||||
writer.write_all(body).await?;
|
||||
writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
pub mod http;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use obscura_browser::{BrowserContext, Page};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RpcMessage {
|
||||
#[allow(dead_code)]
|
||||
jsonrpc: String,
|
||||
#[serde(default)]
|
||||
id: Option<Value>,
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
params: Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RpcResponse {
|
||||
jsonrpc: &'static str,
|
||||
id: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<RpcError>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RpcError {
|
||||
code: i32,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl RpcResponse {
|
||||
fn ok(id: Value, result: Value) -> Self {
|
||||
RpcResponse { jsonrpc: "2.0", id, result: Some(result), error: None }
|
||||
}
|
||||
|
||||
fn err(id: Value, code: i32, message: impl Into<String>) -> Self {
|
||||
RpcResponse { jsonrpc: "2.0", id, result: None, error: Some(RpcError { code, message: message.into() }) }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BrowserState {
|
||||
page: Option<Page>,
|
||||
context: Arc<BrowserContext>,
|
||||
user_agent: Option<String>,
|
||||
console_messages: Vec<String>,
|
||||
}
|
||||
|
||||
impl BrowserState {
|
||||
pub fn new(proxy: Option<String>, user_agent: Option<String>, stealth: bool) -> Self {
|
||||
BrowserState {
|
||||
page: None,
|
||||
context: Arc::new(BrowserContext::with_options("mcp".to_string(), proxy, stealth)),
|
||||
user_agent,
|
||||
console_messages: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn page_mut(&mut self) -> &mut Page {
|
||||
if self.page.is_none() {
|
||||
self.page = Some(Page::new("mcp-page".to_string(), self.context.clone()));
|
||||
}
|
||||
self.page.as_mut().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn dispatch(method: &str, id: Value, params: &Value, state: &mut BrowserState) -> RpcResponse {
|
||||
match method {
|
||||
"initialize" => handle_initialize(id, params),
|
||||
"ping" => RpcResponse::ok(id, json!({})),
|
||||
"tools/list" => handle_tools_list(id),
|
||||
"tools/call" => handle_tool_call(id, params, state).await,
|
||||
"resources/list" => RpcResponse::ok(id, json!({"resources": []})),
|
||||
"prompts/list" => RpcResponse::ok(id, json!({"prompts": []})),
|
||||
_ => RpcResponse::err(id, -32601, format!("Unknown method: {method}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(proxy: Option<String>, user_agent: Option<String>, stealth: bool) -> Result<()> {
|
||||
let stdin = tokio::io::stdin();
|
||||
let stdout = tokio::io::stdout();
|
||||
let mut reader = BufReader::new(stdin);
|
||||
let mut writer = stdout;
|
||||
|
||||
let mut state = BrowserState::new(proxy, user_agent, stealth);
|
||||
|
||||
loop {
|
||||
// MCP stdio transport: newline-delimited JSON (one message per line)
|
||||
let mut line = String::new();
|
||||
let n = reader.read_line(&mut line).await?;
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let msg: RpcMessage = match serde_json::from_str(trimmed) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Notifications (no id) need no response
|
||||
if msg.id.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let id = msg.id.clone().unwrap_or(Value::Null);
|
||||
let response = dispatch(&msg.method, id, &msg.params, &mut state).await;
|
||||
|
||||
let mut body = serde_json::to_string(&response)?;
|
||||
body.push('\n');
|
||||
writer.write_all(body.as_bytes()).await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_initialize(id: Value, params: &Value) -> RpcResponse {
|
||||
let _client_version = params.get("protocolVersion").and_then(Value::as_str).unwrap_or("");
|
||||
RpcResponse::ok(id, json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {
|
||||
"tools": {}
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "obscura-mcp",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn handle_tools_list(id: Value) -> RpcResponse {
|
||||
RpcResponse::ok(id, json!({
|
||||
"tools": [
|
||||
{
|
||||
"name": "browser_navigate",
|
||||
"description": "Navigate to a URL and wait for the page to load",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": { "type": "string", "description": "URL to navigate to" },
|
||||
"waitUntil": {
|
||||
"type": "string",
|
||||
"enum": ["load", "domcontentloaded", "networkidle0"],
|
||||
"description": "Navigation wait condition (default: load)"
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "browser_snapshot",
|
||||
"description": "Get the current page content as text (title, URL, and readable body text)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "browser_click",
|
||||
"description": "Click an element matching the CSS selector",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"selector": { "type": "string", "description": "CSS selector of the element to click" }
|
||||
},
|
||||
"required": ["selector"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "browser_fill",
|
||||
"description": "Set the value of an input element",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"selector": { "type": "string", "description": "CSS selector of the input element" },
|
||||
"value": { "type": "string", "description": "Value to set" }
|
||||
},
|
||||
"required": ["selector", "value"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "browser_type",
|
||||
"description": "Type text into an input element (appends to existing value)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"selector": { "type": "string", "description": "CSS selector of the element" },
|
||||
"text": { "type": "string", "description": "Text to type" }
|
||||
},
|
||||
"required": ["selector", "text"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "browser_press_key",
|
||||
"description": "Dispatch a keyboard event on an element or the document",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": { "type": "string", "description": "Key name (e.g. Enter, Tab, Escape)" },
|
||||
"selector": { "type": "string", "description": "CSS selector (optional, defaults to document)" }
|
||||
},
|
||||
"required": ["key"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "browser_select_option",
|
||||
"description": "Select an option from a <select> element",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"selector": { "type": "string", "description": "CSS selector of the <select> element" },
|
||||
"value": { "type": "string", "description": "Value or text of the option to select" }
|
||||
},
|
||||
"required": ["selector", "value"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "browser_evaluate",
|
||||
"description": "Evaluate a JavaScript expression in the page context and return the result",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expression": { "type": "string", "description": "JavaScript expression to evaluate" }
|
||||
},
|
||||
"required": ["expression"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "browser_wait_for",
|
||||
"description": "Wait for a CSS selector to appear in the DOM",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"selector": { "type": "string", "description": "CSS selector to wait for" },
|
||||
"timeout": { "type": "number", "description": "Timeout in seconds (default: 30)" }
|
||||
},
|
||||
"required": ["selector"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "browser_network_requests",
|
||||
"description": "Return the list of network requests made by the current page",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "browser_console_messages",
|
||||
"description": "Return the console messages logged by the current page",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "browser_close",
|
||||
"description": "Close the current browser page and reset state",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_tool_call(id: Value, params: &Value, state: &mut BrowserState) -> RpcResponse {
|
||||
let name = match params.get("name").and_then(Value::as_str) {
|
||||
Some(n) => n,
|
||||
None => return RpcResponse::err(id, -32602, "Missing tool name"),
|
||||
};
|
||||
let args = params.get("arguments").unwrap_or(&Value::Null);
|
||||
|
||||
let result = match name {
|
||||
"browser_navigate" => tool_navigate(args, state).await,
|
||||
"browser_snapshot" => tool_snapshot(state),
|
||||
"browser_click" => tool_click(args, state),
|
||||
"browser_fill" => tool_fill(args, state),
|
||||
"browser_type" => tool_type(args, state),
|
||||
"browser_press_key" => tool_press_key(args, state),
|
||||
"browser_select_option" => tool_select_option(args, state),
|
||||
"browser_evaluate" => tool_evaluate(args, state),
|
||||
"browser_wait_for" => tool_wait_for(args, state).await,
|
||||
"browser_network_requests" => tool_network_requests(state),
|
||||
"browser_console_messages" => tool_console_messages(state),
|
||||
"browser_close" => tool_close(state),
|
||||
_ => Err(format!("Unknown tool: {name}")),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(content) => RpcResponse::ok(id, json!({
|
||||
"content": [{ "type": "text", "text": content }]
|
||||
})),
|
||||
Err(e) => RpcResponse::ok(id, json!({
|
||||
"content": [{ "type": "text", "text": format!("Error: {e}") }],
|
||||
"isError": true
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn tool_navigate(args: &Value, state: &mut BrowserState) -> Result<String, String> {
|
||||
let url = args.get("url").and_then(Value::as_str)
|
||||
.ok_or("Missing url parameter")?;
|
||||
let wait_until = args.get("waitUntil").and_then(Value::as_str).unwrap_or("load");
|
||||
|
||||
let condition = obscura_browser::lifecycle::WaitUntil::from_str(wait_until);
|
||||
let ua = state.user_agent.clone();
|
||||
let page = state.page_mut();
|
||||
if let Some(ref ua) = ua {
|
||||
page.http_client.set_user_agent(ua).await;
|
||||
}
|
||||
|
||||
page.navigate_with_wait(url, condition).await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(format!("Navigated to {} — \"{}\"", page.url_string(), page.title))
|
||||
}
|
||||
|
||||
fn tool_snapshot(state: &mut BrowserState) -> Result<String, String> {
|
||||
let page = state.page_mut();
|
||||
let url = page.url_string();
|
||||
let title = page.title.clone();
|
||||
|
||||
let body_text = page.with_dom(|dom| {
|
||||
if let Ok(Some(body)) = dom.query_selector("body") {
|
||||
extract_text(dom, body)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}).unwrap_or_default();
|
||||
|
||||
Ok(format!("URL: {url}\nTitle: {title}\n\n{}", body_text.trim()))
|
||||
}
|
||||
|
||||
fn tool_click(args: &Value, state: &mut BrowserState) -> Result<String, String> {
|
||||
let selector = args.get("selector").and_then(Value::as_str)
|
||||
.ok_or("Missing selector parameter")?;
|
||||
|
||||
let js = format!(
|
||||
r#"(function(){{
|
||||
var el = document.querySelector({sel});
|
||||
if (!el) return "error:element not found";
|
||||
el.click();
|
||||
return "ok";
|
||||
}})()"#,
|
||||
sel = serde_json::to_string(selector).unwrap()
|
||||
);
|
||||
|
||||
let result = state.page_mut().evaluate(&js);
|
||||
if result.as_str() == Some("error:element not found") {
|
||||
Err(format!("Element not found: {selector}"))
|
||||
} else {
|
||||
Ok(format!("Clicked '{selector}'"))
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_fill(args: &Value, state: &mut BrowserState) -> Result<String, String> {
|
||||
let selector = args.get("selector").and_then(Value::as_str)
|
||||
.ok_or("Missing selector parameter")?;
|
||||
let value = args.get("value").and_then(Value::as_str)
|
||||
.ok_or("Missing value parameter")?;
|
||||
|
||||
let js = format!(
|
||||
r#"(function(){{
|
||||
var el = document.querySelector({sel});
|
||||
if (!el) return "error:element not found";
|
||||
el.value = {val};
|
||||
el.dispatchEvent(new Event("input", {{bubbles:true}}));
|
||||
el.dispatchEvent(new Event("change", {{bubbles:true}}));
|
||||
return "ok";
|
||||
}})()"#,
|
||||
sel = serde_json::to_string(selector).unwrap(),
|
||||
val = serde_json::to_string(value).unwrap()
|
||||
);
|
||||
|
||||
let result = state.page_mut().evaluate(&js);
|
||||
if result.as_str() == Some("error:element not found") {
|
||||
Err(format!("Element not found: {selector}"))
|
||||
} else {
|
||||
Ok(format!("Filled '{selector}' with value"))
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_type(args: &Value, state: &mut BrowserState) -> Result<String, String> {
|
||||
let selector = args.get("selector").and_then(Value::as_str)
|
||||
.ok_or("Missing selector parameter")?;
|
||||
let text = args.get("text").and_then(Value::as_str)
|
||||
.ok_or("Missing text parameter")?;
|
||||
|
||||
let js = format!(
|
||||
r#"(function(){{
|
||||
var el = document.querySelector({sel});
|
||||
if (!el) return "error:element not found";
|
||||
el.value = (el.value || "") + {txt};
|
||||
el.dispatchEvent(new Event("input", {{bubbles:true}}));
|
||||
return "ok";
|
||||
}})()"#,
|
||||
sel = serde_json::to_string(selector).unwrap(),
|
||||
txt = serde_json::to_string(text).unwrap()
|
||||
);
|
||||
|
||||
let result = state.page_mut().evaluate(&js);
|
||||
if result.as_str() == Some("error:element not found") {
|
||||
Err(format!("Element not found: {selector}"))
|
||||
} else {
|
||||
Ok(format!("Typed into '{selector}'"))
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_press_key(args: &Value, state: &mut BrowserState) -> Result<String, String> {
|
||||
let key = args.get("key").and_then(Value::as_str)
|
||||
.ok_or("Missing key parameter")?;
|
||||
let selector = args.get("selector").and_then(Value::as_str);
|
||||
|
||||
let target = match selector {
|
||||
Some(sel) => format!("document.querySelector({})", serde_json::to_string(sel).unwrap()),
|
||||
None => "document".to_string(),
|
||||
};
|
||||
|
||||
let js = format!(
|
||||
r#"(function(){{
|
||||
var t = {target};
|
||||
if (!t) return "error:element not found";
|
||||
t.dispatchEvent(new KeyboardEvent("keydown", {{key:{key},bubbles:true}}));
|
||||
t.dispatchEvent(new KeyboardEvent("keyup", {{key:{key},bubbles:true}}));
|
||||
return "ok";
|
||||
}})()"#,
|
||||
target = target,
|
||||
key = serde_json::to_string(key).unwrap()
|
||||
);
|
||||
|
||||
state.page_mut().evaluate(&js);
|
||||
Ok(format!("Pressed key '{key}'"))
|
||||
}
|
||||
|
||||
fn tool_select_option(args: &Value, state: &mut BrowserState) -> Result<String, String> {
|
||||
let selector = args.get("selector").and_then(Value::as_str)
|
||||
.ok_or("Missing selector parameter")?;
|
||||
let value = args.get("value").and_then(Value::as_str)
|
||||
.ok_or("Missing value parameter")?;
|
||||
|
||||
let js = format!(
|
||||
r#"(function(){{
|
||||
var el = document.querySelector({sel});
|
||||
if (!el) return "error:element not found";
|
||||
var opts = Array.from(el.options);
|
||||
var opt = opts.find(function(o){{ return o.value === {val} || o.text === {val}; }});
|
||||
if (!opt) return "error:option not found";
|
||||
el.value = opt.value;
|
||||
el.dispatchEvent(new Event("change", {{bubbles:true}}));
|
||||
return "ok";
|
||||
}})()"#,
|
||||
sel = serde_json::to_string(selector).unwrap(),
|
||||
val = serde_json::to_string(value).unwrap()
|
||||
);
|
||||
|
||||
let result = state.page_mut().evaluate(&js);
|
||||
match result.as_str() {
|
||||
Some("error:element not found") => Err(format!("Element not found: {selector}")),
|
||||
Some("error:option not found") => Err(format!("Option not found: {value}")),
|
||||
_ => Ok(format!("Selected '{value}' in '{selector}'")),
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_evaluate(args: &Value, state: &mut BrowserState) -> Result<String, String> {
|
||||
let expression = args.get("expression").and_then(Value::as_str)
|
||||
.ok_or("Missing expression parameter")?;
|
||||
|
||||
let result = state.page_mut().evaluate(expression);
|
||||
Ok(match &result {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Null => "null".to_string(),
|
||||
other => serde_json::to_string_pretty(other).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn tool_wait_for(args: &Value, state: &mut BrowserState) -> Result<String, String> {
|
||||
let selector = args.get("selector").and_then(Value::as_str)
|
||||
.ok_or("Missing selector parameter")?;
|
||||
let timeout_secs = args.get("timeout").and_then(Value::as_f64).unwrap_or(30.0) as u64;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(timeout_secs);
|
||||
loop {
|
||||
let found = state.page_mut().with_dom(|dom| {
|
||||
dom.query_selector(selector).ok().flatten().is_some()
|
||||
}).unwrap_or(false);
|
||||
|
||||
if found {
|
||||
return Ok(format!("Found '{selector}'"));
|
||||
}
|
||||
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(format!("Timeout waiting for '{selector}'"));
|
||||
}
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_network_requests(state: &mut BrowserState) -> Result<String, String> {
|
||||
let page = state.page_mut();
|
||||
let events = &page.network_events;
|
||||
|
||||
if events.is_empty() {
|
||||
return Ok("No network requests recorded.".to_string());
|
||||
}
|
||||
|
||||
let lines: Vec<String> = events.iter().map(|e| {
|
||||
format!("[{}] {} {} ({}B)", e.status, e.method, e.url, e.body_size)
|
||||
}).collect();
|
||||
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn tool_console_messages(state: &BrowserState) -> Result<String, String> {
|
||||
if state.console_messages.is_empty() {
|
||||
Ok("No console messages.".to_string())
|
||||
} else {
|
||||
Ok(state.console_messages.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_close(state: &mut BrowserState) -> Result<String, String> {
|
||||
state.page = None;
|
||||
state.console_messages.clear();
|
||||
Ok("Browser page closed.".to_string())
|
||||
}
|
||||
|
||||
fn extract_text(dom: &obscura_dom::DomTree, node_id: obscura_dom::NodeId) -> String {
|
||||
use obscura_dom::NodeData;
|
||||
|
||||
let mut result = String::new();
|
||||
let node = match dom.get_node(node_id) {
|
||||
Some(n) => n,
|
||||
None => return result,
|
||||
};
|
||||
|
||||
match &node.data {
|
||||
NodeData::Text { contents } => {
|
||||
let trimmed = contents.trim();
|
||||
if !trimmed.is_empty() {
|
||||
result.push_str(trimmed);
|
||||
result.push(' ');
|
||||
}
|
||||
}
|
||||
NodeData::Element { name, .. } => {
|
||||
let tag = name.local.as_ref();
|
||||
if matches!(tag, "script" | "style" | "noscript") {
|
||||
return result;
|
||||
}
|
||||
|
||||
let is_block = matches!(
|
||||
tag,
|
||||
"div" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
|
||||
| "li" | "tr" | "br" | "hr" | "section" | "article"
|
||||
| "header" | "footer" | "nav" | "main" | "aside"
|
||||
| "blockquote" | "pre" | "ul" | "ol" | "table"
|
||||
);
|
||||
|
||||
if is_block {
|
||||
result.push('\n');
|
||||
}
|
||||
|
||||
for child in dom.children(node_id) {
|
||||
result.push_str(&extract_text(dom, child));
|
||||
}
|
||||
|
||||
if is_block {
|
||||
result.push('\n');
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for child in dom.children(node_id) {
|
||||
result.push_str(&extract_text(dom, child));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
Reference in new issue
Block a user