Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 26 additions & 51 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ openssl = { version = "0.10", features = ["vendored"] }
ratatui = { version = "0.29", default-features = false, features = ["crossterm"] }
regex = "1.11"
reqwest = { version = "0.12.18", default-features = false }
rmcp = { version = "2.2.0", features = ["server", "macros", "transport-io", "schemars"] }
rmcp = { version = "3.4.1", features = ["server", "macros", "transport-io", "schemars"] }
schemars = "1"
serde = "1"
serde_json = "1.0.82"
Expand Down
12 changes: 5 additions & 7 deletions crates/cli/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,8 @@ use {
anyhow::{anyhow, Result},
rmcp::{
handler::server::{wrapper::Parameters, ServerHandler},
model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerInfo},
tool, tool_handler, tool_router,
transport::stdio,
ErrorData, ServiceExt,
model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerConfig},
tool, tool_handler, tool_router, ErrorData, ServiceExt,
},
schemars::JsonSchema,
serde::Deserialize,
Expand Down Expand Up @@ -982,14 +980,14 @@ impl SmbMcpServer {

#[tool_handler(router = Self::cloud_tool_router())]
impl ServerHandler for SmbMcpServer {
fn get_info(&self) -> ServerInfo {
fn get_info(&self) -> ServerConfig {
// `Implementation` is `#[non_exhaustive]`, so start from the build-env
// default and override the identity fields to report `smb`, not `rmcp`.
let mut server_info = Implementation::from_build_env();
server_info.name = "smb".to_string();
server_info.version = env!("CARGO_PKG_VERSION").to_string();

ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(server_info)
.with_instructions(
"smbCloud CLI exposed as MCP tools. Authentication uses the token stored by \
Expand All @@ -1008,7 +1006,7 @@ pub async fn serve(environment: Environment, scope: McpScope) -> Result<()> {
}

let running = SmbMcpServer::new(environment)
.serve(stdio())
.serve(xcrs::mcp::compatible_stdio().await?)
.await
.map_err(|e| anyhow!("Failed to start MCP server: {e}"))?;
running
Expand Down
9 changes: 4 additions & 5 deletions crates/cli/src/mcp_xcrs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@
use {
anyhow::{anyhow, Result},
rmcp::{
model::{Implementation, ServerCapabilities, ServerInfo},
transport::stdio,
model::{Implementation, ServerCapabilities, ServerConfig},
ServerHandler, ServiceExt,
},
};
Expand Down Expand Up @@ -52,14 +51,14 @@ xcrs::xcrs_mcp_tools!(

#[rmcp::tool_handler(router = Self::xcrs_tool_router())]
impl ServerHandler for AutomationMcpServer {
fn get_info(&self) -> ServerInfo {
fn get_info(&self) -> ServerConfig {
// `Implementation` is `#[non_exhaustive]`, so start from the build-env
// default and override the identity fields.
let mut server_info = Implementation::from_build_env();
server_info.name = "XCRS Mobile & TV Automation".to_string();
server_info.version = env!("CARGO_PKG_VERSION").to_string();

ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(server_info)
.with_instructions(
"Cross-platform mobile and TV app automation over Apple simulators/ControlKit \
Expand All @@ -76,7 +75,7 @@ impl ServerHandler for AutomationMcpServer {
/// Run the automation MCP server over stdio until the client disconnects.
pub async fn serve() -> Result<()> {
let running = AutomationMcpServer::new()
.serve(stdio())
.serve(xcrs::mcp::compatible_stdio().await?)
.await
.map_err(|error| anyhow!("Failed to start automation MCP server: {error}"))?;
running
Expand Down
79 changes: 73 additions & 6 deletions crates/xcrs/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,60 @@
use {
anyhow::{anyhow, Result},
rmcp::{
model::{Implementation, ServerCapabilities, ServerInfo},
transport::stdio,
model::{Implementation, ServerCapabilities, ServerConfig},
ServerHandler, ServiceExt,
},
schemars::JsonSchema,
serde::{Deserialize, Serialize},
std::path::PathBuf,
serde_json::{json, Value},
std::{io::Cursor, path::PathBuf},
Comment thread
setoelkahfi marked this conversation as resolved.
tokio::io::{
stdin, stdout, AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWriteExt, BufReader, Stdout,
},
};

type StdioReader = Box<dyn AsyncRead + Send + Unpin>;

/// Preserve compatibility with clients that send the legacy, metadata-free
/// `server/discover` probe before falling back to `initialize`.
pub async fn compatible_stdio() -> Result<(StdioReader, Stdout)> {
let mut reader = BufReader::new(stdin());
let mut first_line = String::new();
reader.read_line(&mut first_line).await?;
Comment thread
setoelkahfi marked this conversation as resolved.

let mut output = stdout();
if let Some(response) = legacy_discovery_response(&first_line) {
output.write_all(&response).await?;
output.flush().await?;
return Ok((Box::new(reader), output));
Comment thread
setoelkahfi marked this conversation as resolved.
}

let input = Cursor::new(first_line.into_bytes()).chain(reader);
Comment thread
setoelkahfi marked this conversation as resolved.
Ok((Box::new(input), output))
}

fn legacy_discovery_response(line: &str) -> Option<Vec<u8>> {
let request: Value = serde_json::from_str(line).ok()?;
if request.get("method")?.as_str()? != "server/discover"
Comment thread
setoelkahfi marked this conversation as resolved.
|| request.get("params")?.get("_meta").is_some()
{
return None;
}

let id = request.get("id")?.clone();
let mut response = serde_json::to_vec(&json!({
"jsonrpc": "2.0",
"id": id,
"error": {
"code": -32601,
"message": "Method 'server/discover' is not available without request metadata."
}
}))
.ok()?;
response.push(b'\n');
Some(response)
}

/// A validated, unambiguous automation target: exactly one of an Apple simulator, an
/// Apple ControlKit host (physical device, remote runner, or local Mac), or an
/// Android device. Produced by [`resolve_selected_target`], never constructed
Expand Down Expand Up @@ -2008,12 +2053,12 @@ xcrs_mcp_tools!(

#[rmcp::tool_handler(router = Self::xcrs_tool_router())]
impl ServerHandler for XcrsMcpServer {
fn get_info(&self) -> ServerInfo {
fn get_info(&self) -> ServerConfig {
let mut implementation = Implementation::from_build_env();
implementation.name = "xcrs".to_string();
implementation.version = env!("CARGO_PKG_VERSION").to_string();

ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(implementation)
.with_instructions(
"xcrs exposes a canonical cross-platform automation contract (device_list, \
Expand All @@ -2028,7 +2073,7 @@ impl ServerHandler for XcrsMcpServer {

pub async fn serve() -> Result<()> {
let running = XcrsMcpServer::new()
.serve(stdio())
.serve(compatible_stdio().await?)
.await
.map_err(|error| anyhow!("Failed to start xcrs MCP server: {error}"))?;
running
Expand All @@ -2042,6 +2087,28 @@ pub async fn serve() -> Result<()> {
mod tests {
use super::*;

#[test]
fn legacy_discovery_probe_receives_method_not_found() {
let response = legacy_discovery_response(
r#"{"jsonrpc":"2.0","id":0,"method":"server/discover","params":{}}"#,
)
.expect("legacy discovery probe should receive a fallback response");
let response: Value =
serde_json::from_slice(&response).expect("response should be valid JSON");

assert_eq!(response["id"], 0);
assert_eq!(response["error"]["code"], -32601);
}

#[test]
fn modern_discovery_probe_passes_through() {
let response = legacy_discovery_response(
r#"{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}"#,
);

assert!(response.is_none());
}

/// The 19 canonical tool names shared by the standalone and embedded
/// automation servers.
const CANONICAL_TOOL_NAMES: [&str; 19] = [
Expand Down
Loading