#![cfg(feature = "agentic-worker")]
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::collections::BTreeMap;
use std::fs::File;
use std::io::Write;
use std::path::Path;
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use greentic_runner_host::config::{
FlowRetryConfig, HostConfig, OperatorPolicy, RateLimits, SecretsPolicy, StateStorePolicy,
WebhookPolicy,
};
use greentic_runner_host::pack::{ComponentResolution, PackRuntime};
use greentic_runner_host::runner::engine::{FlowContext, FlowEngine, FlowStatus};
use greentic_runner_host::runner::flow_adapter::{NodeIR, flow_doc_to_ir};
use greentic_runner_host::trace::TraceConfig;
use greentic_runner_host::validate::ValidationConfig;
use greentic_types::{
ExtensionInline, ExtensionRef, PackFlowEntry, PackKind, PackManifest, encode_pack_manifest,
};
use once_cell::sync::Lazy;
use semver::Version;
use serde_json::{Value, json};
use tempfile::TempDir;
use wiremock::matchers::{body_partial_json, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use zip::write::FileOptions;
const RUNTIME_FLOW_EXTENSION_ID: &str = "greentic.pack.runtime_flow";
const PACK_ID: &str = "mcp.flow.node.test";
const FLOW_ID: &str = "mcp.flow";
const SESSION_HINT: &str = "demo:provider:chan:conv:user";
static RUNTIME: Lazy<&'static tokio::runtime::Runtime> = Lazy::new(|| {
Box::leak(Box::new(
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime"),
))
});
static ENV_GUARD: Mutex<()> = Mutex::new(());
fn initialize_ok() -> ResponseTemplate {
ResponseTemplate::new(200)
.insert_header("Mcp-Session-Id", "sess-1")
.set_body_json(json!({
"jsonrpc": "2.0", "id": 1,
"result": {
"protocolVersion": "2025-06-18",
"serverInfo": { "name": "fake", "version": "1.0.0" }
}
}))
}
async fn fake_mcp_server(call_result_json: Value) -> MockServer {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(body_partial_json(json!({ "method": "initialize" })))
.respond_with(initialize_ok())
.mount(&server)
.await;
Mock::given(method("POST"))
.and(body_partial_json(
json!({ "method": "notifications/initialized" }),
))
.respond_with(ResponseTemplate::new(202))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(body_partial_json(json!({ "method": "tools/list" })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0", "id": 2,
"result": { "tools": [
{ "name": "get_issue", "description": "Get an issue",
"inputSchema": { "type": "object",
"properties": { "id": { "type": "string" } } } }
] }
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(body_partial_json(json!({ "method": "tools/call" })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0", "id": 3,
"result": call_result_json
})))
.mount(&server)
.await;
server
}
async fn fake_admin(mcp_url: &str) -> MockServer {
let admin = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/designer/tenant/me/mcp-servers"))
.and(header("authorization", "Bearer gtc_live_test"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"servers": [
{
"id": "github", "name": "GitHub", "transport_url": mcp_url,
"auth_header_name": null, "auth_token": null,
"allowed_tools": null, "roles": ["flow_editor"]
}
]
})))
.mount(&admin)
.await;
admin
}
fn host_config(bindings_path: &Path) -> HostConfig {
HostConfig {
tenant: "demo".into(),
bindings_path: bindings_path.to_path_buf(),
flow_type_bindings: Default::default(),
rate_limits: RateLimits::default(),
retry: FlowRetryConfig::default(),
http_enabled: false,
secrets_policy: SecretsPolicy::allow_all(),
state_store_policy: StateStorePolicy::default(),
webhook_policy: WebhookPolicy::default(),
timers: Vec::new(),
oauth: None,
mocks: None,
pack_bindings: Vec::new(),
env_passthrough: Vec::new(),
trace: TraceConfig::from_env(),
validation: ValidationConfig::from_env(),
operator_policy: OperatorPolicy::allow_all(),
agents: std::collections::HashMap::new(),
graphs: std::collections::HashMap::new(),
}
}
fn build_mcp_pack(pack_path: &Path, node_input: Value) -> Result<()> {
let mut nodes = serde_json::Map::new();
nodes.insert(
"call".to_string(),
json!({
"component": "mcp",
"input": node_input,
"routing": "end",
}),
);
let runtime_flow = json!({
"id": FLOW_ID,
"flow_type": "messaging",
"start": "call",
"nodes": Value::Object(nodes),
});
let runtime_extension = json!({ "flows": [runtime_flow] });
let mut extensions = BTreeMap::new();
extensions.insert(
RUNTIME_FLOW_EXTENSION_ID.to_string(),
ExtensionRef {
kind: RUNTIME_FLOW_EXTENSION_ID.to_string(),
version: "2.0.0".into(),
digest: None,
location: None,
inline: Some(ExtensionInline::Other(runtime_extension)),
},
);
let manifest = PackManifest {
schema_version: "1.0".into(),
pack_id: PACK_ID.parse()?,
name: None,
version: Version::parse("0.0.0")?,
kind: PackKind::Application,
publisher: "test".into(),
components: Vec::new(),
flows: Vec::<PackFlowEntry>::new(),
dependencies: Vec::new(),
capabilities: Vec::new(),
signatures: Default::default(),
secret_requirements: Vec::new(),
bootstrap: None,
agents: BTreeMap::new(),
extensions: Some(extensions),
};
let mut zip = zip::ZipWriter::new(File::create(pack_path).context("create pack archive")?);
let options: FileOptions<'_, ()> =
FileOptions::default().compression_method(zip::CompressionMethod::Stored);
let manifest_bytes = encode_pack_manifest(&manifest)?;
zip.start_file("manifest.cbor", options)?;
zip.write_all(&manifest_bytes)?;
zip.finish().context("finalise pack archive")?;
Ok(())
}
fn build_engine(
pack_path: &Path,
config: Arc<HostConfig>,
) -> Result<(Arc<PackRuntime>, FlowEngine)> {
let rt = *RUNTIME;
let pack = Arc::new(rt.block_on(PackRuntime::load(
pack_path,
Arc::clone(&config),
None,
None,
None,
None,
Arc::new(greentic_runner_host::wasi::RunnerWasiPolicy::new()),
greentic_runner_host::secrets::default_manager()?,
None,
false,
ComponentResolution::default(),
))?);
let engine = rt.block_on(FlowEngine::new(
vec![Arc::clone(&pack)],
Arc::clone(&config),
))?;
Ok((pack, engine))
}
fn flow_ctx<'a>(config: &'a HostConfig, pack_id: &'a str) -> FlowContext<'a> {
FlowContext {
tenant: config.tenant.as_str(),
pack_id,
flow_id: FLOW_ID,
node_id: None,
tool: None,
action: Some("messaging"),
session_id: Some(SESSION_HINT),
provider_id: Some("provider"),
reply_scope: None,
retry_config: config.retry.clone().into(),
attempt: 1,
observer: None,
mocks: None,
}
}
fn find_key<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
match value {
Value::Object(map) => {
if let Some(found) = map.get(key) {
return Some(found);
}
map.values().find_map(|v| find_key(v, key))
}
Value::Array(items) => items.iter().find_map(|v| find_key(v, key)),
_ => None,
}
}
fn lower_ygtc_mcp_node(node_payload: Value, routing: Value) -> Result<NodeIR> {
let doc_json = json!({
"id": FLOW_ID,
"type": "messaging",
"start": "lookup",
"nodes": {
"lookup": {
"mcp": node_payload,
"routing": routing,
}
}
});
let doc: greentic_flow::model::FlowDoc =
serde_json::from_value(doc_json).context("deserialise YGTC FlowDoc")?;
let ir = flow_doc_to_ir(doc).context("flow_doc_to_ir lowering")?;
let node = ir
.nodes
.get("lookup")
.cloned()
.context("`lookup` node missing from lowered FlowIR")?;
Ok(node)
}
fn build_ygtc_mcp_pack(pack_path: &Path, node_ir: &NodeIR) -> Result<()> {
let routing = if node_ir.routes.iter().all(|r| r.out || r.to.is_none()) {
json!("end")
} else {
serde_json::to_value(&node_ir.routes)?
};
let mut nodes = serde_json::Map::new();
nodes.insert(
"lookup".to_string(),
json!({
"component": node_ir.component,
"input": node_ir.payload_expr,
"routing": routing,
}),
);
let runtime_flow = json!({
"id": FLOW_ID,
"flow_type": "messaging",
"start": "lookup",
"nodes": Value::Object(nodes),
});
let runtime_extension = json!({ "flows": [runtime_flow] });
let mut extensions = BTreeMap::new();
extensions.insert(
RUNTIME_FLOW_EXTENSION_ID.to_string(),
ExtensionRef {
kind: RUNTIME_FLOW_EXTENSION_ID.to_string(),
version: "2.0.0".into(),
digest: None,
location: None,
inline: Some(ExtensionInline::Other(runtime_extension)),
},
);
let manifest = PackManifest {
schema_version: "1.0".into(),
pack_id: PACK_ID.parse()?,
name: None,
version: Version::parse("0.0.0")?,
kind: PackKind::Application,
publisher: "test".into(),
components: Vec::new(),
flows: Vec::<PackFlowEntry>::new(),
dependencies: Vec::new(),
capabilities: Vec::new(),
signatures: Default::default(),
secret_requirements: Vec::new(),
bootstrap: None,
agents: BTreeMap::new(),
extensions: Some(extensions),
};
let mut zip = zip::ZipWriter::new(File::create(pack_path).context("create pack archive")?);
let options: FileOptions<'_, ()> =
FileOptions::default().compression_method(zip::CompressionMethod::Stored);
let manifest_bytes = encode_pack_manifest(&manifest)?;
zip.start_file("manifest.cbor", options)?;
zip.write_all(&manifest_bytes)?;
zip.finish().context("finalise pack archive")?;
Ok(())
}
#[test]
fn flow_doc_to_ir_preserves_mcp_op_key_verbatim() -> Result<()> {
let node = lower_ygtc_mcp_node(
json!({
"server": "github",
"tool": "get_issue",
"arguments": { "id": "{{ entry.issue_id }}" },
"output": "issue"
}),
json!([{ "out": true }]),
)?;
assert_eq!(
node.component, "mcp",
"expected the `mcp` op-key to pass through `flow_doc_to_ir` verbatim, got `{}`",
node.component
);
assert!(
greentic_types::ComponentId::from_str(&node.component).is_ok(),
"derived component `{}` must be a valid ComponentId",
node.component
);
assert_eq!(node.payload_expr["server"], json!("github"));
assert_eq!(node.payload_expr["tool"], json!("get_issue"));
assert_eq!(
node.payload_expr["arguments"]["id"],
json!("{{ entry.issue_id }}")
);
assert_eq!(node.payload_expr["output"], json!("issue"));
Ok(())
}
#[test]
fn mcp_node_from_ygtc_op_key_calls_tool_and_binds_output() -> Result<()> {
let _guard = ENV_GUARD.lock().unwrap();
let rt = *RUNTIME;
let temp = TempDir::new()?;
let pack_path = temp.path().join("mcp-ygtc-ok.gtpack");
let bindings_path = temp.path().join("bindings.yaml");
std::fs::write(&bindings_path, b"tenant: demo")?;
let mcp = rt.block_on(fake_mcp_server(
json!({ "structuredContent": { "title": "Bug" } }),
));
let admin = rt.block_on(fake_admin(&mcp.uri()));
let node = lower_ygtc_mcp_node(
json!({
"server": "github",
"tool": "get_issue",
"arguments": { "id": "{{ entry.issue_id }}" },
"output": "issue"
}),
json!([{ "out": true }]),
)?;
assert_eq!(
node.component, "mcp",
"YGTC op-key must lower to component `mcp` for the engine to dispatch it"
);
build_ygtc_mcp_pack(&pack_path, &node)?;
let config = Arc::new(host_config(&bindings_path));
unsafe {
std::env::set_var("GREENTIC_AW_ADMIN_ENDPOINT", admin.uri());
std::env::set_var("GREENTIC_AW_ADMIN_TOKEN", "gtc_live_test");
std::env::remove_var("GREENTIC_AW_MCP");
}
let (pack, engine) = build_engine(&pack_path, Arc::clone(&config))?;
unsafe {
std::env::remove_var("GREENTIC_AW_ADMIN_ENDPOINT");
std::env::remove_var("GREENTIC_AW_ADMIN_TOKEN");
}
let ctx = flow_ctx(&config, pack.metadata().pack_id.as_str());
let execution = rt
.block_on(engine.execute(ctx, json!({ "issue_id": "42" })))
.context("mcp ygtc flow run")?;
match execution.status {
FlowStatus::Completed => {}
FlowStatus::Waiting(wait) => anyhow::bail!("flow paused unexpectedly: {:?}", wait.reason),
}
let issue = find_key(&execution.output, "issue")
.with_context(|| format!("output key `issue` missing, got {:?}", execution.output))?;
assert_eq!(
issue,
&json!({ "title": "Bug" }),
"bound output mismatch, got {:?}",
execution.output
);
assert!(
find_key(&execution.output, "error").is_none(),
"unexpected error in output: {:?}",
execution.output
);
Ok(())
}
#[test]
fn mcp_node_from_ygtc_op_key_degrades_gracefully_when_unconfigured() -> Result<()> {
let _guard = ENV_GUARD.lock().unwrap();
let rt = *RUNTIME;
let temp = TempDir::new()?;
let pack_path = temp.path().join("mcp-ygtc-degraded.gtpack");
let bindings_path = temp.path().join("bindings.yaml");
std::fs::write(&bindings_path, b"tenant: demo")?;
let node = lower_ygtc_mcp_node(
json!({
"server": "github",
"tool": "get_issue",
"arguments": { "id": "1" },
"output": "issue"
}),
json!([{ "out": true }]),
)?;
assert_eq!(node.component, "mcp");
build_ygtc_mcp_pack(&pack_path, &node)?;
let config = Arc::new(host_config(&bindings_path));
unsafe {
std::env::remove_var("GREENTIC_AW_ADMIN_ENDPOINT");
std::env::remove_var("GREENTIC_AW_ADMIN_TOKEN");
std::env::remove_var("GREENTIC_AW_MCP");
}
let (pack, engine) = build_engine(&pack_path, Arc::clone(&config))?;
let ctx = flow_ctx(&config, pack.metadata().pack_id.as_str());
let execution = rt
.block_on(engine.execute(ctx, json!({})))
.context("mcp ygtc degraded flow run")?;
match execution.status {
FlowStatus::Completed => {}
FlowStatus::Waiting(wait) => anyhow::bail!("flow paused unexpectedly: {:?}", wait.reason),
}
let bound = find_key(&execution.output, "issue")
.with_context(|| format!("output key `issue` missing, got {:?}", execution.output))?;
let err = bound
.get("error")
.and_then(Value::as_str)
.with_context(|| format!("expected an `error` string under `issue`, got {bound:?}"))?;
assert!(
err.contains("MCP is not configured"),
"unexpected error message: {err}"
);
Ok(())
}
#[test]
fn mcp_node_calls_tool_and_binds_output() -> Result<()> {
let _guard = ENV_GUARD.lock().unwrap();
let rt = *RUNTIME;
let temp = TempDir::new()?;
let pack_path = temp.path().join("mcp-ok.gtpack");
let bindings_path = temp.path().join("bindings.yaml");
std::fs::write(&bindings_path, b"tenant: demo")?;
let mcp = rt.block_on(fake_mcp_server(
json!({ "structuredContent": { "title": "Bug" } }),
));
let admin = rt.block_on(fake_admin(&mcp.uri()));
assert!(
greentic_types::ComponentId::from_str("mcp").is_ok(),
"component ref `mcp` must be a valid ComponentId"
);
build_mcp_pack(
&pack_path,
json!({
"server": "github",
"tool": "get_issue",
"arguments": { "id": "{{ entry.issue_id }}" },
"output": "issue"
}),
)?;
let config = Arc::new(host_config(&bindings_path));
unsafe {
std::env::set_var("GREENTIC_AW_ADMIN_ENDPOINT", admin.uri());
std::env::set_var("GREENTIC_AW_ADMIN_TOKEN", "gtc_live_test");
std::env::remove_var("GREENTIC_AW_MCP");
}
let (pack, engine) = build_engine(&pack_path, Arc::clone(&config))?;
unsafe {
std::env::remove_var("GREENTIC_AW_ADMIN_ENDPOINT");
std::env::remove_var("GREENTIC_AW_ADMIN_TOKEN");
}
let ctx = flow_ctx(&config, pack.metadata().pack_id.as_str());
let execution = rt
.block_on(engine.execute(ctx, json!({ "issue_id": "42" })))
.context("mcp flow run")?;
match execution.status {
FlowStatus::Completed => {}
FlowStatus::Waiting(wait) => anyhow::bail!("flow paused unexpectedly: {:?}", wait.reason),
}
let issue = find_key(&execution.output, "issue")
.with_context(|| format!("output key `issue` missing, got {:?}", execution.output))?;
assert_eq!(
issue,
&json!({ "title": "Bug" }),
"bound output mismatch, got {:?}",
execution.output
);
assert!(
find_key(&execution.output, "error").is_none(),
"unexpected error in output: {:?}",
execution.output
);
Ok(())
}
#[test]
fn mcp_node_degrades_gracefully_when_unconfigured() -> Result<()> {
let _guard = ENV_GUARD.lock().unwrap();
let rt = *RUNTIME;
let temp = TempDir::new()?;
let pack_path = temp.path().join("mcp-degraded.gtpack");
let bindings_path = temp.path().join("bindings.yaml");
std::fs::write(&bindings_path, b"tenant: demo")?;
build_mcp_pack(
&pack_path,
json!({
"server": "github",
"tool": "get_issue",
"arguments": { "id": "1" },
"output": "issue"
}),
)?;
let config = Arc::new(host_config(&bindings_path));
unsafe {
std::env::remove_var("GREENTIC_AW_ADMIN_ENDPOINT");
std::env::remove_var("GREENTIC_AW_ADMIN_TOKEN");
std::env::remove_var("GREENTIC_AW_MCP");
}
let (pack, engine) = build_engine(&pack_path, Arc::clone(&config))?;
let ctx = flow_ctx(&config, pack.metadata().pack_id.as_str());
let execution = rt
.block_on(engine.execute(ctx, json!({})))
.context("mcp degraded flow run")?;
match execution.status {
FlowStatus::Completed => {}
FlowStatus::Waiting(wait) => anyhow::bail!("flow paused unexpectedly: {:?}", wait.reason),
}
let bound = find_key(&execution.output, "issue")
.with_context(|| format!("output key `issue` missing, got {:?}", execution.output))?;
let err = bound
.get("error")
.and_then(Value::as_str)
.with_context(|| format!("expected an `error` string under `issue`, got {bound:?}"))?;
assert!(
err.contains("MCP is not configured"),
"unexpected error message: {err}"
);
Ok(())
}
#[allow(unsafe_code)]
#[test]
fn mcp_node_local_wasm_calls_echo_tool_in_process() -> Result<()> {
let _guard = ENV_GUARD.lock().unwrap();
let fixture_path = std::env::var("GREENTIC_MCP_ROUTER_ECHO_WASM")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../../greentic-mcp/target/wasm32-wasip2/release/router_echo.wasm")
});
if !fixture_path.exists() {
eprintln!(
"SKIP: router_echo.wasm not found; set GREENTIC_MCP_ROUTER_ECHO_WASM to run live"
);
return Ok(());
}
let rt = *RUNTIME;
let temp = TempDir::new()?;
let pack_path = temp.path().join("mcp-local-wasm.gtpack");
let bindings_path = temp.path().join("bindings.yaml");
let cache_dir = temp.path().join("mcp-local-cache");
std::fs::create_dir_all(&cache_dir)?;
std::fs::write(&bindings_path, b"tenant: demo")?;
std::fs::copy(&fixture_path, cache_dir.join("router_echo.wasm"))?;
let admin_server = rt.block_on(MockServer::start());
rt.block_on(
Mock::given(method("GET"))
.and(path("/api/v1/designer/tenant/me/mcp-servers"))
.and(header("authorization", "Bearer gtc_live_test"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"servers": [
{
"id": "local_echo",
"name": "LocalEcho",
"transport_url": "",
"auth_header_name": null,
"auth_token": null,
"allowed_tools": null,
"roles": ["flow_editor"],
"transport": "local-wasm",
"component_ref": "router_echo",
"component_version": "1.0.0"
}
]
})))
.mount(&admin_server),
);
build_mcp_pack(
&pack_path,
json!({
"server": "local_echo",
"tool": "echo",
"arguments": { "message": "hi" },
"output": "echo_result"
}),
)?;
let config = Arc::new(host_config(&bindings_path));
unsafe {
std::env::set_var("GREENTIC_AW_ADMIN_ENDPOINT", admin_server.uri());
std::env::set_var("GREENTIC_AW_ADMIN_TOKEN", "gtc_live_test");
std::env::remove_var("GREENTIC_AW_MCP");
std::env::set_var("GREENTIC_MCP_LOCAL_CACHE_DIR", &cache_dir);
}
let (pack, engine) = build_engine(&pack_path, Arc::clone(&config))?;
unsafe {
std::env::remove_var("GREENTIC_AW_ADMIN_ENDPOINT");
std::env::remove_var("GREENTIC_AW_ADMIN_TOKEN");
}
let ctx = flow_ctx(&config, pack.metadata().pack_id.as_str());
let execution = rt
.block_on(engine.execute(ctx, json!({})))
.context("mcp local-wasm flow run")?;
unsafe {
std::env::remove_var("GREENTIC_MCP_LOCAL_CACHE_DIR");
}
match execution.status {
FlowStatus::Completed => {}
FlowStatus::Waiting(wait) => anyhow::bail!("flow paused unexpectedly: {:?}", wait.reason),
}
let echo_result = find_key(&execution.output, "echo_result").with_context(|| {
format!(
"output key `echo_result` missing, got {:?}",
execution.output
)
})?;
assert!(
!echo_result.to_string().contains("\"error\""),
"echo result must not contain an error; got: {echo_result}"
);
Ok(())
}