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:
@@ -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`).
|
||||
|
||||
Reference in New Issue
Block a user