use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use greentic_ext_runtime::ExtensionRuntime;
use greentic_ext_runtime::host_ports::HostCallContext;
use redis::AsyncCommands;
use redis::aio::ConnectionManager;
use serde::{Deserialize, Serialize};
use crate::component_source::ComponentToolCatalog;
use crate::config::ToolRef;
use crate::error::{AgentError, StateError};
use crate::llm::LlmToolSchema;
use crate::mcp_source::McpToolCatalog;
use crate::state::ToolCallRecord;
use crate::tenant::TenantContext;
pub fn is_tool_allowed(call: &ToolCallRecord, allowed: &[ToolRef]) -> bool {
allowed
.iter()
.any(|t| t.extension_id == call.extension_id && t.tool_name == call.tool_name)
}
pub fn list_tools_for_llm(
ext_runtime: &ExtensionRuntime,
mcp: Option<&McpToolCatalog>,
components: Option<&ComponentToolCatalog>,
allowed: &[ToolRef],
) -> Vec<LlmToolSchema> {
let mut out = Vec::with_capacity(allowed.len());
for t in allowed {
if let Some(server_id) = t.extension_id.strip_prefix("mcp:") {
match mcp.and_then(|c| c.tool_entry(server_id, &t.tool_name)) {
Some(entry) => out.push(LlmToolSchema {
extension_id: t.extension_id.clone(),
tool_name: t.tool_name.clone(),
description: entry.description.clone(),
parameters: entry.parameters.clone(),
}),
None => tracing::warn!(
extension = %t.extension_id, tool = %t.tool_name,
"mcp tool not found in catalog; dropping from LLM tool list"
),
}
continue;
}
if let Some(component_ref) = t.extension_id.strip_prefix("component:") {
match components.and_then(|c| c.tool_entry(component_ref, &t.tool_name)) {
Some(entry) => out.push(LlmToolSchema {
extension_id: t.extension_id.clone(),
tool_name: t.tool_name.clone(),
description: entry.description.clone(),
parameters: entry.parameters.clone(),
}),
None => tracing::warn!(
extension = %t.extension_id, tool = %t.tool_name,
"component tool not found in catalog; dropping from LLM tool list"
),
}
continue;
}
match ext_runtime.list_tools(&t.extension_id) {
Ok(defs) => {
if let Some(def) = defs.into_iter().find(|d| d.name == t.tool_name) {
let parameters: serde_json::Value = serde_json::from_str(
&def.input_schema_json,
)
.unwrap_or_else(|_| serde_json::json!({"type": "object", "properties": {}}));
out.push(LlmToolSchema {
extension_id: t.extension_id.clone(),
tool_name: t.tool_name.clone(),
description: def.description,
parameters,
});
} else {
tracing::warn!(
extension = %t.extension_id, tool = %t.tool_name,
"tool not found in extension; dropping from LLM tool list"
);
}
}
Err(e) => {
tracing::warn!(
extension = %t.extension_id, error = %e,
"extension list_tools failed; skipping"
);
}
}
}
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MissingTool {
pub extension_id: String,
pub tool_name: String,
pub reason: String,
}
pub fn missing_tools(
ext_runtime: &ExtensionRuntime,
mcp: Option<&McpToolCatalog>,
components: Option<&ComponentToolCatalog>,
allowed: &[ToolRef],
) -> Vec<MissingTool> {
let mut missing = Vec::new();
for t in allowed {
if let Some(server_id) = t.extension_id.strip_prefix("mcp:") {
if mcp
.and_then(|c| c.tool_entry(server_id, &t.tool_name))
.is_none()
{
missing.push(MissingTool {
extension_id: t.extension_id.clone(),
tool_name: t.tool_name.clone(),
reason: "MCP tool not found in the tenant catalog".to_string(),
});
}
continue;
}
if let Some(component_ref) = t.extension_id.strip_prefix("component:") {
if components
.and_then(|c| c.tool_entry(component_ref, &t.tool_name))
.is_none()
{
missing.push(MissingTool {
extension_id: t.extension_id.clone(),
tool_name: t.tool_name.clone(),
reason: "component tool not found in the catalog".to_string(),
});
}
continue;
}
match ext_runtime.list_tools(&t.extension_id) {
Ok(defs) => {
if !defs.iter().any(|d| d.name == t.tool_name) {
missing.push(MissingTool {
extension_id: t.extension_id.clone(),
tool_name: t.tool_name.clone(),
reason: "extension loaded but does not expose this tool".to_string(),
});
}
}
Err(e) => {
missing.push(MissingTool {
extension_id: t.extension_id.clone(),
tool_name: t.tool_name.clone(),
reason: format!("extension failed to load: {e}"),
});
}
}
}
missing
}
pub(crate) fn host_ctx_from_tenant(t: &TenantContext) -> HostCallContext {
HostCallContext {
tenant: if t.tenant_id.is_empty() {
None
} else {
Some(t.tenant_id.clone())
},
user_email: t.user_email.clone(),
}
}
pub async fn dispatch_tool_call(
ext_runtime: Arc<ExtensionRuntime>,
mcp: Option<Arc<McpToolCatalog>>,
components: Option<Arc<ComponentToolCatalog>>,
call: ToolCallRecord,
tenant: &TenantContext,
) -> Result<serde_json::Value, AgentError> {
if let Some(server_id) = call.extension_id.strip_prefix("mcp:") {
let value = match mcp
.as_deref()
.and_then(|c| c.route(server_id, &call.tool_name))
{
Some(route) => {
let args = call.args.to_string();
crate::mcp_source::dispatch_route(route, &args).await
}
None => {
tracing::warn!(
server = %server_id,
tool = %call.tool_name,
"mcp call has no route in the tenant catalog; returning error value"
);
serde_json::json!({
"error": format!("unknown mcp tool '{}/{}'", server_id, call.tool_name)
})
}
};
return Ok(value);
}
if let Some(component_ref) = call.extension_id.strip_prefix("component:") {
let value = match components.as_deref() {
Some(cat) => {
let args = call.args.to_string();
cat.dispatch(component_ref, &call.tool_name, &args).await
}
None => {
tracing::warn!(
component = %component_ref,
tool = %call.tool_name,
"component call has no catalog wired; returning error value"
);
serde_json::json!({
"error": format!(
"unknown component tool '{}/{}'",
component_ref, call.tool_name
)
})
}
};
return Ok(value);
}
let args_json = call.args.to_string();
let extension_id = call.extension_id.clone();
let tool_name = call.tool_name.clone();
let ctx = host_ctx_from_tenant(tenant);
let raw = tokio::task::spawn_blocking(move || {
ext_runtime.invoke_tool_ctx(&extension_id, &tool_name, &args_json, &ctx)
})
.await
.map_err(|e| AgentError::ToolDispatch(format!("join: {e}")))?
.map_err(|e| AgentError::ToolDispatch(format!("invoke: {e}")))?;
serde_json::from_str(&raw).map_err(|e| AgentError::ToolDispatch(format!("decode: {e}")))
}
#[derive(Serialize, Deserialize, Clone)]
pub struct ToolLedgerEntry {
pub result: serde_json::Value,
}
pub fn ledger_key(tenant: &TenantContext, session_id: &str, call_id: &str) -> String {
format!("{}:{session_id}:tool_calls:{call_id}", tenant.key_prefix())
}
pub trait ToolLedger: Send + Sync {
fn get<'a>(
&'a self,
tenant: &'a TenantContext,
session_id: &'a str,
call_id: &'a str,
) -> Pin<Box<dyn Future<Output = Result<Option<serde_json::Value>, StateError>> + Send + 'a>>;
fn record<'a>(
&'a self,
tenant: &'a TenantContext,
session_id: &'a str,
call_id: &'a str,
result: serde_json::Value,
) -> Pin<Box<dyn Future<Output = Result<(), StateError>> + Send + 'a>>;
}
const LEDGER_TTL_SECS: u64 = 7 * 24 * 60 * 60;
pub struct RedisToolLedger {
manager: ConnectionManager,
}
impl RedisToolLedger {
pub fn new(manager: ConnectionManager) -> Self {
Self { manager }
}
}
impl ToolLedger for RedisToolLedger {
fn get<'a>(
&'a self,
tenant: &'a TenantContext,
session_id: &'a str,
call_id: &'a str,
) -> Pin<Box<dyn Future<Output = Result<Option<serde_json::Value>, StateError>> + Send + 'a>>
{
Box::pin(async move {
let key = ledger_key(tenant, session_id, call_id);
let mut conn = self.manager.clone();
let raw: Option<String> = conn
.get(&key)
.await
.map_err(|e| StateError::Redis(format!("ledger get: {e}")))?;
match raw {
Some(json) => {
let entry: ToolLedgerEntry = serde_json::from_str(&json)
.map_err(|e| StateError::Decode(format!("ledger decode: {e}")))?;
Ok(Some(entry.result))
}
None => Ok(None),
}
})
}
fn record<'a>(
&'a self,
tenant: &'a TenantContext,
session_id: &'a str,
call_id: &'a str,
result: serde_json::Value,
) -> Pin<Box<dyn Future<Output = Result<(), StateError>> + Send + 'a>> {
Box::pin(async move {
let key = ledger_key(tenant, session_id, call_id);
let entry = ToolLedgerEntry { result };
let json = serde_json::to_string(&entry)
.map_err(|e| StateError::Decode(format!("ledger encode: {e}")))?;
let mut conn = self.manager.clone();
let _: () = conn
.set_ex(&key, json, LEDGER_TTL_SECS)
.await
.map_err(|e| StateError::Redis(format!("ledger set_ex: {e}")))?;
Ok(())
})
}
}
#[cfg(test)]
mod ctx_tests {
use super::*;
use crate::tenant::TenantContext;
#[test]
fn host_ctx_carries_tenant_and_optional_user() {
let c1 = host_ctx_from_tenant(&TenantContext::new("acme", "prod"));
assert_eq!(c1.tenant.as_deref(), Some("acme"));
assert_eq!(c1.user_email, None);
let c2 = host_ctx_from_tenant(
&TenantContext::new("acme", "prod").with_user_email(Some("u@x.com".into())),
);
assert_eq!(c2.user_email.as_deref(), Some("u@x.com"));
let c3 = host_ctx_from_tenant(&TenantContext::new("", ""));
assert_eq!(
c3.tenant, None,
"empty tenant_id must map to None, not Some(\"\")"
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn is_tool_allowed_returns_true_for_exact_match() {
let allowed = vec![ToolRef {
extension_id: "http".into(),
tool_name: "fetch".into(),
}];
let call = ToolCallRecord {
call_id: "c1".into(),
extension_id: "http".into(),
tool_name: "fetch".into(),
args: serde_json::json!({}),
};
assert!(is_tool_allowed(&call, &allowed));
}
#[test]
fn is_tool_allowed_returns_false_for_unauthorized_tool() {
let allowed = vec![ToolRef {
extension_id: "http".into(),
tool_name: "fetch".into(),
}];
let call = ToolCallRecord {
call_id: "c1".into(),
extension_id: "http".into(),
tool_name: "post".into(),
args: serde_json::json!({}),
};
assert!(!is_tool_allowed(&call, &allowed));
}
#[test]
fn ledger_key_includes_tenant_env_session_callid() {
let tc = TenantContext::new("acme", "prod");
let key = ledger_key(&tc, "sess-1", "call-abc");
assert_eq!(key, "aw:acme:prod:sess-1:tool_calls:call-abc");
}
#[test]
fn list_tools_for_llm_with_no_extensions_returns_empty() {
let rt = ExtensionRuntime::for_test();
let allowed = vec![ToolRef {
extension_id: "http".into(),
tool_name: "fetch".into(),
}];
let schemas = list_tools_for_llm(&rt, None, None, &allowed);
assert!(schemas.is_empty());
}
#[test]
fn missing_tools_reports_unloaded_extension() {
let rt = ExtensionRuntime::for_test();
let allowed = vec![ToolRef {
extension_id: "greentic.hubspot".into(),
tool_name: "hubspot_contacts".into(),
}];
let missing = missing_tools(&rt, None, None, &allowed);
assert_eq!(missing.len(), 1);
assert_eq!(missing[0].extension_id, "greentic.hubspot");
assert_eq!(missing[0].tool_name, "hubspot_contacts");
assert!(
missing[0].reason.contains("failed to load"),
"got: {}",
missing[0].reason
);
}
#[test]
fn missing_tools_reports_mcp_tool_absent_from_catalog() {
let rt = ExtensionRuntime::for_test();
let allowed = vec![ToolRef {
extension_id: "mcp:github".into(),
tool_name: "create_issue".into(),
}];
let missing = missing_tools(&rt, None, None, &allowed);
assert_eq!(missing.len(), 1);
assert!(
missing[0].reason.contains("MCP tool not found"),
"got: {}",
missing[0].reason
);
}
use std::collections::HashMap;
use crate::mcp_source::{McpRoute, McpToolCatalog, McpToolEntry, route_for_tests};
fn catalog_with(
server: &str,
tool: &str,
description: &str,
parameters: serde_json::Value,
transport_url: Option<&str>,
) -> McpToolCatalog {
let mut tools: HashMap<(String, String), McpToolEntry> = HashMap::new();
tools.insert(
(server.to_string(), tool.to_string()),
McpToolEntry {
description: description.to_string(),
parameters,
},
);
let mut routes: HashMap<(String, String), McpRoute> = HashMap::new();
if let Some(url) = transport_url {
routes.insert(
(server.to_string(), tool.to_string()),
route_for_tests(server, tool, url),
);
}
McpToolCatalog::for_tests(tools, routes)
}
async fn fake_mcp_call_server(call_result: serde_json::Value) -> wiremock::MockServer {
use wiremock::matchers::{body_partial_json, method};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(body_partial_json(
serde_json::json!({ "method": "initialize" }),
))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Mcp-Session-Id", "sess-1")
.set_body_json(serde_json::json!({
"jsonrpc": "2.0", "id": 1,
"result": {
"protocolVersion": "2025-06-18",
"serverInfo": { "name": "fake", "version": "1.0.0" }
}
})),
)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(body_partial_json(
serde_json::json!({ "method": "notifications/initialized" }),
))
.respond_with(ResponseTemplate::new(202))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(body_partial_json(
serde_json::json!({ "method": "tools/call" }),
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"jsonrpc": "2.0", "id": 3,
"result": call_result
})))
.mount(&server)
.await;
server
}
#[test]
fn mcp_ref_listed_from_catalog() {
let rt = ExtensionRuntime::for_test();
let params = serde_json::json!({
"type": "object",
"properties": { "id": { "type": "string" } }
});
let catalog = catalog_with("s1", "get_issue", "Get an issue", params.clone(), None);
let allowed = vec![
ToolRef {
extension_id: "mcp:s1".into(),
tool_name: "get_issue".into(),
},
ToolRef {
extension_id: "mcp:s1".into(),
tool_name: "missing".into(),
},
];
let schemas = list_tools_for_llm(&rt, Some(&catalog), None, &allowed);
assert_eq!(schemas.len(), 1, "only the catalog-backed ref is emitted");
let s = &schemas[0];
assert_eq!(s.extension_id, "mcp:s1");
assert_eq!(s.tool_name, "get_issue");
assert_eq!(s.description, "Get an issue");
assert_eq!(s.parameters, params);
}
#[test]
fn non_mcp_ref_unchanged() {
let rt = ExtensionRuntime::for_test();
let catalog = catalog_with(
"greentic.tavily",
"search",
"decoy: must never be emitted for a non-mcp ref",
serde_json::json!({}),
None,
);
let allowed = vec![ToolRef {
extension_id: "greentic.tavily".into(),
tool_name: "search".into(),
}];
let schemas = list_tools_for_llm(&rt, Some(&catalog), None, &allowed);
assert!(
schemas.is_empty(),
"non-mcp ref still goes through ext_runtime (unloaded → dropped)"
);
}
#[test]
fn is_tool_allowed_matches_mcp_ref() {
let allowed = vec![ToolRef {
extension_id: "mcp:s1".into(),
tool_name: "get_issue".into(),
}];
let call = ToolCallRecord {
call_id: "c1".into(),
extension_id: "mcp:s1".into(),
tool_name: "get_issue".into(),
args: serde_json::json!({}),
};
assert!(is_tool_allowed(&call, &allowed));
let other = ToolCallRecord {
call_id: "c1".into(),
extension_id: "mcp:s1".into(),
tool_name: "search_code".into(),
args: serde_json::json!({}),
};
assert!(!is_tool_allowed(&other, &allowed));
}
#[tokio::test]
async fn dispatch_routes_mcp_ref() {
let mcp = fake_mcp_call_server(serde_json::json!({
"structuredContent": { "ok": 1 }
}))
.await;
let uri = mcp.uri();
let catalog = Arc::new(catalog_with(
"s1",
"get_issue",
"Get an issue",
serde_json::json!({}),
Some(&uri),
));
let rt = Arc::new(ExtensionRuntime::for_test());
let call = ToolCallRecord {
call_id: "c1".into(),
extension_id: "mcp:s1".into(),
tool_name: "get_issue".into(),
args: serde_json::json!({}),
};
let tc = TenantContext::new("t", "e");
let out = dispatch_tool_call(rt.clone(), Some(catalog.clone()), None, call, &tc)
.await
.expect("mcp dispatch never returns Err");
assert_eq!(out, serde_json::json!({ "ok": 1 }), "got: {out}");
let missing = ToolCallRecord {
call_id: "c2".into(),
extension_id: "mcp:s1".into(),
tool_name: "no_such".into(),
args: serde_json::json!({}),
};
let out = dispatch_tool_call(rt.clone(), Some(catalog), None, missing, &tc)
.await
.expect("missing mcp route still returns Ok");
assert_eq!(
out,
serde_json::json!({ "error": "unknown mcp tool 's1/no_such'" }),
"got: {out}"
);
let non_mcp = ToolCallRecord {
call_id: "c3".into(),
extension_id: "greentic.absent".into(),
tool_name: "nope".into(),
args: serde_json::json!({}),
};
let res = dispatch_tool_call(rt, None, None, non_mcp, &tc).await;
assert!(
res.is_err(),
"non-mcp dispatch against an unloaded extension must error"
);
}
use crate::component_source::ComponentToolCatalog;
use crate::component_source::test_support::{FakeInvoker, one_tool};
#[test]
fn component_ref_listed_from_catalog() {
let rt = ExtensionRuntime::for_test();
let params = serde_json::json!({
"type": "object",
"properties": { "order_id": { "type": "string" } }
});
let invoker = Arc::new(FakeInvoker::new(vec![], Ok(serde_json::json!({}))));
let catalog = ComponentToolCatalog::for_tests(
one_tool(
"greentic.refund",
"issue_refund",
"Issue a refund",
params.clone(),
),
invoker,
);
let allowed = vec![
ToolRef {
extension_id: "component:greentic.refund".into(),
tool_name: "issue_refund".into(),
},
ToolRef {
extension_id: "component:greentic.refund".into(),
tool_name: "missing".into(),
},
];
let schemas = list_tools_for_llm(&rt, None, Some(&catalog), &allowed);
assert_eq!(schemas.len(), 1, "only the catalog-backed ref is emitted");
let s = &schemas[0];
assert_eq!(s.extension_id, "component:greentic.refund");
assert_eq!(s.tool_name, "issue_refund");
assert_eq!(s.description, "Issue a refund");
assert_eq!(s.parameters, params);
}
#[test]
fn non_component_ref_unaffected_by_catalog() {
let rt = ExtensionRuntime::for_test();
let invoker = Arc::new(FakeInvoker::new(vec![], Ok(serde_json::json!({}))));
let catalog = ComponentToolCatalog::for_tests(
one_tool(
"greentic.tavily",
"search",
"decoy: must never be emitted for a non-component ref",
serde_json::json!({}),
),
invoker,
);
let allowed = vec![ToolRef {
extension_id: "greentic.tavily".into(),
tool_name: "search".into(),
}];
let schemas = list_tools_for_llm(&rt, None, Some(&catalog), &allowed);
assert!(
schemas.is_empty(),
"non-component ref still goes through ext_runtime (unloaded → dropped)"
);
}
#[tokio::test]
async fn dispatch_routes_component_ref() {
let invoker = Arc::new(FakeInvoker::new(
vec![],
Ok(serde_json::json!({ "refund_id": "r-1" })),
));
let catalog = Arc::new(ComponentToolCatalog::for_tests(
one_tool(
"greentic.refund",
"issue_refund",
"Issue a refund",
serde_json::json!({}),
),
invoker,
));
let rt = Arc::new(ExtensionRuntime::for_test());
let tc = TenantContext::new("t", "e");
let call = ToolCallRecord {
call_id: "c1".into(),
extension_id: "component:greentic.refund".into(),
tool_name: "issue_refund".into(),
args: serde_json::json!({}),
};
let out = dispatch_tool_call(rt.clone(), None, Some(catalog.clone()), call, &tc)
.await
.expect("component dispatch never returns Err");
assert_eq!(out, serde_json::json!({ "refund_id": "r-1" }), "got: {out}");
let missing = ToolCallRecord {
call_id: "c2".into(),
extension_id: "component:greentic.refund".into(),
tool_name: "no_such".into(),
args: serde_json::json!({}),
};
let out = dispatch_tool_call(rt.clone(), None, Some(catalog), missing, &tc)
.await
.expect("missing component op still returns Ok");
assert!(out.to_string().contains("error"), "got: {out}");
let no_cat = ToolCallRecord {
call_id: "c3".into(),
extension_id: "component:greentic.refund".into(),
tool_name: "issue_refund".into(),
args: serde_json::json!({}),
};
let out = dispatch_tool_call(rt, None, None, no_cat, &tc)
.await
.expect("component dispatch with no catalog still returns Ok");
assert!(out.to_string().contains("error"), "got: {out}");
}
}