#![cfg(feature = "test-mock")]
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use greentic_aw_runtime::cost::MockTokenMeter;
use greentic_aw_runtime::error::{LlmError, TerminationReason};
use greentic_aw_runtime::llm::{LlmBackend, LlmRequest, LlmResponse};
use greentic_aw_runtime::mock::{
MockAgentStateStore, MockConfigProvider, MockTelemetry, NoopToolLedger,
};
use greentic_aw_runtime::state::ToolCallRecord;
use greentic_aw_runtime::tenant::TenantContext;
use greentic_aw_runtime::{
AgentConfig, AgentInput, AgentLimits, AgentRuntime, AgentStep, LlmProviderRef, McpToolSource,
ToolRef,
};
use serde_json::json;
use wiremock::matchers::{body_partial_json, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
struct RecordingLlmBackend {
responses: Mutex<Vec<Result<LlmResponse, LlmError>>>,
offered: Mutex<Vec<Vec<(String, String)>>>,
}
impl RecordingLlmBackend {
fn new(responses: Vec<Result<LlmResponse, LlmError>>) -> Self {
Self {
responses: Mutex::new(responses),
offered: Mutex::new(Vec::new()),
}
}
}
impl LlmBackend for RecordingLlmBackend {
fn complete<'a>(
&'a self,
req: LlmRequest,
) -> Pin<Box<dyn Future<Output = Result<LlmResponse, LlmError>> + Send + 'a>> {
self.offered.lock().expect("offered mutex").push(
req.tools
.iter()
.map(|t| (t.extension_id.clone(), t.tool_name.clone()))
.collect(),
);
let next = {
let mut queue = self.responses.lock().expect("responses mutex");
if queue.is_empty() {
Err(LlmError::Transport("recording queue exhausted".into()))
} else {
queue.remove(0)
}
};
Box::pin(async move { next })
}
}
fn cfg(tools: Vec<ToolRef>) -> AgentConfig {
AgentConfig {
agent_id: "a".into(),
system_prompt: "sys".into(),
tools,
guardrails: vec![],
llm: LlmProviderRef {
provider: "mock".into(),
model: "m".into(),
credential_ref: None,
},
limits: AgentLimits {
max_iter: 4,
timeout: Duration::from_secs(60),
..AgentLimits::default()
},
memory: None,
knowledge: None,
}
}
fn call_get_issue() -> LlmResponse {
LlmResponse {
content: None,
tool_calls: vec![ToolCallRecord {
call_id: "c1".into(),
extension_id: "mcp:s1".into(),
tool_name: "get_issue".into(),
args: json!({ "id": "42" }),
}],
tokens_in: 5,
tokens_out: 5,
}
}
fn final_reply(text: &str) -> LlmResponse {
LlmResponse {
content: Some(text.into()),
tool_calls: vec![],
tokens_in: 5,
tokens_out: 5,
}
}
async fn fake_mcp_server() -> MockServer {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(body_partial_json(json!({ "method": "initialize" })))
.respond_with(
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" }
}
})),
)
.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": { "structuredContent": { "ok": 1 } }
})))
.mount(&server)
.await;
server
}
async fn mount_admin(server: &MockServer, transport_url: &str) {
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": "s1",
"name": "Worker",
"transport_url": transport_url,
"auth_header_name": null,
"auth_token": null,
"allowed_tools": null,
"roles": ["agentic_worker"]
}
]
})))
.mount(server)
.await;
}
fn build_runtime(
llm: Arc<RecordingLlmBackend>,
mcp: Option<Arc<McpToolSource>>,
config: AgentConfig,
) -> (AgentRuntime, TenantContext) {
let store = Arc::new(MockAgentStateStore::new());
let telemetry = Arc::new(MockTelemetry::new());
let cp = MockConfigProvider::new();
let tc = TenantContext::new("acme", "prod");
cp.insert(&tc, "a", config);
let cp = Arc::new(cp);
let token_meter = Arc::new(MockTokenMeter::new(0));
let ledger = Arc::new(NoopToolLedger);
let ext = Arc::new(greentic_ext_runtime::ExtensionRuntime::for_test());
let rt = AgentRuntime::new(cp, store, ext, llm, telemetry, token_meter, ledger, mcp);
(rt, tc)
}
#[tokio::test]
async fn mcp_tool_offered_called_and_result_in_trail() {
let mcp_server = fake_mcp_server().await;
let admin = MockServer::start().await;
mount_admin(&admin, &mcp_server.uri()).await;
let llm = Arc::new(RecordingLlmBackend::new(vec![
Ok(call_get_issue()),
Ok(final_reply("done")),
]));
let source = Arc::new(McpToolSource::new(admin.uri(), "gtc_live_test"));
let allowed = vec![ToolRef {
extension_id: "mcp:s1".into(),
tool_name: "get_issue".into(),
}];
let (rt, tc) = build_runtime(llm.clone(), Some(source), cfg(allowed));
let out = rt
.step(tc, "s", "a", AgentInput { text: "go".into() })
.await
.unwrap();
let offered = llm.offered.lock().unwrap();
assert!(
offered[0].contains(&("mcp:s1".to_string(), "get_issue".to_string())),
"first turn must offer the mcp tool; got: {:?}",
offered[0]
);
let tool_result = out.trail.iter().find_map(|s| match s {
AgentStep::ToolCall { name, result, .. } if name == "get_issue" => Some(result.clone()),
_ => None,
});
assert_eq!(
tool_result,
Some(json!({ "ok": 1 })),
"mcp tool result must appear in the trail; trail: {:?}",
out.trail
);
assert_eq!(out.terminated_by, TerminationReason::FinalReply);
assert_eq!(out.reply, "done");
}
#[tokio::test]
async fn mcp_unreachable_server_degrades_no_tool_offered() {
let admin = MockServer::start().await;
mount_admin(&admin, "http://127.0.0.1:1/").await;
let llm = Arc::new(RecordingLlmBackend::new(vec![Ok(final_reply("no tools"))]));
let source = Arc::new(McpToolSource::new(admin.uri(), "gtc_live_test"));
let allowed = vec![ToolRef {
extension_id: "mcp:s1".into(),
tool_name: "get_issue".into(),
}];
let (rt, tc) = build_runtime(llm.clone(), Some(source), cfg(allowed));
let out = rt
.step(tc, "s", "a", AgentInput { text: "go".into() })
.await
.unwrap();
let offered = llm.offered.lock().unwrap();
assert!(
offered[0].is_empty(),
"unreachable mcp server → no tools offered; got: {:?}",
offered[0]
);
assert_eq!(out.terminated_by, TerminationReason::FinalReply);
assert_eq!(out.reply, "no tools");
}