use std::net::{IpAddr, Ipv4Addr}; use std::sync::{Arc, Barrier}; use bds_core::db::Database; use bds_core::engine::{EngineError, 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":"

{{ post.title }}

"}), ), ( "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::>(); 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 propose_template_rejects_unsupported_liquid_without_creating_a_proposal() { let fixture = Fixture::new(); let context = fixture.context(); let error = context .call_tool( "propose_template", json!({ "title": "Unsafe template", "kind": "partial", "content": "{{ title | upcase }}" }), ) .unwrap_err(); assert!(error.to_string().contains("unsupported filter: upcase")); let db = fixture.database(); assert!( mcp::list_proposals(db.conn(), &fixture.project_id) .unwrap() .is_empty() ); } #[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: "expired-entity".into(), 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::>(); 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 duplicate_pending_entity_proposals_are_rejected_cleanly() { let fixture = Fixture::new(); let post = fixture.post("One target", "Body"); let context = fixture.context(); let arguments = json!({"postId": post.id, "title": "First change"}); context .call_tool("propose_post_metadata", arguments.clone()) .unwrap(); let error = context .call_tool("propose_post_metadata", arguments) .unwrap_err(); assert!(matches!(error, EngineError::Conflict(_))); assert_eq!( error.to_string(), format!( "conflict: proposal already pending for propose_post_metadata entity {}", post.id ) ); let db = fixture.database(); assert_eq!( mcp::list_pending_proposals(db.conn(), &fixture.project_id) .unwrap() .len(), 1 ); } #[test] fn untargeted_proposals_receive_distinct_entity_ids() { let fixture = Fixture::new(); let context = fixture.context(); context .call_tool("draft_post", json!({"title": "First", "content": "Body"})) .unwrap(); context .call_tool("draft_post", json!({"title": "Second", "content": "Body"})) .unwrap(); let db = fixture.database(); let proposals = mcp::list_pending_proposals(db.conn(), &fixture.project_id).unwrap(); assert_eq!(proposals.len(), 2); assert_ne!(proposals[0].entity_id, proposals[1].entity_id); } #[test] fn newer_terminal_proposals_replace_older_status_history() { let fixture = Fixture::new(); let post = fixture.post("Original", "Body"); let context = fixture.context(); let first = context .call_tool( "propose_post_metadata", json!({"postId": post.id, "title": "First change"}), ) .unwrap(); let db = fixture.database(); mcp::accept_proposal( db.conn(), &fixture.data_dir, first["proposalId"].as_str().unwrap(), ) .unwrap(); let second = context .call_tool( "propose_post_metadata", json!({"postId": post.id, "title": "Second change"}), ) .unwrap(); mcp::accept_proposal( db.conn(), &fixture.data_dir, second["proposalId"].as_str().unwrap(), ) .unwrap(); let accepted = mcp::list_proposals(db.conn(), &fixture.project_id) .unwrap() .into_iter() .filter(|proposal| proposal.status == ProposalStatus::Accepted) .collect::>(); assert_eq!(accepted.len(), 1); assert_eq!(accepted[0].id, second["proposalId"]); } #[test] fn newer_expired_proposals_replace_older_expired_history() { let fixture = Fixture::new(); let db = fixture.database(); for (id, status, expires_at) in [ ("old-expired", ProposalStatus::Expired, 1), ("new-expiring", ProposalStatus::Pending, 2), ] { bds_core::db::queries::mcp_proposal::insert_proposal( db.conn(), &McpProposal { id: id.into(), project_id: fixture.project_id.clone(), kind: ProposalKind::ProposePostMetadata, status, entity_id: "same-post".into(), data: "{}".into(), result: None, created_at: expires_at, expires_at, resolved_at: (status == ProposalStatus::Expired).then_some(1), }, ) .unwrap(); } assert_eq!(mcp::expire_proposals(db.conn()).unwrap(), 1); let proposals = mcp::list_proposals(db.conn(), &fixture.project_id).unwrap(); assert_eq!(proposals.len(), 1); assert_eq!(proposals[0].id, "new-expiring"); assert_eq!(proposals[0].status, ProposalStatus::Expired); } #[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::builder() .no_proxy() .build() .unwrap(); 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(); }