#![expect(
clippy::expect_used,
reason = "test setup panics on failure, which is the desired behavior"
)]
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use serde_json::{Value, json};
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
const ECHO: &str = "---\nname: echo\ndescription: Returns its argument\npromptforge: 1\n---\n\n\
# Test prompt\n\n## Main\n\n```lua\nreturn args\n```\n";
const PATIENCE: Duration = Duration::from_secs(20);
const LINE_LIMIT: u64 = 1 << 20;
fn fixture(root: &Path, prompts: &[(&str, &str)]) -> PathBuf {
fixture_with(
root,
prompts,
"bind = \"127.0.0.1:0\"\ntoken = \"unused-on-stdio\"\n",
)
}
fn fixture_with(root: &Path, prompts: &[(&str, &str)], server_lines: &str) -> PathBuf {
let directory = root.join("prompts");
fs::create_dir(&directory).expect("create the prompts directory");
for (file, contents) in prompts {
fs::write(directory.join(file), contents).expect("write the fixture prompt");
}
let config = root.join("prompts.toml");
fs::write(
&config,
format!(
"[server]\n{server_lines}\n\
[gateway]\nurl = \"http://127.0.0.1:8081/v1\"\nkey = \"gw\"\n\n\
[paths]\nprompts = '{}'\n\n\
[catalog]\ninclude = [\"*.md\"]\n",
directory.display()
),
)
.expect("write the fixture configuration");
config
}
struct Session {
child: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
_dir: tempfile::TempDir,
}
impl Session {
fn spawn() -> Session {
let dir = tempfile::tempdir().expect("create a temporary directory");
let config = fixture(dir.path(), &[("echo.md", ECHO)]);
Session::spawn_at(dir, &config)
}
fn spawn_at(dir: tempfile::TempDir, config: &Path) -> Session {
let mut child = Command::new(env!("CARGO_BIN_EXE_promptforge-mcp-server"))
.arg("serve")
.arg("--stdio")
.arg(config)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
.expect("spawn the server");
let stdin = child.stdin.take().expect("the child's standard input");
let stdout = child.stdout.take().expect("the child's standard output");
Session {
child,
stdin,
stdout: BufReader::new(stdout),
_dir: dir,
}
}
async fn send(&mut self, message: &Value) {
let mut line = serde_json::to_string(message).expect("serialize the request");
line.push('\n');
self.stdin
.write_all(line.as_bytes())
.await
.expect("write to the child");
self.stdin.flush().await.expect("flush the child's input");
}
async fn receive(&mut self) -> Value {
let mut line = String::new();
let mut limited = (&mut self.stdout).take(LINE_LIMIT);
let read = tokio::time::timeout(PATIENCE, limited.read_line(&mut line))
.await
.expect("the server answers within the wait")
.expect("read from the child");
assert!(read > 0, "the server closed its output");
assert!(
line.ends_with('\n'),
"the server's line stayed within {LINE_LIMIT} bytes"
);
serde_json::from_str(&line).expect("the server writes one JSON message per line")
}
}
#[tokio::test]
async fn stdio_completes_initialize_and_lists_its_tools() {
let mut session = Session::spawn();
session
.send(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "stdio-smoke", "version": "0" },
},
}))
.await;
let initialized = session.receive().await;
assert_eq!(initialized["id"], json!(1));
assert_eq!(
initialized["result"]["serverInfo"]["name"],
json!("promptforge-mcp-server"),
"no token was presented and the handshake completed anyway"
);
session
.send(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }))
.await;
session
.send(&json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }))
.await;
let listed = session.receive().await;
assert_eq!(listed["id"], json!(2));
let names: Vec<&str> = listed["result"]["tools"]
.as_array()
.expect("tools/list answers with an array")
.iter()
.filter_map(|tool| tool["name"].as_str())
.collect();
assert!(
names.contains(&"run_prompt"),
"the runner is how the catalog is reached: {names:?}"
);
assert!(
!names.contains(&"echo"),
"a prompt is never published as a tool of its own: {names:?}"
);
session.child.kill().await.expect("stop the server");
}
#[tokio::test]
async fn stdio_leaves_the_configured_bind_address_unlistened() {
use std::net::{SocketAddr, TcpListener};
use tokio::net::TcpStream;
let reserved = TcpListener::bind("127.0.0.1:0").expect("reserve a loopback port");
let addr: SocketAddr = reserved.local_addr().expect("read the reserved address");
drop(reserved);
let dir = tempfile::tempdir().expect("create a temporary directory");
let config = fixture_with(
dir.path(),
&[("echo.md", ECHO)],
&format!("bind = \"{addr}\"\ntoken = \"unused-on-stdio\"\n"),
);
let mut session = Session::spawn_at(dir, &config);
session
.send(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "stdio-bind-check", "version": "0" },
},
}))
.await;
let initialized = session.receive().await;
assert_eq!(initialized["id"], json!(1));
match tokio::time::timeout(PATIENCE, TcpStream::connect(addr)).await {
Ok(Ok(_stream)) => {
panic!("stdio bound {addr}: its HTTP surface is listening")
}
Ok(Err(_refused)) => {}
Err(elapsed) => {
panic!(
"connecting to {addr} neither refused nor completed: {elapsed} within {PATIENCE:?}"
)
}
}
session.child.kill().await.expect("stop the server");
}
#[tokio::test]
async fn stdio_serves_a_configuration_that_carries_no_token() {
let dir = tempfile::tempdir().expect("create a temporary directory");
let config = fixture_with(dir.path(), &[("echo.md", ECHO)], "");
let mut session = Session::spawn_at(dir, &config);
session
.send(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "stdio-no-token", "version": "0" },
},
}))
.await;
let initialized = session.receive().await;
assert_eq!(initialized["id"], json!(1));
assert_eq!(
initialized["result"]["serverInfo"]["name"],
json!("promptforge-mcp-server"),
"the file carries no [server].token and stdio serves anyway"
);
session.child.kill().await.expect("stop the server");
}