use crate::browser::{BrowserSession, ConnectionOptions};
use crate::mcp::{convert_result, mcp_internal_error};
use crate::tools::ToolDescriptor;
#[cfg(feature = "tui")]
use crate::tools::ToolEffect;
use log::debug;
use rmcp::{
ErrorData as McpError, RoleServer, ServerHandler,
model::{
CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams,
ServerCapabilities, ServerInfo, Tool as McpTool, ToolAnnotations as McpToolAnnotations,
},
service::RequestContext,
};
use std::future;
use std::sync::Arc;
#[cfg(feature = "tui")]
use crate::tools::ToolResult as InternalToolResult;
#[cfg(feature = "tui")]
use crate::tui::{FinalizeOutcome, PageCoordinator, SharedTuiState};
#[cfg(feature = "tui")]
use rmcp::model::{
ListResourceTemplatesResult, ListResourcesResult, ReadResourceRequestParams, ReadResourceResult,
};
#[derive(Clone)]
pub struct BrowserServer {
session: Arc<BrowserSession>,
#[cfg(feature = "tui")]
coordinator: Option<Arc<PageCoordinator>>,
}
impl BrowserServer {
pub fn from_session(session: BrowserSession) -> Self {
Self {
session: Arc::new(session),
#[cfg(feature = "tui")]
coordinator: None,
}
}
pub fn from_shared_session(session: Arc<BrowserSession>) -> Self {
Self {
session,
#[cfg(feature = "tui")]
coordinator: None,
}
}
#[cfg(feature = "tui")]
pub fn from_companion(coordinator: Arc<PageCoordinator>) -> Self {
Self {
session: coordinator.session().clone(),
coordinator: Some(coordinator),
}
}
pub fn new() -> Result<Self, String> {
let session =
BrowserSession::new().map_err(|e| format!("Failed to launch browser: {}", e))?;
Ok(Self::from_session(session))
}
pub fn with_options(options: crate::browser::LaunchOptions) -> Result<Self, String> {
let session = BrowserSession::launch(options)
.map_err(|e| format!("Failed to launch browser: {}", e))?;
Ok(Self::from_session(session))
}
pub fn connect(options: ConnectionOptions) -> Result<Self, String> {
let session = BrowserSession::connect(options)
.map_err(|e| format!("Failed to connect browser session: {}", e))?;
Ok(Self::from_session(session))
}
pub(crate) fn session(&self) -> &BrowserSession {
self.session.as_ref()
}
pub(crate) fn list_mcp_tools(&self) -> Vec<McpTool> {
self.session()
.tool_registry()
.descriptors()
.into_iter()
.map(tool_descriptor_to_mcp)
.collect()
}
pub(crate) fn execute_tool_sync(
&self,
request: CallToolRequestParams,
) -> Result<CallToolResult, McpError> {
#[cfg(feature = "tui")]
if let Some(coordinator) = &self.coordinator {
if request.name.as_ref() == "tui_refresh" {
let result = match coordinator.refresh() {
Ok(page) => crate::tools::tui::TuiResult {
available: true,
data: Some(crate::tools::tui::TuiData::Refresh {
document_id: page.document_id,
revision: page.revision,
url: page.url,
title: page.title,
}),
error: None,
},
Err(error) => crate::tools::tui::TuiResult {
available: false,
data: None,
error: Some(error.to_string()),
},
};
return convert_result(InternalToolResult::success_with(result));
}
if self.session().tool_registry().effect(request.name.as_ref())
== ToolEffect::BrowserMutation
{
return execute_companion_page_mutation(coordinator, request);
}
}
let mut context = crate::tools::ToolContext::new(self.session());
let params = request
.arguments
.map(serde_json::Value::Object)
.unwrap_or_else(|| serde_json::json!({}));
match self
.session()
.tool_registry()
.execute(request.name.as_ref(), params, &mut context)
{
Ok(result) => convert_result(result),
Err(error) => Err(mcp_internal_error(error)),
}
}
#[cfg(feature = "tui")]
fn companion_shared(&self) -> Option<&SharedTuiState> {
self.coordinator.as_ref().map(|c| c.shared())
}
}
#[cfg(feature = "tui")]
fn execute_companion_page_mutation(
coordinator: &PageCoordinator,
request: CallToolRequestParams,
) -> Result<CallToolResult, McpError> {
let action = request.name.to_string();
let transaction = match coordinator.begin_companion(&action) {
Ok(transaction) => transaction,
Err(error) => return convert_result(InternalToolResult::failure(error.to_string())),
};
let ticket = transaction.ticket();
let session = coordinator.session().as_ref();
let mut context = crate::tools::ToolContext::new(session);
let params = request
.arguments
.map(serde_json::Value::Object)
.unwrap_or_else(|| serde_json::json!({}));
match session
.tool_registry()
.execute(request.name.as_ref(), params, &mut context)
{
Ok(result) if result.success => {
match coordinator.finalize_browser_mutation(ticket, &action) {
Ok(FinalizeOutcome::Published(_)) => {}
Err(error) => {
return convert_result(InternalToolResult::failure(error.to_string()));
}
}
convert_result(result)
}
Ok(result) => {
let message = result
.error
.clone()
.unwrap_or_else(|| format!("{action} failed"));
let _ = coordinator.fail(ticket, &action, message);
convert_result(result)
}
Err(error) => {
let _ = coordinator.fail(ticket, &action, error.to_string());
Err(mcp_internal_error(error))
}
}
}
#[cfg(feature = "tokio")]
fn join_blocking_tool_result(
result: std::result::Result<Result<CallToolResult, McpError>, tokio::task::JoinError>,
) -> Result<CallToolResult, McpError> {
match result {
Ok(result) => result,
Err(error) => Err(mcp_internal_error(error)),
}
}
fn tool_descriptor_to_mcp(descriptor: ToolDescriptor) -> McpTool {
let ToolDescriptor {
name,
description,
parameters_schema,
output_schema,
annotations,
} = descriptor;
let input_schema = match parameters_schema {
serde_json::Value::Object(object) => object,
_ => serde_json::Map::new(),
};
let output_schema = match output_schema {
serde_json::Value::Object(object) => Some(Arc::new(object)),
_ => None,
};
let mut tool = McpTool::new(name, description, Arc::new(input_schema));
tool.output_schema = output_schema;
tool.annotations = Some(McpToolAnnotations::from_raw(
None,
Some(annotations.read_only_hint),
Some(annotations.destructive_hint),
Some(annotations.idempotent_hint),
Some(annotations.open_world_hint),
));
tool
}
impl Drop for BrowserServer {
fn drop(&mut self) {
debug!("BrowserServer dropped");
}
}
impl ServerHandler for BrowserServer {
fn call_tool(
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> impl std::future::Future<Output = Result<CallToolResult, McpError>> + Send + '_ {
#[cfg(feature = "tokio")]
{
let server = self.clone();
#[cfg(feature = "tui")]
let companion_request = server
.coordinator
.as_ref()
.map(|coordinator| coordinator.begin_companion_request());
async move {
#[cfg(feature = "tui")]
let companion_request = match companion_request {
Some(Some(request)) => Some(request),
Some(None) => return Err(mcp_internal_error("TUI companion is shutting down")),
None => None,
};
join_blocking_tool_result(
tokio::task::spawn_blocking(move || {
#[cfg(feature = "tui")]
let _companion_request = companion_request;
server.execute_tool_sync(request)
})
.await,
)
}
}
#[cfg(not(feature = "tokio"))]
{
future::ready(self.execute_tool_sync(request))
}
}
fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> impl std::future::Future<Output = Result<ListToolsResult, McpError>> + Send + '_ {
future::ready(Ok(ListToolsResult::with_all_items(self.list_mcp_tools())))
}
#[cfg(feature = "tui")]
fn list_resources(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> impl std::future::Future<Output = Result<ListResourcesResult, McpError>> + Send + '_ {
future::ready(Ok(match self.companion_shared() {
Some(shared) => crate::mcp::resources::list_resources(shared),
None => ListResourcesResult::default(),
}))
}
#[cfg(feature = "tui")]
fn list_resource_templates(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> impl std::future::Future<Output = Result<ListResourceTemplatesResult, McpError>> + Send + '_
{
future::ready(Ok(match self.companion_shared() {
Some(_) => crate::mcp::resources::resource_templates(),
None => ListResourceTemplatesResult::default(),
}))
}
#[cfg(feature = "tui")]
fn read_resource(
&self,
request: ReadResourceRequestParams,
_context: RequestContext<RoleServer>,
) -> impl std::future::Future<Output = Result<ReadResourceResult, McpError>> + Send + '_ {
future::ready(match self.companion_shared() {
Some(shared) => {
crate::mcp::resources::read_resource(shared, &request.uri).map_err(|error| {
match error {
crate::mcp::resources::ResourceError::MalformedUri => {
McpError::invalid_params(error.to_string(), None)
}
crate::mcp::resources::ResourceError::NotFound
| crate::mcp::resources::ResourceError::Coordination(_)
| crate::mcp::resources::ResourceError::Render(_) => {
McpError::resource_not_found(error.to_string(), None)
}
}
})
}
None => Err(McpError::method_not_found::<
rmcp::model::ReadResourceRequestMethod,
>()),
})
}
fn get_info(&self) -> ServerInfo {
self.server_info()
}
}
fn server_info() -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_instructions("chromewright MCP server")
}
impl BrowserServer {
fn server_info(&self) -> ServerInfo {
#[cfg(feature = "tui")]
if self.coordinator.is_some() {
return ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.build(),
)
.with_instructions(
"chromewright TUI companion MCP server (shared session, tui_* tools, semantic resources)",
);
}
server_info()
}
}
#[cfg(test)]
mod tests {
#[cfg(feature = "tokio")]
use super::join_blocking_tool_result;
use super::{BrowserServer, server_info};
use crate::browser::BrowserSession;
use crate::browser::backend::FakeSessionBackend;
use rmcp::model::CallToolRequestParams;
#[cfg(feature = "tokio")]
use serde_json::json;
fn call_tool_request(
name: &'static str,
arguments: Option<serde_json::Map<String, serde_json::Value>>,
) -> CallToolRequestParams {
let request = CallToolRequestParams::new(name);
if let Some(arguments) = arguments {
request.with_arguments(arguments)
} else {
request
}
}
#[test]
fn test_server_info_enables_tools_and_instructions() {
let info = server_info();
assert!(
info.instructions
.as_deref()
.unwrap_or_default()
.contains("chromewright MCP server")
);
assert!(info.capabilities.tools.is_some());
assert!(
info.capabilities.resources.is_none(),
"standard stdio/serve servers must not advertise resources"
);
}
#[cfg(feature = "tui")]
#[test]
fn tui_companion_server_info_enables_resources_while_stdio_does_not() {
use crate::tui::SharedTuiState;
use rmcp::ServerHandler;
use std::sync::Arc;
let session = Arc::new(BrowserSession::with_test_backend(FakeSessionBackend::new()));
let shared = SharedTuiState::new();
let companion = BrowserServer::from_companion(Arc::new(crate::tui::PageCoordinator::new(
session.clone(),
shared,
)));
assert!(companion.get_info().capabilities.resources.is_some());
let stdio = BrowserServer::from_shared_session(session);
assert!(stdio.get_info().capabilities.resources.is_none());
}
#[cfg(feature = "tui")]
#[test]
fn tui_companion_rejects_page_actions_while_loading() {
use crate::tui::SharedTuiState;
use std::sync::Arc;
let session = Arc::new(BrowserSession::with_test_backend(FakeSessionBackend::new()));
let shared = SharedTuiState::new();
shared.activate_runtime();
shared.begin_page_action("navigate").expect("claim loading");
let server = BrowserServer::from_companion(Arc::new(crate::tui::PageCoordinator::new(
session, shared,
)));
let result = server
.execute_tool_sync(call_tool_request("navigate", None))
.expect("tool-local rejection");
assert_eq!(result.is_error, Some(true));
let message = result
.structured_content
.as_ref()
.and_then(|content| content.get("error"))
.and_then(|error| error.as_str())
.unwrap_or_default();
assert!(
message.contains("already in progress"),
"unexpected rejection message: {message}"
);
}
#[cfg(feature = "tui")]
#[test]
fn tui_companion_page_mutation_publishes_loading_to_ready_atomically() {
use crate::tui::{Lifecycle, SharedTuiState};
use serde_json::json;
use std::sync::Arc;
let session = Arc::new(BrowserSession::with_test_backend(FakeSessionBackend::new()));
let shared = SharedTuiState::new();
shared.activate_runtime();
let before = session.extract_semantic_document().expect("seed capture");
shared.publish(before);
let prior_revision = shared.active().unwrap().document.revision.clone();
let server = BrowserServer::from_companion(Arc::new(crate::tui::PageCoordinator::new(
session,
shared.clone(),
)));
let mut args = serde_json::Map::new();
args.insert("url".into(), json!("https://example.test/next"));
let result = server
.execute_tool_sync(call_tool_request("navigate", Some(args)))
.expect("navigate");
assert_eq!(result.is_error, Some(false));
assert!(shared.lifecycle().is_ready());
let after = shared.active().expect("published after navigate");
assert_ne!(after.document.revision, prior_revision);
assert_eq!(after.document.url, "https://example.test/next");
assert!(matches!(shared.lifecycle(), Lifecycle::Ready));
}
#[cfg(feature = "tui")]
#[test]
fn tui_refresh_failure_is_a_structured_domain_result() {
use crate::tui::SharedTuiState;
use std::sync::Arc;
let shared = SharedTuiState::new();
shared.activate_runtime();
let session = Arc::new(BrowserSession::with_test_backend(FakeSessionBackend::new()));
session.close_active_tab().expect("remove active tab");
let server = BrowserServer::from_companion(Arc::new(crate::tui::PageCoordinator::new(
session, shared,
)));
let result = server
.execute_tool_sync(call_tool_request("tui_refresh", None))
.expect("domain failure must not become an MCP error");
assert_eq!(result.is_error, Some(false));
let content = result.structured_content.expect("structured TUI result");
assert_eq!(content.get("available"), Some(&serde_json::json!(false)));
assert!(content.get("data").is_none());
assert!(content.get("error").and_then(|v| v.as_str()).is_some());
}
#[cfg(feature = "tui")]
#[test]
fn deactivated_companion_mutation_is_rejected_before_browser_work() {
use crate::tui::SharedTuiState;
use std::sync::Arc;
let shared = SharedTuiState::new();
let session = Arc::new(BrowserSession::with_test_backend(FakeSessionBackend::new()));
let initial_url = session.list_tabs().unwrap()[0].url.clone();
let server = BrowserServer::from_companion(Arc::new(crate::tui::PageCoordinator::new(
session.clone(),
shared.clone(),
)));
let mut args = serde_json::Map::new();
args.insert(
"url".into(),
serde_json::json!("https://example.test/blocked"),
);
let result = server
.execute_tool_sync(call_tool_request("navigate", Some(args)))
.expect("tool-local rejection");
assert_eq!(result.is_error, Some(true));
assert_eq!(session.list_tabs().unwrap()[0].url, initial_url);
assert!(shared.lifecycle().is_ready());
}
#[cfg(feature = "tui")]
#[test]
fn companion_close_final_tab_preserves_refresh_failure_contract() {
use crate::tui::SharedTuiState;
use std::sync::Arc;
let shared = SharedTuiState::new();
shared.activate_runtime();
let session = Arc::new(BrowserSession::with_test_backend(FakeSessionBackend::new()));
shared.publish(session.extract_semantic_document().unwrap());
let server = BrowserServer::from_companion(Arc::new(crate::tui::PageCoordinator::new(
session,
shared.clone(),
)));
let result = server
.execute_tool_sync(call_tool_request("close_tab", None))
.expect("close final tab");
assert_eq!(result.is_error, Some(true));
assert!(matches!(
shared.lifecycle(),
crate::tui::Lifecycle::Error { action, message }
if action == "close_tab"
&& message == crate::tui::CoordinationError::RefreshFailed.to_string()
));
assert!(shared.active().is_ok(), "last valid document is retained");
assert!(shared.selection().is_none());
assert!(!shared.attention().is_set());
}
#[test]
fn execute_tool_sync_converts_success_results() {
let server = BrowserServer::from_session(BrowserSession::with_test_backend(
FakeSessionBackend::new(),
));
let result = server
.execute_tool_sync(call_tool_request("tab_list", None))
.expect("tab_list should execute");
assert_eq!(result.is_error, Some(false));
assert_eq!(
result
.structured_content
.as_ref()
.and_then(|content| content.get("count"))
.and_then(|count| count.as_u64()),
Some(1)
);
}
#[test]
fn execute_tool_sync_preserves_tool_local_failures() {
let server = BrowserServer::from_session(BrowserSession::with_test_backend(
FakeSessionBackend::new(),
));
let result = server
.execute_tool_sync(call_tool_request("missing_tool", None))
.expect("tool-local failures should convert to CallToolResult");
assert_eq!(result.is_error, Some(true));
assert_eq!(
result
.structured_content
.as_ref()
.and_then(|content| content.get("code"))
.and_then(|code| code.as_str()),
Some("tool_error")
);
}
#[test]
fn list_mcp_tools_uses_metadata_without_tool_execution() {
let server = BrowserServer::from_session(BrowserSession::with_test_backend(
FakeSessionBackend::new(),
));
let tools = server.list_mcp_tools();
assert!(tools.iter().any(|tool| tool.name.as_ref() == "snapshot"));
}
#[cfg(feature = "tokio")]
#[tokio::test]
async fn blocking_join_failure_maps_to_internal_mcp_error() {
let joined = tokio::task::spawn_blocking(|| {
panic!("simulated blocking executor panic");
#[allow(unreachable_code)]
Ok(rmcp::model::CallToolResult::structured(json!({})))
})
.await;
let error = join_blocking_tool_result(joined)
.expect_err("blocking executor panic should map to MCP error");
assert!(
error
.to_string()
.contains("simulated blocking executor panic")
);
}
}