M5: rmcp stdio client and tool adapters

Adds an rmcp-based stdio MCP client in harness-mcp plus adapters exposing a configured server's tools as namespaced harness-tools, with an echo-server fixture and integration test, and wiring through harness-app.
This commit is contained in:
2026-07-10 16:20:22 +02:00
parent f71a347061
commit fe6d00ce6d
8 changed files with 770 additions and 55 deletions
+87 -50
View File
@@ -20,7 +20,7 @@ use harness_core::lsp::DiagnosticsSource;
use harness_core::permission::{PermissionReply, PermissionService, Rule, Ruleset};
use harness_core::store::{Store, StoreError};
use harness_core::tool::{
ContextReporter, SpawnError, SpawnOutcome, SpawnRequest, SubagentSpawner, ToolRegistry,
ContextReporter, SpawnError, SpawnOutcome, SpawnRequest, SubagentSpawner, Tool, ToolRegistry,
};
use harness_core::types::{
Message, MessageId, ModelRef, Part, PartBody, PartId, Session, SessionId,
@@ -76,6 +76,71 @@ fn db_path(cwd: &Path) -> PathBuf {
base.join(format!("{}.sqlite", slug))
}
/// Builds the provider registry from config: every provider with an API key is registered.
fn providers_from_config(config: &Config) -> ProviderRegistry {
let mut providers = ProviderRegistry::new();
if let Some(key) = config
.providers
.get("anthropic")
.and_then(|p| p.api_key.clone())
{
providers.register(Arc::new(AnthropicProvider::new(key)));
}
if let Some(key) = config
.providers
.get("openai")
.and_then(|p| p.api_key.clone())
{
let provider = match config
.providers
.get("openai")
.and_then(|p| p.base_url.clone())
{
Some(base_url) => OpenAiProvider::with_base_url(key, base_url),
None => OpenAiProvider::new(key),
};
providers.register(Arc::new(provider));
}
// OpenCode Zen: OpenAI-compatible chat gateway under its own `opencode` provider id, so it
// coexists with a real `openai` provider. `base_url` defaults to Zen's endpoint.
if let Some(key) = config
.providers
.get("opencode")
.and_then(|p| p.api_key.clone())
{
let base_url = config
.providers
.get("opencode")
.and_then(|p| p.base_url.clone());
providers.register(Arc::new(OpenAiProvider::opencode(key, base_url)));
}
providers
}
/// Connects every enabled MCP server declared in config and returns their tool adapters. A
/// server that fails to start is logged and skipped inside `harness_mcp` — never fatal.
async fn connect_mcp_tools(config: &Config) -> Vec<Arc<dyn Tool>> {
let servers: HashMap<String, harness_mcp::ServerConfig> = config
.mcp
.iter()
.filter(|(_, c)| c.enabled.unwrap_or(true) && !c.command.is_empty())
.map(|(name, c)| {
(
name.clone(),
harness_mcp::ServerConfig {
command: c.command.clone(),
args: c.args.clone(),
env: c.env.clone(),
},
)
})
.collect();
if servers.is_empty() {
return Vec::new();
}
harness_mcp::connect_all(servers).await
}
/// Non-blocking, multi-turn engine API used by the TUI.
#[derive(Clone)]
pub struct EngineHandle {
@@ -111,69 +176,36 @@ struct RunHandle {
impl EngineHandle {
/// Persistent SQLite-backed store. Used by the TUI and the default headless `App`.
pub fn init(cwd: PathBuf) -> Result<Self, AppError> {
/// Async because it connects any configured MCP servers (spawn + initialize + list tools)
/// before the first turn so their tools are advertised to the model.
pub async fn init(cwd: PathBuf) -> Result<Self, AppError> {
let path = db_path(&cwd);
let store = Store::open(&path)?;
let handle = Self::new(cwd, store)?;
let config = config::load(&cwd)?;
let providers = providers_from_config(&config);
let mcp_tools = connect_mcp_tools(&config).await;
let handle = Self::build(cwd, store, config, providers, mcp_tools)?;
handle.spawn_catalog_refresh();
Ok(handle)
}
/// In-memory store — useful for tests and ephemeral sessions.
/// In-memory store — useful for tests and ephemeral sessions. Skips MCP (no external
/// servers in tests) and stays synchronous.
pub fn init_in_memory(cwd: PathBuf) -> Result<Self, AppError> {
let store = Store::open_in_memory()?;
Self::new(cwd, store)
}
fn new(cwd: PathBuf, store: Store) -> Result<Self, AppError> {
let config = config::load(&cwd)?;
let mut providers = ProviderRegistry::new();
if let Some(key) = config
.providers
.get("anthropic")
.and_then(|p| p.api_key.clone())
{
providers.register(Arc::new(AnthropicProvider::new(key)));
}
if let Some(key) = config
.providers
.get("openai")
.and_then(|p| p.api_key.clone())
{
let provider = match config
.providers
.get("openai")
.and_then(|p| p.base_url.clone())
{
Some(base_url) => OpenAiProvider::with_base_url(key, base_url),
None => OpenAiProvider::new(key),
};
providers.register(Arc::new(provider));
}
// OpenCode Zen: OpenAI-compatible chat gateway under its own `opencode` provider id,
// so it coexists with a real `openai` provider. `base_url` defaults to Zen's endpoint.
if let Some(key) = config
.providers
.get("opencode")
.and_then(|p| p.api_key.clone())
{
let base_url = config
.providers
.get("opencode")
.and_then(|p| p.base_url.clone());
providers.register(Arc::new(OpenAiProvider::opencode(key, base_url)));
}
Self::build(cwd, store, config, providers)
let providers = providers_from_config(&config);
Self::build(cwd, store, config, providers, Vec::new())
}
/// Shared construction over an explicit provider set — the seam tests use to inject a mock.
/// Shared construction over an explicit provider set and pre-connected extra tools (MCP) —
/// the seam the tests use to inject a mock provider.
fn build(
cwd: PathBuf,
store: Store,
config: Config,
providers: ProviderRegistry,
extra_tools: Vec<Arc<dyn Tool>>,
) -> Result<Self, AppError> {
let bus = EventBus::new();
let permissions = Arc::new(PermissionService::new(bus.clone()));
@@ -181,6 +213,9 @@ impl EngineHandle {
let mut tools = ToolRegistry::new();
harness_tools::register_builtins(&mut tools);
harness_tools::register_task_tool(&mut tools);
for tool in extra_tools {
tools.register(tool);
}
// Layered agent registry: bundled markdown → global dir → project dir → config patches.
let global_agent_dir = dirs::config_dir().map(|d| d.join("ai-harness").join("agent"));
@@ -888,8 +923,8 @@ pub struct App {
impl App {
/// Persistent SQLite-backed store with an auto-approve permission frontend — the default
/// headless configuration used by `harness run -p`.
pub fn init(cwd: PathBuf) -> Result<Self, AppError> {
let engine = EngineHandle::init(cwd)?;
pub async fn init(cwd: PathBuf) -> Result<Self, AppError> {
let engine = EngineHandle::init(cwd).await?;
let _auto_approve_handle = Some(spawn_auto_approve_task(&engine));
Ok(Self {
engine,
@@ -1018,6 +1053,7 @@ mod tests {
Store::open_in_memory().unwrap(),
Config::default(),
providers,
Vec::new(),
)
.unwrap()
}
@@ -1107,6 +1143,7 @@ mod tests {
Store::open_in_memory().unwrap(),
Config::default(),
providers,
Vec::new(),
)
.unwrap();
// Auto-approve permission asks (the child's read tool gates on `read`).