#![expect(
clippy::expect_used,
reason = "test setup panics on failure, which is the desired behavior"
)]
use std::fs;
use std::sync::Arc;
use std::time::Duration;
use promptforge_mcp_server::{
Catalog, CatalogHandle, Config, OnBroken, PreparedTools, PromptForgeServer,
};
use rmcp::model::{CallToolRequestParams, CallToolResponse, ProgressNotificationParam};
use rmcp::service::NotificationContext;
use rmcp::{ClientHandler, RoleClient, ServiceExt};
use tempfile::TempDir;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
struct RecordingClient {
progress: UnboundedSender<ProgressNotificationParam>,
}
impl ClientHandler for RecordingClient {
async fn on_progress(
&self,
params: ProgressNotificationParam,
_context: NotificationContext<RoleClient>,
) {
self.progress
.send(params)
.expect("the test is still receiving progress frames");
}
}
const TRIO: &str = "---\nname: trio\ndescription: Three sections\npromptforge: 1\n---\n\n\
# Trio\n\n\
## First\n\n```lua\nvar.step = 1\n```\n\n\
## Second\n\n```lua\nvar.step = 2\n```\n\n\
## Third\n\n```lua\nreturn 'trio done'\n```\n";
async fn trio_server() -> (TempDir, PromptForgeServer) {
let dir = tempfile::tempdir().expect("create a temporary prompts directory");
fs::write(dir.path().join("trio.md"), TRIO).expect("write the fixture prompt");
let config = Config::from_toml_str(&format!(
"[server]\ntoken = \"t\"\n\n\
[gateway]\nurl = \"http://127.0.0.1:8081/v1\"\nkey = \"gw\"\n\n\
[paths]\nprompts = '{}'\n\n\
[catalog]\ninclude = [\"*.md\"]\n",
dir.path().display()
))
.expect("the fixture configuration parses");
let catalog =
Catalog::resolve(&config, OnBroken::Reject).expect("the fixture catalog resolves");
let tools = Arc::new(
PreparedTools::load(&config)
.await
.expect("prepare fixture live tools"),
);
let server = PromptForgeServer::new(
Arc::new(config),
Arc::new(CatalogHandle::new(catalog)),
tools,
);
(dir, server)
}
#[tokio::test]
async fn a_run_frames_its_start_and_then_each_section() {
let (_dir, server) = trio_server().await;
let (server_io, client_io) = tokio::io::duplex(4096);
let server_task = tokio::spawn(async move {
let running = server
.serve(server_io)
.await
.expect("the server starts its session");
running.waiting().await.expect("the server session ends")
});
let (sender, mut progress) = unbounded_channel();
let client = RecordingClient { progress: sender }
.serve(client_io)
.await
.expect("the in-process client initializes");
let response = client
.call_tool_once(
CallToolRequestParams::new("run_prompt").with_arguments(
serde_json::json!({ "prompt": "trio" })
.as_object()
.expect("the arguments are an object")
.clone(),
),
)
.await
.expect("the call reaches the prompt");
let CallToolResponse::Complete(result) = response else {
panic!("this server answers a call with its result")
};
assert_eq!(result.is_error, Some(false));
assert_eq!(
result.content[0].as_text().expect("a text block").text,
"trio done"
);
let frames = collect(&mut progress, 4).await;
let token = frames[0].progress_token.clone();
for frame in &frames {
assert_eq!(frame.progress_token, token, "one run, one token");
assert_eq!(
frame.total, None,
"how many sections a run will visit is not known when it starts"
);
}
let seen: Vec<(f64, Option<&str>)> = frames
.iter()
.map(|frame| (frame.progress, frame.message.as_deref()))
.collect();
assert_eq!(
seen,
vec![
(0.0, Some("Trio")),
(1.0, Some("First")),
(2.0, Some("Second")),
(3.0, Some("Third")),
]
);
client
.cancel()
.await
.expect("the client disconnects cleanly");
server_task.await.expect("the server task joins");
assert!(
progress.recv().await.is_none(),
"the run frames its start and its sections, and nothing else"
);
}
async fn collect(
progress: &mut UnboundedReceiver<ProgressNotificationParam>,
count: usize,
) -> Vec<ProgressNotificationParam> {
let mut frames = Vec::with_capacity(count);
for _ in 0..count {
let frame = tokio::time::timeout(Duration::from_secs(5), progress.recv())
.await
.expect("a progress notification should arrive")
.expect("the session outlives the wait");
frames.push(frame);
}
frames
}