mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-14 23:22:51 +00:00
Take MCP servers from pebble
Step 3 of .ai/plans/pebble-absorbs-embedder-concerns.md, pinning pebble
6cdb30a with its `mcp` feature.
Pebble starts the stage's MCP servers while the agent is built, registers
their tools under `mcp__{server}__{tool}` with `ToolSource::Mcp`, and
closes them with the agent, for all three placements: a child of the run
worker over stdio, a server over HTTP (streamable or SSE), and a server
launched in the run sandbox and reached through the sandbox's preview
URL. `fabro_mcp::pebble::pebble_server` maps `McpServerSettings` onto
pebble's `McpServer`, keeping fabro's `/sse` path for sandbox-hosted SSE
servers; `RunSandbox::port_routes` hands pebble the driver's `PreviewUrls`
facet as the route to a sandbox port. The stage's event sink mirrors
`McpServerReady` and `McpServerFailed` onto the run's `agent.mcp.ready`
and `agent.mcp.failed` events, as it mirrors `RouteFailover` onto
`agent.failover`, and stores no second copy of a mirrored fact.
Deleted: `sandbox_mcp.rs`, the MCP branches of `pebble.rs` and `fabro
exec`, and fabro-mcp's client, connection manager, HTTP helpers, and SSE
transport, whose tests moved to pebble. fabro-mcp keeps the settings
re-export, the mapping, and a stdio client behind `test-support` for the
tests of fabro's own MCP server. `fabro exec` reports each server's
outcome from the agent's snapshot. The Daytona Playwright live test now
drives the sandbox-hosted server through an agent.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
b5fd907c3d
commit
64c578d044
22 changed files with 686 additions and 2332 deletions
23
Cargo.lock
generated
23
Cargo.lock
generated
|
|
@ -2774,19 +2774,11 @@ name = "fabro-mcp"
|
|||
version = "0.348.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"fabro-config",
|
||||
"fabro-http",
|
||||
"fabro-types",
|
||||
"futures",
|
||||
"pebble-coding-agent",
|
||||
"rmcp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sse-stream",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
|
|
@ -4353,7 +4345,7 @@ dependencies = [
|
|||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.61.2",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5864,7 +5856,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "pebble-agent"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/pebble?rev=1a5abe4a83b3c13f8ecbb38f761d17ede8e3d7d5#1a5abe4a83b3c13f8ecbb38f761d17ede8e3d7d5"
|
||||
source = "git+https://github.com/lithoscomputer/pebble?rev=6cdb30a874010582106bbde80ac91ae000dc13d8#6cdb30a874010582106bbde80ac91ae000dc13d8"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures-util",
|
||||
|
|
@ -5881,20 +5873,25 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "pebble-coding-agent"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/pebble?rev=1a5abe4a83b3c13f8ecbb38f761d17ede8e3d7d5#1a5abe4a83b3c13f8ecbb38f761d17ede8e3d7d5"
|
||||
source = "git+https://github.com/lithoscomputer/pebble?rev=6cdb30a874010582106bbde80ac91ae000dc13d8#6cdb30a874010582106bbde80ac91ae000dc13d8"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures-util",
|
||||
"lithos-llm",
|
||||
"pebble-agent",
|
||||
"reqwest 0.13.4",
|
||||
"rmcp",
|
||||
"rustix",
|
||||
"sandbox-driver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sse-stream",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
|
|
@ -7748,9 +7745,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "sse-stream"
|
||||
version = "0.2.1"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a"
|
||||
checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-util",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ serde_json = { version = "1", features = ["preserve_order"] }
|
|||
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "sqlite-bundled", "migrate", "macros"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "query", "form", "multipart"] }
|
||||
sse-stream = "0.2"
|
||||
ulid = "1"
|
||||
uuid = { version = "1", features = ["v4", "v7", "v8"] }
|
||||
rand = "0.9"
|
||||
|
|
@ -113,8 +112,8 @@ futures-util = "0.3"
|
|||
# the merge commit once it lands. Pebble pins the same lithos-llm rev as
|
||||
# fabro, and its lockfile policy is that every shared crate resolves to the
|
||||
# version lithos-llm locks.
|
||||
pebble-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "1a5abe4a83b3c13f8ecbb38f761d17ede8e3d7d5" }
|
||||
pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "1a5abe4a83b3c13f8ecbb38f761d17ede8e3d7d5" }
|
||||
pebble-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "6cdb30a874010582106bbde80ac91ae000dc13d8" }
|
||||
pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "6cdb30a874010582106bbde80ac91ae000dc13d8", features = ["mcp"] }
|
||||
sandbox-driver = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "23062b6ad62ff4665cbbcb7dce037ec9c4c34318" }
|
||||
sandbox-driver-protocol = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "23062b6ad62ff4665cbbcb7dce037ec9c4c34318" }
|
||||
sandbox-driver-host = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "23062b6ad62ff4665cbbcb7dce037ec9c4c34318" }
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ chrono = { workspace = true }
|
|||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
fabro-acp = { path = "../../components/fabro-acp", features = ["test-support"] }
|
||||
fabro-mcp = { path = "../../components/fabro-mcp", features = ["test-support"] }
|
||||
fabro-build-support = { path = "../../foundation/build-support" }
|
||||
fabro-server = { path = "../fabro-server", features = ["test-support"] }
|
||||
fabro-workflow = { path = "../../components/fabro-workflow", features = ["test-support"] }
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ use fabro_llm::lithos_catalog::{Catalog, CatalogProvider};
|
|||
use fabro_llm::middleware::{Call, Middleware, Next, Output};
|
||||
use fabro_llm::{Client, ClientOptions, Error as LlmError, ErrorKind};
|
||||
use fabro_mcp::config::McpServerSettings;
|
||||
use fabro_mcp::connection_manager::McpConnectionManager;
|
||||
use fabro_mcp::pebble::pebble_servers;
|
||||
use fabro_sandbox::{RunSandbox, SecretRedactor, local_sandbox};
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_types::settings::cli::OutputFormat as SettingsOutputFormat;
|
||||
|
|
@ -627,7 +627,6 @@ async fn run_session(
|
|||
]);
|
||||
}
|
||||
|
||||
let mcp = start_mcp_servers(&mcp_servers, styles).await;
|
||||
let environment: Arc<dyn Environment> = Arc::clone(&sandbox) as Arc<dyn Environment>;
|
||||
let mut builder = CodingAgent::builder(client, environment)
|
||||
.model(format!("{provider_id}/{model}"))
|
||||
|
|
@ -635,9 +634,10 @@ async fn run_session(
|
|||
.tool_middleware(Arc::new(permission_middleware))
|
||||
.redactor(Arc::new(SecretRedactor))
|
||||
.web_fetch_summarizer(summarizer_model(&catalog, &provider_id, &model))
|
||||
.mcp_servers(pebble_servers(&mcp_servers))
|
||||
.subagents(SubagentOptions::enabled());
|
||||
if let Some(manager) = &mcp {
|
||||
builder = builder.tools(manager.tools());
|
||||
if let Some(routes) = sandbox.port_routes() {
|
||||
builder = builder.port_routes(routes);
|
||||
}
|
||||
if let Some(search) = SearchBackend::from_secrets(&cli_search_secrets()) {
|
||||
builder = builder.search_provider(Arc::new(search));
|
||||
|
|
@ -646,6 +646,12 @@ async fn run_session(
|
|||
.build()
|
||||
.await
|
||||
.context("failed to start the agent session")?;
|
||||
if matches!(
|
||||
args.output_format.unwrap_or(ExecOutputFormat::Text),
|
||||
ExecOutputFormat::Text
|
||||
) {
|
||||
print_mcp_servers(&agent, styles);
|
||||
}
|
||||
|
||||
// SIGINT ends the prompt; the session shuts down as cancelled.
|
||||
let cancel_token = CancellationToken::new();
|
||||
|
|
@ -703,36 +709,32 @@ async fn run_session(
|
|||
.map_err(|error| anyhow::Error::new(SessionError::from(error)))
|
||||
}
|
||||
|
||||
/// Connect the configured MCP servers, reporting each outcome on stderr.
|
||||
/// Report what became of each configured MCP server on stderr: pebble
|
||||
/// started them while the agent was built, so the outcomes are read from the
|
||||
/// agent rather than from a stream that had no subscriber yet.
|
||||
#[allow(
|
||||
clippy::print_stderr,
|
||||
reason = "MCP connection outcomes are diagnostics for the person running the CLI."
|
||||
)]
|
||||
async fn start_mcp_servers(
|
||||
servers: &[McpServerSettings],
|
||||
styles: &Styles,
|
||||
) -> Option<Arc<McpConnectionManager>> {
|
||||
if servers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut manager = McpConnectionManager::new();
|
||||
for (server_name, result) in manager.start_servers(servers).await {
|
||||
match result {
|
||||
Ok(tool_count) => eprintln!(
|
||||
fn print_mcp_servers(agent: &CodingAgent, styles: &Styles) {
|
||||
for status in agent.snapshot().mcp_servers() {
|
||||
match &status.error {
|
||||
None => eprintln!(
|
||||
"{}",
|
||||
styles
|
||||
.dim
|
||||
.apply_to(format!("[mcp] {server_name}: {tool_count} tools"))
|
||||
styles.dim.apply_to(format!(
|
||||
"[mcp] {}: {} tools",
|
||||
status.server,
|
||||
status.tools.len()
|
||||
))
|
||||
),
|
||||
Err(error) => eprintln!(
|
||||
Some(error) => eprintln!(
|
||||
"{}",
|
||||
styles
|
||||
.red
|
||||
.apply_to(format!("[mcp] {server_name} failed: {error}"))
|
||||
.apply_to(format!("[mcp] {} failed: {error}", status.server))
|
||||
),
|
||||
}
|
||||
}
|
||||
Some(Arc::new(manager))
|
||||
}
|
||||
|
||||
#[allow(
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ use std::time::{Duration, Instant};
|
|||
|
||||
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||
use fabro_client::{AuthEntry, AuthStore, DevTokenEntry, OAuthEntry, StoredSubject};
|
||||
use fabro_mcp::client::McpClient;
|
||||
use fabro_mcp::config::{McpServerSettings, McpTransport};
|
||||
use fabro_mcp::test_support::McpStdioTestClient as McpClient;
|
||||
use fabro_test::{fabro_json_snapshot, fabro_snapshot, test_context};
|
||||
use fabro_types::{Graph, RunId, WorkflowSettings, test_support};
|
||||
use httpmock::Method::{GET, POST};
|
||||
|
|
|
|||
|
|
@ -4,37 +4,32 @@ edition.workspace = true
|
|||
version.workspace = true
|
||||
publish = false
|
||||
license.workspace = true
|
||||
description = "MCP (Model Context Protocol) client for connecting to external tool servers"
|
||||
description = "MCP server settings for fabro's agents, and how they reach pebble"
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
# A stdio MCP client for tests of fabro's own MCP server. Production agents
|
||||
# reach MCP servers through pebble; nothing here links into a normal build.
|
||||
test-support = ["dep:anyhow", "dep:rmcp", "dep:serde_json", "dep:tokio", "dep:tracing"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
fabro-config = { path = "../../foundation/fabro-config" }
|
||||
fabro-http.workspace = true
|
||||
fabro-types = { path = "../../foundation/fabro-types" }
|
||||
pebble-coding-agent.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
futures.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
sse-stream.workspace = true
|
||||
rmcp = { workspace = true, features = [
|
||||
"client",
|
||||
"macros",
|
||||
"schemars",
|
||||
"server",
|
||||
"transport-child-process",
|
||||
"transport-streamable-http-client-reqwest",
|
||||
] }
|
||||
anyhow = { workspace = true, optional = true }
|
||||
rmcp = { workspace = true, features = ["client", "transport-child-process"], optional = true }
|
||||
serde_json = { workspace = true, optional = true }
|
||||
tokio = { workspace = true, optional = true }
|
||||
tracing = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
axum.workspace = true
|
||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
tokio-stream.workspace = true
|
||||
# The test-support client's dependencies, so `cfg(test)` builds see them too.
|
||||
anyhow.workspace = true
|
||||
rmcp = { workspace = true, features = ["client", "transport-child-process"] }
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,296 +0,0 @@
|
|||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use rmcp::model::{CallToolRequestParams, CallToolResult};
|
||||
use rmcp::service::{RoleClient, RunningService, serve_client};
|
||||
use rmcp::transport::StreamableHttpClientTransport;
|
||||
use rmcp::transport::child_process::TokioChildProcess;
|
||||
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::client_handler::LoggingClientHandler;
|
||||
use crate::config::{McpHttpProtocol, McpServerSettings, McpTransport};
|
||||
use crate::http_transport::headers_from_pairs;
|
||||
use crate::sse_client::SseClientTransport;
|
||||
|
||||
enum ClientState {
|
||||
/// Transport created but handshake not yet performed.
|
||||
Connecting(Option<PendingTransport>),
|
||||
/// Handshake complete, ready for tool calls.
|
||||
Ready(Arc<RunningService<RoleClient, LoggingClientHandler>>),
|
||||
/// Connection was explicitly closed.
|
||||
Closed,
|
||||
}
|
||||
|
||||
enum PendingTransport {
|
||||
Stdio(TokioChildProcess),
|
||||
Http(StreamableHttpClientTransport<fabro_http::HttpClient>),
|
||||
Sse(SseClientTransport),
|
||||
}
|
||||
|
||||
/// MCP client wrapping the rmcp SDK. Handles stdio and HTTP transports.
|
||||
pub struct McpClient {
|
||||
server_name: String,
|
||||
state: Mutex<ClientState>,
|
||||
}
|
||||
|
||||
impl McpClient {
|
||||
/// Create a new MCP client from config. Does not connect yet — call
|
||||
/// `initialize()`.
|
||||
pub fn new(config: &McpServerSettings) -> Result<Self> {
|
||||
let transport = match &config.transport {
|
||||
McpTransport::Stdio { command, env } => {
|
||||
let (program, args) = command.split_first().ok_or_else(|| {
|
||||
anyhow!("MCP server '{}': command must not be empty", config.name)
|
||||
})?;
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
|
||||
if config.clear_env {
|
||||
cmd.env_clear();
|
||||
}
|
||||
if !env.is_empty() {
|
||||
cmd.envs(env);
|
||||
}
|
||||
if let Some(current_dir) = config.current_dir.as_ref() {
|
||||
cmd.current_dir(current_dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
cmd.process_group(0);
|
||||
|
||||
let transport = TokioChildProcess::new(cmd)
|
||||
.with_context(|| format!("failed to spawn MCP server '{}'", config.name))?;
|
||||
|
||||
PendingTransport::Stdio(transport)
|
||||
}
|
||||
McpTransport::Http {
|
||||
protocol,
|
||||
url,
|
||||
headers,
|
||||
} => {
|
||||
let headers = headers_from_pairs(headers)?;
|
||||
let mut builder = fabro_http::HttpClientBuilder::new();
|
||||
if !headers.is_empty() {
|
||||
builder = builder.default_headers(headers);
|
||||
}
|
||||
|
||||
let http_client = builder.build()?;
|
||||
match protocol {
|
||||
McpHttpProtocol::StreamableHttp => {
|
||||
let http_config =
|
||||
StreamableHttpClientTransportConfig::with_uri(url.clone());
|
||||
let transport =
|
||||
StreamableHttpClientTransport::with_client(http_client, http_config);
|
||||
PendingTransport::Http(transport)
|
||||
}
|
||||
McpHttpProtocol::Sse => {
|
||||
PendingTransport::Sse(SseClientTransport::new(url, http_client)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
McpTransport::Sandbox { .. } => {
|
||||
return Err(anyhow!(
|
||||
"MCP server '{}': Sandbox transport must be resolved to Http before connecting",
|
||||
config.name
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let transport_type = match &transport {
|
||||
PendingTransport::Stdio(_) => "stdio",
|
||||
PendingTransport::Http(_) => "http",
|
||||
PendingTransport::Sse(_) => "sse",
|
||||
};
|
||||
debug!(server = %config.name, transport = transport_type, "Creating MCP client");
|
||||
|
||||
Ok(Self {
|
||||
server_name: config.name.clone(),
|
||||
state: Mutex::new(ClientState::Connecting(Some(transport))),
|
||||
})
|
||||
}
|
||||
|
||||
/// Perform the initialization handshake with the MCP server.
|
||||
pub async fn initialize(&self, timeout: Duration) -> Result<()> {
|
||||
let handler = LoggingClientHandler;
|
||||
|
||||
let service = {
|
||||
let mut guard = self.state.lock().await;
|
||||
let transport = match &mut *guard {
|
||||
ClientState::Connecting(t) => t
|
||||
.take()
|
||||
.ok_or_else(|| anyhow!("client already initializing"))?,
|
||||
ClientState::Ready(_) => return Err(anyhow!("client already initialized")),
|
||||
ClientState::Closed => return Err(anyhow!("MCP client is shut down")),
|
||||
};
|
||||
|
||||
// Drop the lock before the blocking handshake
|
||||
drop(guard);
|
||||
|
||||
debug!(server = %self.server_name, "Starting MCP server handshake");
|
||||
|
||||
let handshake = async {
|
||||
match transport {
|
||||
PendingTransport::Stdio(t) => serve_client(handler.clone(), t).await,
|
||||
PendingTransport::Http(t) => serve_client(handler.clone(), t).await,
|
||||
PendingTransport::Sse(t) => serve_client(handler.clone(), t).await,
|
||||
}
|
||||
};
|
||||
|
||||
let service = time::timeout(timeout, handshake)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
error!(server = %self.server_name, timeout_secs = timeout.as_secs(), "MCP server handshake timed out");
|
||||
anyhow!(
|
||||
"timed out initializing MCP server '{}' after {:?}",
|
||||
self.server_name,
|
||||
timeout
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
error!(server = %self.server_name, error = %e, "MCP server handshake failed");
|
||||
anyhow!(
|
||||
"failed to initialize MCP server '{}': {}",
|
||||
self.server_name,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let peer_info = service.peer().peer_info();
|
||||
if let Some(info) = peer_info {
|
||||
info!(
|
||||
server = %self.server_name,
|
||||
server_name = %info.server_info.name,
|
||||
server_version = %info.server_info.version,
|
||||
"MCP server initialized"
|
||||
);
|
||||
}
|
||||
|
||||
Arc::new(service)
|
||||
};
|
||||
|
||||
let mut guard = self.state.lock().await;
|
||||
*guard = ClientState::Ready(service);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all tools exposed by this server.
|
||||
/// Returns `(name, description, input_schema)` tuples.
|
||||
pub async fn list_tools(&self) -> Result<Vec<(String, String, serde_json::Value)>> {
|
||||
let service = self.service().await?;
|
||||
let result = service.list_all_tools().await.map_err(|e| {
|
||||
anyhow!(
|
||||
"failed to list tools from MCP server '{}': {}",
|
||||
self.server_name,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let tools: Vec<_> = result
|
||||
.into_iter()
|
||||
.map(|tool| {
|
||||
let name = tool.name.to_string();
|
||||
let description = tool.description.as_deref().unwrap_or("").to_string();
|
||||
let input_schema = serde_json::to_value(&*tool.input_schema).unwrap_or_default();
|
||||
(name, description, input_schema)
|
||||
})
|
||||
.collect();
|
||||
|
||||
debug!(server = %self.server_name, tool_count = tools.len(), "Listed MCP server tools");
|
||||
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
/// Call a tool on this server.
|
||||
pub async fn call_tool(
|
||||
&self,
|
||||
name: &str,
|
||||
arguments: serde_json::Value,
|
||||
timeout: Duration,
|
||||
) -> Result<CallToolResult> {
|
||||
let service = self.service().await?;
|
||||
|
||||
let args = match arguments {
|
||||
serde_json::Value::Object(map) => Some(map),
|
||||
serde_json::Value::Null => None,
|
||||
other => {
|
||||
return Err(anyhow!(
|
||||
"MCP tool arguments must be a JSON object, got {other}"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let mut params = CallToolRequestParams::new(name.to_string());
|
||||
if let Some(arguments) = args {
|
||||
params = params.with_arguments(arguments);
|
||||
}
|
||||
|
||||
debug!(server = %self.server_name, tool = %name, "Calling MCP tool");
|
||||
|
||||
let result = time::timeout(timeout, service.call_tool(params))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
warn!(server = %self.server_name, tool = %name, timeout_secs = timeout.as_secs(), "MCP tool call timed out");
|
||||
anyhow!(
|
||||
"timed out calling tool '{}' on MCP server '{}' after {:?}",
|
||||
name,
|
||||
self.server_name,
|
||||
timeout
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
anyhow!(
|
||||
"failed to call tool '{}' on MCP server '{}': {}",
|
||||
name,
|
||||
self.server_name,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn shutdown(self) -> Result<()> {
|
||||
let service = {
|
||||
let mut guard = self.state.lock().await;
|
||||
match std::mem::replace(&mut *guard, ClientState::Closed) {
|
||||
ClientState::Connecting(_) | ClientState::Closed => None,
|
||||
ClientState::Ready(service) => Some(service),
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(service) = service {
|
||||
match Arc::try_unwrap(service) {
|
||||
Ok(mut service) => {
|
||||
service
|
||||
.close_with_timeout(Duration::from_secs(2))
|
||||
.await
|
||||
.context("failed to shut down MCP client")?;
|
||||
}
|
||||
Err(service) => {
|
||||
service.cancellation_token().cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn service(&self) -> Result<Arc<RunningService<RoleClient, LoggingClientHandler>>> {
|
||||
let guard = self.state.lock().await;
|
||||
match &*guard {
|
||||
ClientState::Ready(service) => Ok(Arc::clone(service)),
|
||||
ClientState::Connecting(_) => Err(anyhow!("MCP client not initialized")),
|
||||
ClientState::Closed => Err(anyhow!("MCP client is shut down")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
use rmcp::model::{
|
||||
CancelledNotificationParam, ClientCapabilities, ClientInfo, Implementation, LoggingLevel,
|
||||
LoggingMessageNotificationParam, ProgressNotificationParam, ProtocolVersion,
|
||||
ResourceUpdatedNotificationParam,
|
||||
};
|
||||
use rmcp::service::NotificationContext;
|
||||
use rmcp::{ClientHandler, RoleClient};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// Minimal MCP client handler that logs server notifications via tracing.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct LoggingClientHandler;
|
||||
|
||||
impl ClientHandler for LoggingClientHandler {
|
||||
fn get_info(&self) -> ClientInfo {
|
||||
ClientInfo::new(
|
||||
ClientCapabilities::default(),
|
||||
Implementation::new("fabro-mcp", env!("CARGO_PKG_VERSION")),
|
||||
)
|
||||
.with_protocol_version(ProtocolVersion::V_2025_03_26)
|
||||
}
|
||||
|
||||
async fn on_cancelled(
|
||||
&self,
|
||||
params: CancelledNotificationParam,
|
||||
_context: NotificationContext<RoleClient>,
|
||||
) {
|
||||
info!(
|
||||
request_id = %params.request_id,
|
||||
reason = ?params.reason,
|
||||
"MCP server cancelled request"
|
||||
);
|
||||
}
|
||||
|
||||
async fn on_progress(
|
||||
&self,
|
||||
params: ProgressNotificationParam,
|
||||
_context: NotificationContext<RoleClient>,
|
||||
) {
|
||||
debug!(
|
||||
progress_token = ?params.progress_token,
|
||||
progress = params.progress,
|
||||
total = ?params.total,
|
||||
message = ?params.message,
|
||||
"MCP server progress"
|
||||
);
|
||||
}
|
||||
|
||||
async fn on_resource_updated(
|
||||
&self,
|
||||
params: ResourceUpdatedNotificationParam,
|
||||
_context: NotificationContext<RoleClient>,
|
||||
) {
|
||||
info!(uri = %params.uri, "MCP server resource updated");
|
||||
}
|
||||
|
||||
async fn on_resource_list_changed(&self, _context: NotificationContext<RoleClient>) {
|
||||
info!("MCP server resource list changed");
|
||||
}
|
||||
|
||||
async fn on_tool_list_changed(&self, _context: NotificationContext<RoleClient>) {
|
||||
info!("MCP server tool list changed");
|
||||
}
|
||||
|
||||
async fn on_prompt_list_changed(&self, _context: NotificationContext<RoleClient>) {
|
||||
info!("MCP server prompt list changed");
|
||||
}
|
||||
|
||||
async fn on_logging_message(
|
||||
&self,
|
||||
params: LoggingMessageNotificationParam,
|
||||
_context: NotificationContext<RoleClient>,
|
||||
) {
|
||||
let logger = params.logger.as_deref();
|
||||
let data_str = params.data.to_string();
|
||||
let truncated: &str = if data_str.len() > 200 {
|
||||
// Safety: find a char boundary at or before 200 bytes
|
||||
let end = (0..=200)
|
||||
.rev()
|
||||
.find(|&i| data_str.is_char_boundary(i))
|
||||
.unwrap_or(0);
|
||||
&data_str[..end]
|
||||
} else {
|
||||
&data_str
|
||||
};
|
||||
match params.level {
|
||||
LoggingLevel::Emergency
|
||||
| LoggingLevel::Alert
|
||||
| LoggingLevel::Critical
|
||||
| LoggingLevel::Error => {
|
||||
error!(level = ?params.level, ?logger, data = %truncated, "MCP server log");
|
||||
}
|
||||
LoggingLevel::Warning => {
|
||||
warn!(level = ?params.level, ?logger, data = %truncated, "MCP server log");
|
||||
}
|
||||
LoggingLevel::Notice | LoggingLevel::Info => {
|
||||
info!(level = ?params.level, ?logger, data = %truncated, "MCP server log");
|
||||
}
|
||||
LoggingLevel::Debug => {
|
||||
debug!(level = ?params.level, ?logger, data = %truncated, "MCP server log");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,408 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use pebble_coding_agent::tools::{RegisteredTool, ToolError, ToolSource};
|
||||
use rmcp::model::{CallToolResult, RawContent};
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::client::McpClient;
|
||||
use crate::config::McpServerSettings;
|
||||
|
||||
const MCP_TOOL_NAME_DELIMITER: &str = "__";
|
||||
|
||||
/// Produce a qualified tool name: `mcp__{server}__{tool}`.
|
||||
/// Non-alphanumeric characters in `server` and `tool` (except `_`) are replaced
|
||||
/// with `_`.
|
||||
#[must_use]
|
||||
pub fn qualified_tool_name(server: &str, tool: &str) -> String {
|
||||
format!(
|
||||
"mcp{delim}{server}{delim}{tool}",
|
||||
delim = MCP_TOOL_NAME_DELIMITER,
|
||||
server = sanitize_name(server),
|
||||
tool = sanitize_name(tool),
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse a qualified tool name back into `(server, tool)`.
|
||||
/// Returns `None` if the name doesn't match the expected pattern.
|
||||
#[must_use]
|
||||
pub fn parse_qualified_name(qualified: &str) -> Option<(String, String)> {
|
||||
let rest = qualified.strip_prefix("mcp")?;
|
||||
let rest = rest.strip_prefix(MCP_TOOL_NAME_DELIMITER)?;
|
||||
let idx = rest.find(MCP_TOOL_NAME_DELIMITER)?;
|
||||
let server = &rest[..idx];
|
||||
let tool = &rest[idx + MCP_TOOL_NAME_DELIMITER.len()..];
|
||||
if server.is_empty() || tool.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((server.to_string(), tool.to_string()))
|
||||
}
|
||||
|
||||
fn sanitize_name(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Convert an MCP `CallToolResult` to a `Result<String, String>`.
|
||||
/// `Ok(text)` for success, `Err(text)` if the result has `is_error` set.
|
||||
pub fn call_result_to_string(result: &CallToolResult) -> Result<String, String> {
|
||||
let text = result
|
||||
.content
|
||||
.iter()
|
||||
.map(|c| match &c.raw {
|
||||
RawContent::Text(t) => t.text.clone(),
|
||||
RawContent::Image(_) => "[image content]".to_string(),
|
||||
RawContent::Audio(_) => "[audio content]".to_string(),
|
||||
RawContent::Resource(_) | RawContent::ResourceLink(_) => {
|
||||
"[resource content]".to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
if result.is_error.unwrap_or(false) {
|
||||
Err(text)
|
||||
} else {
|
||||
Ok(text)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool info stored per-tool in the connection manager.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolInfo {
|
||||
pub server_name: String,
|
||||
pub original_tool_name: String,
|
||||
pub description: String,
|
||||
pub input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
struct ServerConnection {
|
||||
client: Arc<McpClient>,
|
||||
tool_timeout: Duration,
|
||||
}
|
||||
|
||||
/// Manages connections to multiple MCP servers and their tools.
|
||||
pub struct McpConnectionManager {
|
||||
clients: HashMap<String, ServerConnection>,
|
||||
tools: HashMap<String, ToolInfo>,
|
||||
}
|
||||
|
||||
impl McpConnectionManager {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
clients: HashMap::new(),
|
||||
tools: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start all configured MCP servers. Failed servers are logged but don't
|
||||
/// block others. Returns a list of `(server_name, result)` for each
|
||||
/// server.
|
||||
pub async fn start_servers(
|
||||
&mut self,
|
||||
configs: &[McpServerSettings],
|
||||
) -> Vec<(String, Result<usize>)> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
for config in configs {
|
||||
match self.start_one_server(config).await {
|
||||
Ok(tool_count) => {
|
||||
info!(server = %config.name, tools = tool_count, "MCP server ready");
|
||||
results.push((config.name.clone(), Ok(tool_count)));
|
||||
}
|
||||
Err(e) => {
|
||||
error!(server = %config.name, error = %e, "MCP server failed to start");
|
||||
results.push((config.name.clone(), Err(e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
async fn start_one_server(&mut self, config: &McpServerSettings) -> Result<usize> {
|
||||
let client = McpClient::new(config)?;
|
||||
client.initialize(config.startup_timeout()).await?;
|
||||
let tools = client.list_tools().await?;
|
||||
let tool_count = tools.len();
|
||||
|
||||
for (name, description, input_schema) in tools {
|
||||
let qualified = qualified_tool_name(&config.name, &name);
|
||||
self.tools.insert(qualified, ToolInfo {
|
||||
server_name: config.name.clone(),
|
||||
original_tool_name: name,
|
||||
description,
|
||||
input_schema,
|
||||
});
|
||||
}
|
||||
|
||||
self.clients.insert(config.name.clone(), ServerConnection {
|
||||
client: Arc::new(client),
|
||||
tool_timeout: config.tool_timeout(),
|
||||
});
|
||||
|
||||
Ok(tool_count)
|
||||
}
|
||||
|
||||
/// All tools across all connected servers.
|
||||
#[must_use]
|
||||
pub fn all_tools(&self) -> &HashMap<String, ToolInfo> {
|
||||
&self.tools
|
||||
}
|
||||
|
||||
/// Names-only tool summaries for the given server, sorted by qualified
|
||||
/// name. Returns `(qualified_name, original_tool_name)` pairs. Useful
|
||||
/// for emitting deterministic `agent.mcp.ready` payloads without
|
||||
/// leaking descriptions or input schemas.
|
||||
#[must_use]
|
||||
pub fn tool_summaries_for_server(&self, server_name: &str) -> Vec<(String, String)> {
|
||||
let mut summaries: Vec<(String, String)> = self
|
||||
.tools
|
||||
.iter()
|
||||
.filter(|(_, info)| info.server_name == server_name)
|
||||
.map(|(qualified, info)| (qualified.clone(), info.original_tool_name.clone()))
|
||||
.collect();
|
||||
summaries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
summaries
|
||||
}
|
||||
|
||||
/// Every connected server's tools as coding-agent tools, each carrying
|
||||
/// its MCP origin. Sorted by qualified name so registration order is
|
||||
/// deterministic.
|
||||
#[must_use]
|
||||
pub fn tools(self: &Arc<Self>) -> Vec<RegisteredTool> {
|
||||
let mut tools: Vec<(&String, &ToolInfo)> = self.tools.iter().collect();
|
||||
tools.sort_by(|left, right| left.0.cmp(right.0));
|
||||
tools
|
||||
.into_iter()
|
||||
.map(|(qualified_name, info)| {
|
||||
let manager = Arc::clone(self);
|
||||
let name = qualified_name.clone();
|
||||
RegisteredTool::function(
|
||||
qualified_name.clone(),
|
||||
info.description.clone(),
|
||||
info.input_schema.clone(),
|
||||
move |_context, arguments| {
|
||||
let manager = Arc::clone(&manager);
|
||||
let name = name.clone();
|
||||
async move {
|
||||
let result = manager
|
||||
.call_tool(&name, arguments)
|
||||
.await
|
||||
.map_err(|error| ToolError::execution(error.to_string()))?;
|
||||
call_result_to_string(&result).map_err(ToolError::execution)
|
||||
}
|
||||
},
|
||||
)
|
||||
.with_source(ToolSource::Mcp {
|
||||
server_name: info.server_name.clone(),
|
||||
original_name: info.original_tool_name.clone(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Call a tool by its qualified name.
|
||||
pub async fn call_tool(
|
||||
&self,
|
||||
qualified_name: &str,
|
||||
arguments: serde_json::Value,
|
||||
) -> Result<CallToolResult> {
|
||||
let info = self
|
||||
.tools
|
||||
.get(qualified_name)
|
||||
.ok_or_else(|| anyhow::anyhow!("unknown MCP tool: {qualified_name}"))?;
|
||||
|
||||
let connection = self
|
||||
.clients
|
||||
.get(&info.server_name)
|
||||
.ok_or_else(|| anyhow::anyhow!("no client for MCP server: {}", info.server_name))?;
|
||||
|
||||
connection
|
||||
.client
|
||||
.call_tool(&info.original_tool_name, arguments, connection.tool_timeout)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for McpConnectionManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rmcp::model::Content;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn qualified_tool_name_basic() {
|
||||
assert_eq!(
|
||||
qualified_tool_name("filesystem", "read_file"),
|
||||
"mcp__filesystem__read_file"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualified_tool_name_sanitizes_special_chars() {
|
||||
assert_eq!(
|
||||
qualified_tool_name("my-server", "read.file"),
|
||||
"mcp__my_server__read_file"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualified_tool_name_preserves_underscores() {
|
||||
assert_eq!(
|
||||
qualified_tool_name("my_server", "read_file"),
|
||||
"mcp__my_server__read_file"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_qualified_name_roundtrip() {
|
||||
let qualified = qualified_tool_name("filesystem", "read_file");
|
||||
let (server, tool) = parse_qualified_name(&qualified).unwrap();
|
||||
assert_eq!(server, "filesystem");
|
||||
assert_eq!(tool, "read_file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_qualified_name_with_sanitized_input() {
|
||||
let qualified = qualified_tool_name("my-server", "read.file");
|
||||
let (server, tool) = parse_qualified_name(&qualified).unwrap();
|
||||
assert_eq!(server, "my_server");
|
||||
assert_eq!(tool, "read_file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_qualified_name_invalid_prefix() {
|
||||
assert!(parse_qualified_name("not_mcp__server__tool").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_qualified_name_missing_delimiter() {
|
||||
assert!(parse_qualified_name("mcp__serveronly").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_qualified_name_empty_parts() {
|
||||
assert!(parse_qualified_name("mcp____tool").is_none());
|
||||
}
|
||||
|
||||
fn make_text_content(text: &str) -> Content {
|
||||
Content::text(text)
|
||||
}
|
||||
|
||||
fn make_call_result(content: Vec<Content>, is_error: Option<bool>) -> CallToolResult {
|
||||
if is_error == Some(true) {
|
||||
CallToolResult::error(content)
|
||||
} else {
|
||||
CallToolResult::success(content)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_result_to_string_text_success() {
|
||||
let result = make_call_result(vec![make_text_content("hello world")], Some(false));
|
||||
assert_eq!(
|
||||
call_result_to_string(&result),
|
||||
Ok("hello world".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_result_to_string_text_error() {
|
||||
let result = make_call_result(vec![make_text_content("something failed")], Some(true));
|
||||
assert_eq!(
|
||||
call_result_to_string(&result),
|
||||
Err("something failed".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_result_to_string_multiple_blocks_concatenated() {
|
||||
let result = make_call_result(
|
||||
vec![make_text_content("line 1"), make_text_content("line 2")],
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
call_result_to_string(&result),
|
||||
Ok("line 1\nline 2".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_result_to_string_image_placeholder() {
|
||||
let result = CallToolResult::success(vec![Content::image("base64data", "image/png")]);
|
||||
assert_eq!(
|
||||
call_result_to_string(&result),
|
||||
Ok("[image content]".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_result_to_string_none_is_error_treated_as_success() {
|
||||
let result = make_call_result(vec![make_text_content("ok")], None);
|
||||
assert_eq!(call_result_to_string(&result), Ok("ok".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_manager_new_has_empty_tools() {
|
||||
let mgr = McpConnectionManager::new();
|
||||
assert!(mgr.all_tools().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_summaries_for_server_filters_and_sorts_by_qualified_name() {
|
||||
let mut mgr = McpConnectionManager::new();
|
||||
mgr.tools
|
||||
.insert(qualified_tool_name("github", "list_issues"), ToolInfo {
|
||||
server_name: "github".to_string(),
|
||||
original_tool_name: "list_issues".to_string(),
|
||||
description: "list issues".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
});
|
||||
mgr.tools
|
||||
.insert(qualified_tool_name("github", "create_issue"), ToolInfo {
|
||||
server_name: "github".to_string(),
|
||||
original_tool_name: "create_issue".to_string(),
|
||||
description: "create issue".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
});
|
||||
mgr.tools
|
||||
.insert(qualified_tool_name("other", "noop"), ToolInfo {
|
||||
server_name: "other".to_string(),
|
||||
original_tool_name: "noop".to_string(),
|
||||
description: "noop".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
});
|
||||
|
||||
let summaries = mgr.tool_summaries_for_server("github");
|
||||
assert_eq!(summaries.len(), 2);
|
||||
assert_eq!(summaries[0].0, "mcp__github__create_issue");
|
||||
assert_eq!(summaries[0].1, "create_issue");
|
||||
assert_eq!(summaries[1].0, "mcp__github__list_issues");
|
||||
assert_eq!(summaries[1].1, "list_issues");
|
||||
|
||||
let other = mgr.tool_summaries_for_server("other");
|
||||
assert_eq!(other.len(), 1);
|
||||
assert_eq!(other[0].0, "mcp__other__noop");
|
||||
assert_eq!(other[0].1, "noop");
|
||||
|
||||
let none = mgr.tool_summaries_for_server("missing");
|
||||
assert!(none.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
#![expect(
|
||||
clippy::disallowed_types,
|
||||
reason = "MCP HTTP helpers parse operator-provided endpoint URLs; callers control what is logged"
|
||||
)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use fabro_http::{HeaderMap, HeaderName, HeaderValue, Url};
|
||||
|
||||
use crate::config::McpHttpProtocol;
|
||||
|
||||
pub fn sandbox_mcp_http_url(protocol: McpHttpProtocol, preview_url: &str) -> Result<String> {
|
||||
match protocol {
|
||||
McpHttpProtocol::StreamableHttp => Ok(preview_url.to_string()),
|
||||
McpHttpProtocol::Sse => {
|
||||
let mut url = Url::parse(preview_url).context("invalid sandbox MCP preview URL")?;
|
||||
let path = url.path().trim_end_matches('/');
|
||||
url.set_path(&format!("{path}/sse"));
|
||||
Ok(url.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn headers_from_pairs(headers: &HashMap<String, String>) -> Result<HeaderMap> {
|
||||
let mut header_map = HeaderMap::new();
|
||||
for (key, value) in headers {
|
||||
let name = HeaderName::from_bytes(key.as_bytes())
|
||||
.with_context(|| format!("invalid header name '{key}'"))?;
|
||||
let val = HeaderValue::from_str(value)
|
||||
.with_context(|| format!("invalid header value for '{key}'"))?;
|
||||
header_map.insert(name, val);
|
||||
}
|
||||
Ok(header_map)
|
||||
}
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
pub mod client;
|
||||
mod client_handler;
|
||||
//! MCP server settings for fabro's agents, and how they reach pebble.
|
||||
//!
|
||||
//! Fabro configures MCP servers in its settings ([`config`]); pebble starts
|
||||
//! them, registers their tools, and closes them with the agent. [`pebble`]
|
||||
//! maps one to the other. The stdio client fabro once ran itself is gone;
|
||||
//! [`test_support`] keeps a small one for the tests of fabro's own MCP server.
|
||||
|
||||
pub mod config;
|
||||
pub mod connection_manager;
|
||||
pub mod http_transport;
|
||||
mod sse_client;
|
||||
pub mod pebble;
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test_support;
|
||||
|
|
|
|||
191
lib/components/fabro-mcp/src/pebble.rs
Normal file
191
lib/components/fabro-mcp/src/pebble.rs
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
//! Fabro's MCP server settings as the servers pebble starts.
|
||||
//!
|
||||
//! Fabro's three transports are pebble's three placements: a `stdio` server
|
||||
//! is a child of fabro's process, an `http` server is reached directly, and a
|
||||
//! `sandbox` server is launched in the run sandbox and reached through the
|
||||
//! sandbox's route to its port. Fabro has always reached a sandbox-hosted SSE
|
||||
//! server at `/sse` under that route, and still does.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use pebble_coding_agent::mcp::{McpHttpProtocol as PebbleProtocol, McpPlacement, McpServer};
|
||||
|
||||
use crate::config::{McpHttpProtocol, McpServerSettings, McpTransport};
|
||||
|
||||
/// Where a sandbox-hosted SSE server serves its event stream.
|
||||
const SSE_PATH: &str = "/sse";
|
||||
|
||||
/// The pebble server `settings` describes.
|
||||
#[must_use]
|
||||
pub fn pebble_server(settings: &McpServerSettings) -> McpServer {
|
||||
let placement = match &settings.transport {
|
||||
McpTransport::Stdio { command, env } => McpPlacement::Stdio {
|
||||
command: command.clone(),
|
||||
env: sorted(env),
|
||||
current_dir: settings.current_dir.clone(),
|
||||
clear_env: settings.clear_env,
|
||||
},
|
||||
McpTransport::Http {
|
||||
protocol,
|
||||
url,
|
||||
headers,
|
||||
} => McpPlacement::Http {
|
||||
url: url.clone(),
|
||||
headers: sorted(headers),
|
||||
protocol: pebble_protocol(*protocol),
|
||||
},
|
||||
McpTransport::Sandbox {
|
||||
protocol,
|
||||
command,
|
||||
port,
|
||||
env,
|
||||
} => McpPlacement::Environment {
|
||||
command: command.clone(),
|
||||
port: *port,
|
||||
env: sorted(env),
|
||||
protocol: pebble_protocol(*protocol),
|
||||
path: matches!(protocol, McpHttpProtocol::Sse).then(|| SSE_PATH.to_string()),
|
||||
},
|
||||
};
|
||||
McpServer::new(settings.name.clone(), placement)
|
||||
.with_startup_timeout(settings.startup_timeout())
|
||||
.with_tool_timeout(settings.tool_timeout())
|
||||
}
|
||||
|
||||
/// The pebble servers for every configured server, in configuration order.
|
||||
pub fn pebble_servers<'a>(
|
||||
settings: impl IntoIterator<Item = &'a McpServerSettings>,
|
||||
) -> Vec<McpServer> {
|
||||
settings.into_iter().map(pebble_server).collect()
|
||||
}
|
||||
|
||||
fn pebble_protocol(protocol: McpHttpProtocol) -> PebbleProtocol {
|
||||
match protocol {
|
||||
McpHttpProtocol::StreamableHttp => PebbleProtocol::StreamableHttp,
|
||||
McpHttpProtocol::Sse => PebbleProtocol::Sse,
|
||||
}
|
||||
}
|
||||
|
||||
fn sorted(map: &HashMap<String, String>) -> BTreeMap<String, String> {
|
||||
map.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_stdio_server_keeps_its_directory_and_environment_policy() {
|
||||
let settings = McpServerSettings {
|
||||
name: "echo".into(),
|
||||
transport: McpTransport::Stdio {
|
||||
command: vec!["python3".into(), "server.py".into()],
|
||||
env: HashMap::from([("B".into(), "2".into()), ("A".into(), "1".into())]),
|
||||
},
|
||||
current_dir: Some(PathBuf::from("/work")),
|
||||
clear_env: true,
|
||||
startup_timeout_secs: 7,
|
||||
tool_timeout_secs: 9,
|
||||
};
|
||||
let server = pebble_server(&settings);
|
||||
assert_eq!(server.name(), "echo");
|
||||
assert_eq!(server.startup_timeout(), Duration::from_secs(7));
|
||||
assert_eq!(server.tool_timeout(), Duration::from_secs(9));
|
||||
match server.placement() {
|
||||
McpPlacement::Stdio {
|
||||
command,
|
||||
env,
|
||||
current_dir,
|
||||
clear_env,
|
||||
} => {
|
||||
assert_eq!(command, &["python3", "server.py"]);
|
||||
assert_eq!(
|
||||
env.keys().collect::<Vec<_>>(),
|
||||
["A", "B"],
|
||||
"a sorted map, so the launch is deterministic"
|
||||
);
|
||||
assert_eq!(current_dir.as_deref(), Some(std::path::Path::new("/work")));
|
||||
assert!(*clear_env);
|
||||
}
|
||||
other => panic!("expected a stdio placement, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_http_server_keeps_its_protocol_and_headers() {
|
||||
let settings = McpServerSettings {
|
||||
name: "web".into(),
|
||||
transport: McpTransport::Http {
|
||||
protocol: McpHttpProtocol::Sse,
|
||||
url: "https://mcp.example/sse".into(),
|
||||
headers: HashMap::from([("authorization".into(), "Bearer x".into())]),
|
||||
},
|
||||
..McpServerSettings::default()
|
||||
};
|
||||
match pebble_server(&settings).placement() {
|
||||
McpPlacement::Http {
|
||||
url,
|
||||
headers,
|
||||
protocol,
|
||||
} => {
|
||||
assert_eq!(url, "https://mcp.example/sse");
|
||||
assert_eq!(
|
||||
headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer x")
|
||||
);
|
||||
assert_eq!(*protocol, PebbleProtocol::Sse);
|
||||
}
|
||||
other => panic!("expected an http placement, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sandbox_server_is_an_environment_placement_with_fabros_sse_path() {
|
||||
let sse = McpServerSettings {
|
||||
name: "playwright".into(),
|
||||
transport: McpTransport::Sandbox {
|
||||
protocol: McpHttpProtocol::Sse,
|
||||
command: vec!["npx".into(), "@playwright/mcp".into()],
|
||||
port: 3100,
|
||||
env: HashMap::new(),
|
||||
},
|
||||
..McpServerSettings::default()
|
||||
};
|
||||
match pebble_server(&sse).placement() {
|
||||
McpPlacement::Environment {
|
||||
port,
|
||||
protocol,
|
||||
path,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(*port, 3100);
|
||||
assert_eq!(*protocol, PebbleProtocol::Sse);
|
||||
assert_eq!(path.as_deref(), Some("/sse"));
|
||||
}
|
||||
other => panic!("expected an environment placement, got {other:?}"),
|
||||
}
|
||||
let streamable = McpServerSettings {
|
||||
transport: McpTransport::Sandbox {
|
||||
protocol: McpHttpProtocol::StreamableHttp,
|
||||
command: vec!["server".into()],
|
||||
port: 3100,
|
||||
env: HashMap::new(),
|
||||
},
|
||||
..sse
|
||||
};
|
||||
match pebble_server(&streamable).placement() {
|
||||
McpPlacement::Environment { path, .. } => {
|
||||
assert!(
|
||||
path.is_none(),
|
||||
"a streamable server is reached at the route itself"
|
||||
);
|
||||
}
|
||||
other => panic!("expected an environment placement, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,319 +0,0 @@
|
|||
#![expect(
|
||||
clippy::disallowed_types,
|
||||
reason = "SSE transport needs URL parsing for internal request routing; error messages omit raw URLs"
|
||||
)]
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use fabro_http::{Url, header};
|
||||
use futures::{StreamExt as _, TryStreamExt as _};
|
||||
use rmcp::RoleClient;
|
||||
use rmcp::model::ServerJsonRpcMessage;
|
||||
use rmcp::service::{RxJsonRpcMessage, TxJsonRpcMessage};
|
||||
use rmcp::transport::Transport;
|
||||
use sse_stream::{Sse, SseStream};
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
const MAX_SSE_MESSAGE_BYTES: usize = 1024 * 1024;
|
||||
|
||||
pub(crate) struct SseClientTransport {
|
||||
client: fabro_http::HttpClient,
|
||||
endpoint_rx: watch::Receiver<Option<String>>,
|
||||
messages_rx: mpsc::Receiver<ServerJsonRpcMessage>,
|
||||
stream_task: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl SseClientTransport {
|
||||
pub(crate) fn new(url: &str, client: fabro_http::HttpClient) -> Result<Self> {
|
||||
let (endpoint_tx, endpoint_rx) = watch::channel(None);
|
||||
let (messages_tx, messages_rx) = mpsc::channel(64);
|
||||
let sse_url = Url::parse(url).context("invalid SSE MCP URL")?;
|
||||
let stream_client = client.clone();
|
||||
|
||||
let stream_task = tokio::spawn(async move {
|
||||
if let Err(err) =
|
||||
read_sse_stream(stream_client, sse_url, endpoint_tx, messages_tx).await
|
||||
{
|
||||
tracing::warn!(error = %err, "SSE MCP stream ended");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
endpoint_rx,
|
||||
messages_rx,
|
||||
stream_task: Some(stream_task),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SseClientTransport {
|
||||
fn drop(&mut self) {
|
||||
if let Some(stream_task) = &self.stream_task {
|
||||
stream_task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport<RoleClient> for SseClientTransport {
|
||||
type Error = SseClientError;
|
||||
|
||||
fn send(
|
||||
&mut self,
|
||||
item: TxJsonRpcMessage<RoleClient>,
|
||||
) -> impl Future<Output = Result<(), Self::Error>> + Send + 'static {
|
||||
let client = self.client.clone();
|
||||
let mut endpoint_rx = self.endpoint_rx.clone();
|
||||
async move {
|
||||
let endpoint = wait_for_endpoint(&mut endpoint_rx).await?;
|
||||
client
|
||||
.post(endpoint)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.json(&item)
|
||||
.send()
|
||||
.await
|
||||
.map_err(SseClientError::from_error)?
|
||||
.error_for_status()
|
||||
.map_err(SseClientError::from_error)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn receive(&mut self) -> impl Future<Output = Option<RxJsonRpcMessage<RoleClient>>> + Send {
|
||||
self.messages_rx.recv()
|
||||
}
|
||||
|
||||
async fn close(&mut self) -> std::result::Result<(), Self::Error> {
|
||||
if let Some(stream_task) = self.stream_task.take() {
|
||||
stream_task.abort();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_endpoint(
|
||||
endpoint_rx: &mut watch::Receiver<Option<String>>,
|
||||
) -> std::result::Result<String, SseClientError> {
|
||||
loop {
|
||||
if let Some(endpoint) = endpoint_rx.borrow().clone() {
|
||||
return Ok(endpoint);
|
||||
}
|
||||
endpoint_rx
|
||||
.changed()
|
||||
.await
|
||||
.map_err(|_| SseClientError::EndpointUnavailable)?;
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_sse_stream(
|
||||
client: fabro_http::HttpClient,
|
||||
sse_url: Url,
|
||||
endpoint_tx: watch::Sender<Option<String>>,
|
||||
messages_tx: mpsc::Sender<ServerJsonRpcMessage>,
|
||||
) -> std::result::Result<(), SseClientError> {
|
||||
let request = client
|
||||
.get(sse_url.clone())
|
||||
.header(header::ACCEPT, "text/event-stream");
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(SseClientError::from_error)?
|
||||
.error_for_status()
|
||||
.map_err(SseClientError::from_error)?;
|
||||
let mut size_guard = SseSizeGuard::default();
|
||||
let byte_stream = response.bytes_stream().map(move |chunk| {
|
||||
let chunk = chunk.map_err(SseClientError::from_error)?;
|
||||
size_guard.check_chunk(&chunk)?;
|
||||
Ok::<_, SseClientError>(chunk)
|
||||
});
|
||||
let mut stream = SseStream::from_byte_stream(byte_stream);
|
||||
|
||||
while let Some(event) = stream
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(SseClientError::from_error)?
|
||||
{
|
||||
handle_sse_event(event, &sse_url, &endpoint_tx, &messages_tx).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_sse_event(
|
||||
event: Sse,
|
||||
sse_url: &Url,
|
||||
endpoint_tx: &watch::Sender<Option<String>>,
|
||||
messages_tx: &mpsc::Sender<ServerJsonRpcMessage>,
|
||||
) -> std::result::Result<(), SseClientError> {
|
||||
let data = event.data.unwrap_or_default();
|
||||
match event.event.as_deref() {
|
||||
Some("endpoint") => {
|
||||
let endpoint =
|
||||
resolve_endpoint_url(sse_url, data.trim()).context("invalid SSE MCP endpoint")?;
|
||||
let _ = endpoint_tx.send(Some(endpoint.to_string()));
|
||||
}
|
||||
None | Some("" | "message") => {
|
||||
if data.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let message: ServerJsonRpcMessage =
|
||||
serde_json::from_str(&data).context("invalid SSE MCP JSON-RPC message")?;
|
||||
messages_tx
|
||||
.send(message)
|
||||
.await
|
||||
.map_err(|_| SseClientError::ReceiverClosed)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_endpoint_url(sse_url: &Url, endpoint: &str) -> Result<Url> {
|
||||
if endpoint.starts_with("//") {
|
||||
return Err(anyhow!("SSE MCP endpoint must not be protocol-relative"));
|
||||
}
|
||||
|
||||
let resolved = if endpoint.starts_with('/') {
|
||||
let (path, query) = endpoint
|
||||
.split_once('?')
|
||||
.map_or((endpoint, None), |(path, query)| (path, Some(query)));
|
||||
let base_path = sse_url.path().trim_end_matches('/');
|
||||
let prefix = base_path.rsplit_once('/').map_or("", |(prefix, _)| prefix);
|
||||
let mut url = sse_url.clone();
|
||||
url.set_path(&format!("{prefix}{path}"));
|
||||
url.set_query(query);
|
||||
url
|
||||
} else {
|
||||
sse_url.join(endpoint)?
|
||||
};
|
||||
|
||||
if resolved.origin() != sse_url.origin() {
|
||||
return Err(anyhow!("SSE MCP endpoint origin must match SSE URL origin"));
|
||||
}
|
||||
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SseSizeGuard {
|
||||
current_event_bytes: usize,
|
||||
current_line_bytes: usize,
|
||||
}
|
||||
|
||||
impl SseSizeGuard {
|
||||
fn check_chunk(&mut self, chunk: &[u8]) -> std::result::Result<(), SseClientError> {
|
||||
for byte in chunk {
|
||||
self.current_event_bytes = self
|
||||
.current_event_bytes
|
||||
.checked_add(1)
|
||||
.filter(|bytes| *bytes <= MAX_SSE_MESSAGE_BYTES)
|
||||
.ok_or(SseClientError::MessageTooLarge {
|
||||
max_bytes: MAX_SSE_MESSAGE_BYTES,
|
||||
})?;
|
||||
|
||||
match *byte {
|
||||
b'\n' => {
|
||||
if self.current_line_bytes == 0 {
|
||||
self.current_event_bytes = 0;
|
||||
}
|
||||
self.current_line_bytes = 0;
|
||||
}
|
||||
b'\r' => {}
|
||||
_ => {
|
||||
self.current_line_bytes += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum SseClientError {
|
||||
#[error(transparent)]
|
||||
Source(#[from] anyhow::Error),
|
||||
#[error("SSE MCP endpoint was not received before the stream closed")]
|
||||
EndpointUnavailable,
|
||||
#[error("SSE MCP receiver closed")]
|
||||
ReceiverClosed,
|
||||
#[error("SSE MCP message exceeds maximum size of {max_bytes} bytes")]
|
||||
MessageTooLarge { max_bytes: usize },
|
||||
}
|
||||
|
||||
impl SseClientError {
|
||||
fn from_error(error: impl std::error::Error + Send + Sync + 'static) -> Self {
|
||||
Self::Source(anyhow::Error::new(error))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sse_url() -> Url {
|
||||
Url::parse("https://srv.example.com/sse").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_relative_path_with_query() {
|
||||
let resolved = resolve_endpoint_url(&sse_url(), "/messages?sessionId=abc").unwrap();
|
||||
assert_eq!(
|
||||
resolved.as_str(),
|
||||
"https://srv.example.com/messages?sessionId=abc"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_same_origin_absolute_url() {
|
||||
let resolved =
|
||||
resolve_endpoint_url(&sse_url(), "https://srv.example.com/messages?s=1").unwrap();
|
||||
assert_eq!(resolved.as_str(), "https://srv.example.com/messages?s=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_same_origin_default_port() {
|
||||
let resolved =
|
||||
resolve_endpoint_url(&sse_url(), "https://srv.example.com:443/messages").unwrap();
|
||||
assert_eq!(resolved.origin(), sse_url().origin());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_cross_host_absolute_url() {
|
||||
let err = resolve_endpoint_url(&sse_url(), "https://evil.example/steal").unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("origin"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_scheme_downgrade() {
|
||||
let err = resolve_endpoint_url(&sse_url(), "http://srv.example.com/messages").unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("origin"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_port_mismatch() {
|
||||
let err =
|
||||
resolve_endpoint_url(&sse_url(), "https://srv.example.com:8443/messages").unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("origin"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_protocol_relative_url() {
|
||||
let err = resolve_endpoint_url(&sse_url(), "//evil.example/steal").unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("protocol-relative"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
200
lib/components/fabro-mcp/src/test_support.rs
Normal file
200
lib/components/fabro-mcp/src/test_support.rs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
//! A stdio MCP client for tests of fabro's own MCP server.
|
||||
//!
|
||||
//! Production agents reach MCP servers through pebble, which owns the client.
|
||||
//! Fabro's `fabro mcp` command *is* an MCP server, and its tests need a
|
||||
//! client to speak to it over its standard streams; this is that client and
|
||||
//! nothing more. It links only into tests.
|
||||
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use rmcp::model::{
|
||||
CallToolRequestParams, CallToolResult, ClientCapabilities, ClientInfo, Implementation,
|
||||
ProtocolVersion,
|
||||
};
|
||||
use rmcp::service::{RoleClient, RunningService, serve_client};
|
||||
use rmcp::transport::child_process::TokioChildProcess;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time;
|
||||
|
||||
use crate::config::{McpServerSettings, McpTransport};
|
||||
|
||||
enum State {
|
||||
Connecting(Option<TokioChildProcess>),
|
||||
Ready(Arc<RunningService<RoleClient, ClientInfo>>),
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// A test client over a stdio MCP server.
|
||||
pub struct McpStdioTestClient {
|
||||
server_name: String,
|
||||
state: Mutex<State>,
|
||||
}
|
||||
|
||||
impl McpStdioTestClient {
|
||||
/// Spawns the server `config` names. Only a `stdio` transport is
|
||||
/// supported; call [`initialize`](Self::initialize) next.
|
||||
pub fn new(config: &McpServerSettings) -> Result<Self> {
|
||||
let McpTransport::Stdio { command, env } = &config.transport else {
|
||||
return Err(anyhow!(
|
||||
"MCP test client '{}': only a stdio transport is supported",
|
||||
config.name
|
||||
));
|
||||
};
|
||||
let (program, args) = command
|
||||
.split_first()
|
||||
.ok_or_else(|| anyhow!("MCP server '{}': command must not be empty", config.name))?;
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
if config.clear_env {
|
||||
cmd.env_clear();
|
||||
}
|
||||
if !env.is_empty() {
|
||||
cmd.envs(env);
|
||||
}
|
||||
if let Some(current_dir) = config.current_dir.as_ref() {
|
||||
cmd.current_dir(current_dir);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
cmd.process_group(0);
|
||||
let transport = TokioChildProcess::new(cmd)
|
||||
.with_context(|| format!("failed to spawn MCP server '{}'", config.name))?;
|
||||
Ok(Self {
|
||||
server_name: config.name.clone(),
|
||||
state: Mutex::new(State::Connecting(Some(transport))),
|
||||
})
|
||||
}
|
||||
|
||||
/// Performs the MCP handshake within `timeout`.
|
||||
pub async fn initialize(&self, timeout: Duration) -> Result<()> {
|
||||
let transport = {
|
||||
let mut guard = self.state.lock().await;
|
||||
match &mut *guard {
|
||||
State::Connecting(transport) => transport
|
||||
.take()
|
||||
.ok_or_else(|| anyhow!("client already initializing"))?,
|
||||
State::Ready(_) => return Err(anyhow!("client already initialized")),
|
||||
State::Closed => return Err(anyhow!("MCP client is shut down")),
|
||||
}
|
||||
};
|
||||
let info = ClientInfo::new(
|
||||
ClientCapabilities::default(),
|
||||
Implementation::new("fabro-mcp-test", env!("CARGO_PKG_VERSION")),
|
||||
)
|
||||
.with_protocol_version(ProtocolVersion::V_2025_03_26);
|
||||
let service = time::timeout(timeout, serve_client(info, transport))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"timed out initializing MCP server '{}' after {timeout:?}",
|
||||
self.server_name
|
||||
)
|
||||
})?
|
||||
.map_err(|error| {
|
||||
anyhow!(
|
||||
"failed to initialize MCP server '{}': {error}",
|
||||
self.server_name
|
||||
)
|
||||
})?;
|
||||
if let Some(peer) = service.peer().peer_info() {
|
||||
tracing::info!(
|
||||
server = %self.server_name,
|
||||
server_name = %peer.server_info.name,
|
||||
server_version = %peer.server_info.version,
|
||||
"MCP server initialized"
|
||||
);
|
||||
}
|
||||
*self.state.lock().await = State::Ready(Arc::new(service));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every tool the server exposes, as `(name, description, input_schema)`.
|
||||
pub async fn list_tools(&self) -> Result<Vec<(String, String, serde_json::Value)>> {
|
||||
let service = self.service().await?;
|
||||
let tools = service.list_all_tools().await.map_err(|error| {
|
||||
anyhow!(
|
||||
"failed to list tools from MCP server '{}': {error}",
|
||||
self.server_name
|
||||
)
|
||||
})?;
|
||||
Ok(tools
|
||||
.into_iter()
|
||||
.map(|tool| {
|
||||
(
|
||||
tool.name.to_string(),
|
||||
tool.description.as_deref().unwrap_or("").to_string(),
|
||||
serde_json::to_value(&*tool.input_schema).unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Calls `name` with `arguments`, waiting at most `timeout`.
|
||||
pub async fn call_tool(
|
||||
&self,
|
||||
name: &str,
|
||||
arguments: serde_json::Value,
|
||||
timeout: Duration,
|
||||
) -> Result<CallToolResult> {
|
||||
let service = self.service().await?;
|
||||
let mut params = CallToolRequestParams::new(name.to_string());
|
||||
match arguments {
|
||||
serde_json::Value::Object(map) => params = params.with_arguments(map),
|
||||
serde_json::Value::Null => {}
|
||||
other => {
|
||||
return Err(anyhow!(
|
||||
"MCP tool arguments must be a JSON object, got {other}"
|
||||
));
|
||||
}
|
||||
}
|
||||
time::timeout(timeout, service.call_tool(params))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"timed out calling tool '{name}' on MCP server '{}' after {timeout:?}",
|
||||
self.server_name
|
||||
)
|
||||
})?
|
||||
.map_err(|error| {
|
||||
anyhow!(
|
||||
"failed to call tool '{name}' on MCP server '{}': {error}",
|
||||
self.server_name
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Ends the session and stops the server.
|
||||
pub async fn shutdown(self) -> Result<()> {
|
||||
let service = match std::mem::replace(&mut *self.state.lock().await, State::Closed) {
|
||||
State::Connecting(_) | State::Closed => None,
|
||||
State::Ready(service) => Some(service),
|
||||
};
|
||||
if let Some(service) = service {
|
||||
match Arc::try_unwrap(service) {
|
||||
Ok(mut service) => {
|
||||
service
|
||||
.close_with_timeout(Duration::from_secs(2))
|
||||
.await
|
||||
.context("failed to shut down MCP client")?;
|
||||
}
|
||||
Err(service) => service.cancellation_token().cancel(),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn service(&self) -> Result<Arc<RunningService<RoleClient, ClientInfo>>> {
|
||||
match &*self.state.lock().await {
|
||||
State::Ready(service) => Ok(Arc::clone(service)),
|
||||
State::Connecting(_) => Err(anyhow!("MCP client not initialized")),
|
||||
State::Closed => Err(anyhow!("MCP client is shut down")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,542 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::{Body, Bytes};
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode, header};
|
||||
use axum::response::Response;
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use fabro_mcp::client::McpClient;
|
||||
use fabro_mcp::config::{McpHttpProtocol, McpServerSettings, McpTransport};
|
||||
use fabro_mcp::connection_manager::{McpConnectionManager, call_result_to_string};
|
||||
use fabro_mcp::http_transport::sandbox_mcp_http_url;
|
||||
use futures::{StreamExt as _, stream};
|
||||
use serde_json::Value;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
|
||||
fn test_server_config() -> McpServerSettings {
|
||||
let test_server = format!("{}/tests/test_mcp_server.py", env!("CARGO_MANIFEST_DIR"));
|
||||
McpServerSettings {
|
||||
name: "test-echo".into(),
|
||||
transport: McpTransport::Stdio {
|
||||
command: vec!["python3".into(), test_server],
|
||||
env: HashMap::new(),
|
||||
},
|
||||
current_dir: None,
|
||||
clear_env: false,
|
||||
startup_timeout_secs: 10,
|
||||
tool_timeout_secs: 30,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stdio_client_initialize_and_list_tools() {
|
||||
let config = test_server_config();
|
||||
let client = McpClient::new(&config).unwrap();
|
||||
client.initialize(config.startup_timeout()).await.unwrap();
|
||||
|
||||
let tools = client.list_tools().await.unwrap();
|
||||
assert_eq!(tools.len(), 1);
|
||||
assert_eq!(tools[0].0, "echo");
|
||||
assert_eq!(tools[0].1, "Echo back the message");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "stdio integration test stages a local process cwd and inherits PATH for python3 lookup"
|
||||
)]
|
||||
async fn stdio_client_uses_configured_cwd_and_exact_env() {
|
||||
let test_server = format!("{}/tests/test_mcp_server.py", env!("CARGO_MANIFEST_DIR"));
|
||||
let temp_dir = std::env::temp_dir().join(format!(
|
||||
"fabro-mcp-stdio-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir(&temp_dir).unwrap();
|
||||
let canonical_temp_dir = std::fs::canonicalize(&temp_dir).unwrap();
|
||||
let mut env = HashMap::new();
|
||||
env.insert(
|
||||
"PATH".to_string(),
|
||||
std::env::var("PATH").expect("PATH should be set for python3 lookup"),
|
||||
);
|
||||
env.insert("FABRO_MCP_TEST_SENTINEL".to_string(), "fixture".to_string());
|
||||
let config = McpServerSettings {
|
||||
name: "test-echo".into(),
|
||||
transport: McpTransport::Stdio {
|
||||
command: vec!["python3".into(), test_server],
|
||||
env,
|
||||
},
|
||||
current_dir: Some(canonical_temp_dir.clone()),
|
||||
clear_env: true,
|
||||
startup_timeout_secs: 10,
|
||||
tool_timeout_secs: 30,
|
||||
};
|
||||
let client = McpClient::new(&config).unwrap();
|
||||
client.initialize(config.startup_timeout()).await.unwrap();
|
||||
|
||||
let cwd = client
|
||||
.call_tool(
|
||||
"echo",
|
||||
serde_json::json!({"message": "__cwd__"}),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
call_result_to_string(&cwd).unwrap(),
|
||||
canonical_temp_dir.display().to_string()
|
||||
);
|
||||
let sentinel = client
|
||||
.call_tool(
|
||||
"echo",
|
||||
serde_json::json!({"message": "__env:FABRO_MCP_TEST_SENTINEL__"}),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(call_result_to_string(&sentinel).unwrap(), "fixture");
|
||||
let home = client
|
||||
.call_tool(
|
||||
"echo",
|
||||
serde_json::json!({"message": "__env:HOME__"}),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(call_result_to_string(&home).unwrap(), "");
|
||||
|
||||
client.shutdown().await.unwrap();
|
||||
std::fs::remove_dir(&temp_dir).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stdio_client_call_tool_echo() {
|
||||
let config = test_server_config();
|
||||
let client = McpClient::new(&config).unwrap();
|
||||
client.initialize(config.startup_timeout()).await.unwrap();
|
||||
|
||||
let result = client
|
||||
.call_tool(
|
||||
"echo",
|
||||
serde_json::json!({"message": "hello from rust"}),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let text = call_result_to_string(&result).unwrap();
|
||||
assert_eq!(text, "hello from rust");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connection_manager_stdio_roundtrip() {
|
||||
let config = test_server_config();
|
||||
let mut mgr = McpConnectionManager::new();
|
||||
let results = mgr.start_servers(&[config]).await;
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
let (name, tool_count) = &results[0];
|
||||
assert_eq!(name, "test-echo");
|
||||
assert_eq!(*tool_count.as_ref().unwrap(), 1);
|
||||
|
||||
let tools = mgr.all_tools();
|
||||
assert!(tools.contains_key("mcp__test_echo__echo"));
|
||||
|
||||
let result = mgr
|
||||
.call_tool(
|
||||
"mcp__test_echo__echo",
|
||||
serde_json::json!({"message": "roundtrip"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let text = call_result_to_string(&result).unwrap();
|
||||
assert_eq!(text, "roundtrip");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connection_manager_call_tool_uses_configured_tool_timeout() {
|
||||
let mut config = test_server_config();
|
||||
config.tool_timeout_secs = 1;
|
||||
|
||||
let mut mgr = McpConnectionManager::new();
|
||||
let results = mgr.start_servers(&[config]).await;
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(
|
||||
results[0].1.is_ok(),
|
||||
"server should start: {:?}",
|
||||
results[0]
|
||||
);
|
||||
|
||||
let err = mgr
|
||||
.call_tool(
|
||||
"mcp__test_echo__echo",
|
||||
serde_json::json!({"message": "__sleep_ms:1500__"}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("timed out calling tool 'echo' on MCP server 'test-echo'"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_client_initialize_and_call_tool() {
|
||||
#[derive(Clone)]
|
||||
struct SseState {
|
||||
messages: Arc<Mutex<HashMap<String, mpsc::Sender<String>>>>,
|
||||
}
|
||||
|
||||
async fn sse(State(state): State<SseState>) -> Response {
|
||||
let session_id = "session-1".to_string();
|
||||
let (tx, rx) = mpsc::channel::<String>(16);
|
||||
state.messages.lock().await.insert(session_id.clone(), tx);
|
||||
let endpoint = format!("event: endpoint\ndata: /sse?sessionId={session_id}\n\n");
|
||||
let body = Body::from_stream(
|
||||
stream::once(async move { Ok::<_, Infallible>(Bytes::from(endpoint)) }).chain(
|
||||
ReceiverStream::new(rx).map(|event| Ok::<_, Infallible>(Bytes::from(event))),
|
||||
),
|
||||
);
|
||||
Response::builder()
|
||||
.header(header::CONTENT_TYPE, "text/event-stream")
|
||||
.body(body)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn post_sse(
|
||||
State(state): State<SseState>,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
headers: HeaderMap,
|
||||
Json(message): Json<Value>,
|
||||
) -> StatusCode {
|
||||
assert_eq!(
|
||||
headers
|
||||
.get("x-test-token")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("secret")
|
||||
);
|
||||
let session_id = query.get("sessionId").expect("sessionId query").clone();
|
||||
let sender = state
|
||||
.messages
|
||||
.lock()
|
||||
.await
|
||||
.get(&session_id)
|
||||
.cloned()
|
||||
.expect("active SSE stream");
|
||||
let Some(id) = message.get("id").cloned() else {
|
||||
return StatusCode::ACCEPTED;
|
||||
};
|
||||
let method = message.get("method").and_then(Value::as_str).unwrap_or("");
|
||||
let result = match method {
|
||||
"initialize" => serde_json::json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "legacy-sse-test", "version": "1.0.0"}
|
||||
}),
|
||||
"tools/list" => serde_json::json!({
|
||||
"tools": [{
|
||||
"name": "echo",
|
||||
"description": "Echo back the message",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"message": {"type": "string"}},
|
||||
"required": ["message"]
|
||||
}
|
||||
}]
|
||||
}),
|
||||
"tools/call" => serde_json::json!({
|
||||
"content": [{"type": "text", "text": "hello from sse"}],
|
||||
"isError": false
|
||||
}),
|
||||
_ => serde_json::json!({}),
|
||||
};
|
||||
let response = serde_json::json!({"jsonrpc": "2.0", "id": id, "result": result});
|
||||
sender
|
||||
.send(format!("data: {response}\n\n"))
|
||||
.await
|
||||
.expect("SSE stream should be open");
|
||||
StatusCode::ACCEPTED
|
||||
}
|
||||
|
||||
let state = SseState {
|
||||
messages: Arc::new(Mutex::new(HashMap::new())),
|
||||
};
|
||||
let app = Router::new()
|
||||
.route("/sse", get(sse).post(post_sse))
|
||||
.with_state(state);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let config = McpServerSettings {
|
||||
name: "test-sse".into(),
|
||||
transport: McpTransport::Http {
|
||||
protocol: McpHttpProtocol::Sse,
|
||||
url: format!("http://{addr}/sse"),
|
||||
headers: HashMap::from([("x-test-token".to_string(), "secret".to_string())]),
|
||||
},
|
||||
current_dir: None,
|
||||
clear_env: false,
|
||||
startup_timeout_secs: 10,
|
||||
tool_timeout_secs: 30,
|
||||
};
|
||||
let client = McpClient::new(&config).unwrap();
|
||||
client.initialize(config.startup_timeout()).await.unwrap();
|
||||
|
||||
let tools = client.list_tools().await.unwrap();
|
||||
assert_eq!(tools[0].0, "echo");
|
||||
|
||||
let result = client
|
||||
.call_tool(
|
||||
"echo",
|
||||
serde_json::json!({"message": "hello"}),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(call_result_to_string(&result).unwrap(), "hello from sse");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_client_rejects_oversized_messages() {
|
||||
#[derive(Clone)]
|
||||
struct SseState {
|
||||
messages: Arc<Mutex<HashMap<String, mpsc::Sender<String>>>>,
|
||||
}
|
||||
|
||||
async fn sse(State(state): State<SseState>) -> Response {
|
||||
let session_id = "oversized-session".to_string();
|
||||
let (tx, rx) = mpsc::channel::<String>(16);
|
||||
state.messages.lock().await.insert(session_id.clone(), tx);
|
||||
let endpoint = format!("event: endpoint\ndata: /sse?sessionId={session_id}\n\n");
|
||||
let body = Body::from_stream(
|
||||
stream::once(async move { Ok::<_, Infallible>(Bytes::from(endpoint)) }).chain(
|
||||
ReceiverStream::new(rx).map(|event| Ok::<_, Infallible>(Bytes::from(event))),
|
||||
),
|
||||
);
|
||||
Response::builder()
|
||||
.header(header::CONTENT_TYPE, "text/event-stream")
|
||||
.body(body)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn post_sse(
|
||||
State(state): State<SseState>,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
Json(message): Json<Value>,
|
||||
) -> StatusCode {
|
||||
let session_id = query.get("sessionId").expect("sessionId query").clone();
|
||||
let sender = state
|
||||
.messages
|
||||
.lock()
|
||||
.await
|
||||
.get(&session_id)
|
||||
.cloned()
|
||||
.expect("active SSE stream");
|
||||
let Some(id) = message.get("id").cloned() else {
|
||||
return StatusCode::ACCEPTED;
|
||||
};
|
||||
let oversized_name = "x".repeat(1024 * 1024 + 1);
|
||||
let response = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": oversized_name, "version": "1.0.0"}
|
||||
}
|
||||
});
|
||||
sender
|
||||
.send(format!("data: {response}\n\n"))
|
||||
.await
|
||||
.expect("SSE stream should be open");
|
||||
StatusCode::ACCEPTED
|
||||
}
|
||||
|
||||
let state = SseState {
|
||||
messages: Arc::new(Mutex::new(HashMap::new())),
|
||||
};
|
||||
let app = Router::new()
|
||||
.route("/sse", get(sse).post(post_sse))
|
||||
.with_state(state);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let config = McpServerSettings {
|
||||
name: "test-sse".into(),
|
||||
transport: McpTransport::Http {
|
||||
protocol: McpHttpProtocol::Sse,
|
||||
url: format!("http://{addr}/sse"),
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
current_dir: None,
|
||||
clear_env: false,
|
||||
startup_timeout_secs: 2,
|
||||
tool_timeout_secs: 30,
|
||||
};
|
||||
let client = McpClient::new(&config).unwrap();
|
||||
let error = client
|
||||
.initialize(config.startup_timeout())
|
||||
.await
|
||||
.expect_err("oversized SSE message should fail initialization");
|
||||
let error = error.to_string();
|
||||
|
||||
assert!(
|
||||
error.contains("connection closed"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
assert!(
|
||||
!error.contains("xxxxxxxx"),
|
||||
"oversized payload leaked: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_client_rejects_cross_origin_endpoint() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use axum::routing::post;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct EvilState {
|
||||
hits: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
async fn evil_post(State(state): State<EvilState>) -> StatusCode {
|
||||
state.hits.fetch_add(1, Ordering::SeqCst);
|
||||
StatusCode::ACCEPTED
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct VictimState {
|
||||
endpoint: String,
|
||||
}
|
||||
|
||||
async fn sse(State(state): State<VictimState>) -> Response {
|
||||
let body = Body::from_stream(stream::once(async move {
|
||||
Ok::<_, Infallible>(Bytes::from(format!(
|
||||
"event: endpoint\ndata: {endpoint}\n\n",
|
||||
endpoint = state.endpoint
|
||||
)))
|
||||
}));
|
||||
Response::builder()
|
||||
.header(header::CONTENT_TYPE, "text/event-stream")
|
||||
.body(body)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
let evil_state = EvilState {
|
||||
hits: Arc::new(AtomicUsize::new(0)),
|
||||
};
|
||||
let evil_app = Router::new()
|
||||
.route("/steal", post(evil_post))
|
||||
.with_state(evil_state.clone());
|
||||
let evil_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let evil_addr = evil_listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(evil_listener, evil_app).await.unwrap();
|
||||
});
|
||||
|
||||
let victim_state = VictimState {
|
||||
endpoint: format!("http://127.0.0.1:{}/steal", evil_addr.port()),
|
||||
};
|
||||
let victim_app = Router::new()
|
||||
.route("/sse", get(sse))
|
||||
.with_state(victim_state);
|
||||
let victim_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let victim_addr = victim_listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(victim_listener, victim_app).await.unwrap();
|
||||
});
|
||||
|
||||
let config = McpServerSettings {
|
||||
name: "test-sse".into(),
|
||||
transport: McpTransport::Http {
|
||||
protocol: McpHttpProtocol::Sse,
|
||||
url: format!("http://{victim_addr}/sse"),
|
||||
headers: HashMap::from([("authorization".to_string(), "Bearer secret".to_string())]),
|
||||
},
|
||||
current_dir: None,
|
||||
clear_env: false,
|
||||
startup_timeout_secs: 2,
|
||||
tool_timeout_secs: 30,
|
||||
};
|
||||
let client = McpClient::new(&config).unwrap();
|
||||
client
|
||||
.initialize(config.startup_timeout())
|
||||
.await
|
||||
.expect_err("cross-origin SSE endpoint should fail initialization");
|
||||
|
||||
assert_eq!(
|
||||
evil_state.hits.load(Ordering::SeqCst),
|
||||
0,
|
||||
"client must not POST to a cross-origin endpoint advertised by the SSE server"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_mcp_http_url_builds_sse_endpoint_under_preview_path() {
|
||||
let url = sandbox_mcp_http_url(
|
||||
McpHttpProtocol::Sse,
|
||||
"https://preview.example.com/proxy/3100/",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(url, "https://preview.example.com/proxy/3100/sse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_mcp_http_url_leaves_streamable_http_preview_url_unchanged() {
|
||||
let url = sandbox_mcp_http_url(
|
||||
McpHttpProtocol::StreamableHttp,
|
||||
"https://preview.example.com/proxy/3100/mcp",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(url, "https://preview.example.com/proxy/3100/mcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_mcp_http_url_preserves_query_and_path_without_trailing_slash() {
|
||||
let url = sandbox_mcp_http_url(
|
||||
McpHttpProtocol::Sse,
|
||||
"https://preview.example.com/proxy/3100?token=abc",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(url, "https://preview.example.com/proxy/3100/sse?token=abc");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connection_manager_exposes_tools_as_coding_agent_tools() {
|
||||
use pebble_coding_agent::tools::ToolSource;
|
||||
|
||||
let mut mgr = McpConnectionManager::new();
|
||||
mgr.start_servers(&[test_server_config()]).await;
|
||||
let mgr = std::sync::Arc::new(mgr);
|
||||
|
||||
let tools = mgr.tools();
|
||||
assert_eq!(tools.len(), 1);
|
||||
let tool = &tools[0];
|
||||
assert_eq!(tool.definition().name, "mcp__test_echo__echo");
|
||||
assert_eq!(tool.source(), &ToolSource::Mcp {
|
||||
server_name: "test-echo".to_string(),
|
||||
original_name: "echo".to_string(),
|
||||
});
|
||||
}
|
||||
|
|
@ -23,9 +23,10 @@ use fabro_github::token_source::InstallationTokenSource;
|
|||
use fabro_types::SandboxProviderKind;
|
||||
use fabro_util::workspace_glob::WorkspaceGlob;
|
||||
use sandbox_driver::{
|
||||
DirEntry, EventContext, FileKind, GrepMatch, GrepOptions, LifecycleTimers, PtyOptions, PtySize,
|
||||
Sandbox as DriverHandle, SandboxProvider as DriverProvider, SandboxSource,
|
||||
SandboxSpec as DriverSpec, SandboxState, Search as _, WaitOptions, WalkOptions,
|
||||
Capability, DirEntry, EventContext, FileKind, GrepMatch, GrepOptions, LifecycleTimers,
|
||||
PreviewUrl, PreviewUrls, PtyOptions, PtySize, Sandbox as DriverHandle,
|
||||
SandboxProvider as DriverProvider, SandboxSource, SandboxSpec as DriverSpec, SandboxState,
|
||||
Search as _, WaitOptions, WalkOptions,
|
||||
};
|
||||
use sandbox_driver_host::HostProvider;
|
||||
use tokio::fs;
|
||||
|
|
@ -1151,6 +1152,16 @@ impl RunSandbox {
|
|||
.map_err(|error| crate::Error::context("Failed to build sandbox shell command", error))
|
||||
}
|
||||
|
||||
/// The route from fabro to a port inside the sandbox, as pebble's MCP
|
||||
/// support takes it: the driver's preview URLs, when the provider has
|
||||
/// them. `None` for a provider without forwarding, which is where pebble
|
||||
/// reaches the port on the loopback address instead.
|
||||
#[must_use]
|
||||
pub fn port_routes(self: &Arc<Self>) -> Option<Arc<dyn PreviewUrls>> {
|
||||
self.handle().ok()?.preview_urls()?;
|
||||
Some(Arc::new(SandboxPortRoutes(Arc::clone(self))))
|
||||
}
|
||||
|
||||
pub async fn get_preview_url(
|
||||
&self,
|
||||
port: u16,
|
||||
|
|
@ -1169,6 +1180,37 @@ impl RunSandbox {
|
|||
}
|
||||
}
|
||||
|
||||
/// [`PreviewUrls`] over a run sandbox's driver handle, for pebble.
|
||||
struct SandboxPortRoutes(Arc<RunSandbox>);
|
||||
|
||||
impl SandboxPortRoutes {
|
||||
/// The driver's facet, present whenever [`RunSandbox::port_routes`] handed
|
||||
/// this out: the handle is set once and never cleared.
|
||||
fn facet(&self) -> Option<&dyn PreviewUrls> {
|
||||
self.0
|
||||
.handle()
|
||||
.ok()
|
||||
.and_then(|handle| handle.preview_urls())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl PreviewUrls for SandboxPortRoutes {
|
||||
async fn preview_url(&self, port: u16) -> sandbox_driver::Result<PreviewUrl> {
|
||||
match self.facet() {
|
||||
Some(facet) => facet.preview_url(port).await,
|
||||
None => Err(sandbox_driver::Error::unsupported(Capability::PreviewUrls)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn release_preview_url(&self, port: u16) -> sandbox_driver::Result<()> {
|
||||
match self.facet() {
|
||||
Some(facet) => facet.release_preview_url(port).await,
|
||||
None => Err(sandbox_driver::Error::unsupported(Capability::PreviewUrls)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RunSandbox {
|
||||
fn repo_cloned(&self) -> bool {
|
||||
self.workspace
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ fabro-environment = { path = "../fabro-environment" }
|
|||
fabro-sandbox = { path = "../fabro-sandbox", features = ["test-support"] }
|
||||
fabro-mcp = { path = "../fabro-mcp" }
|
||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
pebble-coding-agent = { workspace = true, features = ["test-util"] }
|
||||
object_store.workspace = true
|
||||
assert_cmd = "2"
|
||||
predicates = "3"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ pub mod pebble;
|
|||
pub mod preamble;
|
||||
pub mod router;
|
||||
pub mod routing;
|
||||
mod sandbox_mcp;
|
||||
|
||||
pub use acp::AgentAcpBackend;
|
||||
pub use controls::EffectiveRequestControls;
|
||||
|
|
|
|||
|
|
@ -20,12 +20,12 @@ use fabro_llm::lithos_catalog::Catalog;
|
|||
use fabro_llm::types::ResponseFormat;
|
||||
use fabro_llm::{Client, ClientOptions, Request, Response};
|
||||
use fabro_mcp::config::McpServerSettings;
|
||||
use fabro_mcp::connection_manager::McpConnectionManager;
|
||||
use fabro_mcp::pebble::pebble_servers;
|
||||
use fabro_sandbox::{RunSandbox, SecretRedactor};
|
||||
use fabro_types::settings::run::RunModelControls;
|
||||
use fabro_types::{
|
||||
AgentProfileKind, ModelRef, PermissionLevel, Principal, SessionCapability, StageId,
|
||||
StageTiming, UsdMicros, billing,
|
||||
AgentMcpToolSummary, AgentProfileKind, ModelRef, PermissionLevel, Principal, SessionCapability,
|
||||
StageId, StageTiming, UsdMicros, billing,
|
||||
};
|
||||
use fabro_util::home::Home;
|
||||
use lithos_llm::catalog::{ModelId, ProviderId};
|
||||
|
|
@ -58,7 +58,6 @@ use super::controls::{
|
|||
use super::fabro_tools::register_fabro_run_tools;
|
||||
use super::fallback::{self, FallbackPlan, LlmRoute};
|
||||
use super::routing::{self, ProviderContext};
|
||||
use super::sandbox_mcp::{self, McpServerOutcome};
|
||||
use crate::agent_memory;
|
||||
use crate::context::WorkflowContext;
|
||||
use crate::context::keys::Fidelity;
|
||||
|
|
@ -100,11 +99,12 @@ pub struct PebbleBackend {
|
|||
fabro_run_tools: Option<FabroRunToolServices>,
|
||||
}
|
||||
|
||||
/// A conversation between stages: what the next stage resumes from.
|
||||
/// A conversation between stages: what the next stage resumes from. The
|
||||
/// successor starts the stage's MCP servers again; the same settings give
|
||||
/// the same tool names, so the conversation's earlier calls stay valid.
|
||||
struct CachedThread {
|
||||
export: CodingAgentExport,
|
||||
fallback_plan: FallbackPlan,
|
||||
mcp: Option<Arc<McpConnectionManager>>,
|
||||
}
|
||||
|
||||
/// How the backend reports a failed prompt.
|
||||
|
|
@ -165,8 +165,10 @@ fn classify_agent_error(error: pebble_coding_agent::Error) -> AgentErrorDisposit
|
|||
// --- Event sink -----------------------------------------------------------
|
||||
|
||||
/// Pebble's durable event sink for one stage: every agent event becomes a
|
||||
/// run event in the run's log before the agent goes on, and a route failover
|
||||
/// is mirrored as the run's own `agent.failover` event on the way.
|
||||
/// run event in the run's log before the agent goes on. A route failover and
|
||||
/// an MCP server's outcome are facts the run already has events for, so
|
||||
/// those are mirrored onto the run's own `agent.failover`, `agent.mcp.ready`,
|
||||
/// and `agent.mcp.failed` events instead of being stored twice.
|
||||
struct WorkflowEventSink {
|
||||
emitter: Arc<Emitter>,
|
||||
node_id: String,
|
||||
|
|
@ -182,20 +184,54 @@ impl EventSink for WorkflowEventSink {
|
|||
// Every event, including streaming deltas, resets the run's activity
|
||||
// watchdog.
|
||||
self.emitter.touch();
|
||||
if let CodingEvent::RouteFailover {
|
||||
from,
|
||||
to,
|
||||
attempt,
|
||||
error,
|
||||
} = &event.event
|
||||
{
|
||||
self.emitter.emit_scoped(
|
||||
&Event::Failover {
|
||||
stage: self.node_id.clone(),
|
||||
props: self.plan.failover_props(from, to, *attempt, &error.message),
|
||||
},
|
||||
&self.scope,
|
||||
);
|
||||
match &event.event {
|
||||
CodingEvent::RouteFailover {
|
||||
from,
|
||||
to,
|
||||
attempt,
|
||||
error,
|
||||
} => {
|
||||
self.emitter.emit_scoped(
|
||||
&Event::Failover {
|
||||
stage: self.node_id.clone(),
|
||||
props: self.plan.failover_props(from, to, *attempt, &error.message),
|
||||
},
|
||||
&self.scope,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
CodingEvent::McpServerReady { server, tools } => {
|
||||
self.emitter.emit_scoped(
|
||||
&Event::AgentMcpReady {
|
||||
node_id: self.node_id.clone(),
|
||||
visit: self.scope.visit,
|
||||
server_name: server.clone(),
|
||||
tool_count: tools.len(),
|
||||
tools: tools
|
||||
.iter()
|
||||
.map(|tool| AgentMcpToolSummary {
|
||||
name: tool.name.clone(),
|
||||
original_name: tool.original_name.clone(),
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
&self.scope,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
CodingEvent::McpServerFailed { server, error } => {
|
||||
self.emitter.emit_scoped(
|
||||
&Event::AgentMcpFailed {
|
||||
node_id: self.node_id.clone(),
|
||||
visit: self.scope.visit,
|
||||
server_name: server.clone(),
|
||||
error: error.clone(),
|
||||
},
|
||||
&self.scope,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Deltas and the prompt's own durability barrier are not run history.
|
||||
if event.event.is_streaming_noise() || matches!(event.event, CodingEvent::ProcessingEnd) {
|
||||
|
|
@ -329,7 +365,6 @@ struct LiveAgent {
|
|||
agent: CodingAgent,
|
||||
handle: Arc<PebbleControlHandle>,
|
||||
lease: Option<Arc<ActivationLease>>,
|
||||
mcp: Option<Arc<McpConnectionManager>>,
|
||||
total_usage: TokenCounts,
|
||||
total_cost: Option<UsdMicros>,
|
||||
inference_duration: Duration,
|
||||
|
|
@ -341,16 +376,11 @@ struct LiveAgent {
|
|||
}
|
||||
|
||||
impl LiveAgent {
|
||||
fn new(
|
||||
agent: CodingAgent,
|
||||
handle: Arc<PebbleControlHandle>,
|
||||
mcp: Option<Arc<McpConnectionManager>>,
|
||||
) -> Self {
|
||||
fn new(agent: CodingAgent, handle: Arc<PebbleControlHandle>) -> Self {
|
||||
Self {
|
||||
agent,
|
||||
handle,
|
||||
lease: None,
|
||||
mcp,
|
||||
total_usage: TokenCounts::default(),
|
||||
total_cost: None,
|
||||
inference_duration: Duration::ZERO,
|
||||
|
|
@ -594,49 +624,13 @@ impl PebbleBackend {
|
|||
.with_compaction_preserve_turns(COMPACTION_PRESERVE_TURNS)
|
||||
}
|
||||
|
||||
/// Start the stage's MCP servers, reporting each as a run event.
|
||||
async fn start_mcp(
|
||||
&self,
|
||||
bindings: &StageBindings<'_>,
|
||||
cancel_token: &CancellationToken,
|
||||
) -> Result<Option<Arc<McpConnectionManager>>, Error> {
|
||||
if self.mcp_servers.is_empty() {
|
||||
return Ok(None);
|
||||
/// The application tools a stage agent gets beyond pebble's own and the
|
||||
/// MCP servers'.
|
||||
fn stage_tools(&self) -> Vec<RegisteredTool> {
|
||||
match &self.fabro_run_tools {
|
||||
Some(services) => register_fabro_run_tools(services),
|
||||
None => Vec::new(),
|
||||
}
|
||||
let startup =
|
||||
sandbox_mcp::start_mcp_servers(bindings.sandbox, &self.mcp_servers, cancel_token)
|
||||
.await?;
|
||||
for (server_name, outcome) in startup.outcomes {
|
||||
let event = match outcome {
|
||||
McpServerOutcome::Ready { tool_count, tools } => Event::AgentMcpReady {
|
||||
node_id: bindings.node_id.to_string(),
|
||||
visit: bindings.stage_scope.visit,
|
||||
server_name,
|
||||
tool_count,
|
||||
tools,
|
||||
},
|
||||
McpServerOutcome::Failed { error } => Event::AgentMcpFailed {
|
||||
node_id: bindings.node_id.to_string(),
|
||||
visit: bindings.stage_scope.visit,
|
||||
server_name,
|
||||
error,
|
||||
},
|
||||
};
|
||||
bindings.emitter.emit_scoped(&event, bindings.stage_scope);
|
||||
}
|
||||
Ok(Some(startup.manager))
|
||||
}
|
||||
|
||||
/// The application tools a stage agent gets beyond pebble's own.
|
||||
fn stage_tools(&self, mcp: Option<&Arc<McpConnectionManager>>) -> Vec<RegisteredTool> {
|
||||
let mut tools = Vec::new();
|
||||
if let Some(services) = &self.fabro_run_tools {
|
||||
tools.extend(register_fabro_run_tools(services));
|
||||
}
|
||||
if let Some(manager) = mcp {
|
||||
tools.extend(manager.tools());
|
||||
}
|
||||
tools
|
||||
}
|
||||
|
||||
/// Bind the stage's services, the plan's current route, and the routes
|
||||
|
|
@ -648,12 +642,12 @@ impl PebbleBackend {
|
|||
plan: &FallbackPlan,
|
||||
provider: &ProviderContext,
|
||||
bindings: &StageBindings<'_>,
|
||||
mcp: Option<&Arc<McpConnectionManager>>,
|
||||
) -> CodingAgentBuilder {
|
||||
let route = plan.current();
|
||||
let max_tokens = node_max_output_tokens(node).map(i64::from);
|
||||
builder = builder
|
||||
.tools(self.stage_tools(mcp))
|
||||
.tools(self.stage_tools())
|
||||
.mcp_servers(pebble_servers(&self.mcp_servers))
|
||||
.permission_level(PermissionLevel::Full)
|
||||
.options(self.agent_options(
|
||||
node,
|
||||
|
|
@ -670,6 +664,9 @@ impl PebbleBackend {
|
|||
}))
|
||||
.redactor(Arc::new(SecretRedactor))
|
||||
.subagents(SubagentOptions::enabled());
|
||||
if let Some(routes) = bindings.sandbox.port_routes() {
|
||||
builder = builder.port_routes(routes);
|
||||
}
|
||||
if let Some(provider) = &self.tool_env {
|
||||
builder = builder.tool_env_provider(Arc::clone(provider));
|
||||
}
|
||||
|
|
@ -695,13 +692,12 @@ impl PebbleBackend {
|
|||
plan: &FallbackPlan,
|
||||
provider: &ProviderContext,
|
||||
bindings: &StageBindings<'_>,
|
||||
mcp: Option<&Arc<McpConnectionManager>>,
|
||||
) -> Result<CodingAgent, Error> {
|
||||
let client = self.build_llm_client().await?;
|
||||
let environment: Arc<dyn Environment> =
|
||||
Arc::clone(bindings.sandbox) as Arc<dyn Environment>;
|
||||
let builder = CodingAgent::builder(client, environment).model(plan.current().selector());
|
||||
self.bind_builder(builder, node, plan, provider, bindings, mcp)
|
||||
self.bind_builder(builder, node, plan, provider, bindings)
|
||||
.build()
|
||||
.await
|
||||
.map_err(|error| Error::handler_with_source("Failed to start agent session", error))
|
||||
|
|
@ -716,13 +712,12 @@ impl PebbleBackend {
|
|||
plan: &FallbackPlan,
|
||||
provider: &ProviderContext,
|
||||
bindings: &StageBindings<'_>,
|
||||
mcp: Option<&Arc<McpConnectionManager>>,
|
||||
) -> Result<CodingAgent, Error> {
|
||||
let client = self.build_llm_client().await?;
|
||||
let environment: Arc<dyn Environment> =
|
||||
Arc::clone(bindings.sandbox) as Arc<dyn Environment>;
|
||||
let builder = CodingAgent::resume_from_export(client, environment, export);
|
||||
self.bind_builder(builder, node, plan, provider, bindings, mcp)
|
||||
self.bind_builder(builder, node, plan, provider, bindings)
|
||||
.build()
|
||||
.await
|
||||
.map_err(|error| Error::handler_with_source("Failed to resume agent session", error))
|
||||
|
|
@ -950,8 +945,8 @@ async fn build_llm_client(
|
|||
#[async_trait]
|
||||
impl CodergenBackend for PebbleBackend {
|
||||
async fn shutdown(&self, _emitter: &Arc<Emitter>) {
|
||||
// Exported conversations were shut down when their stages ended; the
|
||||
// MCP connections they kept alive close with the exports.
|
||||
// Exported conversations were shut down when their stages ended, and
|
||||
// their MCP servers with them.
|
||||
self.threads
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
|
|
@ -1092,7 +1087,7 @@ impl CodergenBackend for PebbleBackend {
|
|||
|
||||
let cached = reuse_key.as_ref().and_then(|key| self.take_thread(key));
|
||||
let is_reused = cached.is_some();
|
||||
let (agent, mut fallback_plan, mcp) = if let Some(thread) = cached {
|
||||
let (agent, mut fallback_plan) = if let Some(thread) = cached {
|
||||
let route = thread.fallback_plan.current().clone();
|
||||
let provider = self.resolve_provider_context(
|
||||
route.target.model.as_str(),
|
||||
|
|
@ -1105,10 +1100,9 @@ impl CodergenBackend for PebbleBackend {
|
|||
&thread.fallback_plan,
|
||||
&provider,
|
||||
&bindings,
|
||||
thread.mcp.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
(agent, thread.fallback_plan, thread.mcp)
|
||||
(agent, thread.fallback_plan)
|
||||
} else {
|
||||
let model = node.model().unwrap_or(&self.model);
|
||||
let provider = routing::resolve_node_provider_context(
|
||||
|
|
@ -1126,17 +1120,10 @@ impl CodergenBackend for PebbleBackend {
|
|||
route.target.model.as_str(),
|
||||
Some(route.target.provider.as_str()),
|
||||
)?;
|
||||
let mcp = self.start_mcp(&bindings, cancel_token).await?;
|
||||
let agent = self
|
||||
.build_agent(
|
||||
node,
|
||||
&fallback_plan,
|
||||
&route_provider,
|
||||
&bindings,
|
||||
mcp.as_ref(),
|
||||
)
|
||||
.build_agent(node, &fallback_plan, &route_provider, &bindings)
|
||||
.await?;
|
||||
(agent, fallback_plan, mcp)
|
||||
(agent, fallback_plan)
|
||||
};
|
||||
if cancel_token.is_cancelled() {
|
||||
let mut agent = agent;
|
||||
|
|
@ -1152,7 +1139,7 @@ impl CodergenBackend for PebbleBackend {
|
|||
);
|
||||
|
||||
let handle = Arc::new(PebbleControlHandle::new(agent.control_handle()));
|
||||
let mut live = LiveAgent::new(agent, handle, mcp);
|
||||
let mut live = LiveAgent::new(agent, handle);
|
||||
let route = fallback_plan.current().clone();
|
||||
if let Err(error) =
|
||||
self.activate(&mut live, &route, &stage_id, request.thread_id, &bindings)
|
||||
|
|
@ -1267,7 +1254,6 @@ impl CodergenBackend for PebbleBackend {
|
|||
self.store_thread(key, CachedThread {
|
||||
export: export.clone(),
|
||||
fallback_plan: fallback_plan.clone(),
|
||||
mcp: live.mcp.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,302 +0,0 @@
|
|||
//! MCP servers for a workflow agent stage.
|
||||
//!
|
||||
//! `McpTransport::Sandbox` servers start inside the run sandbox and are
|
||||
//! reached over HTTP through the sandbox's preview URL; every other transport
|
||||
//! is connected as configured. The outcome of each server is reported back so
|
||||
//! the stage can record it as a run event.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_mcp::config::{McpServerSettings, McpTransport};
|
||||
use fabro_mcp::connection_manager::McpConnectionManager;
|
||||
use fabro_mcp::http_transport;
|
||||
use fabro_sandbox::{RunSandbox, shell_quote};
|
||||
use fabro_types::AgentMcpToolSummary;
|
||||
use fabro_util::shell::shell_join;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// What became of one configured MCP server.
|
||||
pub(crate) enum McpServerOutcome {
|
||||
Ready {
|
||||
tool_count: usize,
|
||||
tools: Vec<AgentMcpToolSummary>,
|
||||
},
|
||||
Failed {
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct McpStartup {
|
||||
pub(crate) manager: Arc<McpConnectionManager>,
|
||||
/// One entry per configured server, in configuration order.
|
||||
pub(crate) outcomes: Vec<(String, McpServerOutcome)>,
|
||||
}
|
||||
|
||||
/// Start every configured server and connect to it.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Cancelled`] when `cancel_token` fires; a server that
|
||||
/// fails to start or connect is reported in the outcomes instead.
|
||||
pub(crate) async fn start_mcp_servers(
|
||||
sandbox: &RunSandbox,
|
||||
servers: &[McpServerSettings],
|
||||
cancel_token: &CancellationToken,
|
||||
) -> Result<McpStartup, Error> {
|
||||
let mut outcomes = Vec::with_capacity(servers.len());
|
||||
let mut resolved = Vec::with_capacity(servers.len());
|
||||
for config in servers {
|
||||
if cancel_token.is_cancelled() {
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
match &config.transport {
|
||||
McpTransport::Sandbox {
|
||||
protocol,
|
||||
command,
|
||||
port,
|
||||
env,
|
||||
} => {
|
||||
match start_sandbox_mcp_server(sandbox, command, *port, env, cancel_token).await? {
|
||||
Ok((url, headers)) => {
|
||||
match http_transport::sandbox_mcp_http_url(*protocol, &url) {
|
||||
Ok(url) => {
|
||||
info!(
|
||||
server = %config.name,
|
||||
url = %url,
|
||||
"Sandbox MCP server started, connecting via HTTP"
|
||||
);
|
||||
resolved.push(McpServerSettings {
|
||||
name: config.name.clone(),
|
||||
transport: McpTransport::Http {
|
||||
protocol: *protocol,
|
||||
url,
|
||||
headers,
|
||||
},
|
||||
current_dir: config.current_dir.clone(),
|
||||
clear_env: config.clear_env,
|
||||
startup_timeout_secs: config.startup_timeout_secs,
|
||||
tool_timeout_secs: config.tool_timeout_secs,
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
outcomes.push((config.name.clone(), McpServerOutcome::Failed {
|
||||
error: error.to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(server = %config.name, error = %error, "Failed to start sandbox MCP server");
|
||||
outcomes.push((config.name.clone(), McpServerOutcome::Failed { error }));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => resolved.push(config.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
let mut manager = McpConnectionManager::new();
|
||||
for (server_name, result) in manager.start_servers(&resolved).await {
|
||||
let outcome = match result {
|
||||
Ok(tool_count) => McpServerOutcome::Ready {
|
||||
tool_count,
|
||||
tools: manager
|
||||
.tool_summaries_for_server(&server_name)
|
||||
.into_iter()
|
||||
.map(|(name, original_name)| AgentMcpToolSummary {
|
||||
name,
|
||||
original_name,
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
Err(error) => McpServerOutcome::Failed {
|
||||
error: error.to_string(),
|
||||
},
|
||||
};
|
||||
outcomes.push((server_name, outcome));
|
||||
}
|
||||
|
||||
Ok(McpStartup {
|
||||
manager: Arc::new(manager),
|
||||
outcomes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Start an MCP server inside the sandbox and return `(url, headers)` for
|
||||
/// the HTTP connection.
|
||||
///
|
||||
/// The outer `Result` is cancellation (the running MCP process group is
|
||||
/// terminated before returning). The inner `Result` is a non-fatal startup
|
||||
/// failure the caller reports as `agent.mcp.failed`.
|
||||
async fn start_sandbox_mcp_server(
|
||||
sandbox: &RunSandbox,
|
||||
command: &[String],
|
||||
port: u16,
|
||||
env: &HashMap<String, String>,
|
||||
cancel_token: &CancellationToken,
|
||||
) -> Result<Result<(String, HashMap<String, String>), String>, Error> {
|
||||
let launch_script = sandbox_mcp_launch_script(command);
|
||||
let env_ref = if env.is_empty() { None } else { Some(env) };
|
||||
|
||||
if cancel_token.is_cancelled() {
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
let launch_result = match sandbox
|
||||
.exec_command(
|
||||
&launch_script,
|
||||
30_000,
|
||||
None,
|
||||
env_ref,
|
||||
Some(cancel_token.child_token()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
if cancel_token.is_cancelled() {
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
return Ok(Err(format!(
|
||||
"Failed to launch MCP server: {}",
|
||||
error.display_with_causes()
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let pid = launch_result.stdout.trim().to_string();
|
||||
info!(pid = %pid, port, "MCP server process launched in sandbox");
|
||||
|
||||
// Wait for the server to start listening on the port.
|
||||
let poll_cmd = format!(
|
||||
"for i in $(seq 1 30); do ss -tln | grep -q ':{port} ' && echo ready && exit 0; sleep 1; done; echo timeout"
|
||||
);
|
||||
let poll_result = sandbox
|
||||
.exec_command(
|
||||
&poll_cmd,
|
||||
60_000,
|
||||
None,
|
||||
None,
|
||||
Some(cancel_token.child_token()),
|
||||
)
|
||||
.await;
|
||||
|
||||
if cancel_token.is_cancelled() {
|
||||
kill_mcp_pid(sandbox, &pid).await;
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
|
||||
let poll_result = match poll_result {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
return Ok(Err(format!(
|
||||
"Failed to poll MCP server readiness: {}",
|
||||
error.display_with_causes()
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
if poll_result.stdout.trim() != "ready" {
|
||||
let stderr = sandbox
|
||||
.exec_command(
|
||||
"cat /tmp/mcp_server_stderr.log 2>/dev/null | tail -20",
|
||||
10_000,
|
||||
None,
|
||||
None,
|
||||
Some(cancel_token.child_token()),
|
||||
)
|
||||
.await
|
||||
.map(|result| result.stdout)
|
||||
.unwrap_or_default();
|
||||
return Ok(Err(format!(
|
||||
"MCP server did not start listening on port {port} within 30s. stderr:\n{stderr}"
|
||||
)));
|
||||
}
|
||||
|
||||
// The preview URL for the port, or localhost for local sandboxes.
|
||||
let preview = match sandbox.get_preview_url(port).await {
|
||||
Ok(preview) => preview,
|
||||
Err(error) => return Ok(Err(error.display_with_causes())),
|
||||
};
|
||||
|
||||
if cancel_token.is_cancelled() {
|
||||
kill_mcp_pid(sandbox, &pid).await;
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
|
||||
if let Some(url_and_headers) = preview {
|
||||
Ok(Ok(url_and_headers))
|
||||
} else {
|
||||
info!(port, "No preview URL available, using localhost");
|
||||
Ok(Ok((format!("http://localhost:{port}"), HashMap::new())))
|
||||
}
|
||||
}
|
||||
|
||||
fn sandbox_mcp_launch_script(command: &[String]) -> String {
|
||||
let command_source = match command {
|
||||
// Sandbox MCP `script` entries resolve to this exact argv shape. The
|
||||
// surrounding launcher is already the provider-selected Bash, so
|
||||
// evaluate the source in that process instead of PATH-resolving a
|
||||
// second interpreter. Grouping keeps the log redirections scoped to
|
||||
// the whole script, including multi-command and trailing-comment
|
||||
// forms.
|
||||
[interpreter, flag, source] if interpreter == "bash" && flag == "-c" => {
|
||||
format!("{{\n{source}\n}}")
|
||||
}
|
||||
_ => shell_join(command),
|
||||
};
|
||||
let inner =
|
||||
format!("{command_source} > /tmp/mcp_server_stdout.log 2>/tmp/mcp_server_stderr.log");
|
||||
format!(
|
||||
"setsid \"$BASH\" -c {quoted} </dev/null >/dev/null 2>&1 &\necho $!",
|
||||
quoted = shell_quote(&inner)
|
||||
)
|
||||
}
|
||||
|
||||
/// Best-effort kill of a sandbox MCP server process group, used when startup
|
||||
/// is cancelled after the detached process has been spawned.
|
||||
async fn kill_mcp_pid(sandbox: &RunSandbox, pid: &str) {
|
||||
let pid = pid.trim();
|
||||
if pid.is_empty() {
|
||||
return;
|
||||
}
|
||||
let script =
|
||||
format!("kill -TERM -{pid} 2>/dev/null; sleep 1; kill -KILL -{pid} 2>/dev/null; true");
|
||||
if let Err(error) = sandbox.exec_command(&script, 5_000, None, None, None).await {
|
||||
warn!(pid, error = %error.display_with_causes(), "Failed to kill MCP server process group during cancellation");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::sandbox_mcp_launch_script;
|
||||
|
||||
#[test]
|
||||
fn launch_script_evaluates_bash_c_source_in_place() {
|
||||
let script = sandbox_mcp_launch_script(&[
|
||||
"bash".to_string(),
|
||||
"-c".to_string(),
|
||||
"echo hi # trailing comment".to_string(),
|
||||
]);
|
||||
|
||||
assert!(script.starts_with("setsid \"$BASH\" -c "));
|
||||
assert!(script.contains("echo hi # trailing comment\n}"));
|
||||
assert!(script.ends_with("&\necho $!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_script_quotes_other_commands() {
|
||||
let script = sandbox_mcp_launch_script(&[
|
||||
"python3".to_string(),
|
||||
"server.py".to_string(),
|
||||
"--name".to_string(),
|
||||
"it's".to_string(),
|
||||
]);
|
||||
|
||||
assert!(script.contains("python3 server.py --name"));
|
||||
assert!(script.contains("/tmp/mcp_server_stderr.log"));
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +30,6 @@ use fabro_sandbox::{
|
|||
use fabro_static::EnvVars;
|
||||
use fabro_store::{ArtifactKey, ArtifactStore};
|
||||
use fabro_types::{RunId, StageId, WorkflowSettings, parse_blob_ref};
|
||||
use fabro_util::shell;
|
||||
use fabro_workflow::artifact;
|
||||
use fabro_workflow::context::Context;
|
||||
use fabro_workflow::error::Error;
|
||||
|
|
@ -1841,9 +1840,12 @@ async fn daytona_playwright_mcp_sandbox_transport() {
|
|||
);
|
||||
assert_eq!(install.exit_code, Some(0), "Playwright install failed");
|
||||
|
||||
// 2. Start the Playwright MCP server via the sandbox transport resolution path
|
||||
// 2. The Playwright MCP server as an agent stage gets it: launched in the
|
||||
// sandbox by pebble and reached over SSE through Daytona's preview link,
|
||||
// token header included. A scripted model drives the tools, so the test is
|
||||
// about the sandbox transport and nothing else.
|
||||
let mcp_port = 3100u16;
|
||||
let mcp_config = fabro_mcp::config::McpServerSettings {
|
||||
let server = fabro_mcp::pebble::pebble_server(&fabro_mcp::config::McpServerSettings {
|
||||
name: "playwright".into(),
|
||||
transport: fabro_mcp::config::McpTransport::Sandbox {
|
||||
protocol: fabro_mcp::config::McpHttpProtocol::Sse,
|
||||
|
|
@ -1861,171 +1863,106 @@ async fn daytona_playwright_mcp_sandbox_transport() {
|
|||
},
|
||||
current_dir: None,
|
||||
clear_env: false,
|
||||
startup_timeout_secs: 30,
|
||||
startup_timeout_secs: 60,
|
||||
tool_timeout_secs: 120,
|
||||
};
|
||||
});
|
||||
let (client, _provider) = pebble_coding_agent::test_support::client_from(
|
||||
pebble_coding_agent::test_support::ScriptedProvider::new(vec![
|
||||
pebble_coding_agent::test_support::ScriptedCall::response(
|
||||
pebble_coding_agent::test_support::tool_call_response(
|
||||
"mcp__playwright__browser_install",
|
||||
"install",
|
||||
serde_json::json!({}),
|
||||
),
|
||||
),
|
||||
pebble_coding_agent::test_support::ScriptedCall::response(
|
||||
pebble_coding_agent::test_support::tool_call_response(
|
||||
"mcp__playwright__browser_navigate",
|
||||
"navigate",
|
||||
serde_json::json!({"url": "https://example.com"}),
|
||||
),
|
||||
),
|
||||
pebble_coding_agent::test_support::ScriptedCall::response(
|
||||
pebble_coding_agent::test_support::tool_call_response(
|
||||
"mcp__playwright__browser_snapshot",
|
||||
"snapshot",
|
||||
serde_json::json!({}),
|
||||
),
|
||||
),
|
||||
pebble_coding_agent::test_support::ScriptedCall::response(
|
||||
pebble_coding_agent::test_support::text_response("browsed"),
|
||||
),
|
||||
]),
|
||||
);
|
||||
let sandbox = Arc::new(sandbox);
|
||||
let routes = sandbox
|
||||
.port_routes()
|
||||
.expect("Daytona forwards ports through preview URLs");
|
||||
let mut agent = pebble_coding_agent::CodingAgent::builder(
|
||||
client,
|
||||
Arc::clone(&sandbox) as Arc<dyn pebble_coding_agent::environment::Environment>,
|
||||
)
|
||||
.model("test/model")
|
||||
.permission_level(pebble_coding_agent::events::PermissionLevel::Full)
|
||||
.mcp_servers([server])
|
||||
.port_routes(routes)
|
||||
.build()
|
||||
.await
|
||||
.expect("the agent builds with the sandbox-hosted server");
|
||||
|
||||
// Resolve the sandbox transport: start the server, get preview URL, rewrite to
|
||||
// HTTP
|
||||
let resolved = match &mcp_config.transport {
|
||||
fabro_mcp::config::McpTransport::Sandbox {
|
||||
protocol,
|
||||
command,
|
||||
port,
|
||||
// 3. The server started and its tools are registered.
|
||||
let statuses = agent.snapshot().mcp_servers().to_vec();
|
||||
assert_eq!(statuses.len(), 1, "{statuses:?}");
|
||||
assert_eq!(
|
||||
statuses[0].error, None,
|
||||
"the Playwright server should start: {statuses:?}"
|
||||
);
|
||||
eprintln!("Discovered {} MCP tools:", statuses[0].tools.len());
|
||||
for tool in &statuses[0].tools {
|
||||
eprintln!(" - {}", tool.name);
|
||||
}
|
||||
assert!(
|
||||
statuses[0]
|
||||
.tools
|
||||
.iter()
|
||||
.any(|tool| tool.name == "mcp__playwright__browser_navigate"),
|
||||
"Should have discovered Playwright tools"
|
||||
);
|
||||
|
||||
// 4. Install the browser, navigate, and snapshot through the agent.
|
||||
let mut events = agent.subscribe();
|
||||
let report = agent.prompt("browse example.com").await;
|
||||
assert!(report.result.is_ok(), "{report:?}");
|
||||
let mut completions = Vec::new();
|
||||
while let Ok(event) = events.try_recv() {
|
||||
if let pebble_coding_agent::events::CodingEvent::ToolCallCompleted {
|
||||
tool_name,
|
||||
output,
|
||||
is_error,
|
||||
..
|
||||
} => {
|
||||
let (url, headers) = {
|
||||
let cmd_str = shell::shell_join(command);
|
||||
let inner =
|
||||
format!("{cmd_str} > /tmp/mcp_server_stdout.log 2>/tmp/mcp_server_stderr.log");
|
||||
let launch_script = format!(
|
||||
"setsid \"$BASH\" -c {} </dev/null >/dev/null 2>&1 &\necho $!",
|
||||
shell::shell_quote(&inner)
|
||||
);
|
||||
let launch_result = sandbox
|
||||
.exec_command(&launch_script, 30_000, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
eprintln!("MCP server PID: {}", launch_result.stdout.trim());
|
||||
|
||||
// Wait for server to listen
|
||||
let poll_cmd = format!(
|
||||
"for i in $(seq 1 30); do ss -tln | grep -q ':{port} ' && echo ready && exit 0; sleep 1; done; echo timeout"
|
||||
);
|
||||
let poll_result = sandbox
|
||||
.exec_command(&poll_cmd, 60_000, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
eprintln!("Server readiness: {}", poll_result.stdout.trim());
|
||||
|
||||
if poll_result.stdout.trim() != "ready" {
|
||||
let stderr = sandbox
|
||||
.exec_command(
|
||||
"cat /tmp/mcp_server_stderr.log 2>/dev/null | tail -20",
|
||||
10_000,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map(|r| r.stdout)
|
||||
.unwrap_or_default();
|
||||
panic!("MCP server did not start on port {port}. stderr:\n{stderr}");
|
||||
}
|
||||
|
||||
sandbox
|
||||
.get_preview_url(*port)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("sandbox should support preview URLs")
|
||||
};
|
||||
eprintln!("Preview URL: {url}");
|
||||
|
||||
let url = fabro_mcp::http_transport::sandbox_mcp_http_url(*protocol, &url).unwrap();
|
||||
|
||||
fabro_mcp::config::McpServerSettings {
|
||||
name: mcp_config.name.clone(),
|
||||
transport: fabro_mcp::config::McpTransport::Http {
|
||||
protocol: *protocol,
|
||||
url,
|
||||
headers,
|
||||
},
|
||||
current_dir: mcp_config.current_dir.clone(),
|
||||
clear_env: mcp_config.clear_env,
|
||||
startup_timeout_secs: mcp_config.startup_timeout_secs,
|
||||
tool_timeout_secs: mcp_config.tool_timeout_secs,
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
// 3. Connect the MCP client to the resolved HTTP endpoint
|
||||
let mut manager = fabro_mcp::connection_manager::McpConnectionManager::new();
|
||||
let results = manager.start_servers(&[resolved]).await;
|
||||
for (name, result) in &results {
|
||||
match result {
|
||||
Ok(count) => eprintln!("MCP server '{name}' ready with {count} tools"),
|
||||
Err(e) => panic!("MCP server '{name}' failed: {e}"),
|
||||
} = event.event
|
||||
{
|
||||
completions.push((tool_name, output, is_error));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. List the tools to verify we got Playwright tools
|
||||
let tools = manager.all_tools();
|
||||
eprintln!("Discovered {} MCP tools:", tools.len());
|
||||
for (name, info) in tools {
|
||||
eprintln!(
|
||||
" - {name}: {}",
|
||||
info.description.chars().take(80).collect::<String>()
|
||||
);
|
||||
}
|
||||
assert!(!tools.is_empty(), "Should have discovered Playwright tools");
|
||||
|
||||
// 5. Install the browser via MCP tool (ensures correct version is available)
|
||||
let install_tool = tools
|
||||
.keys()
|
||||
.find(|k| k.ends_with("browser_install"))
|
||||
.expect("no browser_install tool found");
|
||||
eprintln!("Calling tool: {install_tool}");
|
||||
let install_result = manager.call_tool(install_tool, serde_json::json!({})).await;
|
||||
match &install_result {
|
||||
Ok(result) => eprintln!(
|
||||
"Install result: {}",
|
||||
result
|
||||
.content
|
||||
.first()
|
||||
.map(|c| format!("{c:?}"))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
Err(e) => eprintln!("Install error (non-fatal): {e}"),
|
||||
}
|
||||
|
||||
// 6. Call the browser_navigate tool to load a page
|
||||
let nav_tool = tools
|
||||
.keys()
|
||||
.find(|k| k.ends_with("browser_navigate"))
|
||||
.expect("no browser_navigate tool found");
|
||||
eprintln!("Calling tool: {nav_tool}");
|
||||
let nav_result = manager
|
||||
.call_tool(nav_tool, serde_json::json!({"url": "https://example.com"}))
|
||||
.await;
|
||||
match &nav_result {
|
||||
Ok(result) => eprintln!(
|
||||
"Navigate result: {}",
|
||||
&result
|
||||
.content
|
||||
.first()
|
||||
.map(|c| format!("{c:?}"))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
Err(e) => eprintln!("Navigate error: {e}"),
|
||||
}
|
||||
assert!(nav_result.is_ok(), "Navigate should succeed");
|
||||
|
||||
// 7. Take a snapshot to verify the page loaded
|
||||
let snap_tool = tools
|
||||
.keys()
|
||||
.find(|k| k.contains("snapshot"))
|
||||
.expect("no snapshot tool found");
|
||||
eprintln!("Calling tool: {snap_tool}");
|
||||
let snap_result = manager.call_tool(snap_tool, serde_json::json!({})).await;
|
||||
match &snap_result {
|
||||
Ok(result) => {
|
||||
let text = result
|
||||
.content
|
||||
.first()
|
||||
.map(|c| format!("{c:?}"))
|
||||
.unwrap_or_default();
|
||||
eprintln!(
|
||||
"Snapshot result (first 500 chars): {}",
|
||||
&text[..text.len().min(500)]
|
||||
);
|
||||
assert!(
|
||||
text.contains("Example Domain"),
|
||||
"Snapshot should contain 'Example Domain'"
|
||||
);
|
||||
}
|
||||
Err(e) => panic!("Snapshot failed: {e}"),
|
||||
}
|
||||
let navigate = completions
|
||||
.iter()
|
||||
.find(|(name, _, _)| name == "mcp__playwright__browser_navigate")
|
||||
.expect("navigate ran");
|
||||
assert!(!navigate.2, "Navigate should succeed: {navigate:?}");
|
||||
let snapshot = completions
|
||||
.iter()
|
||||
.find(|(name, _, _)| name == "mcp__playwright__browser_snapshot")
|
||||
.expect("snapshot ran");
|
||||
assert!(!snapshot.2, "Snapshot should succeed: {snapshot:?}");
|
||||
assert!(
|
||||
snapshot.1.to_string().contains("Example Domain"),
|
||||
"Snapshot should contain 'Example Domain'"
|
||||
);
|
||||
agent
|
||||
.shutdown(pebble_coding_agent::ShutdownReason::Completed)
|
||||
.await
|
||||
.expect("the agent shuts down");
|
||||
|
||||
// 8. Cleanup
|
||||
sandbox.cleanup().await.unwrap();
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ pub fn coding_event_name(event: &CodingEvent) -> &'static str {
|
|||
CodingEvent::LoopDetected => "agent.loop.detected",
|
||||
CodingEvent::ToolRoundsExhausted { .. } => "agent.tool.rounds.exhausted",
|
||||
CodingEvent::RouteFailover { .. } => "agent.route.failover",
|
||||
CodingEvent::McpServerReady { .. } => "agent.mcp.server.ready",
|
||||
CodingEvent::McpServerFailed { .. } => "agent.mcp.server.failed",
|
||||
CodingEvent::SteeringInjected { .. } => "agent.steering.injected",
|
||||
CodingEvent::RoundInterrupted { .. } => "agent.round.interrupted",
|
||||
CodingEvent::CompactionStarted { .. } => "agent.compaction.started",
|
||||
|
|
@ -122,6 +124,8 @@ pub const CODING_EVENT_NAMES: &[&str] = &[
|
|||
"agent.loop.detected",
|
||||
"agent.tool.rounds.exhausted",
|
||||
"agent.route.failover",
|
||||
"agent.mcp.server.ready",
|
||||
"agent.mcp.server.failed",
|
||||
"agent.steering.injected",
|
||||
"agent.round.interrupted",
|
||||
"agent.compaction.started",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue