use std::fs;
use axum::body::{to_bytes, Body};
use axum::http::Request;
use base64::Engine;
use serde_json::{json, Value};
use tempfile::TempDir;
use tower::util::ServiceExt;
use writestead::config::{AppConfig, McpConfig, RawConfig, SearchConfig, SyncBackend, SyncConfig};
use writestead::server;
use writestead::vault;
fn test_config(vault_path: &str) -> AppConfig {
AppConfig {
name: "test".to_string(),
vault_path: vault_path.to_string(),
host: "127.0.0.1".to_string(),
port: 0,
sync: SyncConfig {
backend: SyncBackend::None,
},
mcp: McpConfig::default(),
search: SearchConfig::default(),
raw: RawConfig::default(),
}
}
async fn post_mcp(
app: &axum::Router,
session_id: Option<&str>,
payload: Value,
) -> (axum::http::response::Parts, Value) {
let mut builder = Request::builder()
.method("POST")
.uri("/mcp")
.header("content-type", "application/json");
if let Some(session_id) = session_id {
builder = builder.header("mcp-session-id", session_id);
}
let req = builder
.body(Body::from(payload.to_string()))
.expect("request");
let resp = app.clone().oneshot(req).await.expect("response");
let (parts, body) = resp.into_parts();
let bytes = to_bytes(body, usize::MAX).await.expect("body bytes");
let json: Value = serde_json::from_slice(&bytes).expect("json response");
(parts, json)
}
#[tokio::test]
async fn tools_list_matches_snapshot_and_pagination_contract() {
let dir = TempDir::new().expect("tempdir");
let cfg = test_config(dir.path().to_str().expect("path str"));
vault::init_vault(&cfg, true).expect("init vault");
let page = "---\ntitle: Demo\ntype: entity\ncreated: 2026-04-23\nupdated: 2026-04-23\ntags: [demo]\n---\n\n# Demo\n\nline1\nline2\nline3\n";
fs::write(dir.path().join("wiki/entities/demo.md"), page).expect("write demo");
fs::create_dir_all(dir.path().join("raw")).expect("raw dir");
fs::write(dir.path().join("raw/source.txt"), "alpha\nbeta\n").expect("write raw source");
let state = server::build_state(cfg.clone());
let app = server::build_app(state);
let (init_parts, init_body) = post_mcp(
&app,
None,
json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": { "protocolVersion": "2025-06-18" }
}),
)
.await;
let instructions = init_body["result"]["instructions"]
.as_str()
.expect("initialize instructions");
assert!(instructions.contains("Ingest workflow:"));
assert!(instructions.contains("raw_list to discover source files"));
let session_id = init_parts
.headers
.get("mcp-session-id")
.expect("session header")
.to_str()
.expect("session header str")
.to_string();
let (_list_parts, list_body) = post_mcp(
&app,
Some(&session_id),
json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
}),
)
.await;
let tools = list_body["result"]["tools"].clone();
let fixture_path = format!(
"{}/tests/fixtures/tools_list.json",
env!("CARGO_MANIFEST_DIR")
);
let expected_tools: Value =
serde_json::from_str(&fs::read_to_string(fixture_path).expect("fixture read"))
.expect("fixture json");
assert_eq!(tools, expected_tools);
let (_read_parts, read_body) = post_mcp(
&app,
Some(&session_id),
json!({
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "wiki_read",
"arguments": { "path": "wiki/entities/demo.md", "offset": 1, "limit": 2 }
}
}),
)
.await;
let read_payload_text = read_body["result"]["content"][0]["text"]
.as_str()
.expect("read text payload");
let read_payload: Value = serde_json::from_str(read_payload_text).expect("read payload json");
assert!(read_payload.get("total_lines").is_some());
assert!(read_payload.get("has_more").is_some());
assert_eq!(read_payload["offset"], json!(1));
assert_eq!(read_payload["limit"], json!(2));
let (_list_call_parts, list_call_body) = post_mcp(
&app,
Some(&session_id),
json!({
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "wiki_list",
"arguments": { "offset": 0, "limit": 5 }
}
}),
)
.await;
let list_payload_text = list_call_body["result"]["content"][0]["text"]
.as_str()
.expect("list text payload");
let list_payload: Value = serde_json::from_str(list_payload_text).expect("list payload json");
assert!(list_payload.get("total").is_some());
assert!(list_payload.get("has_more").is_some());
assert_eq!(list_payload["offset"], json!(0));
assert_eq!(list_payload["limit"], json!(5));
let (_raw_list_parts, raw_list_body) = post_mcp(
&app,
Some(&session_id),
json!({
"jsonrpc": "2.0",
"id": 5,
"method": "tools/call",
"params": {
"name": "raw_list",
"arguments": { "offset": 0, "limit": 1 }
}
}),
)
.await;
let raw_list_text = raw_list_body["result"]["content"][0]["text"]
.as_str()
.expect("raw list text payload");
let raw_list_payload: Value =
serde_json::from_str(raw_list_text).expect("raw list payload json");
assert_eq!(raw_list_payload["total"], json!(1));
assert_eq!(raw_list_payload["offset"], json!(0));
assert_eq!(raw_list_payload["limit"], json!(1));
assert_eq!(raw_list_payload["files"], json!(["source.txt"]));
let (_raw_read_parts, raw_read_body) = post_mcp(
&app,
Some(&session_id),
json!({
"jsonrpc": "2.0",
"id": 6,
"method": "tools/call",
"params": {
"name": "raw_read",
"arguments": {
"path": "source.txt"
}
}
}),
)
.await;
let raw_read_text = raw_read_body["result"]["content"][0]["text"]
.as_str()
.expect("raw read text payload");
let raw_read_payload: Value =
serde_json::from_str(raw_read_text).expect("raw read payload json");
assert_eq!(raw_read_payload["extractor"], json!("direct"));
assert!(raw_read_payload["content"]
.as_str()
.unwrap_or_default()
.contains("alpha"));
let inline_b64 = base64::engine::general_purpose::STANDARD.encode("inline text\n");
let (_upload_parts, upload_body) = post_mcp(
&app,
Some(&session_id),
json!({
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "raw_upload",
"arguments": {
"name": "inline.txt",
"content": inline_b64,
"overwrite": false
}
}
}),
)
.await;
let upload_text = upload_body["result"]["content"][0]["text"]
.as_str()
.expect("upload text payload");
let upload_payload: Value = serde_json::from_str(upload_text).expect("upload payload json");
assert_eq!(upload_payload["ok"], json!(true));
assert_eq!(upload_payload["path"], json!("raw/inline.txt"));
let (_upload_missing_name_parts, upload_missing_name_body) = post_mcp(
&app,
Some(&session_id),
json!({
"jsonrpc": "2.0",
"id": 8,
"method": "tools/call",
"params": {
"name": "raw_upload",
"arguments": {
"content": base64::engine::general_purpose::STANDARD.encode("x")
}
}
}),
)
.await;
assert_eq!(upload_missing_name_body["result"]["isError"], json!(true));
let missing_name_text = upload_missing_name_body["result"]["content"][0]["text"]
.as_str()
.expect("missing name text");
assert!(missing_name_text.contains("missing name"));
let (_upload_multi_mode_parts, upload_multi_mode_body) = post_mcp(
&app,
Some(&session_id),
json!({
"jsonrpc": "2.0",
"id": 9,
"method": "tools/call",
"params": {
"name": "raw_upload",
"arguments": {
"name": "bad.txt",
"path": "raw/source.txt",
"content": base64::engine::general_purpose::STANDARD.encode("x")
}
}
}),
)
.await;
assert_eq!(upload_multi_mode_body["result"]["isError"], json!(true));
let multi_mode_text = upload_multi_mode_body["result"]["content"][0]["text"]
.as_str()
.expect("multi mode text");
assert!(multi_mode_text.contains("exactly one of url, path, content"));
let (_upload_bad_b64_parts, upload_bad_b64_body) = post_mcp(
&app,
Some(&session_id),
json!({
"jsonrpc": "2.0",
"id": 10,
"method": "tools/call",
"params": {
"name": "raw_upload",
"arguments": {
"name": "bad.txt",
"content": "%%%"
}
}
}),
)
.await;
assert_eq!(upload_bad_b64_body["result"]["isError"], json!(true));
let bad_b64_text = upload_bad_b64_body["result"]["content"][0]["text"]
.as_str()
.expect("bad b64 text");
assert!(bad_b64_text.contains("invalid base64 content"));
let (_help_parts, help_body) = post_mcp(
&app,
Some(&session_id),
json!({
"jsonrpc": "2.0",
"id": 11,
"method": "tools/call",
"params": {
"name": "wiki_help",
"arguments": {}
}
}),
)
.await;
let help_text = help_body["result"]["content"][0]["text"]
.as_str()
.expect("wiki help payload");
let help_payload: Value = serde_json::from_str(help_text).expect("wiki help json");
assert!(help_payload["instructions"]
.as_str()
.unwrap_or_default()
.contains("Vault layout:"));
let (_edit_parts, edit_body) = post_mcp(
&app,
Some(&session_id),
json!({
"jsonrpc": "2.0",
"id": 12,
"method": "tools/call",
"params": {
"name": "wiki_edit",
"arguments": {
"path": "wiki/entities/demo.md",
"edits": [{"oldText": "line1", "newText": "line1"}]
}
}
}),
)
.await;
assert_eq!(edit_body["result"]["isError"], json!(true));
let edit_error_text = edit_body["result"]["content"][0]["text"]
.as_str()
.expect("edit error text");
assert!(edit_error_text.contains("missing log_action"));
}