Files
RuDS/crates/bds-core/tests/mcp.rs

715 lines
23 KiB
Rust

use std::net::{IpAddr, Ipv4Addr};
use std::sync::{Arc, Barrier};
use bds_core::db::Database;
use bds_core::engine::{mcp, project};
use bds_core::model::{DomainEntity, DomainEvent, McpProposal, ProposalKind, ProposalStatus};
use serde_json::json;
struct Fixture {
_root: tempfile::TempDir,
database_path: std::path::PathBuf,
data_dir: std::path::PathBuf,
project_id: String,
}
impl Fixture {
fn new() -> Self {
let root = tempfile::tempdir().unwrap();
let database_path = root.path().join("app/bds.db");
let data_dir = root.path().join("project");
std::fs::create_dir_all(database_path.parent().unwrap()).unwrap();
std::fs::create_dir_all(&data_dir).unwrap();
let db = Database::open(&database_path).unwrap();
db.migrate().unwrap();
bds_core::engine::search::prepare_search_index(db.conn()).unwrap();
let project = project::create_project(db.conn(), "MCP Test", data_dir.to_str()).unwrap();
project::set_active_project(db.conn(), &project.id).unwrap();
Self {
_root: root,
database_path,
data_dir,
project_id: project.id,
}
}
fn context(&self) -> mcp::McpContext {
mcp::McpContext::new(self.database_path.clone())
}
fn database(&self) -> Database {
Database::open(&self.database_path).unwrap()
}
fn post(&self, title: &str, content: &str) -> bds_core::model::Post {
let db = self.database();
bds_core::engine::post::create_post(
db.conn(),
&self.data_dir,
&self.project_id,
title,
Some(content),
vec!["rust".into()],
vec!["article".into()],
Some("Alice"),
Some("en"),
None,
)
.unwrap()
}
fn media(&self) -> bds_core::model::Media {
let source = self._root.path().join("source.png");
std::fs::write(
&source,
include_bytes!(
"../../../fixtures/golden-generated-sites/rfc1437-sample/images/close.png"
),
)
.unwrap();
let db = self.database();
bds_core::engine::media::import_media(
db.conn(),
&self.data_dir,
&self.project_id,
&source,
"source.png",
Some("Close"),
Some("Close icon"),
None,
None,
Some("en"),
vec!["ui".into()],
)
.unwrap()
}
}
fn resource_json(content: mcp::ResourceContent) -> serde_json::Value {
serde_json::from_str(content.text.as_deref().unwrap()).unwrap()
}
#[test]
fn protocol_lists_every_resource_and_tool_without_mutating_state() {
let fixture = Fixture::new();
let context = fixture.context();
let post = fixture.post("Read only", "Body");
let db = fixture.database();
let before_posts =
bds_core::db::queries::post::count_posts_by_project(db.conn(), &fixture.project_id)
.unwrap();
let before_proposals = mcp::list_proposals(db.conn(), &fixture.project_id)
.unwrap()
.len();
let resources = context.list_resources();
let templates = context.list_resource_templates();
let tools = context.list_tools();
assert_eq!(resources.len(), 6);
assert_eq!(templates.len(), 4);
for name in [
"check_term",
"search_posts",
"count_posts",
"read_post_by_slug",
"get_post_translations",
"get_media_translations",
"upsert_media_translation",
"draft_post",
"propose_script",
"propose_template",
"propose_media_metadata",
"propose_post_metadata",
] {
assert!(tools.iter().any(|tool| tool["name"] == name));
}
context.read_resource("bds://stats").unwrap();
context.read_resource("bds://project").unwrap();
context
.call_tool("read_post_by_slug", json!({"slug": post.slug}))
.unwrap();
assert_eq!(
bds_core::db::queries::post::count_posts_by_project(db.conn(), &fixture.project_id)
.unwrap(),
before_posts
);
assert_eq!(
mcp::list_proposals(db.conn(), &fixture.project_id)
.unwrap()
.len(),
before_proposals
);
}
#[test]
fn write_tools_create_inert_proposals_and_desktop_approval_executes_once() {
let fixture = Fixture::new();
let context = fixture.context();
let proposed = context
.call_tool(
"draft_post",
json!({"title":"Proposed post","content":"Body","tags":["rust"]}),
)
.unwrap();
let proposal_id = proposed["proposalId"].as_str().unwrap();
let db = Database::open(&fixture.database_path).unwrap();
assert!(
bds_core::db::queries::post::list_posts_by_project(db.conn(), &fixture.project_id)
.unwrap()
.is_empty()
);
let proposal = mcp::get_proposal(db.conn(), proposal_id).unwrap();
assert_eq!(proposal.status, mcp::ProposalStatus::Pending);
let events = bds_core::engine::domain_events::subscribe();
let accepted = mcp::accept_proposal(db.conn(), &fixture.data_dir, proposal_id).unwrap();
assert_eq!(accepted.status, mcp::ProposalStatus::Accepted);
let posts =
bds_core::db::queries::post::list_posts_by_project(db.conn(), &fixture.project_id).unwrap();
assert_eq!(posts.len(), 1);
assert_eq!(posts[0].title, "Proposed post");
assert_eq!(posts[0].status, bds_core::model::PostStatus::Published);
assert!(fixture.data_dir.join(&posts[0].file_path).is_file());
assert!(events.drain().into_iter().any(|event| matches!(
event,
DomainEvent::EntityChanged {
project_id,
entity: DomainEntity::Post,
..
} if project_id == fixture.project_id
)));
assert!(mcp::accept_proposal(db.conn(), &fixture.data_dir, proposal_id).is_err());
assert_eq!(
bds_core::db::queries::post::list_posts_by_project(db.conn(), &fixture.project_id)
.unwrap()
.len(),
1
);
}
#[test]
fn every_resource_supports_pagination_links_images_and_not_found() {
let fixture = Fixture::new();
let first = fixture.post("First post", "First body");
for index in 0..50 {
fixture.post(&format!("Post {index}"), "Body");
}
let media = fixture.media();
let db = fixture.database();
bds_core::engine::post_media::link_media_to_post(
db.conn(),
&fixture.data_dir,
&fixture.project_id,
&first.id,
&media.id,
0,
)
.unwrap();
bds_core::engine::tag::sync_tags_from_posts(db.conn(), &fixture.project_id).unwrap();
bds_core::engine::meta::write_categories_json(&fixture.data_dir, &["article".into()]).unwrap();
let context = fixture.context();
let project = resource_json(context.read_resource("bds://project").unwrap());
assert_eq!(project["id"], fixture.project_id);
assert_eq!(project["name"], "MCP Test");
let posts = resource_json(context.read_resource("bds://posts").unwrap());
assert_eq!(posts["items"].as_array().unwrap().len(), 50);
assert_eq!(posts["total"], 51);
let cursor = posts["nextCursor"].as_str().unwrap();
let next = resource_json(
context
.read_resource(&format!("bds://posts?cursor={cursor}"))
.unwrap(),
);
assert_eq!(next["items"].as_array().unwrap().len(), 1);
assert!(
context
.read_resource("bds://posts?cursor=not-base64")
.is_err()
);
assert_eq!(
resource_json(context.read_resource("bds://media").unwrap())["total"],
1
);
assert_eq!(
resource_json(context.read_resource("bds://tags").unwrap())["items"][0]["name"],
"rust"
);
assert_eq!(
resource_json(context.read_resource("bds://categories").unwrap())["items"][0]["name"],
"article"
);
let stats = resource_json(context.read_resource("bds://stats").unwrap());
assert_eq!(stats["post_count"], 51);
assert_eq!(stats["media_count"], 1);
let linked = resource_json(
context
.read_resource(&format!("bds://posts/{}/media", first.id))
.unwrap(),
);
assert_eq!(linked["items"][0]["id"], media.id);
let image = context
.read_resource(&format!("bds://media/{}/image", media.id))
.unwrap();
assert_eq!(image.mime_type, "image/png");
assert!(!image.blob.unwrap().is_empty());
assert!(context.read_resource("bds://posts/missing/media").is_err());
assert!(context.read_resource("bds://unknown").is_err());
}
#[test]
fn every_read_tool_uses_shared_queries_filters_translations_and_grouping() {
let fixture = Fixture::new();
let post = fixture.post("Rust search", "Semantic body");
let media = fixture.media();
let db = fixture.database();
bds_core::engine::post::upsert_translation(
db.conn(),
&fixture.data_dir,
&post.id,
"de",
"Rust Suche",
Some("Kurz"),
Some("Deutscher Inhalt"),
)
.unwrap();
bds_core::engine::media::upsert_media_translation(
db.conn(),
&fixture.data_dir,
&media.id,
"de",
Some("Schließen"),
Some("Schließen-Symbol"),
None,
)
.unwrap();
let context = fixture.context();
let term = context
.call_tool("check_term", json!({"term":"rust"}))
.unwrap();
assert_eq!(term["is_tag"], true);
assert_eq!(term["tag_post_count"], 1);
let search = context
.call_tool(
"search_posts",
json!({"query":"semantic","category":"article","tags":["rust"],"language":"en","offset":0,"limit":10}),
)
.unwrap();
assert_eq!(search["total"], 1);
assert_eq!(search["posts"][0]["id"], post.id);
let missing = context
.call_tool("search_posts", json!({"missingTranslationLanguage":"fr"}))
.unwrap();
assert_eq!(missing["total"], 1);
let count = context
.call_tool("count_posts", json!({"groupBy":["status","tag"]}))
.unwrap();
assert_eq!(count["totalPosts"], 1);
assert_eq!(count["groups"][0]["count"], 1);
let translated = context
.call_tool(
"read_post_by_slug",
json!({"slug":post.slug,"language":"de"}),
)
.unwrap();
assert_eq!(translated["post"]["title"], "Rust Suche");
assert_eq!(translated["post"]["content"], "Deutscher Inhalt");
assert_eq!(
context
.call_tool("get_post_translations", json!({"postId":post.id}))
.unwrap()["translations"]
.as_array()
.unwrap()
.len(),
1
);
assert_eq!(
context
.call_tool("get_media_translations", json!({"mediaId":media.id}))
.unwrap()["translations"]
.as_array()
.unwrap()
.len(),
1
);
assert!(
context
.call_tool("search_posts", json!({"month":2}))
.is_err()
);
assert!(context.call_tool("missing", json!({})).is_err());
}
#[test]
fn every_write_tool_is_inert_then_approves_or_rejects_through_shared_engines() {
let fixture = Fixture::new();
let post = fixture.post("Existing", "Body");
let media = fixture.media();
let context = fixture.context();
let db = fixture.database();
let requests = [
(
"upsert_media_translation",
json!({"mediaId":media.id,"language":"fr","title":"Fermer"}),
),
(
"propose_script",
json!({"title":"Task","kind":"utility","content":"function main()\n return true\nend"}),
),
(
"propose_template",
json!({"title":"Card","kind":"partial","content":"<p>{{ post.title }}</p>"}),
),
(
"propose_media_metadata",
json!({"mediaId":media.id,"title":"Updated media","tags":["updated"]}),
),
(
"propose_post_metadata",
json!({"postId":post.id,"title":"Updated post","categories":["news"]}),
),
];
let mut proposal_ids = Vec::new();
for (tool, params) in requests {
let response = context.call_tool(tool, params).unwrap();
proposal_ids.push(response["proposalId"].as_str().unwrap().to_string());
}
assert_eq!(
bds_core::db::queries::script::list_scripts_by_project(db.conn(), &fixture.project_id)
.unwrap()
.len(),
0
);
assert_eq!(
bds_core::db::queries::template::list_templates_by_project(db.conn(), &fixture.project_id)
.unwrap()
.len(),
0
);
assert_eq!(
bds_core::db::queries::post::get_post_by_id(db.conn(), &post.id)
.unwrap()
.title,
"Existing"
);
assert_eq!(
bds_core::db::queries::media::get_media_by_id(db.conn(), &media.id)
.unwrap()
.title
.as_deref(),
Some("Close")
);
let events = bds_core::engine::domain_events::subscribe();
for proposal_id in proposal_ids.iter().take(4) {
mcp::accept_proposal(db.conn(), &fixture.data_dir, proposal_id).unwrap();
}
mcp::reject_proposal(db.conn(), &fixture.data_dir, &proposal_ids[4]).unwrap();
assert_eq!(
bds_core::db::queries::media_translation::list_media_translations_by_media(
db.conn(),
&media.id
)
.unwrap()
.len(),
1
);
assert_eq!(
bds_core::db::queries::script::list_scripts_by_project(db.conn(), &fixture.project_id)
.unwrap()[0]
.status,
bds_core::model::ScriptStatus::Published
);
assert_eq!(
bds_core::db::queries::template::list_templates_by_project(db.conn(), &fixture.project_id)
.unwrap()[0]
.status,
bds_core::model::TemplateStatus::Published
);
assert_eq!(
bds_core::db::queries::media::get_media_by_id(db.conn(), &media.id)
.unwrap()
.title
.as_deref(),
Some("Updated media")
);
assert_eq!(
bds_core::db::queries::post::get_post_by_id(db.conn(), &post.id)
.unwrap()
.title,
"Existing"
);
assert_eq!(
mcp::get_proposal(db.conn(), &proposal_ids[4])
.unwrap()
.status,
ProposalStatus::Rejected
);
let changed_entities = events
.drain()
.into_iter()
.filter_map(|event| match event {
DomainEvent::EntityChanged {
project_id, entity, ..
} if project_id == fixture.project_id => Some(entity),
_ => None,
})
.collect::<Vec<_>>();
for entity in [
DomainEntity::Media,
DomainEntity::Script,
DomainEntity::Template,
] {
assert!(
changed_entities.contains(&entity),
"approved {entity:?} proposal did not emit a domain event"
);
}
assert!(
context
.call_tool(
"propose_script",
json!({"title":"Bad","kind":"utility","content":"function ("}),
)
.is_err()
);
assert!(
context
.call_tool(
"propose_template",
json!({"title":"Bad","kind":"post","content":"{% if x %}"}),
)
.is_err()
);
}
#[test]
fn expiry_invalid_ids_unavailable_projects_and_concurrent_acceptance_are_safe() {
let fixture = Fixture::new();
let db = fixture.database();
let expired = McpProposal {
id: "expired".into(),
project_id: fixture.project_id.clone(),
kind: ProposalKind::DraftPost,
status: ProposalStatus::Pending,
entity_id: None,
data: json!({"title":"Expired","content":"Body"}).to_string(),
result: None,
created_at: 1,
expires_at: 2,
resolved_at: None,
};
bds_core::db::queries::mcp_proposal::insert_proposal(db.conn(), &expired).unwrap();
assert!(
mcp::list_pending_proposals(db.conn(), &fixture.project_id)
.unwrap()
.is_empty()
);
assert_eq!(
mcp::get_proposal(db.conn(), "expired").unwrap().status,
ProposalStatus::Expired
);
assert!(mcp::accept_proposal(db.conn(), &fixture.data_dir, "missing").is_err());
let response = fixture
.context()
.call_tool("draft_post", json!({"title":"Once","content":"Body"}))
.unwrap();
let proposal_id = response["proposalId"].as_str().unwrap().to_string();
let barrier = Arc::new(Barrier::new(2));
let threads = (0..2)
.map(|_| {
let barrier = Arc::clone(&barrier);
let database_path = fixture.database_path.clone();
let data_dir = fixture.data_dir.clone();
let proposal_id = proposal_id.clone();
std::thread::spawn(move || {
let db = Database::open(&database_path).unwrap();
barrier.wait();
mcp::accept_proposal(db.conn(), &data_dir, &proposal_id).is_ok()
})
})
.collect::<Vec<_>>();
assert_eq!(
threads
.into_iter()
.map(|thread| thread.join().unwrap())
.filter(|accepted| *accepted)
.count(),
1
);
assert_eq!(
bds_core::db::queries::post::list_posts_by_project(db.conn(), &fixture.project_id)
.unwrap()
.len(),
1
);
let empty_root = tempfile::tempdir().unwrap();
let empty_path = empty_root.path().join("empty.db");
let empty_db = Database::open(&empty_path).unwrap();
empty_db.migrate().unwrap();
assert!(
mcp::McpContext::new(empty_path)
.call_tool("check_term", json!({"term":"rust"}))
.is_err()
);
}
#[test]
fn protocol_routes_every_family_and_http_enforces_loopback_origin_and_cors() {
let fixture = Fixture::new();
let post = fixture.post("Protocol post", "Protocol body");
let media = fixture.media();
let db = fixture.database();
bds_core::engine::post_media::link_media_to_post(
db.conn(),
&fixture.data_dir,
&fixture.project_id,
&post.id,
&media.id,
0,
)
.unwrap();
let context = fixture.context();
for (id, method, params, result_key) in [
(
1,
"initialize",
json!({"protocolVersion":"2025-06-18"}),
"serverInfo",
),
(2, "tools/list", json!({}), "tools"),
(3, "resources/list", json!({}), "resources"),
(
4,
"resources/templates/list",
json!({}),
"resourceTemplates",
),
] {
let response = mcp::handle_rpc(
&context,
&json!({"jsonrpc":"2.0","id":id,"method":method,"params":params}),
)
.unwrap();
assert!(response["result"].get(result_key).is_some(), "{method}");
}
for (id, uri) in [
"bds://project".to_string(),
"bds://posts".to_string(),
"bds://media".to_string(),
"bds://tags".to_string(),
"bds://categories".to_string(),
"bds://stats".to_string(),
format!("bds://posts/{}/media", post.id),
format!("bds://media/{}/image", media.id),
]
.into_iter()
.enumerate()
{
let response = mcp::handle_rpc(
&context,
&json!({"jsonrpc":"2.0","id":10+id,"method":"resources/read","params":{"uri":uri}}),
)
.unwrap();
assert!(response["result"]["contents"].is_array(), "{uri}");
}
let tool_calls = [
("check_term", json!({"term":"rust"})),
("search_posts", json!({"query":"protocol"})),
("count_posts", json!({"groupBy":["status"]})),
("read_post_by_slug", json!({"slug":post.slug})),
("get_post_translations", json!({"postId":post.id})),
("get_media_translations", json!({"mediaId":media.id})),
(
"upsert_media_translation",
json!({"mediaId":media.id,"language":"de","title":"Bild"}),
),
(
"draft_post",
json!({"title":"Protocol proposal","content":"Body"}),
),
(
"propose_script",
json!({"title":"Protocol script","kind":"utility","content":"function main() end"}),
),
(
"propose_template",
json!({"title":"Protocol template","kind":"partial","content":"{{ post.title }}"}),
),
(
"propose_media_metadata",
json!({"mediaId":media.id,"title":"Changed"}),
),
(
"propose_post_metadata",
json!({"postId":post.id,"title":"Changed"}),
),
];
for (id, (name, arguments)) in tool_calls.into_iter().enumerate() {
let call = mcp::handle_rpc(
&context,
&json!({"jsonrpc":"2.0","id":30+id,"method":"tools/call","params":{"name":name,"arguments":arguments}}),
)
.unwrap();
assert_eq!(call["result"]["isError"], false, "{name}: {call}");
}
assert_eq!(
mcp::handle_rpc(
&context,
&json!({"jsonrpc":"2.0","id":7,"method":"missing"}),
)
.unwrap()["error"]["code"],
-32601
);
let server = mcp::McpHttpServer::start(fixture.database_path.clone(), 0).unwrap();
let client = reqwest::blocking::Client::new();
let response = client
.post(server.endpoint())
.header("accept", "application/json, text/event-stream")
.header("origin", "http://localhost:3000")
.json(&json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}))
.send()
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::OK);
assert_eq!(
response
.headers()
.get("access-control-allow-origin")
.unwrap(),
"http://localhost:3000"
);
assert!(response.headers().get("mcp-session-id").is_none());
let forbidden = client
.post(server.endpoint())
.header("accept", "application/json, text/event-stream")
.header("origin", "https://attacker.example")
.json(&json!({"jsonrpc":"2.0","id":2,"method":"ping"}))
.send()
.unwrap();
assert_eq!(forbidden.status(), reqwest::StatusCode::FORBIDDEN);
let get = client.get(server.endpoint()).send().unwrap();
assert_eq!(get.status(), reqwest::StatusCode::METHOD_NOT_ALLOWED);
server.stop().unwrap();
}
#[test]
fn http_server_binds_loopback_and_rejects_remote_origins() {
let fixture = Fixture::new();
let server = mcp::McpHttpServer::start(fixture.database_path.clone(), 0).unwrap();
assert_eq!(server.address().ip(), IpAddr::V4(Ipv4Addr::LOCALHOST));
assert_eq!(
server.endpoint(),
format!("http://{}/mcp", server.address())
);
server.stop().unwrap();
}