use std::path::Path;
use std::sync::Arc;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use theway_core::AgentTool;
use theway_mcp::{
HttpMcpAuth, HttpMcpTransport, HttpMcpTransportOptions, McpClient, ReconnectPolicy,
StdioTransport,
};
use crate::triggers::McpNotificationHook;
use theway_daemon::tools::mcp_adapter::McpAgentTool;
use theway_transport::auth::AuthStore;
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct McpConfig {
#[serde(default)]
pub server: Vec<ServerConfig>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ServerConfig {
pub name: String,
#[serde(default)]
pub kind: ServerKind,
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
pub endpoint: Option<String>,
pub auth: Option<HttpAuthConfig>,
pub request_timeout_ms: Option<u64>,
pub sse_idle_timeout_ms: Option<u64>,
pub body_cap_bytes: Option<usize>,
pub reconnect: Option<ReconnectConfig>,
#[serde(default)]
pub inject_summary: bool,
#[serde(default)]
pub inject_and_run: bool,
}
#[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ServerKind {
#[default]
Stdio,
StreamableHttp,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct HttpAuthConfig {
pub kind: String,
pub token_keychain_ref: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ReconnectConfig {
pub initial_ms: Option<u64>,
pub max_ms: Option<u64>,
pub max_attempts: Option<usize>,
}
pub struct LoadedMcp {
pub tools: Vec<Arc<dyn AgentTool>>,
pub diagnostics: Vec<String>,
pub client_count: usize,
pub server_names: Vec<String>,
pub notification_hooks: Vec<Arc<McpNotificationHook>>,
pub inject_summary_servers: std::collections::HashSet<String>,
pub inject_and_run_servers: std::collections::HashSet<String>,
}
impl LoadedMcp {
pub fn empty() -> Self {
Self {
tools: Vec::new(),
diagnostics: Vec::new(),
client_count: 0,
server_names: Vec::new(),
notification_hooks: Vec::new(),
inject_summary_servers: std::collections::HashSet::new(),
inject_and_run_servers: std::collections::HashSet::new(),
}
}
}
pub async fn load_all(paths: &theway_daemon::DaemonPaths) -> LoadedMcp {
let mut diagnostics = Vec::new();
let project_path = paths.work_dir.join(".theway").join("mcp.toml");
let user_path = paths.base.join("mcp.toml");
let mut configs: Vec<ServerConfig> = Vec::new();
for (path, label) in [(&user_path, "user"), (&project_path, "project")] {
if let Some(cfg) = read_config(path, &mut diagnostics, label).await {
for s in cfg.server {
if let Some(i) = configs.iter().position(|x| x.name == s.name) {
configs[i] = s;
} else {
configs.push(s);
}
}
}
}
let inject_summary_servers: std::collections::HashSet<String> = configs
.iter()
.filter(|c| c.inject_summary)
.map(|c| c.name.clone())
.collect();
let inject_and_run_servers: std::collections::HashSet<String> = configs
.iter()
.filter(|c| c.inject_and_run)
.map(|c| c.name.clone())
.collect();
let (tools, notification_hooks, connect_diagnostics, client_count, server_names) =
connect_all(&configs, &paths.work_dir, &paths.base.join("auth.json")).await;
diagnostics.extend(connect_diagnostics);
LoadedMcp {
tools,
diagnostics,
client_count,
server_names,
notification_hooks,
inject_summary_servers,
inject_and_run_servers,
}
}
async fn connect_all(
configs: &[ServerConfig],
cwd: &Path,
auth_path: &Path,
) -> (
Vec<Arc<dyn AgentTool>>,
Vec<Arc<McpNotificationHook>>,
Vec<String>,
usize,
Vec<String>,
) {
let mut tools: Vec<Arc<dyn AgentTool>> = Vec::new();
let mut notification_hooks: Vec<Arc<McpNotificationHook>> = Vec::new();
let mut diagnostics: Vec<String> = Vec::new();
let mut client_count = 0usize;
let mut server_names = Vec::new();
for s in configs.iter() {
match connect_one(s, cwd, auth_path).await {
Ok((server_tools, hook)) => {
tools.extend(server_tools);
notification_hooks.push(hook);
client_count += 1;
server_names.push(s.name.clone());
}
Err(e) => {
diagnostics.push(format!("mcp server '{}' failed: {e}", s.name));
}
}
}
(
tools,
notification_hooks,
diagnostics,
client_count,
server_names,
)
}
async fn read_config(path: &Path, diagnostics: &mut Vec<String>, label: &str) -> Option<McpConfig> {
if !tokio::fs::try_exists(path).await.unwrap_or(false) {
return None;
}
match tokio::fs::read_to_string(path).await {
Ok(text) => match toml::from_str::<McpConfig>(&text) {
Ok(cfg) => Some(cfg),
Err(e) => {
diagnostics.push(format!(
"mcp config ({label}, {}): parse failed: {e}",
path.display()
));
None
}
},
Err(e) => {
diagnostics.push(format!(
"mcp config ({label}, {}): read failed: {e}",
path.display()
));
None
}
}
}
async fn connect_one(
s: &ServerConfig,
cwd: &Path,
auth_path: &Path,
) -> Result<(Vec<Arc<dyn AgentTool>>, Arc<McpNotificationHook>)> {
let client = match s.kind {
ServerKind::Stdio => connect_stdio(s, cwd).await?,
ServerKind::StreamableHttp => connect_streamable_http(s, auth_path).await?,
};
client.initialize("theway").await?;
let rx = client.take_notifications().ok_or_else(|| {
anyhow::anyhow!("McpClient::take_notifications returned None — receiver already consumed")
})?;
let hook = Arc::new(McpNotificationHook::new(s.name.clone(), rx));
let tools = client.tools_list().await?;
let mut out: Vec<Arc<dyn AgentTool>> = Vec::with_capacity(tools.len());
for tool in &tools {
let adapter = McpAgentTool::new(client.clone(), tool);
out.push(Arc::new(adapter));
}
Ok((out, hook))
}
async fn connect_stdio(s: &ServerConfig, cwd: &Path) -> Result<Arc<McpClient>> {
if s.endpoint.is_some() || s.auth.is_some() {
anyhow::bail!(
"stdio MCP server '{}' must not set endpoint or auth; remove streamable_http fields",
s.name
);
}
let command = s
.command
.as_deref()
.ok_or_else(|| anyhow::anyhow!("stdio MCP server '{}' missing command", s.name))?;
let args: Vec<&str> = s.args.iter().map(String::as_str).collect();
let transport = StdioTransport::spawn_in(command, &args, cwd).await?;
let client = Arc::new(McpClient::new(Arc::new(transport)));
Ok(client)
}
async fn connect_streamable_http(s: &ServerConfig, auth_path: &Path) -> Result<Arc<McpClient>> {
if s.command.is_some() || !s.args.is_empty() {
anyhow::bail!(
"streamable_http MCP server '{}' must set endpoint, not command/args",
s.name
);
}
let endpoint = s
.endpoint
.as_ref()
.ok_or_else(|| anyhow::anyhow!("streamable_http MCP server '{}' missing endpoint", s.name))?
.clone();
let mut opts = HttpMcpTransportOptions::new(endpoint);
opts.auth = resolve_http_auth(s.auth.as_ref(), auth_path)?;
if let Some(ms) = s.request_timeout_ms {
if ms == 0 {
anyhow::bail!(
"streamable_http MCP server '{}' request_timeout_ms must be positive",
s.name
);
}
opts.request_timeout = std::time::Duration::from_millis(ms);
}
if let Some(ms) = s.sse_idle_timeout_ms {
if ms == 0 {
anyhow::bail!(
"streamable_http MCP server '{}' sse_idle_timeout_ms must be positive",
s.name
);
}
opts.sse_idle_timeout = std::time::Duration::from_millis(ms);
}
if let Some(cap) = s.body_cap_bytes {
if cap == 0 {
anyhow::bail!(
"streamable_http MCP server '{}' body_cap_bytes must be positive",
s.name
);
}
opts.body_cap_bytes = cap;
}
if let Some(reconnect) = &s.reconnect {
if reconnect.initial_ms == Some(0) || reconnect.max_ms == Some(0) {
anyhow::bail!(
"streamable_http MCP server '{}' reconnect delays must be positive",
s.name
);
}
opts.reconnect_policy = ReconnectPolicy {
initial_delay: std::time::Duration::from_millis(reconnect.initial_ms.unwrap_or(500)),
max_delay: std::time::Duration::from_millis(reconnect.max_ms.unwrap_or(30_000)),
max_attempts: reconnect.max_attempts,
};
}
let transport = HttpMcpTransport::connect(opts)?;
Ok(Arc::new(McpClient::new(Arc::new(transport))))
}
fn resolve_http_auth(auth: Option<&HttpAuthConfig>, auth_path: &Path) -> Result<HttpMcpAuth> {
let Some(auth_cfg) = auth else {
return Ok(HttpMcpAuth::None);
};
let recovery = http_auth_recovery(auth_cfg);
let store = AuthStore::load_from(auth_path)
.map_err(|e| anyhow::anyhow!("failed to load local credential store: {e}; {recovery}"))?;
resolve_http_auth_from_store(Some(auth_cfg), &store)
}
fn resolve_http_auth_from_store(
auth: Option<&HttpAuthConfig>,
store: &AuthStore,
) -> Result<HttpMcpAuth> {
let Some(auth) = auth else {
return Ok(HttpMcpAuth::None);
};
if auth.kind != "bearer" {
anyhow::bail!("unsupported streamable_http auth kind; expected bearer");
}
let token_ref = auth
.token_keychain_ref
.as_deref()
.ok_or_else(|| anyhow::anyhow!("bearer auth requires token_keychain_ref"))?;
let recovery = http_auth_recovery(auth);
let token = store
.resolve_for_provider(token_ref)
.ok_or_else(|| anyhow::anyhow!("configured bearer credential was not found; {recovery}"))?;
Ok(HttpMcpAuth::Bearer { token })
}
fn http_auth_recovery(auth: &HttpAuthConfig) -> &'static str {
let _ = auth;
"run /login <configured-token-ref>"
}
#[cfg(test)]
tests_bridge_macro::tests_bridge!("mcp_loader");