use std::sync::Arc;
use std::time::Duration;
use dashmap::DashMap;
use secrecy::ExposeSecret;
use serde_json::json;
use greentic_mcp_client::{McpAuth, McpClientOptions, McpHttpClient, McpToolDef};
use crate::tenant::TenantContext;
use super::types::{
CATALOG_TTL, MCP_ROLE_AGENTIC_WORKER, McpRoute, McpToolCatalog, McpToolEntry, ParsedServer,
SERVER_TIMEOUT, Transport, WireBody,
};
pub struct McpToolSource {
base_url: String,
token: String,
client: reqwest::Client,
cache: DashMap<String, Arc<McpToolCatalog>>,
}
impl McpToolSource {
pub fn new(base_url: impl Into<String>, token: impl Into<String>) -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.unwrap_or_default();
Self {
base_url: base_url.into().trim_end_matches('/').to_string(),
token: token.into(),
client,
cache: DashMap::new(),
}
}
fn cache_key(tenant: &TenantContext, role: &str) -> String {
format!("{}:{}:{}", tenant.tenant_id, tenant.env_id, role)
}
pub async fn catalog(&self, tenant: &TenantContext) -> Arc<McpToolCatalog> {
self.catalog_for_role(tenant, MCP_ROLE_AGENTIC_WORKER).await
}
pub async fn catalog_for_role(
&self,
tenant: &TenantContext,
role: &str,
) -> Arc<McpToolCatalog> {
let key = Self::cache_key(tenant, role);
if let Some(entry) = self.cache.get(&key) {
let snap = entry.value();
if snap.fetched_at.elapsed() < CATALOG_TTL {
return snap.clone();
}
}
let built = Arc::new(self.build_catalog(&key, role).await);
self.cache.insert(key, built.clone());
built
}
async fn build_catalog(&self, tenant_key: &str, role: &str) -> McpToolCatalog {
let servers = match self.fetch_servers().await {
Ok(s) => s,
Err(e) => {
tracing::warn!(
tenant = %tenant_key,
error = %e,
"mcp-servers fetch failed; serving empty MCP catalog"
);
return McpToolCatalog::empty();
}
};
let mut catalog = McpToolCatalog::empty();
for server in &servers {
if !server.roles.iter().any(|r| r == role) {
continue;
}
self.probe_server(&mut catalog, server, tenant_key).await;
}
catalog
}
async fn fetch_servers(&self) -> Result<Vec<ParsedServer>, String> {
let url = format!("{}/api/v1/designer/tenant/me/mcp-servers", self.base_url);
let resp = self
.client
.get(&url)
.bearer_auth(&self.token)
.send()
.await
.map_err(|e| format!("request failed: {e}"))?;
let status = resp.status();
if status.as_u16() != 200 {
return Err(format!("admin returned status {}", status.as_u16()));
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("decode failed: {e}"))?;
parse_rows(body)
}
async fn probe_server(
&self,
catalog: &mut McpToolCatalog,
server: &ParsedServer,
tenant_key: &str,
) {
match tokio::time::timeout(SERVER_TIMEOUT, list_server_tools(server)).await {
Ok(Ok(defs)) => ingest_server_tools(catalog, server, &defs),
Ok(Err(e)) => tracing::warn!(
tenant = %tenant_key,
server = %server.id,
error = %e,
"mcp server probe failed; skipping its tools"
),
Err(_) => tracing::warn!(
tenant = %tenant_key,
server = %server.id,
"mcp server probe timed out after {}s; skipping its tools",
SERVER_TIMEOUT.as_secs()
),
}
}
}
pub(crate) fn parse_rows(body: serde_json::Value) -> Result<Vec<ParsedServer>, String> {
use secrecy::SecretString;
let wire: WireBody = serde_json::from_value(body).map_err(|e| e.to_string())?;
Ok(wire
.servers
.into_iter()
.map(|w| ParsedServer {
id: w.id,
transport_url: w.transport_url.unwrap_or_default(),
auth_header_name: w.auth_header_name,
auth_token: w.auth_token.map(SecretString::from),
allowed_tools: w.allowed_tools,
roles: w.roles,
transport: w.transport,
component_ref: w.component_ref,
component_version: w.component_version,
component_digest: w.component_digest,
})
.collect())
}
fn ingest_server_tools(catalog: &mut McpToolCatalog, server: &ParsedServer, defs: &[McpToolDef]) {
for def in defs {
if let Some(allow) = &server.allowed_tools
&& !allow.iter().any(|a| a == &def.name)
{
continue;
}
let key = (server.id.clone(), def.name.clone());
catalog.tools.insert(
key.clone(),
McpToolEntry {
description: def.description.clone(),
parameters: def.input_schema.clone(),
},
);
catalog.routes.insert(
key,
McpRoute {
server_id: server.id.clone(),
transport_url: server.transport_url.clone(),
auth_header_name: server.auth_header_name.clone(),
auth_token: server.auth_token.clone(),
raw_tool_name: def.name.clone(),
transport: server.transport,
component_ref: server.component_ref.clone(),
component_version: server.component_version.clone(),
component_digest: server.component_digest.clone(),
},
);
}
}
fn build_auth(token: Option<&secrecy::SecretString>, header_name: Option<&str>) -> Option<McpAuth> {
token.map(|t| McpAuth {
header_name: header_name.map(str::to_string),
token: t.expose_secret().to_string(),
})
}
fn client_opts() -> McpClientOptions {
McpClientOptions {
timeout: SERVER_TIMEOUT,
client_name: "greentic-aw-runtime".to_string(),
client_version: env!("CARGO_PKG_VERSION").to_string(),
}
}
fn connect(server: &ParsedServer) -> Result<McpHttpClient, String> {
let endpoint = url::Url::parse(&server.transport_url)
.map_err(|e| format!("invalid transport_url '{}': {e}", server.transport_url))?;
let auth = build_auth(
server.auth_token.as_ref(),
server.auth_header_name.as_deref(),
);
McpHttpClient::new(endpoint, auth, client_opts()).map_err(|e| e.to_string())
}
fn connect_route(route: &McpRoute) -> Result<McpHttpClient, String> {
let endpoint = url::Url::parse(&route.transport_url)
.map_err(|e| format!("invalid transport_url '{}': {e}", route.transport_url))?;
let auth = build_auth(route.auth_token.as_ref(), route.auth_header_name.as_deref());
McpHttpClient::new(endpoint, auth, client_opts()).map_err(|e| e.to_string())
}
async fn list_server_tools(server: &ParsedServer) -> Result<Vec<McpToolDef>, String> {
match server.transport {
Transport::Http => {
let mut client = connect(server)?;
client.initialize().await.map_err(|e| e.to_string())?;
client.list_tools().await.map_err(|e| e.to_string())
}
Transport::LocalWasm => {
let component = server
.component_ref
.as_deref()
.ok_or_else(|| "local-wasm server missing component_ref".to_string())?;
let version = server.component_version.as_deref().ok_or_else(|| {
format!(
"local-wasm server '{}' missing component_version; cannot pull",
component
)
})?;
let digest = server.component_digest.as_deref().ok_or_else(|| {
format!(
"local-wasm server '{}' missing component_digest; cannot pull",
component
)
})?;
crate::mcp_store_pull::ensure_cached(component, version, digest)
.await
.map_err(|e| format!("local-wasm store-pull failed for '{}': {e}", component))?;
#[cfg(not(greentic_mcp_local_wasm))]
return Err(format!(
"local-wasm MCP tool discovery is not available on this build; \
server '{component}' cannot be catalogued"
));
#[cfg(greentic_mcp_local_wasm)]
let tools = crate::mcp_local::local_list_tools(component).await;
#[cfg(greentic_mcp_local_wasm)]
Ok(tools
.into_iter()
.map(|tool_def| McpToolDef {
name: tool_def.name,
description: tool_def.description,
input_schema: tool_def.input_schema,
})
.collect())
}
}
}
pub async fn dispatch_route(route: &McpRoute, args: &str) -> serde_json::Value {
let parsed: serde_json::Value = match serde_json::from_str(args) {
Ok(v) => v,
Err(e) => return json!({ "error": format!("invalid tool arguments: {e}") }),
};
match tokio::time::timeout(SERVER_TIMEOUT, call_route(route, &parsed)).await {
Ok(Ok(value)) => value,
Ok(Err(e)) => json!({ "error": e }),
Err(_) => json!({
"error": format!("tool call timed out after {}s", SERVER_TIMEOUT.as_secs())
}),
}
}
async fn call_route(
route: &McpRoute,
args: &serde_json::Value,
) -> Result<serde_json::Value, String> {
match route.transport {
Transport::Http => {
let mut client = connect_route(route)?;
client.initialize().await.map_err(|e| e.to_string())?;
let out = client
.call_tool(&route.raw_tool_name, args)
.await
.map_err(|e| e.to_string())?;
Ok(out.to_value())
}
Transport::LocalWasm => {
let component = route
.component_ref
.as_deref()
.ok_or_else(|| "local-wasm route missing component_ref".to_string())?;
let version = route.component_version.as_deref().ok_or_else(|| {
format!(
"local-wasm route '{}' missing component_version; cannot pull",
component
)
})?;
let digest = route.component_digest.as_deref().ok_or_else(|| {
format!(
"local-wasm route '{}' missing component_digest; cannot pull",
component
)
})?;
crate::mcp_store_pull::ensure_cached(component, version, digest)
.await
.map_err(|e| {
format!(
"local-wasm store-pull failed for '{}' during dispatch: {e}",
component
)
})?;
Ok(crate::mcp_local::local_call_tool(component, &route.raw_tool_name, args).await)
}
}
}