Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View file

@ -0,0 +1,30 @@
[package]
license = "Apache-2.0"
name = "xai-computer-hub-mcp-adapter"
version = "0.1.0"
edition.workspace = true
description = "Bridge between MCP servers and the xAI Computer Hub, registering MCP-discovered tools as native hub tools."
[features]
metrics = ["dep:prometheus"]
[dependencies]
async-trait = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
tracing = { workspace = true }
prometheus = { workspace = true, optional = true }
xai-tool-protocol = { workspace = true }
xai-tool-runtime = { workspace = true }
xai-tool-types = { workspace = true }
xai-computer-hub-sdk = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["full", "test-util"] }
futures = { workspace = true }
[lints]
workspace = true

View file

@ -0,0 +1,781 @@
//! Core bridge that connects an MCP server to the computer hub.
//!
//! [`McpBridge`] discovers tools from an [`McpTransport`] and registers
//! them with a hub `ToolServer` via one `ToolServerHandler` per
//! tool. Incoming hub calls are translated to MCP `tools/call` and the
//! response is mapped back to [`ToolOutputWire`].
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::Value;
use tracing::{debug, info, warn};
use xai_tool_protocol::{McpBlock, SessionId, ToolId, ToolOutputWire};
use xai_tool_runtime::{ToolCallContext, ToolError, ToolStream, TypedToolOutput, terminal_only};
use xai_tool_types::ToolDescription;
use crate::transport::McpTransport;
use crate::types::{McpCallResult, McpContent, McpError, McpServerInfo, McpToolDefinition};
/// Configuration for an [`McpBridge`] instance.
#[derive(Debug, Clone)]
pub struct McpBridgeConfig {
/// Hub session to bind tools to.
pub session_id: SessionId,
/// Optional namespace prefix for tool descriptions.
pub namespace: Option<String>,
}
/// Result of a successful [`McpBridge::connect`] call.
///
/// Contains the bridge handle and the server info returned during the
/// MCP initialize handshake.
pub struct McpBridgeHandle {
/// The bridge managing the MCP-to-hub tool registrations.
pub bridge: McpBridge,
/// Server metadata from the MCP `initialize` response.
pub server_info: McpServerInfo,
}
impl std::fmt::Debug for McpBridgeHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("McpBridgeHandle")
.field("server_info", &self.server_info.name)
.field("tool_count", &self.bridge.tool_count())
.finish_non_exhaustive()
}
}
/// Bridges an MCP server's tools into the computer hub.
///
/// On construction the bridge performs the MCP `initialize` handshake,
/// discovers tools via `tools/list`, and builds a handler
/// for each one. Callers wire these handlers into a
/// [`xai_computer_hub_sdk::ToolServerBuilder`] to register them
/// with the hub.
///
/// Callers **must** call [`McpBridge::shutdown`] before dropping to
/// close the underlying MCP transport cleanly. If the bridge is dropped
/// without an explicit shutdown, a best-effort `close()` is spawned on
/// the tokio runtime (mirroring `ToolServer`'s drop behavior).
pub struct McpBridge {
transport: Arc<dyn McpTransport>,
handlers: Vec<Arc<McpToolHandler>>,
server_info: McpServerInfo,
}
impl std::fmt::Debug for McpBridge {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("McpBridge")
.field("server", &self.server_info.name)
.field("tool_count", &self.handlers.len())
.finish_non_exhaustive()
}
}
impl McpBridge {
/// Initialize the MCP server, discover its tools, and build handlers.
///
/// Returns `Err` if the MCP handshake or tool discovery fails.
pub async fn connect(
transport: Arc<dyn McpTransport>,
config: &McpBridgeConfig,
) -> Result<McpBridgeHandle, McpError> {
let server_info = match transport.initialize().await {
Ok(info) => info,
Err(e) => {
crate::metrics::mcp_error();
return Err(e);
}
};
info!(
server_name = %server_info.name,
version = %server_info.version,
"MCP server initialized"
);
let tools = match transport.list_tools().await {
Ok(t) => t,
Err(e) => {
// Close the transport so the initialized connection is not leaked
// when list_tools fails after a successful initialize.
if let Err(close_err) = transport.close().await {
warn!(
?close_err,
"failed to close transport after list_tools error"
);
}
crate::metrics::mcp_error();
return Err(e);
}
};
debug!(
server_name = %server_info.name,
tool_count = tools.len(),
"discovered MCP tools"
);
let handlers: Vec<Arc<McpToolHandler>> = tools
.into_iter()
.filter_map(|def| {
let tool_id = match ToolId::new(&def.name) {
Ok(id) => id,
Err(err) => {
warn!(
tool_name = %def.name,
%err,
"skipping MCP tool with invalid name"
);
return None;
}
};
Some(Arc::new(McpToolHandler {
tool_id,
definition: def,
transport: Arc::clone(&transport),
namespace: config.namespace.clone(),
}))
})
.collect();
crate::metrics::mcp_tools_bridged_set(handlers.len() as i64);
let bridge = McpBridge {
transport,
handlers,
server_info: server_info.clone(),
};
Ok(McpBridgeHandle {
bridge,
server_info,
})
}
/// Handlers to register with a [`xai_computer_hub_sdk::ToolServerBuilder`].
///
/// Each handler implements `ToolServerHandler` for one MCP tool.
pub fn handlers(&self) -> &[Arc<McpToolHandler>] {
&self.handlers
}
/// Server metadata from the MCP `initialize` response.
pub fn server_info(&self) -> &McpServerInfo {
&self.server_info
}
/// Number of tools discovered and registered.
pub fn tool_count(&self) -> usize {
self.handlers.len()
}
/// Close the underlying MCP transport.
pub async fn shutdown(&self) -> Result<(), McpError> {
crate::metrics::mcp_tools_bridged_set(0);
self.transport.close().await
}
}
impl Drop for McpBridge {
fn drop(&mut self) {
crate::metrics::mcp_tools_bridged_set(0);
let transport = Arc::clone(&self.transport);
if tokio::runtime::Handle::try_current().is_ok() {
tokio::spawn(async move {
if let Err(err) = transport.close().await {
warn!(?err, "best-effort transport close on drop failed");
}
});
}
}
}
/// Hub-facing handler for a single MCP tool.
///
/// Translates hub `tool_call_request` frames into MCP `tools/call`
/// invocations and maps the result back to [`ToolOutputWire`].
pub struct McpToolHandler {
tool_id: ToolId,
definition: McpToolDefinition,
transport: Arc<dyn McpTransport>,
namespace: Option<String>,
}
impl std::fmt::Debug for McpToolHandler {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("McpToolHandler")
.field("tool_id", &self.tool_id)
.finish_non_exhaustive()
}
}
#[async_trait]
impl xai_computer_hub_sdk::ToolServerHandler for McpToolHandler {
fn tool_id(&self) -> ToolId {
self.tool_id.clone()
}
fn description(&self) -> ToolDescription {
let desc = ToolDescription::new(
self.definition.name.clone(),
self.definition.description.clone().unwrap_or_default(),
);
match self.namespace {
Some(ref ns) => desc.with_namespace(ns.clone()),
None => desc,
}
}
fn input_schema(&self) -> Option<Value> {
self.definition.input_schema.clone()
}
async fn handle_call(&self, _ctx: ToolCallContext, args: Value) -> ToolStream<TypedToolOutput> {
let _start = std::time::Instant::now();
let tool_id = self.tool_id.clone();
let result = self
.transport
.call_tool(self.definition.name.as_str(), args)
.await;
crate::metrics::mcp_call_duration_observe(_start.elapsed().as_secs_f64());
let terminal = match result {
Ok(call_result) => {
let output = translate_mcp_result(&call_result);
serde_json::to_value(output)
.map(|value| TypedToolOutput::from_value(tool_id, value))
.map_err(|e| {
crate::metrics::mcp_error();
ToolError::execution(self.tool_id.clone(), e.to_string()).with_source(e)
})
}
Err(mcp_err) => {
crate::metrics::mcp_error();
Err(ToolError::execution(
self.tool_id.clone(),
format!("{mcp_err}"),
))
}
};
terminal_only(terminal)
}
}
/// Convert an [`McpCallResult`] into the wire output format.
///
/// - **Error responses** (`is_error: true`): concatenates text-only blocks
/// into a single [`ToolOutputWire::Text`], discarding non-text content
/// (with a warning when content is dropped).
/// - **Empty content**: returns `ToolOutputWire::Text("")` regardless of
/// `is_error` — matches side-effect-only MCP tools.
/// - **Single text block**: returns [`ToolOutputWire::Text`] directly.
/// - **Multi-block / non-text**: returns [`ToolOutputWire::Mcp`] with
/// structured blocks.
fn translate_mcp_result(result: &McpCallResult) -> ToolOutputWire {
if result.content.is_empty() {
return ToolOutputWire::Text(String::new());
}
if result.is_error {
let error_text = result
.content
.iter()
.filter_map(|c| match c {
McpContent::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
if error_text.is_empty() {
warn!(
content_count = result.content.len(),
"MCP error response contained only non-text blocks; content dropped"
);
}
return ToolOutputWire::Text(error_text);
}
// Single text block → flat text output.
if result.content.len() == 1
&& let Some(McpContent::Text { text }) = result.content.first()
{
return ToolOutputWire::Text(text.clone());
}
let blocks: Vec<McpBlock> = result
.content
.iter()
.map(|c| match c {
McpContent::Text { text } => McpBlock::Text { text: text.clone() },
McpContent::Image { mime_type, data } => McpBlock::Image {
mime_type: mime_type.clone(),
data: data.clone(),
},
McpContent::Resource {
uri,
mime_type,
text,
} => McpBlock::Resource {
uri: uri.clone(),
mime_type: mime_type.clone(),
text: text.clone(),
},
})
.collect();
ToolOutputWire::Mcp { blocks }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{McpCallResult, McpContent, McpServerInfo, McpToolDefinition};
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::Mutex;
struct MockTransport {
server_info: McpServerInfo,
tools: Vec<McpToolDefinition>,
call_response: Mutex<Option<McpCallResult>>,
call_error: Mutex<Option<McpError>>,
closed: AtomicBool,
last_call: Mutex<Option<(String, Value)>>,
}
impl MockTransport {
fn new(server_info: McpServerInfo, tools: Vec<McpToolDefinition>) -> Self {
Self {
server_info,
tools,
call_response: Mutex::new(None),
call_error: Mutex::new(None),
closed: AtomicBool::new(false),
last_call: Mutex::new(None),
}
}
fn with_call_response(self, response: McpCallResult) -> Self {
Self {
call_response: Mutex::new(Some(response)),
..self
}
}
fn with_call_error(self, error: McpError) -> Self {
Self {
call_error: Mutex::new(Some(error)),
..self
}
}
}
#[async_trait]
impl McpTransport for MockTransport {
async fn initialize(&self) -> Result<McpServerInfo, McpError> {
Ok(self.server_info.clone())
}
async fn list_tools(&self) -> Result<Vec<McpToolDefinition>, McpError> {
Ok(self.tools.clone())
}
async fn call_tool(&self, name: &str, arguments: Value) -> Result<McpCallResult, McpError> {
*self.last_call.lock().await = Some((name.to_string(), arguments));
if let Some(err) = self.call_error.lock().await.take() {
return Err(err);
}
self.call_response
.lock()
.await
.clone()
.ok_or_else(|| McpError::Transport("no canned response".into()))
}
async fn close(&self) -> Result<(), McpError> {
self.closed.store(true, Ordering::SeqCst);
Ok(())
}
}
fn sample_server_info() -> McpServerInfo {
McpServerInfo {
name: "test-server".into(),
version: "1.0.0".into(),
capabilities: Value::Null,
}
}
fn sample_tools() -> Vec<McpToolDefinition> {
vec![
McpToolDefinition {
name: "search".into(),
description: Some("Search for items".into()),
input_schema: Some(serde_json::json!({
"type": "object",
"properties": { "query": { "type": "string" } }
})),
},
McpToolDefinition {
name: "create".into(),
description: Some("Create an item".into()),
input_schema: None,
},
]
}
fn make_transport(mock: MockTransport) -> Arc<dyn McpTransport> {
Arc::new(mock) as Arc<dyn McpTransport>
}
#[tokio::test]
async fn bridge_discovers_and_builds_handlers() {
let transport = make_transport(MockTransport::new(sample_server_info(), sample_tools()));
let config = McpBridgeConfig {
session_id: SessionId::new("test-session").unwrap(),
namespace: None,
};
let handle = McpBridge::connect(transport, &config).await.unwrap();
assert_eq!(handle.server_info.name, "test-server");
assert_eq!(handle.bridge.tool_count(), 2);
let ids: Vec<String> = handle
.bridge
.handlers()
.iter()
.map(|h| h.tool_id.as_str().to_string())
.collect();
assert!(ids.contains(&"search".to_string()));
assert!(ids.contains(&"create".to_string()));
}
#[tokio::test]
async fn bridge_handler_descriptions() {
let transport = make_transport(MockTransport::new(sample_server_info(), sample_tools()));
let config = McpBridgeConfig {
session_id: SessionId::new("test-session").unwrap(),
namespace: Some("mcp".into()),
};
let handle = McpBridge::connect(transport, &config).await.unwrap();
let handler = handle
.bridge
.handlers()
.iter()
.find(|h| h.tool_id.as_str() == "search")
.unwrap();
use xai_computer_hub_sdk::ToolServerHandler;
let desc = handler.description();
assert_eq!(desc.name, "search");
assert_eq!(desc.description, "Search for items");
assert_eq!(desc.namespace.as_deref(), Some("mcp"));
assert!(handler.input_schema().is_some());
}
#[tokio::test]
async fn bridge_forwards_call_text_response() {
let call_result = McpCallResult {
content: vec![McpContent::Text {
text: "found 3 results".into(),
}],
is_error: false,
};
let transport = make_transport(
MockTransport::new(sample_server_info(), sample_tools())
.with_call_response(call_result),
);
let config = McpBridgeConfig {
session_id: SessionId::new("test-session").unwrap(),
namespace: None,
};
let handle = McpBridge::connect(Arc::clone(&transport), &config)
.await
.unwrap();
let handler = handle
.bridge
.handlers()
.iter()
.find(|h| h.tool_id.as_str() == "search")
.unwrap();
use futures::StreamExt;
use xai_computer_hub_sdk::ToolServerHandler;
let ctx = ToolCallContext::default();
let args = serde_json::json!({"query": "test"});
let mut stream = handler.handle_call(ctx, args).await;
let item = stream.next().await.unwrap();
match item {
xai_tool_runtime::ToolStreamItem::Terminal(Ok(typed)) => {
let output: ToolOutputWire = serde_json::from_value(typed.value).unwrap();
assert_eq!(output, ToolOutputWire::Text("found 3 results".into()));
}
other => panic!("expected Terminal(Ok(_)), got {other:?}"),
}
}
#[tokio::test]
async fn bridge_forwards_call_mcp_blocks_response() {
let call_result = McpCallResult {
content: vec![
McpContent::Text {
text: "result text".into(),
},
McpContent::Image {
mime_type: "image/png".into(),
data: "base64data".into(),
},
],
is_error: false,
};
let transport = make_transport(
MockTransport::new(sample_server_info(), sample_tools())
.with_call_response(call_result),
);
let config = McpBridgeConfig {
session_id: SessionId::new("test-session").unwrap(),
namespace: None,
};
let handle = McpBridge::connect(transport, &config).await.unwrap();
let handler = &handle.bridge.handlers()[0];
use futures::StreamExt;
use xai_computer_hub_sdk::ToolServerHandler;
let ctx = ToolCallContext::default();
let mut stream = handler
.handle_call(ctx, Value::Object(Default::default()))
.await;
let item = stream.next().await.unwrap();
match item {
xai_tool_runtime::ToolStreamItem::Terminal(Ok(typed)) => {
let output: ToolOutputWire = serde_json::from_value(typed.value).unwrap();
match output {
ToolOutputWire::Mcp { blocks } => {
assert_eq!(blocks.len(), 2);
assert!(
matches!(&blocks[0], McpBlock::Text { text } if text == "result text")
);
assert!(
matches!(&blocks[1], McpBlock::Image { mime_type, .. } if mime_type == "image/png")
);
}
other => panic!("expected Mcp blocks, got {other:?}"),
}
}
other => panic!("expected Terminal(Ok(_)), got {other:?}"),
}
}
#[tokio::test]
async fn bridge_handles_mcp_error_response() {
let call_result = McpCallResult {
content: vec![McpContent::Text {
text: "permission denied".into(),
}],
is_error: true,
};
let transport = make_transport(
MockTransport::new(sample_server_info(), sample_tools())
.with_call_response(call_result),
);
let config = McpBridgeConfig {
session_id: SessionId::new("test-session").unwrap(),
namespace: None,
};
let handle = McpBridge::connect(transport, &config).await.unwrap();
let handler = &handle.bridge.handlers()[0];
use futures::StreamExt;
use xai_computer_hub_sdk::ToolServerHandler;
let ctx = ToolCallContext::default();
let mut stream = handler.handle_call(ctx, Value::Null).await;
let item = stream.next().await.unwrap();
match item {
xai_tool_runtime::ToolStreamItem::Terminal(Ok(typed)) => {
let output: ToolOutputWire = serde_json::from_value(typed.value).unwrap();
assert_eq!(output, ToolOutputWire::Text("permission denied".into()));
}
other => panic!("expected Terminal(Ok(_)), got {other:?}"),
}
}
#[tokio::test]
async fn bridge_handles_transport_error() {
let transport = make_transport(
MockTransport::new(sample_server_info(), sample_tools())
.with_call_error(McpError::Transport("connection reset".into())),
);
let config = McpBridgeConfig {
session_id: SessionId::new("test-session").unwrap(),
namespace: None,
};
let handle = McpBridge::connect(transport, &config).await.unwrap();
let handler = &handle.bridge.handlers()[0];
use futures::StreamExt;
use xai_computer_hub_sdk::ToolServerHandler;
let ctx = ToolCallContext::default();
let mut stream = handler.handle_call(ctx, Value::Null).await;
let item = stream.next().await.unwrap();
match item {
xai_tool_runtime::ToolStreamItem::Terminal(Err(ref e))
if e.kind == xai_tool_runtime::ToolErrorKind::Execution =>
{
assert!(
e.detail.contains("connection reset"),
"expected 'connection reset' in: {}",
e.detail
);
}
other => panic!("expected Terminal(Err(Execution)), got {other:?}"),
}
}
#[tokio::test]
async fn bridge_shutdown_closes_transport() {
let mock = Arc::new(MockTransport::new(sample_server_info(), sample_tools()));
let transport: Arc<dyn McpTransport> = Arc::clone(&mock) as Arc<dyn McpTransport>;
let config = McpBridgeConfig {
session_id: SessionId::new("test-session").unwrap(),
namespace: None,
};
let handle = McpBridge::connect(transport, &config).await.unwrap();
assert!(!mock.closed.load(Ordering::SeqCst));
handle.bridge.shutdown().await.unwrap();
assert!(mock.closed.load(Ordering::SeqCst));
}
#[tokio::test]
async fn bridge_skips_tools_with_invalid_names() {
let tools = vec![
McpToolDefinition {
name: "valid_tool".into(),
description: Some("a valid tool".into()),
input_schema: None,
},
McpToolDefinition {
name: "".into(),
description: Some("empty name".into()),
input_schema: None,
},
];
let transport = make_transport(MockTransport::new(sample_server_info(), tools));
let config = McpBridgeConfig {
session_id: SessionId::new("test-session").unwrap(),
namespace: None,
};
let handle = McpBridge::connect(transport, &config).await.unwrap();
assert_eq!(handle.bridge.tool_count(), 1);
assert_eq!(handle.bridge.handlers()[0].tool_id.as_str(), "valid_tool");
}
#[test]
fn translate_mcp_result_single_text() {
let result = McpCallResult {
content: vec![McpContent::Text {
text: "hello".into(),
}],
is_error: false,
};
assert_eq!(
translate_mcp_result(&result),
ToolOutputWire::Text("hello".into())
);
}
#[test]
fn translate_mcp_result_error_concatenates_text() {
let result = McpCallResult {
content: vec![
McpContent::Text {
text: "line 1".into(),
},
McpContent::Text {
text: "line 2".into(),
},
],
is_error: true,
};
assert_eq!(
translate_mcp_result(&result),
ToolOutputWire::Text("line 1\nline 2".into())
);
}
#[test]
fn translate_mcp_result_mixed_content_uses_blocks() {
let result = McpCallResult {
content: vec![
McpContent::Text {
text: "hello".into(),
},
McpContent::Resource {
uri: "file:///test".into(),
mime_type: Some("text/plain".into()),
text: Some("content".into()),
},
],
is_error: false,
};
match translate_mcp_result(&result) {
ToolOutputWire::Mcp { blocks } => assert_eq!(blocks.len(), 2),
other => panic!("expected Mcp, got {other:?}"),
}
}
#[test]
fn translate_mcp_result_empty_content_returns_empty_text() {
let result = McpCallResult {
content: vec![],
is_error: false,
};
assert_eq!(
translate_mcp_result(&result),
ToolOutputWire::Text(String::new())
);
}
#[test]
fn translate_mcp_result_empty_error_content_returns_empty_text() {
let result = McpCallResult {
content: vec![],
is_error: true,
};
assert_eq!(
translate_mcp_result(&result),
ToolOutputWire::Text(String::new())
);
}
#[test]
fn translate_mcp_result_error_with_only_image_drops_content() {
let result = McpCallResult {
content: vec![McpContent::Image {
mime_type: "image/png".into(),
data: "base64data".into(),
}],
is_error: true,
};
assert_eq!(
translate_mcp_result(&result),
ToolOutputWire::Text(String::new())
);
}
}

View file

@ -0,0 +1,52 @@
//! Unified MCP adapter for the xAI Computer Hub.
//!
//! This crate bridges MCP (Model Context Protocol) servers into the
//! computer hub's tool routing infrastructure. An [`McpBridge`] connects
//! to an MCP server via an [`McpTransport`], discovers the server's
//! tools, and produces [`ToolServerHandler`](xai_computer_hub_sdk::ToolServerHandler)
//! implementations that can be registered with a hub
//! [`ToolServerBuilder`](xai_computer_hub_sdk::ToolServerBuilder).
//!
//! # Architecture
//!
//! ```text
//! MCP Server <──McpTransport──> McpBridge ──handlers──> ToolServerBuilder
//! (stdio/SSE) (discover+forward) (register with hub)
//! ```
//!
//! The [`McpTransport`] trait abstracts the wire protocol so the bridge
//! is testable with in-memory mocks. Concrete transport implementations
//! (stdio, HTTP+SSE) are provided by downstream crates.
//!
//! # Usage
//!
//! ```rust,ignore
//! let transport: Arc<dyn McpTransport> = /* ... */;
//! let config = McpBridgeConfig {
//! session_id: SessionId::new("session-1").unwrap(),
//! namespace: Some("my-mcp-server".into()),
//! };
//! let handle = McpBridge::connect(transport, &config).await?;
//!
//! let mut builder = ToolServerBuilder::default()
//! .pool(pool)
//! .url(hub_url)
//! .auth(auth);
//!
//! for handler in handle.bridge.handlers() {
//! builder = builder.tool(handler.clone());
//! }
//!
//! let server = builder.build().await?;
//! ```
#![forbid(unsafe_code)]
pub mod bridge;
pub(crate) mod metrics;
pub mod transport;
pub mod types;
pub use bridge::{McpBridge, McpBridgeConfig, McpBridgeHandle, McpToolHandler};
pub use transport::McpTransport;
pub use types::{McpCallResult, McpContent, McpError, McpServerInfo, McpToolDefinition};

View file

@ -0,0 +1,61 @@
//! Feature-gated Prometheus metrics for the MCP adapter bridge.
//!
//! When the `metrics` cargo feature is enabled, each helper records to a
//! lazily-registered Prometheus counter / gauge / histogram. When
//! disabled (the default), every helper compiles to an empty function
//! body so the crate carries zero prometheus dependency.
#[cfg(feature = "metrics")]
mod inner {
use prometheus::{
Histogram, IntCounter, IntGauge, exponential_buckets, register_histogram,
register_int_counter, register_int_gauge,
};
use std::sync::LazyLock;
static MCP_CALL_DURATION_SECONDS: LazyLock<Histogram> = LazyLock::new(|| {
register_histogram!(
"computer_hub_mcp_call_duration_seconds",
"MCP server response latency for tool calls.",
exponential_buckets(0.01, 2.0, 14).expect("valid bucket params")
)
.expect("computer_hub_mcp_call_duration_seconds must register once")
});
static MCP_ERRORS_TOTAL: LazyLock<IntCounter> = LazyLock::new(|| {
register_int_counter!(
"computer_hub_mcp_errors_total",
"Errors in the MCP adapter pipeline (transport, protocol, or serialization)."
)
.expect("computer_hub_mcp_errors_total must register once")
});
static MCP_TOOLS_BRIDGED: LazyLock<IntGauge> = LazyLock::new(|| {
register_int_gauge!(
"computer_hub_mcp_tools_bridged",
"MCP tools currently bridged into the computer hub."
)
.expect("computer_hub_mcp_tools_bridged must register once")
});
pub(crate) fn mcp_call_duration_observe(secs: f64) {
MCP_CALL_DURATION_SECONDS.observe(secs);
}
pub(crate) fn mcp_error() {
MCP_ERRORS_TOTAL.inc();
}
pub(crate) fn mcp_tools_bridged_set(count: i64) {
MCP_TOOLS_BRIDGED.set(count);
}
}
#[cfg(not(feature = "metrics"))]
mod inner {
pub(crate) fn mcp_call_duration_observe(_secs: f64) {}
pub(crate) fn mcp_error() {}
pub(crate) fn mcp_tools_bridged_set(_count: i64) {}
}
pub(crate) use inner::*;

View file

@ -0,0 +1,38 @@
//! MCP transport abstraction.
//!
//! [`McpTransport`] defines the async interface consumed by [`crate::McpBridge`].
//! Concrete implementations (stdio, HTTP+SSE) live outside this crate;
//! the trait boundary keeps the bridge testable with in-memory mocks.
use async_trait::async_trait;
use serde_json::Value;
use crate::types::{McpCallResult, McpError, McpServerInfo, McpToolDefinition};
/// Async interface to a single MCP server connection.
///
/// Implementations manage the underlying JSON-RPC framing (stdio pipe,
/// HTTP+SSE stream, etc.) and expose the four lifecycle operations the
/// bridge needs.
#[async_trait]
pub trait McpTransport: Send + Sync {
/// Perform the MCP `initialize` handshake with the server.
///
/// Must be called exactly once before any other method. Returns
/// the server's advertised name, version, and capabilities.
async fn initialize(&self) -> Result<McpServerInfo, McpError>;
/// Discover available tools via MCP `tools/list`.
async fn list_tools(&self) -> Result<Vec<McpToolDefinition>, McpError>;
/// Invoke a tool via MCP `tools/call`.
///
/// `arguments` is the JSON object the model produced for the tool's
/// input schema.
async fn call_tool(&self, name: &str, arguments: Value) -> Result<McpCallResult, McpError>;
/// Gracefully shut down the transport (close pipes, drop connections).
/// Implementations must be idempotent — a second call after a
/// successful close must return `Ok(())` without error.
async fn close(&self) -> Result<(), McpError>;
}

View file

@ -0,0 +1,107 @@
//! MCP protocol types used by the adapter.
//!
//! These mirror the MCP specification's JSON-RPC shapes for server
//! metadata, tool definitions, and call results. They are intentionally
//! decoupled from any specific transport implementation so the bridge
//! stays testable with in-memory mocks.
use serde::{Deserialize, Serialize};
/// Metadata returned by a successful MCP `initialize` handshake.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerInfo {
/// Human-readable server name (e.g. `"linear"`, `"github"`).
pub name: String,
/// Semver-ish version reported by the server.
pub version: String,
/// Free-form capability flags advertised during init.
#[serde(default)]
pub capabilities: serde_json::Value,
}
/// A single tool definition from MCP `tools/list`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpToolDefinition {
/// Unqualified tool name (e.g. `"create_issue"`).
pub name: String,
/// Model-facing description of the tool.
#[serde(default)]
pub description: Option<String>,
/// JSON Schema for the tool's input arguments.
#[serde(default)]
pub input_schema: Option<serde_json::Value>,
}
/// Result of an MCP `tools/call` invocation.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpCallResult {
/// Content blocks returned by the tool.
#[serde(default)]
pub content: Vec<McpContent>,
/// When `true`, the tool signalled an application-level error.
#[serde(default)]
pub is_error: bool,
}
/// A single content block inside an [`McpCallResult`].
///
/// Covers the three content types defined by the MCP specification:
/// text, image (base64-encoded), and embedded resource.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum McpContent {
/// Plain text content.
#[serde(rename = "text")]
Text {
/// The text payload.
text: String,
},
/// Base64-encoded image content.
#[serde(rename = "image")]
Image {
/// MIME type (e.g. `"image/png"`).
#[serde(rename = "mimeType")]
mime_type: String,
/// Base64-encoded image bytes.
data: String,
},
/// Embedded resource content.
#[serde(rename = "resource")]
Resource {
/// Resource URI.
uri: String,
/// Optional MIME type.
#[serde(default, rename = "mimeType")]
mime_type: Option<String>,
/// Optional text body.
#[serde(default)]
text: Option<String>,
},
}
/// Errors originating from MCP transport or protocol handling.
#[derive(Debug, Clone, thiserror::Error)]
pub enum McpError {
/// The underlying transport failed (connection refused, pipe broken, etc.).
#[error("transport error: {0}")]
Transport(String),
/// The server returned a JSON-RPC error response.
#[error("protocol error (code {code}): {message}")]
Protocol {
/// JSON-RPC error code.
code: i64,
/// Human-readable error message.
message: String,
},
/// Timeout waiting for MCP server response.
#[error("timeout: {0}")]
Timeout(String),
/// The response could not be decoded.
#[error("decode error: {0}")]
Decode(String),
}