#[cfg(feature = "agentic-worker")]
pub mod aw {
use std::sync::Arc;
use serde_json::{Value, json};
use greentic_aw_runtime::{MCP_ROLE_FLOW_EDITOR, McpToolSource, TenantContext, dispatch_route};
use crate::runner::mcp_pack_routes::{PackMcpRoute, PackMcpRoutes};
pub(crate) fn source_from_env() -> Option<Arc<McpToolSource>> {
if std::env::var("GREENTIC_AW_MCP").ok().as_deref() == Some("0") {
tracing::info!("GREENTIC_AW_MCP=0; flow MCP node source disabled");
return None;
}
let endpoint = std::env::var("GREENTIC_AW_ADMIN_ENDPOINT")
.ok()
.filter(|s| !s.is_empty())?;
let token = std::env::var("GREENTIC_AW_ADMIN_TOKEN")
.ok()
.filter(|s| !s.is_empty())?;
tracing::info!(endpoint = %endpoint, "flow MCP node source constructed");
Some(Arc::new(McpToolSource::new(endpoint, token)))
}
async fn route_from_pack(
route: &PackMcpRoute,
secrets: Option<&crate::secrets::DynSecretsManager>,
tenant: &str,
team: Option<&str>,
) -> Result<greentic_aw_runtime::McpRoute, String> {
let is_http = route.transport != "local-wasm";
let token = match secrets.filter(|_| is_http) {
Some(manager) => match greentic_aw_runtime::mcp_secrets::read_mcp_secret(
manager.as_ref(),
tenant,
team,
&route.server_id,
)
.await
{
Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
Err(miss) => return Err(format!("mcp server '{}' has {miss}", route.server_id)),
},
None => None,
};
Ok(greentic_aw_runtime::McpRoute::from_parts(
&route.server_id,
route.transport_url.as_deref().unwrap_or_default(),
route.auth_header_name.as_deref(),
token.as_deref(),
&route.transport,
route.component_ref.as_deref(),
route.component_version.as_deref(),
route.component_digest.as_deref(),
))
}
#[allow(clippy::too_many_arguments)]
pub async fn invoke_with_secrets(
source: Option<&Arc<McpToolSource>>,
pack_routes: Option<&PackMcpRoutes>,
secrets: Option<&crate::secrets::DynSecretsManager>,
tenant: &str,
env: &str,
team: Option<&str>,
server_id: &str,
tool: &str,
arguments: &Value,
) -> Value {
let args_str = arguments.to_string();
if let Some(route) = pack_routes.and_then(|routes| routes.get(server_id)) {
return match route_from_pack(route, secrets, tenant, team).await {
Ok(resolved) => {
let result = dispatch_route(&resolved.with_tool(tool), &args_str).await;
if let Some(error) = result.get("error") {
tracing::warn!(
tenant,
env,
server_id,
tool,
error = %error,
"mcp node dispatch failed (pack-carried route)"
);
}
result
}
Err(e) => {
tracing::warn!(
tenant,
env,
server_id,
tool,
error = %e,
"mcp node did not run: pack-carried route could not be resolved"
);
json!({ "error": e })
}
};
}
let Some(source) = source else {
tracing::warn!(
tenant,
env,
server_id,
tool,
"mcp node did not run: no route in the pack, and MCP is not configured \
on this runner (GREENTIC_AW_ADMIN_ENDPOINT + GREENTIC_AW_ADMIN_TOKEN)"
);
return json!({
"error": "MCP is not configured on this runner (no route in the pack, and no \
GREENTIC_AW_ADMIN_ENDPOINT + GREENTIC_AW_ADMIN_TOKEN)"
});
};
let tenant_ctx = TenantContext::new(tenant, env);
let catalog = source
.catalog_for_role(&tenant_ctx, MCP_ROLE_FLOW_EDITOR)
.await;
let Some(route) = catalog.route(server_id, tool) else {
tracing::warn!(
tenant,
env,
server_id,
tool,
"mcp node did not run: tool is absent from the tenant's flow_editor catalog"
);
return json!({
"error": format!(
"mcp tool '{server_id}/{tool}' not found in the tenant's flow_editor catalog"
)
});
};
let result = dispatch_route(route, &args_str).await;
if let Some(error) = result.get("error") {
tracing::warn!(
tenant,
env,
server_id,
tool,
error = %error,
"mcp node dispatch failed"
);
}
result
}
}
#[cfg(feature = "agentic-worker")]
pub(crate) use aw::{invoke_with_secrets, source_from_env};
use serde_json::Value;
pub(crate) fn str_field(payload: &Value, key: &str) -> Option<String> {
payload
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
pub(crate) fn server_tool_from_payload(payload: &Value) -> Option<(String, String)> {
let server = str_field(payload, "server")?;
let tool = str_field(payload, "tool")?;
Some((server, tool))
}
#[cfg(all(test, feature = "agentic-worker"))]
mod failure_logging_tests {
use serde_json::json;
use std::sync::{Arc, Mutex};
use tracing_subscriber::fmt::MakeWriter;
#[derive(Clone, Default)]
struct Captured(Arc<Mutex<Vec<u8>>>);
impl Captured {
fn text(&self) -> String {
String::from_utf8_lossy(&self.0.lock().expect("capture lock")).into_owned()
}
}
impl std::io::Write for Captured {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().expect("capture lock").extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> MakeWriter<'a> for Captured {
type Writer = Self;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
#[tokio::test]
async fn an_unconfigured_mcp_node_warns_rather_than_failing_silently() {
let captured = Captured::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(captured.clone())
.with_max_level(tracing::Level::WARN)
.finish();
let bound = tracing::subscriber::with_default(subscriber, || {
futures::executor::block_on(super::aw::invoke_with_secrets(
None,
None,
None,
"acme",
"prod",
None,
"srv-1",
"create_quote",
&json!({ "company": "Acme" }),
))
});
assert!(
bound.get("error").is_some(),
"the bound value must still carry the error, got {bound}"
);
let logged = captured.text();
assert!(
logged.contains("WARN"),
"an unconfigured MCP node must log at WARN; captured: {logged:?}"
);
assert!(
logged.contains("create_quote"),
"the warning must name the tool that did not run; captured: {logged:?}"
);
}
}
#[cfg(test)]
mod tests {
use super::{server_tool_from_payload, str_field};
use serde_json::json;
#[test]
fn reads_server_and_tool_from_payload() {
assert_eq!(
server_tool_from_payload(&json!({ "server": "github", "tool": "get_issue" })),
Some(("github".to_string(), "get_issue".to_string()))
);
}
#[test]
fn tolerates_dotted_tool_names_in_payload() {
assert_eq!(
server_tool_from_payload(&json!({ "server": "srv", "tool": "do.thing" })),
Some(("srv".to_string(), "do.thing".to_string()))
);
}
#[test]
fn missing_or_empty_fields_yield_none() {
assert_eq!(
server_tool_from_payload(&json!({ "server": "github" })),
None
);
assert_eq!(
server_tool_from_payload(&json!({ "server": "", "tool": "get_issue" })),
None
);
assert_eq!(
server_tool_from_payload(&json!({ "server": "github", "tool": " " })),
None
);
assert_eq!(server_tool_from_payload(&json!("not an object")), None);
}
#[test]
fn str_field_trims_and_rejects_empty() {
assert_eq!(
str_field(&json!({ "k": " v " }), "k"),
Some("v".to_string())
);
assert_eq!(str_field(&json!({ "k": "" }), "k"), None);
assert_eq!(str_field(&json!({ "k": 7 }), "k"), None);
assert_eq!(str_field(&json!({}), "missing"), None);
}
}