stygian_browser/
mcp.rs

1//! MCP (Model Context Protocol) server for browser automation.
2//!
3//! Exposes `stygian-browser` capabilities as an MCP server over stdin/stdout
4//! using the JSON-RPC 2.0 protocol.  External tools (LLM agents, IDE plugins)
5//! can acquire browsers, navigate pages, evaluate JavaScript, and capture
6//! screenshots via the standardised MCP interface.
7//!
8//! ## Enabling
9//!
10//! ```toml
11//! [dependencies]
12//! stygian-browser = { version = "0.1", features = ["mcp"] }
13//! ```
14//!
15//! ## Running the server
16//!
17//! ```sh
18//! STYGIAN_MCP_ENABLED=true cargo run --example mcp_server -p stygian-browser
19//! ```
20//!
21//! ## Protocol
22//!
23//! The server implements MCP 2024-11-05 over JSON-RPC 2.0 on stdin/stdout.
24//! Supported methods:
25//!
26//! | MCP Method | Description |
27//! | ----------- | ------------- |
28//! | `initialize` | Handshake, return server capabilities |
29//! | `tools/list` | List available browser tools |
30//! | `tools/call` | Execute a browser tool |
31//! | `resources/list` | List active browser sessions as MCP resources |
32//! | `resources/read` | Read session state |
33//!
34//! ## Tools
35//!
36//! | Tool | Parameters | Returns |
37//! | ------ | ----------- | --------- |
38//! | `browser_acquire` | – | `session_id: String` |
39//! | `browser_open_page` | `session_id` | `page_url: String` |
40//! | `browser_navigate` | `session_id, url, timeout_secs?` | `title, url` |
41//! | `browser_eval` | `session_id, script` | `result: Value` |
42//! | `browser_screenshot` | `session_id` | `data: base64 PNG` |
43//! | `browser_content` | `session_id` | `html: String` |
44//! | `browser_release` | `session_id` | success |
45//! | `pool_stats` | – | `active, max, available` |
46
47use std::{collections::HashMap, sync::Arc, time::Duration};
48
49use serde::{Deserialize, Serialize};
50use serde_json::{Value, json};
51use tokio::{
52    io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
53    sync::Mutex,
54};
55use tracing::{debug, info};
56use ulid::Ulid;
57
58use crate::{
59    BrowserHandle, BrowserPool,
60    error::{BrowserError, Result},
61    page::WaitUntil,
62};
63
64// ─── JSON-RPC types ──────────────────────────────────────────────────────────
65
66/// A JSON-RPC 2.0 request.
67#[derive(Debug, Deserialize)]
68pub struct JsonRpcRequest {
69    /// Protocol version — always `"2.0"`.
70    pub jsonrpc: String,
71    /// Method name (e.g. `"tools/call"`).
72    pub method: String,
73    /// Method parameters.
74    #[serde(default)]
75    pub params: Value,
76    /// Request ID. `null` for notifications.
77    #[serde(default)]
78    pub id: Value,
79}
80
81/// A JSON-RPC 2.0 response.
82#[derive(Debug, Serialize)]
83pub struct JsonRpcResponse {
84    jsonrpc: &'static str,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    result: Option<Value>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    error: Option<JsonRpcError>,
89    id: Value,
90}
91
92/// A JSON-RPC 2.0 error object.
93#[derive(Debug, Serialize)]
94pub struct JsonRpcError {
95    code: i32,
96    message: String,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    data: Option<Value>,
99}
100
101impl JsonRpcResponse {
102    const fn ok(id: Value, result: Value) -> Self {
103        Self {
104            jsonrpc: "2.0",
105            result: Some(result),
106            error: None,
107            id,
108        }
109    }
110
111    fn err(id: Value, code: i32, message: impl Into<String>) -> Self {
112        Self {
113            jsonrpc: "2.0",
114            result: None,
115            error: Some(JsonRpcError {
116                code,
117                message: message.into(),
118                data: None,
119            }),
120            id,
121        }
122    }
123
124    fn method_not_found(id: Value, method: &str) -> Self {
125        Self::err(id, -32601, format!("Method not found: {method}"))
126    }
127}
128
129// ─── Session state ────────────────────────────────────────────────────────────
130
131/// An active MCP browser session.
132///
133/// The handle is wrapped in an `Arc<Mutex<Option<_>>>` so callers can clone
134/// the `Arc` and release the sessions map lock before performing long browser
135/// I/O operations.
136struct McpSession {
137    /// Pool handle for this session — `None` after [`tool_browser_release`].
138    handle: Arc<Mutex<Option<BrowserHandle>>>,
139}
140
141// ─── MCP server ──────────────────────────────────────────────────────────────
142
143/// MCP server that exposes `BrowserPool` over stdin/stdout JSON-RPC.
144///
145/// # Example
146///
147/// ```no_run
148/// use stygian_browser::{BrowserConfig, BrowserPool};
149/// use stygian_browser::mcp::McpBrowserServer;
150/// use std::sync::Arc;
151///
152/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
153/// let pool = BrowserPool::new(BrowserConfig::default()).await?;
154/// let server = McpBrowserServer::new(pool);
155/// server.run().await?;
156/// # Ok(())
157/// # }
158/// ```
159pub struct McpBrowserServer {
160    pool: Arc<BrowserPool>,
161    sessions: Arc<Mutex<HashMap<String, McpSession>>>,
162}
163
164impl McpBrowserServer {
165    /// Create a new server backed by the given `pool`.
166    ///
167    /// Call [`run`](Self::run) to start the stdin/stdout event loop.
168    pub fn new(pool: Arc<BrowserPool>) -> Self {
169        Self {
170            pool,
171            sessions: Arc::new(Mutex::new(HashMap::new())),
172        }
173    }
174
175    /// Run the JSON-RPC event loop.
176    ///
177    /// Reads newline-delimited JSON from stdin and writes responses to stdout.
178    /// Runs until stdin is closed (EOF).
179    ///
180    /// # Errors
181    ///
182    /// Returns an I/O error if stdin/stdout cannot be read from or written to.
183    pub async fn run(&self) -> Result<()> {
184        info!("MCP browser server starting (stdin/stdout mode)");
185
186        let stdin = tokio::io::stdin();
187        let stdout = tokio::io::stdout();
188        let mut reader = BufReader::new(stdin).lines();
189        let mut stdout = stdout;
190
191        while let Some(line) = reader.next_line().await.map_err(BrowserError::Io)? {
192            let line = line.trim().to_string();
193            if line.is_empty() {
194                continue;
195            }
196
197            debug!(?line, "MCP request");
198
199            let response = match serde_json::from_str::<JsonRpcRequest>(&line) {
200                Ok(req) => self.handle_request(req).await,
201                Err(e) => JsonRpcResponse::err(Value::Null, -32700, format!("Parse error: {e}")),
202            };
203
204            let mut out = serde_json::to_string(&response).unwrap_or_default();
205            out.push('\n');
206            stdout
207                .write_all(out.as_bytes())
208                .await
209                .map_err(BrowserError::Io)?;
210            stdout.flush().await.map_err(BrowserError::Io)?;
211        }
212
213        info!("MCP browser server stopping (stdin closed)");
214        Ok(())
215    }
216
217    async fn handle_request(&self, req: JsonRpcRequest) -> JsonRpcResponse {
218        let id = req.id.clone();
219        match req.method.as_str() {
220            "initialize" => Self::handle_initialize(id),
221            "tools/list" => Self::handle_tools_list(id),
222            "tools/call" => self.handle_tools_call(id, req.params).await,
223            "resources/list" => self.handle_resources_list(id).await,
224            "resources/read" => self.handle_resources_read(id, req.params).await,
225            "notifications/initialized" | "ping" => {
226                // Notifications — no response needed; return a no-op result.
227                JsonRpcResponse::ok(id, json!({}))
228            }
229            other => JsonRpcResponse::method_not_found(id, other),
230        }
231    }
232
233    // ── MCP lifecycle ──────────────────────────────────────────────────────────
234
235    fn handle_initialize(id: Value) -> JsonRpcResponse {
236        JsonRpcResponse::ok(
237            id,
238            json!({
239                "protocolVersion": "2024-11-05",
240                "capabilities": {
241                    "tools": { "listChanged": false },
242                    "resources": { "listChanged": false, "subscribe": false }
243                },
244                "serverInfo": {
245                    "name": "stygian-browser",
246                    "version": env!("CARGO_PKG_VERSION")
247                }
248            }),
249        )
250    }
251
252    // ── tools/list ────────────────────────────────────────────────────────────
253
254    fn handle_tools_list(id: Value) -> JsonRpcResponse {
255        JsonRpcResponse::ok(
256            id,
257            json!({
258                "tools": [
259                    {
260                        "name": "browser_acquire",
261                        "description": "Acquire a browser from the pool. Returns a session_id.",
262                        "inputSchema": {
263                            "type": "object",
264                            "properties": {},
265                            "required": []
266                        }
267                    },
268                    {
269                        "name": "browser_navigate",
270                        "description": "Navigate to a URL within a session. Opens a new page if needed.",
271                        "inputSchema": {
272                            "type": "object",
273                            "properties": {
274                                "session_id": { "type": "string" },
275                                "url": { "type": "string" },
276                                "timeout_secs": { "type": "number", "default": 30 }
277                            },
278                            "required": ["session_id", "url"]
279                        }
280                    },
281                    {
282                        "name": "browser_eval",
283                        "description": "Evaluate JavaScript in the current page of a session.",
284                        "inputSchema": {
285                            "type": "object",
286                            "properties": {
287                                "session_id": { "type": "string" },
288                                "script": { "type": "string" }
289                            },
290                            "required": ["session_id", "script"]
291                        }
292                    },
293                    {
294                        "name": "browser_screenshot",
295                        "description": "Capture a full-page PNG screenshot. Returns base64-encoded PNG.",
296                        "inputSchema": {
297                            "type": "object",
298                            "properties": {
299                                "session_id": { "type": "string" }
300                            },
301                            "required": ["session_id"]
302                        }
303                    },
304                    {
305                        "name": "browser_content",
306                        "description": "Get the full HTML content of the current page.",
307                        "inputSchema": {
308                            "type": "object",
309                            "properties": {
310                                "session_id": { "type": "string" }
311                            },
312                            "required": ["session_id"]
313                        }
314                    },
315                    {
316                        "name": "browser_release",
317                        "description": "Release a browser session back to the pool.",
318                        "inputSchema": {
319                            "type": "object",
320                            "properties": {
321                                "session_id": { "type": "string" }
322                            },
323                            "required": ["session_id"]
324                        }
325                    },
326                    {
327                        "name": "pool_stats",
328                        "description": "Return current browser pool statistics.",
329                        "inputSchema": {
330                            "type": "object",
331                            "properties": {},
332                            "required": []
333                        }
334                    }
335                ]
336            }),
337        )
338    }
339
340    // ── tools/call ────────────────────────────────────────────────────────────
341
342    async fn handle_tools_call(&self, id: Value, params: Value) -> JsonRpcResponse {
343        let name = match params.get("name").and_then(|v| v.as_str()) {
344            Some(n) => n.to_string(),
345            None => return JsonRpcResponse::err(id, -32602, "Missing tool 'name'"),
346        };
347        let args = params
348            .get("arguments")
349            .cloned()
350            .unwrap_or_else(|| json!({}));
351
352        let result = match name.as_str() {
353            "browser_acquire" => self.tool_browser_acquire().await,
354            "browser_navigate" => self.tool_browser_navigate(&args).await,
355            "browser_eval" => self.tool_browser_eval(&args).await,
356            "browser_screenshot" => self.tool_browser_screenshot(&args).await,
357            "browser_content" => self.tool_browser_content(&args).await,
358            "browser_release" => self.tool_browser_release(&args).await,
359            "pool_stats" => Ok(self.tool_pool_stats()),
360            other => Err(BrowserError::ConfigError(format!("Unknown tool: {other}"))),
361        };
362
363        match result {
364            Ok(content) => JsonRpcResponse::ok(
365                id,
366                json!({ "content": [{ "type": "text", "text": content.to_string() }], "isError": false }),
367            ),
368            Err(e) => JsonRpcResponse::ok(
369                id,
370                json!({ "content": [{ "type": "text", "text": e.to_string() }], "isError": true }),
371            ),
372        }
373    }
374
375    async fn tool_browser_acquire(&self) -> Result<Value> {
376        let handle = self.pool.acquire().await?;
377        let session_id = Ulid::new().to_string();
378
379        self.sessions.lock().await.insert(
380            session_id.clone(),
381            McpSession {
382                handle: Arc::new(Mutex::new(Some(handle))),
383            },
384        );
385
386        info!(%session_id, "MCP session acquired");
387        Ok(json!({ "session_id": session_id }))
388    }
389
390    async fn tool_browser_navigate(&self, args: &Value) -> Result<Value> {
391        let session_id = Self::require_str(args, "session_id")?;
392        let url = Self::require_str(args, "url")?;
393        let timeout_secs = args
394            .get("timeout_secs")
395            .and_then(serde_json::Value::as_f64)
396            .unwrap_or(30.0);
397
398        // Clone Arc — drops map lock before browser I/O
399        let session_arc = {
400            let sessions = self.sessions.lock().await;
401            sessions
402                .get(&session_id)
403                .ok_or_else(|| BrowserError::ConfigError(format!("Unknown session: {session_id}")))?
404                .handle
405                .clone()
406        };
407
408        let mut page = session_arc
409            .lock()
410            .await
411            .as_ref()
412            .ok_or_else(|| {
413                BrowserError::ConfigError(format!("Session already released: {session_id}"))
414            })?
415            .browser()
416            .ok_or_else(|| {
417                BrowserError::ConfigError(format!("Browser handle invalid: {session_id}"))
418            })?
419            .new_page()
420            .await?;
421
422        page.navigate(
423            &url,
424            WaitUntil::Selector("body".to_string()),
425            Duration::from_secs_f64(timeout_secs),
426        )
427        .await?;
428
429        let title = page.title().await.unwrap_or_default();
430        let current_url = url.clone();
431        page.close().await?;
432
433        Ok(json!({ "title": title, "url": current_url }))
434    }
435
436    async fn tool_browser_eval(&self, args: &Value) -> Result<Value> {
437        let session_id = Self::require_str(args, "session_id")?;
438        let script = Self::require_str(args, "script")?;
439
440        let session_arc = {
441            let sessions = self.sessions.lock().await;
442            sessions
443                .get(&session_id)
444                .ok_or_else(|| BrowserError::ConfigError(format!("Unknown session: {session_id}")))?
445                .handle
446                .clone()
447        };
448
449        let mut page = session_arc
450            .lock()
451            .await
452            .as_ref()
453            .ok_or_else(|| {
454                BrowserError::ConfigError(format!("Session already released: {session_id}"))
455            })?
456            .browser()
457            .ok_or_else(|| {
458                BrowserError::ConfigError(format!("Browser handle invalid: {session_id}"))
459            })?
460            .new_page()
461            .await?;
462
463        page.navigate(
464            "about:blank",
465            WaitUntil::Selector("body".to_string()),
466            Duration::from_secs(5),
467        )
468        .await?;
469
470        let result: Value = page.eval(&script).await?;
471        page.close().await?;
472
473        Ok(json!({ "result": result }))
474    }
475
476    async fn tool_browser_screenshot(&self, args: &Value) -> Result<Value> {
477        use base64::Engine as _;
478        let session_id = Self::require_str(args, "session_id")?;
479
480        let session_arc = {
481            let sessions = self.sessions.lock().await;
482            sessions
483                .get(&session_id)
484                .ok_or_else(|| BrowserError::ConfigError(format!("Unknown session: {session_id}")))?
485                .handle
486                .clone()
487        };
488
489        let mut page = session_arc
490            .lock()
491            .await
492            .as_ref()
493            .ok_or_else(|| {
494                BrowserError::ConfigError(format!("Session already released: {session_id}"))
495            })?
496            .browser()
497            .ok_or_else(|| {
498                BrowserError::ConfigError(format!("Browser handle invalid: {session_id}"))
499            })?
500            .new_page()
501            .await?;
502
503        page.navigate(
504            "about:blank",
505            WaitUntil::Selector("body".to_string()),
506            Duration::from_secs(5),
507        )
508        .await?;
509
510        let png_bytes = page.screenshot().await?;
511        page.close().await?;
512
513        let encoded = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
514        Ok(json!({ "data": encoded, "mimeType": "image/png", "bytes": png_bytes.len() }))
515    }
516
517    async fn tool_browser_content(&self, args: &Value) -> Result<Value> {
518        let session_id = Self::require_str(args, "session_id")?;
519
520        let session_arc = {
521            let sessions = self.sessions.lock().await;
522            sessions
523                .get(&session_id)
524                .ok_or_else(|| BrowserError::ConfigError(format!("Unknown session: {session_id}")))?
525                .handle
526                .clone()
527        };
528
529        let mut page = session_arc
530            .lock()
531            .await
532            .as_ref()
533            .ok_or_else(|| {
534                BrowserError::ConfigError(format!("Session already released: {session_id}"))
535            })?
536            .browser()
537            .ok_or_else(|| {
538                BrowserError::ConfigError(format!("Browser handle invalid: {session_id}"))
539            })?
540            .new_page()
541            .await?;
542
543        page.navigate(
544            "about:blank",
545            WaitUntil::Selector("body".to_string()),
546            Duration::from_secs(5),
547        )
548        .await?;
549
550        let html = page.content().await?;
551        page.close().await?;
552
553        Ok(json!({ "html": html, "bytes": html.len() }))
554    }
555
556    async fn tool_browser_release(&self, args: &Value) -> Result<Value> {
557        let session_id = Self::require_str(args, "session_id")?;
558
559        // Remove Arc from the map — brief lock
560        let session_arc = {
561            let mut sessions = self.sessions.lock().await;
562            sessions
563                .remove(&session_id)
564                .ok_or_else(|| BrowserError::ConfigError(format!("Unknown session: {session_id}")))?
565                .handle
566        };
567
568        // Take and release the handle without holding the map lock
569        let handle = session_arc.lock().await.take();
570        if let Some(h) = handle {
571            h.release().await;
572        }
573
574        info!(%session_id, "MCP session released");
575        Ok(json!({ "released": true, "session_id": session_id }))
576    }
577
578    fn tool_pool_stats(&self) -> Value {
579        let stats = self.pool.stats();
580        json!({
581            "active": stats.active,
582            "max": stats.max,
583            "available": stats.available
584        })
585    }
586
587    // ── resources/list ────────────────────────────────────────────────────────
588
589    async fn handle_resources_list(&self, id: Value) -> JsonRpcResponse {
590        let resources: Vec<Value> = self
591            .sessions
592            .lock()
593            .await
594            .keys()
595            .map(|sid| {
596                json!({
597                    "uri": format!("browser://session/{sid}"),
598                    "name": format!("Browser session {sid}"),
599                    "mimeType": "application/json"
600                })
601            })
602            .collect();
603
604        JsonRpcResponse::ok(id, json!({ "resources": resources }))
605    }
606
607    // ── resources/read ────────────────────────────────────────────────────────
608
609    async fn handle_resources_read(&self, id: Value, params: Value) -> JsonRpcResponse {
610        let uri = match params.get("uri").and_then(|v| v.as_str()) {
611            Some(u) => u.to_string(),
612            None => return JsonRpcResponse::err(id, -32602, "Missing 'uri'"),
613        };
614
615        // Parse browser://session/<session_id>
616        let session_id = uri
617            .strip_prefix("browser://session/")
618            .unwrap_or("")
619            .to_string();
620        let session_exists = self.sessions.lock().await.contains_key(&session_id);
621
622        if session_exists {
623            let pool_stats = self.pool.stats();
624            JsonRpcResponse::ok(
625                id,
626                json!({
627                    "contents": [{
628                        "uri": uri,
629                        "mimeType": "application/json",
630                        "text": serde_json::to_string_pretty(&json!({
631                            "session_id": session_id,
632                            "pool_active": pool_stats.active,
633                            "pool_max": pool_stats.max
634                        })).unwrap_or_default()
635                    }]
636                }),
637            )
638        } else {
639            JsonRpcResponse::err(id, -32002, format!("Resource not found: {uri}"))
640        }
641    }
642
643    // ── Helper ────────────────────────────────────────────────────────────────
644
645    fn require_str(args: &Value, key: &str) -> Result<String> {
646        args.get(key)
647            .and_then(|v| v.as_str())
648            .map(ToString::to_string)
649            .ok_or_else(|| BrowserError::ConfigError(format!("Missing required argument: {key}")))
650    }
651}
652
653/// Returns `true` if `value` is a truthy string (`"true"`, `"1"`, or `"yes"`,
654/// case-insensitive).
655fn mcp_enabled_from(value: &str) -> bool {
656    matches!(value.to_lowercase().as_str(), "true" | "1" | "yes")
657}
658
659/// Returns `true` if the MCP server is enabled via the `STYGIAN_MCP_ENABLED`
660/// environment variable.
661///
662/// Set `STYGIAN_MCP_ENABLED=true` to enable the server.
663pub fn is_mcp_enabled() -> bool {
664    mcp_enabled_from(&std::env::var("STYGIAN_MCP_ENABLED").unwrap_or_default())
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670
671    #[test]
672    fn jsonrpc_response_ok_serializes() -> std::result::Result<(), Box<dyn std::error::Error>> {
673        let r = JsonRpcResponse::ok(json!(1), json!({ "hello": "world" }));
674        let s = serde_json::to_string(&r)?;
675        assert!(s.contains("\"hello\""));
676        assert!(s.contains("\"jsonrpc\":\"2.0\""));
677        assert!(!s.contains("\"error\""));
678        Ok(())
679    }
680
681    #[test]
682    fn jsonrpc_response_err_serializes() -> std::result::Result<(), Box<dyn std::error::Error>> {
683        let r = JsonRpcResponse::err(json!(2), -32601, "Method not found");
684        let s = serde_json::to_string(&r)?;
685        assert!(s.contains("-32601"));
686        assert!(s.contains("Method not found"));
687        assert!(!s.contains("\"result\""));
688        Ok(())
689    }
690
691    #[test]
692    fn mcp_env_disabled_by_default() {
693        // If STYGIAN_MCP_ENABLED is not "true"/"1"/"yes", function returns false
694        let cases = ["false", "0", "no", "", "off"];
695        for val in cases {
696            assert!(!mcp_enabled_from(val), "expected disabled for {val:?}");
697        }
698    }
699
700    #[test]
701    fn mcp_env_enabled_values() {
702        let cases = ["true", "True", "TRUE", "1", "yes", "YES"];
703        for val in cases {
704            assert!(mcp_enabled_from(val), "expected enabled for {val:?}");
705        }
706    }
707}