1use 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#[derive(Debug, Deserialize)]
68pub struct JsonRpcRequest {
69 pub jsonrpc: String,
71 pub method: String,
73 #[serde(default)]
75 pub params: Value,
76 #[serde(default)]
78 pub id: Value,
79}
80
81#[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#[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
129struct McpSession {
137 handle: Arc<Mutex<Option<BrowserHandle>>>,
139}
140
141pub struct McpBrowserServer {
160 pool: Arc<BrowserPool>,
161 sessions: Arc<Mutex<HashMap<String, McpSession>>>,
162}
163
164impl McpBrowserServer {
165 pub fn new(pool: Arc<BrowserPool>) -> Self {
169 Self {
170 pool,
171 sessions: Arc::new(Mutex::new(HashMap::new())),
172 }
173 }
174
175 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 JsonRpcResponse::ok(id, json!({}))
228 }
229 other => JsonRpcResponse::method_not_found(id, other),
230 }
231 }
232
233 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 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 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 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 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 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 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 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 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 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
653fn mcp_enabled_from(value: &str) -> bool {
656 matches!(value.to_lowercase().as_str(), "true" | "1" | "yes")
657}
658
659pub 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 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}