From fb5cae21319f7163a4b2fe0e747102338c8fcc74 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 19 Jul 2026 15:10:43 +0200 Subject: [PATCH] Implement MCP automation and approvals. --- Cargo.lock | 9 + Cargo.toml | 2 + README.md | 2 + RUST_PLAN_EXTENSION.md | 10 +- .../down.sql | 1 + .../up.sql | 24 + crates/bds-core/src/db/migrations.rs | 8 +- .../bds-core/src/db/queries/mcp_proposal.rs | 153 ++++ crates/bds-core/src/db/queries/mod.rs | 1 + crates/bds-core/src/db/schema.rs | 17 + crates/bds-core/src/db/types.rs | 6 +- .../bds-core/src/engine/mcp/agent_config.rs | 182 +++++ crates/bds-core/src/engine/mcp/http.rs | 234 ++++++ crates/bds-core/src/engine/mcp/mod.rs | 379 ++++++++++ crates/bds-core/src/engine/mcp/protocol.rs | 130 ++++ crates/bds-core/src/engine/mcp/resources.rs | 382 ++++++++++ crates/bds-core/src/engine/mcp/tools.rs | 646 ++++++++++++++++ crates/bds-core/src/engine/media.rs | 1 + crates/bds-core/src/engine/mod.rs | 1 + crates/bds-core/src/model/mcp.rs | 128 ++++ crates/bds-core/src/model/mod.rs | 2 + crates/bds-core/tests/mcp.rs | 714 ++++++++++++++++++ crates/bds-mcp/Cargo.toml | 12 + crates/bds-mcp/src/main.rs | 45 ++ crates/bds-mcp/tests/process.rs | 38 + crates/bds-ui/Cargo.toml | 3 +- crates/bds-ui/src/app.rs | 195 +++++ crates/bds-ui/src/views/settings_view.rs | 160 +++- crates/bds-ui/tests/packaging_assets.rs | 2 + locales/ui/de.ftl | 23 +- locales/ui/en.ftl | 23 +- locales/ui/es.ftl | 23 +- locales/ui/fr.ftl | 23 +- locales/ui/it.ftl | 23 +- specs/mcp.allium | 132 ++-- 35 files changed, 3654 insertions(+), 80 deletions(-) create mode 100644 crates/bds-core/migrations/2026-07-19-123029-0000_add_mcp_proposals/down.sql create mode 100644 crates/bds-core/migrations/2026-07-19-123029-0000_add_mcp_proposals/up.sql create mode 100644 crates/bds-core/src/db/queries/mcp_proposal.rs create mode 100644 crates/bds-core/src/engine/mcp/agent_config.rs create mode 100644 crates/bds-core/src/engine/mcp/http.rs create mode 100644 crates/bds-core/src/engine/mcp/mod.rs create mode 100644 crates/bds-core/src/engine/mcp/protocol.rs create mode 100644 crates/bds-core/src/engine/mcp/resources.rs create mode 100644 crates/bds-core/src/engine/mcp/tools.rs create mode 100644 crates/bds-core/src/model/mcp.rs create mode 100644 crates/bds-core/tests/mcp.rs create mode 100644 crates/bds-mcp/Cargo.toml create mode 100644 crates/bds-mcp/src/main.rs create mode 100644 crates/bds-mcp/tests/process.rs diff --git a/Cargo.lock b/Cargo.lock index dc8c71a..897fc73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -846,6 +846,15 @@ dependencies = [ "syntect", ] +[[package]] +name = "bds-mcp" +version = "0.1.0" +dependencies = [ + "bds-core", + "serde_json", + "tempfile", +] + [[package]] name = "bds-ui" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index e06b91a..286ab14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/bds-editor", "crates/bds-ui", "crates/bds-cli", + "crates/bds-mcp", ] [workspace.package] @@ -67,3 +68,4 @@ rfd = "0.15" # Internal crates bds-core = { path = "crates/bds-core" } bds-editor = { path = "crates/bds-editor" } +bds-mcp = { path = "crates/bds-mcp" } diff --git a/README.md b/README.md index 1681722..62946ad 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ The project is under active development. Core blogging workflows are broadly ava - SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, and FTS5 search. - Project-scoped typed domain events synchronize desktop views with shared-engine and future CLI mutations; persisted CLI notifications are consumed once, and the selected UI language is shared through settings. - Headless `bds-cli` automation for rebuild/repair/render, publishing and Git sync, post/media/gallery creation, shared settings/projects, utility Lua tasks, JSON I/O, airplane-mode AI routing, and guarded launcher installation from Settings → Data or `bds-cli install`. +- Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, inert write proposals, explicit desktop approval, and opt-in Claude Code/Copilot configuration. - Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation through cancellable section task groups. - Local preview in the app or system browser. - Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using online or local OpenAI-compatible endpoints with airplane-mode gating. @@ -29,6 +30,7 @@ RuDS uses no JavaScript application runtime and loads no CSS or JavaScript from - `crates/bds-editor` — reusable syntax-highlighting editor - `crates/bds-ui` — desktop application and platform integration - `crates/bds-cli` — headless automation CLI over the shared engines +- `crates/bds-mcp` — packaged stdio MCP transport over the shared MCP engine - `specs` — authoritative Allium behavior specifications - `fixtures` — compatibility projects and generated-site fixtures - `locales` — UI and native-menu translations diff --git a/RUST_PLAN_EXTENSION.md b/RUST_PLAN_EXTENSION.md index 2b7f58e..b450b84 100644 --- a/RUST_PLAN_EXTENSION.md +++ b/RUST_PLAN_EXTENSION.md @@ -81,7 +81,7 @@ Open: - OPML/menu editor UI. - Replace the Menu Editor placeholder. -### CLI and domain events — Done; MCP — Open +### CLI, domain events, and MCP — Done Done: @@ -89,11 +89,9 @@ Done: - Native `bds-cli` with Clap help/error handling, optional JSON output, shared application paths/database/projects/settings, full and incremental rebuild, derived-data repair, full/targeted/forced generation, publishing, fast-forward Git sync, post/media/gallery creation, offline/local AI routing and translation, project/config operations, sandboxed utility Lua execution, and guarded launcher installation. - CLI process and dispatch tests use temporary databases/projects; CLI mutations persist deduplicated desktop notifications and imported filesystem metadata survives rebuild. - Settings → Data exposes the same localized packaged-launcher installer as `bds-cli install`. - -Open: - -- MCP tools/resources and proposal-based writes from `mcp.allium`. -- Replace the MCP settings placeholder. +- MCP exposes the complete `bds://` resource set and typed read/search/count tools through packaged stdio and stateless localhost-only HTTP transports with Origin/Host validation and CORS. +- Every MCP write is an inert persisted proposal until one desktop approval applies it exactly once through the shared post/media/script/template engines; rejection, expiry, concurrent resolution, result state, and normal domain events are covered. +- Settings → MCP provides localized server enablement/status/endpoint, full proposal review controls, and opt-in guarded Claude Code and GitHub Copilot configuration without secrets. ### Blogmark and transform pipeline — Done diff --git a/crates/bds-core/migrations/2026-07-19-123029-0000_add_mcp_proposals/down.sql b/crates/bds-core/migrations/2026-07-19-123029-0000_add_mcp_proposals/down.sql new file mode 100644 index 0000000..dc816f8 --- /dev/null +++ b/crates/bds-core/migrations/2026-07-19-123029-0000_add_mcp_proposals/down.sql @@ -0,0 +1 @@ +DROP TABLE mcp_proposals; diff --git a/crates/bds-core/migrations/2026-07-19-123029-0000_add_mcp_proposals/up.sql b/crates/bds-core/migrations/2026-07-19-123029-0000_add_mcp_proposals/up.sql new file mode 100644 index 0000000..6ae766b --- /dev/null +++ b/crates/bds-core/migrations/2026-07-19-123029-0000_add_mcp_proposals/up.sql @@ -0,0 +1,24 @@ +CREATE TABLE mcp_proposals ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ( + 'draft_post', + 'propose_script', + 'propose_template', + 'propose_media_translation', + 'propose_media_metadata', + 'propose_post_metadata' + )), + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ( + 'pending', 'executing', 'accepted', 'rejected', 'expired' + )), + entity_id TEXT, + data TEXT NOT NULL, + result TEXT, + created_at BIGINT NOT NULL, + expires_at BIGINT NOT NULL, + resolved_at BIGINT +); + +CREATE INDEX mcp_proposals_project_status_idx + ON mcp_proposals(project_id, status, created_at); diff --git a/crates/bds-core/src/db/migrations.rs b/crates/bds-core/src/db/migrations.rs index 9b5e125..3f0864f 100644 --- a/crates/bds-core/src/db/migrations.rs +++ b/crates/bds-core/src/db/migrations.rs @@ -17,8 +17,9 @@ mod tests { use crate::db::schema::{ ai_catalog_meta, ai_model_modalities, ai_models, ai_providers, chat_conversations, chat_messages, db_notifications, dismissed_duplicate_pairs, embedding_keys, - generated_file_hashes, import_definitions, media, media_translations, post_links, - post_media, post_translations, posts, projects, scripts, settings, tags, templates, + generated_file_hashes, import_definitions, mcp_proposals, media, media_translations, + post_links, post_media, post_translations, posts, projects, scripts, settings, tags, + templates, }; use diesel::prelude::*; use diesel_migrations::MigrationHarness; @@ -35,7 +36,7 @@ mod tests { let applied = db .conn() .with_migrations(|conn| conn.applied_migrations().unwrap().len()); - assert_eq!(applied, 3); + assert_eq!(applied, 4); } #[test] @@ -75,6 +76,7 @@ mod tests { embedding_keys::table, dismissed_duplicate_pairs::table, import_definitions::table, + mcp_proposals::table, db_notifications::table, ); } diff --git a/crates/bds-core/src/db/queries/mcp_proposal.rs b/crates/bds-core/src/db/queries/mcp_proposal.rs new file mode 100644 index 0000000..3f335f5 --- /dev/null +++ b/crates/bds-core/src/db/queries/mcp_proposal.rs @@ -0,0 +1,153 @@ +use diesel::prelude::*; + +use crate::db::DbConnection; +use crate::db::schema::mcp_proposals; +use crate::model::{McpProposal, ProposalStatus}; + +pub fn insert_proposal(conn: &DbConnection, proposal: &McpProposal) -> QueryResult<()> { + conn.with(|connection| { + diesel::insert_into(mcp_proposals::table) + .values(proposal) + .execute(connection) + .map(|_| ()) + }) +} + +pub fn get_proposal(conn: &DbConnection, id: &str) -> QueryResult { + conn.with(|connection| { + mcp_proposals::table + .filter(mcp_proposals::id.eq(id)) + .select(McpProposal::as_select()) + .first(connection) + }) +} + +pub fn list_proposals(conn: &DbConnection, project_id: &str) -> QueryResult> { + conn.with(|connection| { + mcp_proposals::table + .filter(mcp_proposals::project_id.eq(project_id)) + .order(mcp_proposals::created_at.desc()) + .select(McpProposal::as_select()) + .load(connection) + }) +} + +pub fn list_pending_proposals( + conn: &DbConnection, + project_id: &str, +) -> QueryResult> { + conn.with(|connection| { + mcp_proposals::table + .filter(mcp_proposals::project_id.eq(project_id)) + .filter(mcp_proposals::status.eq(ProposalStatus::Pending)) + .order(mcp_proposals::created_at.asc()) + .select(McpProposal::as_select()) + .load(connection) + }) +} + +pub fn expire_pending(conn: &DbConnection, now: i64) -> QueryResult { + conn.with(|connection| { + diesel::update( + mcp_proposals::table + .filter(mcp_proposals::status.eq(ProposalStatus::Pending)) + .filter(mcp_proposals::expires_at.le(now)), + ) + .set(( + mcp_proposals::status.eq(ProposalStatus::Expired), + mcp_proposals::resolved_at.eq(Some(now)), + mcp_proposals::result.eq(Some("{\"message\":\"expired\"}".to_string())), + )) + .execute(connection) + }) +} + +pub fn claim_pending(conn: &DbConnection, id: &str, now: i64) -> QueryResult { + conn.with(|connection| { + diesel::update( + mcp_proposals::table + .filter(mcp_proposals::id.eq(id)) + .filter(mcp_proposals::status.eq(ProposalStatus::Pending)) + .filter(mcp_proposals::expires_at.gt(now)), + ) + .set(mcp_proposals::status.eq(ProposalStatus::Executing)) + .execute(connection) + .map(|changed| changed == 1) + }) +} + +pub fn resolve_claimed( + conn: &DbConnection, + id: &str, + status: ProposalStatus, + result: &str, + resolved_at: i64, +) -> QueryResult { + debug_assert!(matches!( + status, + ProposalStatus::Accepted | ProposalStatus::Rejected + )); + conn.with(|connection| { + diesel::update( + mcp_proposals::table + .filter(mcp_proposals::id.eq(id)) + .filter(mcp_proposals::status.eq(ProposalStatus::Executing)), + ) + .set(( + mcp_proposals::status.eq(status), + mcp_proposals::result.eq(Some(result.to_string())), + mcp_proposals::resolved_at.eq(Some(resolved_at)), + )) + .execute(connection) + .map(|changed| changed == 1) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::Database; + use crate::db::queries::project::{insert_project, make_test_project}; + use crate::model::ProposalKind; + + fn setup() -> Database { + let db = Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap(); + db + } + + fn proposal(id: &str, expires_at: i64) -> McpProposal { + McpProposal { + id: id.into(), + project_id: "p1".into(), + kind: ProposalKind::DraftPost, + status: ProposalStatus::Pending, + entity_id: None, + data: "{}".into(), + result: None, + created_at: 1, + expires_at, + resolved_at: None, + } + } + + #[test] + fn lifecycle_claims_once_and_expires_pending_rows() { + let db = setup(); + insert_proposal(db.conn(), &proposal("p1", 10)).unwrap(); + insert_proposal(db.conn(), &proposal("p2", 1)).unwrap(); + assert_eq!(expire_pending(db.conn(), 5).unwrap(), 1); + assert!(claim_pending(db.conn(), "p1", 5).unwrap()); + assert!(!claim_pending(db.conn(), "p1", 5).unwrap()); + assert!(resolve_claimed(db.conn(), "p1", ProposalStatus::Accepted, "{}", 6).unwrap()); + assert_eq!( + get_proposal(db.conn(), "p1").unwrap().status, + ProposalStatus::Accepted + ); + assert_eq!( + get_proposal(db.conn(), "p2").unwrap().status, + ProposalStatus::Expired + ); + } +} diff --git a/crates/bds-core/src/db/queries/mod.rs b/crates/bds-core/src/db/queries/mod.rs index 854235f..65e0625 100644 --- a/crates/bds-core/src/db/queries/mod.rs +++ b/crates/bds-core/src/db/queries/mod.rs @@ -1,6 +1,7 @@ pub mod db_notification; pub mod generated_file_hash; pub mod import_definition; +pub mod mcp_proposal; pub mod media; pub mod media_translation; pub mod post; diff --git a/crates/bds-core/src/db/schema.rs b/crates/bds-core/src/db/schema.rs index ca72387..1f8b963 100644 --- a/crates/bds-core/src/db/schema.rs +++ b/crates/bds-core/src/db/schema.rs @@ -141,6 +141,21 @@ diesel::table! { } } +diesel::table! { + mcp_proposals (id) { + id -> Text, + project_id -> Text, + kind -> Text, + status -> Text, + entity_id -> Nullable, + data -> Text, + result -> Nullable, + created_at -> BigInt, + expires_at -> BigInt, + resolved_at -> Nullable, + } +} + diesel::table! { media (id) { id -> Text, @@ -319,6 +334,7 @@ diesel::joinable!(chat_messages -> chat_conversations (conversation_id)); diesel::joinable!(dismissed_duplicate_pairs -> projects (project_id)); diesel::joinable!(generated_file_hashes -> projects (project_id)); diesel::joinable!(import_definitions -> projects (project_id)); +diesel::joinable!(mcp_proposals -> projects (project_id)); diesel::joinable!(media -> projects (project_id)); diesel::joinable!(media_translations -> media (translation_for)); diesel::joinable!(media_translations -> projects (project_id)); @@ -344,6 +360,7 @@ diesel::allow_tables_to_appear_in_same_query!( embedding_keys, generated_file_hashes, import_definitions, + mcp_proposals, media, media_translations, post_links, diff --git a/crates/bds-core/src/db/types.rs b/crates/bds-core/src/db/types.rs index c8711c1..69da482 100644 --- a/crates/bds-core/src/db/types.rs +++ b/crates/bds-core/src/db/types.rs @@ -5,8 +5,8 @@ use diesel::sql_types::{Integer, Text}; use diesel::sqlite::{Sqlite, SqliteValue}; use crate::model::{ - NotificationAction, NotificationEntity, PostStatus, ScriptKind, ScriptStatus, TemplateKind, - TemplateStatus, + NotificationAction, NotificationEntity, PostStatus, ProposalKind, ProposalStatus, ScriptKind, + ScriptStatus, TemplateKind, TemplateStatus, }; #[derive(Debug, AsExpression, FromSqlRow)] @@ -93,3 +93,5 @@ text_enum_sql!(ScriptKind); text_enum_sql!(ScriptStatus); text_enum_sql!(NotificationEntity); text_enum_sql!(NotificationAction); +text_enum_sql!(ProposalKind); +text_enum_sql!(ProposalStatus); diff --git a/crates/bds-core/src/engine/mcp/agent_config.rs b/crates/bds-core/src/engine/mcp/agent_config.rs new file mode 100644 index 0000000..21439c1 --- /dev/null +++ b/crates/bds-core/src/engine/mcp/agent_config.rs @@ -0,0 +1,182 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::engine::{EngineError, EngineResult}; + +const SERVER_NAME: &str = "bDS"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum McpAgent { + ClaudeCode, + GithubCopilot, +} + +impl McpAgent { + pub const fn all() -> [Self; 2] { + [Self::ClaudeCode, Self::GithubCopilot] + } + + pub const fn label(self) -> &'static str { + match self { + Self::ClaudeCode => "Claude Code", + Self::GithubCopilot => "GitHub Copilot", + } + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::ClaudeCode => "claude_code", + Self::GithubCopilot => "github_copilot", + } + } +} + +pub fn agent_config_path(agent: McpAgent, home_dir: &Path) -> PathBuf { + match agent { + McpAgent::ClaudeCode => home_dir.join(".claude.json"), + McpAgent::GithubCopilot => { + #[cfg(target_os = "macos")] + let path = home_dir.join("Library/Application Support/Code/User/mcp.json"); + #[cfg(target_os = "windows")] + let path = home_dir.join("AppData/Roaming/Code/User/mcp.json"); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + let path = home_dir.join(".config/Code/User/mcp.json"); + path + } + } +} + +pub fn packaged_mcp_executable() -> EngineResult { + let executable = std::env::current_exe()?; + let sibling = executable.with_file_name(if cfg!(windows) { + "bds-mcp.exe" + } else { + "bds-mcp" + }); + if sibling.is_file() { + Ok(sibling) + } else { + Err(EngineError::NotFound(format!( + "packaged MCP executable {}", + sibling.display() + ))) + } +} + +pub fn is_agent_configured(agent: McpAgent, home_dir: &Path) -> bool { + read_config(&agent_config_path(agent, home_dir)) + .ok() + .is_some_and(|config| { + server_map(&config, agent).is_some_and(|servers| servers.contains_key(SERVER_NAME)) + }) +} + +pub fn install_agent_config( + agent: McpAgent, + home_dir: &Path, + executable: &Path, +) -> EngineResult { + if !executable.is_file() { + return Err(EngineError::NotFound(format!( + "MCP executable {}", + executable.display() + ))); + } + let path = agent_config_path(agent, home_dir); + let mut config = read_config(&path)?; + let key = server_key(agent); + let servers = config + .as_object_mut() + .ok_or_else(|| { + EngineError::Validation(format!("{} must contain a JSON object", path.display())) + })? + .entry(key) + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + .ok_or_else(|| { + EngineError::Validation(format!("{key} in {} must be an object", path.display())) + })?; + let executable = executable.to_string_lossy(); + let server = match agent { + McpAgent::ClaudeCode => json!({"command": executable, "args": []}), + McpAgent::GithubCopilot => { + json!({"type": "stdio", "command": executable, "args": []}) + } + }; + servers.insert(SERVER_NAME.into(), server); + write_config(&path, &config)?; + Ok(path) +} + +pub fn remove_agent_config(agent: McpAgent, home_dir: &Path) -> EngineResult { + let path = agent_config_path(agent, home_dir); + let mut config = read_config(&path)?; + if let Some(servers) = config + .as_object_mut() + .and_then(|object| object.get_mut(server_key(agent))) + .and_then(Value::as_object_mut) + { + servers.remove(SERVER_NAME); + } + write_config(&path, &config)?; + Ok(path) +} + +fn server_key(agent: McpAgent) -> &'static str { + match agent { + McpAgent::ClaudeCode => "mcpServers", + McpAgent::GithubCopilot => "servers", + } +} + +fn server_map(config: &Value, agent: McpAgent) -> Option<&Map> { + config.get(server_key(agent)).and_then(Value::as_object) +} + +fn read_config(path: &Path) -> EngineResult { + match std::fs::read_to_string(path) { + Ok(source) => serde_json::from_str(&source) + .map_err(|error| EngineError::Parse(format!("{}: {error}", path.display()))), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(json!({})), + Err(error) => Err(error.into()), + } +} + +fn write_config(path: &Path, config: &Value) -> EngineResult<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let source = serde_json::to_string_pretty(config)?; + crate::util::atomic_write_str(path, &source)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn install_and_remove_preserve_unrelated_config_without_secrets() { + let root = tempfile::tempdir().unwrap(); + let executable = root.path().join("bds-mcp"); + std::fs::write(&executable, "binary").unwrap(); + for agent in McpAgent::all() { + let path = agent_config_path(agent, root.path()); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&path, r#"{"unrelated":{"token":"kept"}}"#).unwrap(); + install_agent_config(agent, root.path(), &executable).unwrap(); + assert!(is_agent_configured(agent, root.path())); + let source = std::fs::read_to_string(&path).unwrap(); + assert!(source.contains("kept")); + assert!(!source.contains("api_key")); + remove_agent_config(agent, root.path()).unwrap(); + assert!(!is_agent_configured(agent, root.path())); + assert!(std::fs::read_to_string(path).unwrap().contains("kept")); + } + } +} diff --git a/crates/bds-core/src/engine/mcp/http.rs b/crates/bds-core/src/engine/mcp/http.rs new file mode 100644 index 0000000..e0266a1 --- /dev/null +++ b/crates/bds-core/src/engine/mcp/http.rs @@ -0,0 +1,234 @@ +use std::net::{Ipv4Addr, SocketAddr}; +use std::path::PathBuf; +use std::thread::JoinHandle; + +use axum::Router; +use axum::extract::State; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use serde_json::Value; + +use crate::engine::{EngineError, EngineResult}; + +use super::McpContext; +use super::protocol::{MCP_PROTOCOL_VERSION, error, handle_rpc}; + +pub struct McpHttpServer { + address: SocketAddr, + shutdown: Option>, + thread: Option>, +} + +impl McpHttpServer { + pub fn start(database_path: PathBuf, port: u16) -> EngineResult { + McpContext::new(database_path.clone()).prepare()?; + let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, port))?; + listener.set_nonblocking(true)?; + let address = listener.local_addr()?; + let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel(); + let thread = std::thread::Builder::new() + .name("bds-mcp-http".into()) + .spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("MCP Tokio runtime"); + runtime.block_on(async move { + let listener = + tokio::net::TcpListener::from_std(listener).expect("MCP loopback listener"); + let context = McpContext::new(database_path); + let router = Router::new() + .route("/mcp", post(post_mcp).options(options_mcp)) + .with_state(context); + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await; + }); + })?; + Ok(Self { + address, + shutdown: Some(shutdown), + thread: Some(thread), + }) + } + + pub fn address(&self) -> SocketAddr { + self.address + } + + pub fn endpoint(&self) -> String { + format!("http://{}/mcp", self.address) + } + + pub fn stop(mut self) -> EngineResult<()> { + self.shutdown.take(); + if let Some(thread) = self.thread.take() { + thread + .join() + .map_err(|_| EngineError::Parse("MCP server thread panicked".into()))?; + } + Ok(()) + } +} + +impl Drop for McpHttpServer { + fn drop(&mut self) { + self.shutdown.take(); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +async fn post_mcp( + State(context): State, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Response { + if let Err((status, message)) = validate_http_headers(&headers) { + return with_cors((status, message).into_response(), &headers); + } + let request = match serde_json::from_slice::(&body) { + Ok(request) => request, + Err(_) => { + return with_cors( + ( + StatusCode::BAD_REQUEST, + axum::Json(error(Value::Null, -32700, "Parse error")), + ) + .into_response(), + &headers, + ); + } + }; + let response = match handle_rpc(&context, &request) { + Some(response) => (StatusCode::OK, axum::Json(response)).into_response(), + None => StatusCode::ACCEPTED.into_response(), + }; + with_cors(response, &headers) +} + +async fn options_mcp(headers: HeaderMap) -> Response { + if let Err((status, message)) = validate_origin_and_host(&headers) { + return with_cors((status, message).into_response(), &headers); + } + with_cors(StatusCode::NO_CONTENT.into_response(), &headers) +} + +fn validate_http_headers(headers: &HeaderMap) -> Result<(), (StatusCode, &'static str)> { + validate_origin_and_host(headers)?; + if let Some(version) = headers + .get("mcp-protocol-version") + .and_then(|value| value.to_str().ok()) + && !["2025-03-26", MCP_PROTOCOL_VERSION].contains(&version) + { + return Err((StatusCode::BAD_REQUEST, "Unsupported MCP protocol version")); + } + if headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_none_or(|value| !value.starts_with("application/json")) + { + return Err(( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "Expected application/json", + )); + } + if headers + .get(header::ACCEPT) + .and_then(|value| value.to_str().ok()) + .is_none_or(|value| { + !value.contains("application/json") || !value.contains("text/event-stream") + }) + { + return Err(( + StatusCode::NOT_ACCEPTABLE, + "Accept must include application/json and text/event-stream", + )); + } + Ok(()) +} + +fn validate_origin_and_host(headers: &HeaderMap) -> Result<(), (StatusCode, &'static str)> { + let host = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + let host_name = host + .strip_prefix('[') + .and_then(|value| value.split_once(']').map(|(host, _)| host)) + .unwrap_or_else(|| host.split(':').next().unwrap_or_default()); + if !["localhost", "127.0.0.1", "::1"].contains(&host_name) { + return Err((StatusCode::FORBIDDEN, "Forbidden host")); + } + if let Some(origin) = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + { + let local = url::Url::parse(origin).ok().is_some_and(|origin| { + matches!(origin.host_str(), Some("localhost" | "127.0.0.1" | "::1")) + }); + if !local { + return Err((StatusCode::FORBIDDEN, "Forbidden origin")); + } + } + Ok(()) +} + +fn with_cors(mut response: Response, request_headers: &HeaderMap) -> Response { + let headers = response.headers_mut(); + let origin = request_headers + .get(header::ORIGIN) + .filter(|value| { + value.to_str().ok().is_some_and(|origin| { + url::Url::parse(origin).ok().is_some_and(|origin| { + matches!(origin.host_str(), Some("localhost" | "127.0.0.1" | "::1")) + }) + }) + }) + .cloned() + .unwrap_or_else(|| HeaderValue::from_static("http://127.0.0.1")); + headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin); + headers.insert( + header::ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_static("POST, OPTIONS"), + ); + headers.insert( + header::ACCESS_CONTROL_ALLOW_HEADERS, + HeaderValue::from_static("content-type, accept, origin, mcp-protocol-version"), + ); + headers.insert(header::VARY, HeaderValue::from_static("Origin")); + response +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn header_validation_rejects_dns_rebinding_and_remote_origins() { + let mut headers = HeaderMap::new(); + headers.insert(header::HOST, HeaderValue::from_static("attacker.example")); + assert!(validate_origin_and_host(&headers).is_err()); + headers.insert(header::HOST, HeaderValue::from_static("127.0.0.1:4124")); + headers.insert( + header::ORIGIN, + HeaderValue::from_static("https://attacker.example"), + ); + assert!(validate_origin_and_host(&headers).is_err()); + headers.insert( + header::ORIGIN, + HeaderValue::from_static("http://localhost:3000"), + ); + assert!(validate_origin_and_host(&headers).is_ok()); + } + + #[test] + fn router_only_allows_post_and_options() { + assert_eq!(axum::http::Method::POST.as_str(), "POST"); + assert_eq!(axum::http::Method::OPTIONS.as_str(), "OPTIONS"); + } +} diff --git a/crates/bds-core/src/engine/mcp/mod.rs b/crates/bds-core/src/engine/mcp/mod.rs new file mode 100644 index 0000000..7a1a2f7 --- /dev/null +++ b/crates/bds-core/src/engine/mcp/mod.rs @@ -0,0 +1,379 @@ +mod agent_config; +mod http; +mod protocol; +mod resources; +mod tools; + +use std::path::{Path, PathBuf}; + +use serde_json::Value; +use uuid::Uuid; + +use crate::db::queries::{mcp_proposal as proposal_q, project as project_q}; +use crate::db::{Database, DbConnection}; +use crate::engine::{EngineError, EngineResult, cli_sync, domain_events}; +use crate::model::DomainEvent; +use crate::util::now_unix_ms; + +pub use crate::model::{McpProposal, ProposalKind, ProposalStatus}; +pub use agent_config::{ + McpAgent, agent_config_path, install_agent_config, is_agent_configured, + packaged_mcp_executable, remove_agent_config, +}; +pub use http::McpHttpServer; +pub use protocol::{MCP_PROTOCOL_VERSION, handle_rpc}; +pub use resources::ResourceContent; + +pub const DEFAULT_HTTP_PORT: u16 = 4124; +pub const PROPOSAL_TTL_MS: i64 = 30 * 60 * 1_000; +pub const PROPOSALS_EVENT_KEY: &str = "mcp.proposals"; + +#[derive(Debug, Clone)] +pub struct McpContext { + database_path: PathBuf, +} + +impl McpContext { + pub fn new(database_path: PathBuf) -> Self { + Self { database_path } + } + + pub fn database_path(&self) -> &Path { + &self.database_path + } + + /// Prepare shared storage once when a transport starts. Individual + /// stateless read requests never run migrations or repair derived state. + pub fn prepare(&self) -> EngineResult<()> { + let db = Database::open(&self.database_path)?; + db.migrate() + .map_err(|error| EngineError::Parse(error.to_string()))?; + crate::engine::search::prepare_search_index(db.conn())?; + Ok(()) + } + + pub fn list_resources(&self) -> Vec { + resources::list() + } + + pub fn list_resource_templates(&self) -> Vec { + resources::templates() + } + + pub fn read_resource(&self, uri: &str) -> EngineResult { + let db = self.open_database()?; + resources::read(db.conn(), uri) + } + + pub fn list_tools(&self) -> Vec { + tools::list() + } + + pub fn call_tool(&self, name: &str, params: Value) -> EngineResult { + let db = self.open_database()?; + tools::call(db.conn(), name, params) + } + + pub(crate) fn open_database(&self) -> EngineResult { + Ok(Database::open(&self.database_path)?) + } +} + +pub fn get_proposal(conn: &DbConnection, proposal_id: &str) -> EngineResult { + proposal_q::get_proposal(conn, proposal_id) + .map_err(|_| EngineError::NotFound(format!("MCP proposal {proposal_id}"))) +} + +pub fn list_proposals(conn: &DbConnection, project_id: &str) -> EngineResult> { + expire_proposals(conn)?; + Ok(proposal_q::list_proposals(conn, project_id)?) +} + +pub fn list_pending_proposals( + conn: &DbConnection, + project_id: &str, +) -> EngineResult> { + expire_proposals(conn)?; + Ok(proposal_q::list_pending_proposals(conn, project_id)?) +} + +pub fn expire_proposals(conn: &DbConnection) -> EngineResult { + let expired = proposal_q::expire_pending(conn, now_unix_ms())?; + if expired > 0 { + notify_proposals_changed(); + } + Ok(expired) +} + +pub(crate) fn create_proposal( + conn: &DbConnection, + kind: ProposalKind, + project_id: &str, + entity_id: Option<&str>, + data: &Value, +) -> EngineResult { + expire_proposals(conn)?; + let now = now_unix_ms(); + let proposal = McpProposal { + id: Uuid::new_v4().to_string(), + project_id: project_id.to_string(), + kind, + status: ProposalStatus::Pending, + entity_id: entity_id.map(str::to_string), + data: serde_json::to_string(data)?, + result: None, + created_at: now, + expires_at: now + PROPOSAL_TTL_MS, + resolved_at: None, + }; + proposal_q::insert_proposal(conn, &proposal)?; + cli_sync::record_cli_event( + conn, + &DomainEvent::SettingsChanged { + project_id: None, + key: PROPOSALS_EVENT_KEY.to_string(), + }, + )?; + Ok(proposal) +} + +pub fn accept_proposal( + conn: &DbConnection, + data_dir: &Path, + proposal_id: &str, +) -> EngineResult { + resolve_proposal(conn, data_dir, proposal_id, true) +} + +pub fn reject_proposal( + conn: &DbConnection, + data_dir: &Path, + proposal_id: &str, +) -> EngineResult { + resolve_proposal(conn, data_dir, proposal_id, false) +} + +fn resolve_proposal( + conn: &DbConnection, + data_dir: &Path, + proposal_id: &str, + accept: bool, +) -> EngineResult { + expire_proposals(conn)?; + conn.begin_savepoint()?; + let outcome = (|| { + let now = now_unix_ms(); + if !proposal_q::claim_pending(conn, proposal_id, now)? { + let current = get_proposal(conn, proposal_id)?; + return Err(EngineError::Conflict(format!( + "MCP proposal {} is {}", + current.id, + current.status.as_str() + ))); + } + let proposal = get_proposal(conn, proposal_id)?; + let result = if accept { + execute_proposal(conn, data_dir, &proposal)? + } else { + serde_json::json!({"message": "rejected"}) + }; + let status = if accept { + ProposalStatus::Accepted + } else { + ProposalStatus::Rejected + }; + if !proposal_q::resolve_claimed( + conn, + proposal_id, + status, + &serde_json::to_string(&result)?, + now_unix_ms(), + )? { + return Err(EngineError::Conflict(format!( + "MCP proposal {proposal_id} was resolved concurrently" + ))); + } + get_proposal(conn, proposal_id) + })(); + match outcome { + Ok(proposal) => { + conn.release_savepoint()?; + notify_proposals_changed(); + Ok(proposal) + } + Err(error) => { + let _ = conn.rollback_savepoint(); + Err(error) + } + } +} + +fn execute_proposal( + conn: &DbConnection, + data_dir: &Path, + proposal: &McpProposal, +) -> EngineResult { + let data: Value = serde_json::from_str(&proposal.data)?; + match proposal.kind { + ProposalKind::DraftPost => { + let post = crate::engine::post::create_post( + conn, + data_dir, + &proposal.project_id, + required_string(&data, "title")?, + Some(required_string(&data, "content")?), + string_array(&data, "tags"), + string_array(&data, "categories"), + optional_string(&data, "author"), + optional_string(&data, "language"), + None, + )?; + let post = if let Some(excerpt) = optional_string(&data, "excerpt") { + crate::engine::post::update_post( + conn, + data_dir, + &post.id, + None, + None, + Some(Some(excerpt)), + None, + None, + None, + None, + None, + None, + None, + )? + } else { + post + }; + let post = crate::engine::post::publish_post(conn, data_dir, &post.id)?; + Ok(serde_json::to_value(post)?) + } + ProposalKind::ProposeScript => { + let kind = required_string(&data, "kind")? + .parse() + .map_err(EngineError::Validation)?; + let script = crate::engine::script::create_script( + conn, + &proposal.project_id, + required_string(&data, "title")?, + kind, + required_string(&data, "content")?, + optional_string(&data, "entrypoint"), + )?; + let script = crate::engine::script::publish_script(conn, data_dir, &script.id)?; + Ok(serde_json::to_value(script)?) + } + ProposalKind::ProposeTemplate => { + let kind = required_string(&data, "kind")? + .parse() + .map_err(EngineError::Validation)?; + let template = crate::engine::template::create_template( + conn, + &proposal.project_id, + required_string(&data, "title")?, + kind, + required_string(&data, "content")?, + )?; + let template = crate::engine::template::publish_template(conn, data_dir, &template.id)?; + Ok(serde_json::to_value(template)?) + } + ProposalKind::ProposeMediaTranslation => { + let translation = crate::engine::media::upsert_media_translation( + conn, + data_dir, + required_string(&data, "mediaId")?, + required_string(&data, "language")?, + optional_string(&data, "title"), + optional_string(&data, "alt"), + optional_string(&data, "caption"), + )?; + Ok(serde_json::to_value(translation)?) + } + ProposalKind::ProposeMediaMetadata => { + let media = crate::engine::media::update_media( + conn, + data_dir, + required_string(&data, "mediaId")?, + optional_optional_string(&data, "title"), + optional_optional_string(&data, "alt"), + optional_optional_string(&data, "caption"), + None, + None, + data.get("tags") + .is_some() + .then(|| string_array(&data, "tags")), + )?; + Ok(serde_json::to_value(media)?) + } + ProposalKind::ProposePostMetadata => { + let post = crate::engine::post::update_post( + conn, + data_dir, + required_string(&data, "postId")?, + optional_string(&data, "title"), + None, + optional_optional_string(&data, "excerpt"), + None, + data.get("tags") + .is_some() + .then(|| string_array(&data, "tags")), + data.get("categories") + .is_some() + .then(|| string_array(&data, "categories")), + None, + None, + None, + None, + )?; + Ok(serde_json::to_value(post)?) + } + } +} + +pub(crate) fn active_project( + conn: &DbConnection, +) -> EngineResult<(crate::model::Project, PathBuf)> { + let project = project_q::get_active_project(conn) + .map_err(|_| EngineError::NotFound("active project".into()))?; + let data_dir = project + .data_path + .as_deref() + .map(PathBuf::from) + .ok_or_else(|| EngineError::Validation("active project has no data path".into()))?; + Ok((project, data_dir)) +} + +pub(crate) fn required_string<'a>(value: &'a Value, key: &str) -> EngineResult<&'a str> { + value + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| EngineError::Validation(format!("{key} is required"))) +} + +pub(crate) fn optional_string<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value.get(key).and_then(Value::as_str) +} + +fn optional_optional_string<'a>(value: &'a Value, key: &str) -> Option> { + value.get(key).map(|value| value.as_str()) +} + +pub(crate) fn string_array(value: &Value, key: &str) -> Vec { + value + .get(key) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect() +} + +fn notify_proposals_changed() { + domain_events::settings_changed(None, PROPOSALS_EVENT_KEY); +} diff --git a/crates/bds-core/src/engine/mcp/protocol.rs b/crates/bds-core/src/engine/mcp/protocol.rs new file mode 100644 index 0000000..a0c1eef --- /dev/null +++ b/crates/bds-core/src/engine/mcp/protocol.rs @@ -0,0 +1,130 @@ +use serde_json::{Value, json}; + +use crate::engine::{EngineError, EngineResult}; + +use super::McpContext; + +pub const MCP_PROTOCOL_VERSION: &str = "2025-06-18"; + +pub fn handle_rpc(context: &McpContext, request: &Value) -> Option { + let id = request.get("id").cloned(); + let Some(method) = request.get("method").and_then(Value::as_str) else { + return Some(error(id.unwrap_or(Value::Null), -32600, "Invalid Request")); + }; + if request.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return Some(error(id.unwrap_or(Value::Null), -32600, "Invalid Request")); + } + let id = id?; + let params = request.get("params").cloned().unwrap_or_else(|| json!({})); + let result = match method { + "initialize" => Ok(json!({ + "protocolVersion": negotiated_version(¶ms), + "capabilities": { + "tools": {"listChanged": false}, + "resources": {"subscribe": false, "listChanged": false} + }, + "serverInfo": { + "name": "Blogging Desktop Server", + "version": env!("CARGO_PKG_VERSION") + } + })), + "ping" => Ok(json!({})), + "tools/list" => Ok(json!({"tools": context.list_tools()})), + "tools/call" => call_tool(context, ¶ms), + "resources/list" => Ok(json!({"resources": context.list_resources()})), + "resources/templates/list" => Ok(json!({ + "resourceTemplates": context.list_resource_templates() + })), + "resources/read" => read_resource(context, ¶ms), + _ => return Some(error(id, -32601, "Method not found")), + }; + Some(match result { + Ok(result) => success(id, result), + Err(EngineError::NotFound(message)) => error(id, -32004, &message), + Err(EngineError::Validation(message) | EngineError::Conflict(message)) => { + error(id, -32602, &message) + } + Err(error_value) => error(id, -32000, &error_value.to_string()), + }) +} + +fn negotiated_version(params: &Value) -> &str { + match params.get("protocolVersion").and_then(Value::as_str) { + Some("2025-03-26") => "2025-03-26", + Some("2025-06-18") => "2025-06-18", + _ => MCP_PROTOCOL_VERSION, + } +} + +fn call_tool(context: &McpContext, params: &Value) -> EngineResult { + let name = params + .get("name") + .and_then(Value::as_str) + .filter(|name| !name.is_empty()) + .ok_or_else(|| EngineError::Validation("tool name is required".into()))?; + let arguments = params + .get("arguments") + .cloned() + .unwrap_or_else(|| json!({})); + let result = context.call_tool(name, arguments)?; + Ok(json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string(&result)? + }], + "structuredContent": result, + "isError": false + })) +} + +fn read_resource(context: &McpContext, params: &Value) -> EngineResult { + let uri = params + .get("uri") + .and_then(Value::as_str) + .filter(|uri| !uri.is_empty()) + .ok_or_else(|| EngineError::Validation("resource URI is required".into()))?; + let content = context.read_resource(uri)?; + let content = if let Some(blob) = content.blob { + json!({ + "uri": content.uri, + "mimeType": content.mime_type, + "blob": blob + }) + } else { + json!({ + "uri": content.uri, + "mimeType": content.mime_type, + "text": content.text.unwrap_or_default() + }) + }; + Ok(json!({"contents": [content]})) +} + +fn success(id: Value, result: Value) -> Value { + json!({"jsonrpc":"2.0","id":id,"result":result}) +} + +pub(crate) fn error(id: Value, code: i64, message: &str) -> Value { + json!({"jsonrpc":"2.0","id":id,"error":{"code":code,"message":message}}) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn notifications_have_no_response_and_invalid_requests_are_rejected() { + let context = McpContext::new("missing.sqlite".into()); + assert!( + handle_rpc( + &context, + &json!({"jsonrpc":"2.0","method":"notifications/initialized"}) + ) + .is_none() + ); + assert_eq!( + handle_rpc(&context, &json!({"jsonrpc":"2.0","id":1})).unwrap()["error"]["code"], + -32600 + ); + } +} diff --git a/crates/bds-core/src/engine/mcp/resources.rs b/crates/bds-core/src/engine/mcp/resources.rs new file mode 100644 index 0000000..3757842 --- /dev/null +++ b/crates/bds-core/src/engine/mcp/resources.rs @@ -0,0 +1,382 @@ +use std::path::Path; + +use base64::Engine as _; +use serde_json::{Value, json}; + +use crate::db::DbConnection; +use crate::db::queries::{media as media_q, post as post_q, post_link, post_media, tag as tag_q}; +use crate::engine::{EngineError, EngineResult}; +use crate::model::{Media, Post}; + +use super::active_project; + +const PAGE_SIZE: usize = 50; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResourceContent { + pub uri: String, + pub mime_type: String, + pub text: Option, + pub blob: Option, +} + +impl ResourceContent { + fn json(uri: &str, value: &Value) -> EngineResult { + Ok(Self { + uri: uri.to_string(), + mime_type: "application/json".into(), + text: Some(serde_json::to_string(value)?), + blob: None, + }) + } +} + +pub fn list() -> Vec { + [ + ("project", "Active project", "bds://project"), + ("posts", "Blog posts", "bds://posts"), + ("media", "Media", "bds://media"), + ("tags", "Tags", "bds://tags"), + ("categories", "Categories", "bds://categories"), + ("stats", "Blog statistics", "bds://stats"), + ] + .into_iter() + .map(|(name, title, uri)| { + json!({ + "name": name, + "title": title, + "uri": uri, + "mimeType": "application/json" + }) + }) + .collect() +} + +pub fn templates() -> Vec { + [ + ("posts", "Paginated blog posts", "bds://posts{?cursor}"), + ("media", "Paginated media", "bds://media{?cursor}"), + ( + "post media", + "Media linked to a post", + "bds://posts/{id}/media", + ), + ( + "media image", + "Original media bytes", + "bds://media/{id}/image", + ), + ] + .into_iter() + .map(|(name, title, uri_template)| { + json!({ + "name": name, + "title": title, + "uriTemplate": uri_template + }) + }) + .collect() +} + +pub fn read(conn: &DbConnection, uri: &str) -> EngineResult { + let url = url::Url::parse(uri) + .map_err(|_| EngineError::Validation("invalid MCP resource URI".into()))?; + if url.scheme() != "bds" { + return Err(EngineError::NotFound(uri.into())); + } + let host = url + .host_str() + .ok_or_else(|| EngineError::NotFound(uri.into()))?; + let path = url.path().trim_matches('/'); + let (project, data_dir) = active_project(conn)?; + let value = match (host, path) { + ("project", "") => project_resource(&project, &data_dir), + ("posts", "") => posts_page(conn, &project.id, cursor_offset(&url)?), + ("media", "") => media_page(conn, &project.id, cursor_offset(&url)?), + ("tags", "") => tags(conn, &project.id), + ("categories", "") => categories(conn, &project.id, &data_dir), + ("stats", "") => stats(conn, &project.id, &data_dir), + ("posts", path) => { + let parts = path.split('/').collect::>(); + match parts.as_slice() { + [post_id] => post_detail_by_id(conn, &project.id, &data_dir, post_id), + [post_id, "media"] => post_media_items(conn, &project.id, post_id), + _ => return Err(EngineError::NotFound(uri.into())), + } + } + ("media", path) => { + let parts = path.split('/').collect::>(); + match parts.as_slice() { + [media_id] => media_detail_by_id(conn, &project.id, media_id), + [media_id, "image"] => { + return media_image(conn, &project.id, &data_dir, media_id, uri); + } + _ => return Err(EngineError::NotFound(uri.into())), + } + } + _ => return Err(EngineError::NotFound(uri.into())), + }?; + ResourceContent::json(uri, &value) +} + +fn project_resource(project: &crate::model::Project, data_dir: &Path) -> EngineResult { + let metadata = crate::engine::meta::read_project_json(data_dir).ok(); + Ok(json!({ + "id": project.id, + "name": project.name, + "slug": project.slug, + "description": project.description, + "public_url": metadata.as_ref().and_then(|value| value.public_url.clone()), + "main_language": metadata.as_ref().and_then(|value| value.main_language.clone()), + "blog_languages": metadata.map(|value| value.blog_languages).unwrap_or_default() + })) +} + +fn cursor_offset(url: &url::Url) -> EngineResult { + let Some(cursor) = url + .query_pairs() + .find_map(|(key, value)| (key == "cursor").then(|| value.into_owned())) + else { + return Ok(0); + }; + if cursor.is_empty() { + return Err(EngineError::Validation("invalid cursor".into())); + } + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(cursor) + .map_err(|_| EngineError::Validation("invalid cursor".into()))?; + let value: Value = serde_json::from_slice(&decoded) + .map_err(|_| EngineError::Validation("invalid cursor".into()))?; + value["offset"] + .as_u64() + .map(|offset| offset as usize) + .ok_or_else(|| EngineError::Validation("invalid cursor".into())) +} + +fn encode_cursor(offset: usize) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&json!({"offset": offset})).unwrap_or_default()) +} + +fn page(items: Vec, total: usize, offset: usize) -> Value { + let mut value = json!({ + "items": items, + "total": total, + "offset": offset, + "limit": PAGE_SIZE + }); + let next = offset.saturating_add(PAGE_SIZE); + if next < total { + value["nextCursor"] = Value::String(encode_cursor(next)); + } + value +} + +fn posts_page(conn: &DbConnection, project_id: &str, offset: usize) -> EngineResult { + let posts = post_q::list_posts_by_project(conn, project_id)?; + let total = posts.len(); + let items = posts + .into_iter() + .skip(offset) + .take(PAGE_SIZE) + .map(|post| post_summary(conn, &post)) + .collect::>>()?; + Ok(page(items, total, offset)) +} + +fn media_page(conn: &DbConnection, project_id: &str, offset: usize) -> EngineResult { + let media = media_q::list_media_by_project(conn, project_id)?; + let total = media.len(); + let items = media + .into_iter() + .skip(offset) + .take(PAGE_SIZE) + .map(|item| media_summary(&item)) + .collect(); + Ok(page(items, total, offset)) +} + +pub(crate) fn post_summary(conn: &DbConnection, post: &Post) -> EngineResult { + Ok(json!({ + "id": post.id, + "title": post.title, + "slug": post.slug, + "status": post.status, + "tags": post.tags, + "categories": post.categories, + "created_at": post.created_at, + "backlinks": linked_posts(conn, &post.id, false)?, + "linksTo": linked_posts(conn, &post.id, true)? + })) +} + +fn linked_posts(conn: &DbConnection, post_id: &str, outgoing: bool) -> EngineResult> { + let links = if outgoing { + post_link::list_links_by_source(conn, post_id)? + .into_iter() + .map(|link| link.target_post_id) + .collect::>() + } else { + post_link::list_links_by_target(conn, post_id)? + .into_iter() + .map(|link| link.source_post_id) + .collect::>() + }; + Ok(links + .into_iter() + .filter_map(|id| post_q::get_post_by_id(conn, &id).ok()) + .map(|post| json!({"id": post.id, "title": post.title, "slug": post.slug})) + .collect()) +} + +pub(crate) fn post_detail( + conn: &DbConnection, + data_dir: &Path, + post: &Post, +) -> EngineResult { + let mut value = serde_json::to_value(post)?; + value["content"] = Value::String(post_body(data_dir, post)); + value["backlinks"] = Value::Array(linked_posts(conn, &post.id, false)?); + value["linksTo"] = Value::Array(linked_posts(conn, &post.id, true)?); + let translations = + crate::db::queries::post_translation::list_post_translations_by_post(conn, &post.id)?; + let mut languages = post.language.clone().into_iter().collect::>(); + languages.extend( + translations + .into_iter() + .map(|translation| translation.language), + ); + languages.sort(); + languages.dedup(); + value["availableLanguages"] = serde_json::to_value(languages)?; + Ok(value) +} + +fn post_body(data_dir: &Path, post: &Post) -> String { + if let Some(content) = &post.content { + return content.clone(); + } + if post.file_path.is_empty() { + return String::new(); + } + std::fs::read_to_string(data_dir.join(&post.file_path)) + .ok() + .and_then(|source| { + crate::util::frontmatter::read_post_file(&source) + .ok() + .map(|(_, body)| body) + }) + .unwrap_or_default() +} + +fn post_detail_by_id( + conn: &DbConnection, + project_id: &str, + data_dir: &Path, + id: &str, +) -> EngineResult { + let post = post_q::get_post_by_id(conn, id) + .map_err(|_| EngineError::NotFound(format!("post {id}")))?; + ensure_project(project_id, &post.project_id, "post", id)?; + post_detail(conn, data_dir, &post) +} + +pub(crate) fn media_summary(media: &Media) -> Value { + json!({ + "id": media.id, + "filename": media.filename, + "title": media.title, + "alt": media.alt, + "caption": media.caption, + "tags": media.tags + }) +} + +fn media_detail_by_id(conn: &DbConnection, project_id: &str, id: &str) -> EngineResult { + let media = media_q::get_media_by_id(conn, id) + .map_err(|_| EngineError::NotFound(format!("media {id}")))?; + ensure_project(project_id, &media.project_id, "media", id)?; + Ok(serde_json::to_value(media)?) +} + +fn post_media_items(conn: &DbConnection, project_id: &str, post_id: &str) -> EngineResult { + let post = post_q::get_post_by_id(conn, post_id) + .map_err(|_| EngineError::NotFound(format!("post {post_id}")))?; + ensure_project(project_id, &post.project_id, "post", post_id)?; + let items = post_media::list_post_media_by_post(conn, post_id)? + .into_iter() + .filter_map(|link| media_q::get_media_by_id(conn, &link.media_id).ok()) + .map(|media| media_summary(&media)) + .collect::>(); + Ok(json!({"items": items})) +} + +fn media_image( + conn: &DbConnection, + project_id: &str, + data_dir: &Path, + media_id: &str, + uri: &str, +) -> EngineResult { + let media = media_q::get_media_by_id(conn, media_id) + .map_err(|_| EngineError::NotFound(format!("media {media_id}")))?; + ensure_project(project_id, &media.project_id, "media", media_id)?; + let bytes = std::fs::read(data_dir.join(&media.file_path)) + .map_err(|_| EngineError::NotFound(format!("media file {media_id}")))?; + Ok(ResourceContent { + uri: uri.to_string(), + mime_type: media.mime_type, + text: None, + blob: Some(base64::engine::general_purpose::STANDARD.encode(bytes)), + }) +} + +fn tags(conn: &DbConnection, project_id: &str) -> EngineResult { + let posts = post_q::list_posts_by_project(conn, project_id)?; + let items = tag_q::list_tags_by_project(conn, project_id)? + .into_iter() + .map(|tag| { + let count = posts + .iter() + .filter(|post| post.tags.iter().any(|name| name == &tag.name)) + .count(); + json!({"name": tag.name, "color": tag.color, "post_count": count}) + }) + .collect::>(); + Ok(json!({"items": items})) +} + +fn categories(conn: &DbConnection, project_id: &str, data_dir: &Path) -> EngineResult { + let posts = post_q::list_posts_by_project(conn, project_id)?; + let names = crate::engine::meta::read_categories_json(data_dir) + .unwrap_or_else(|_| post_q::distinct_post_categories(conn, project_id).unwrap_or_default()); + let items = names + .into_iter() + .map(|name| { + let count = posts + .iter() + .filter(|post| post.categories.iter().any(|value| value == &name)) + .count(); + json!({"name": name, "post_count": count}) + }) + .collect::>(); + Ok(json!({"items": items})) +} + +fn stats(conn: &DbConnection, project_id: &str, data_dir: &Path) -> EngineResult { + let categories = categories(conn, project_id, data_dir)?; + Ok(json!({ + "post_count": post_q::count_posts_by_project(conn, project_id)?, + "media_count": media_q::count_media_by_project(conn, project_id)?, + "tag_count": tag_q::list_tags_by_project(conn, project_id)?.len(), + "category_count": categories["items"].as_array().map_or(0, Vec::len) + })) +} + +fn ensure_project(expected: &str, actual: &str, entity: &str, id: &str) -> EngineResult<()> { + if expected == actual { + Ok(()) + } else { + Err(EngineError::NotFound(format!("{entity} {id}"))) + } +} diff --git a/crates/bds-core/src/engine/mcp/tools.rs b/crates/bds-core/src/engine/mcp/tools.rs new file mode 100644 index 0000000..2235de5 --- /dev/null +++ b/crates/bds-core/src/engine/mcp/tools.rs @@ -0,0 +1,646 @@ +use std::collections::BTreeMap; + +use chrono::{Datelike, TimeZone as _, Utc}; +use serde_json::{Map, Value, json}; + +use crate::db::DbConnection; +use crate::db::queries::{media as media_q, media_translation, post as post_q, post_translation}; +use crate::engine::{EngineError, EngineResult}; +use crate::model::{Post, ProposalKind}; + +use super::resources::{post_detail, post_summary}; +use super::{active_project, create_proposal, optional_string, required_string, string_array}; + +const MAX_PAGE_SIZE: usize = 50; + +pub fn list() -> Vec { + [ + tool( + "check_term", + "Check Term", + "Check whether a term is a category, a tag, or both, with post counts.", + object_schema(json!({"term": string_schema("Term to check")}), &["term"]), + true, + ), + tool( + "search_posts", + "Search Posts", + "Full-text and filtered post search with pagination, backlinks, and outgoing links.", + post_query_schema(false), + true, + ), + tool( + "count_posts", + "Count Posts", + "Count filtered posts grouped by year, month, tag, category, or status.", + count_schema(), + true, + ), + tool( + "read_post_by_slug", + "Read Post By Slug", + "Read full post content and metadata, optionally in a translated language.", + object_schema( + json!({ + "slug": string_schema("Post slug"), + "language": string_schema("Optional translation language") + }), + &["slug"], + ), + true, + ), + tool( + "get_post_translations", + "Get Post Translations", + "List every translation for a post.", + id_schema("postId", "Post ID"), + true, + ), + tool( + "get_media_translations", + "Get Media Translations", + "List every translated metadata record for a media item.", + id_schema("mediaId", "Media ID"), + true, + ), + tool( + "upsert_media_translation", + "Propose Media Translation", + "Propose translated media metadata for explicit desktop approval.", + object_schema( + json!({ + "mediaId": string_schema("Media ID"), + "language": string_schema("Language code"), + "title": string_schema("Translated title"), + "alt": string_schema("Translated alt text"), + "caption": string_schema("Translated caption") + }), + &["mediaId", "language"], + ), + false, + ), + tool( + "draft_post", + "Draft Post", + "Propose a post. No post is created until explicit desktop approval.", + object_schema( + json!({ + "title": string_schema("Post title"), + "content": string_schema("Markdown body"), + "excerpt": string_schema("Excerpt"), + "tags": string_array_schema("Tags"), + "categories": string_array_schema("Categories"), + "author": string_schema("Author"), + "language": string_schema("Language") + }), + &["title", "content"], + ), + false, + ), + tool( + "propose_script", + "Propose Script", + "Validate and propose a Lua script for explicit desktop approval.", + object_schema( + json!({ + "title": string_schema("Script title"), + "kind": {"type":"string","enum":["macro","utility","transform"]}, + "content": string_schema("Lua source"), + "entrypoint": string_schema("Entrypoint function") + }), + &["title", "kind", "content"], + ), + false, + ), + tool( + "propose_template", + "Propose Template", + "Validate and propose a Liquid template for explicit desktop approval.", + object_schema( + json!({ + "title": string_schema("Template title"), + "kind": {"type":"string","enum":["post","list","not-found","partial"]}, + "content": string_schema("Liquid source") + }), + &["title", "kind", "content"], + ), + false, + ), + tool( + "propose_media_metadata", + "Propose Media Metadata", + "Propose media metadata changes for explicit desktop approval.", + object_schema( + json!({ + "mediaId": string_schema("Media ID"), + "title": nullable_string_schema("Title"), + "alt": nullable_string_schema("Alt text"), + "caption": nullable_string_schema("Caption"), + "tags": string_array_schema("Tags") + }), + &["mediaId"], + ), + false, + ), + tool( + "propose_post_metadata", + "Propose Post Metadata", + "Propose post metadata changes for explicit desktop approval.", + object_schema( + json!({ + "postId": string_schema("Post ID"), + "title": string_schema("Title"), + "excerpt": nullable_string_schema("Excerpt"), + "tags": string_array_schema("Tags"), + "categories": string_array_schema("Categories") + }), + &["postId"], + ), + false, + ), + ] + .into_iter() + .collect() +} + +pub fn call(conn: &DbConnection, name: &str, params: Value) -> EngineResult { + if !params.is_object() { + return Err(EngineError::Validation( + "tool arguments must be an object".into(), + )); + } + match name { + "check_term" => check_term(conn, ¶ms), + "search_posts" => search_posts(conn, ¶ms), + "count_posts" => count_posts(conn, ¶ms), + "read_post_by_slug" => read_post_by_slug(conn, ¶ms), + "get_post_translations" => get_post_translations(conn, ¶ms), + "get_media_translations" => get_media_translations(conn, ¶ms), + "upsert_media_translation" => propose_media_translation(conn, ¶ms), + "draft_post" => propose(conn, ProposalKind::DraftPost, None, ¶ms), + "propose_script" => propose_script(conn, ¶ms), + "propose_template" => propose_template(conn, ¶ms), + "propose_media_metadata" => propose_media_metadata(conn, ¶ms), + "propose_post_metadata" => propose_post_metadata(conn, ¶ms), + _ => Err(EngineError::NotFound(format!("MCP tool {name}"))), + } +} + +fn tool(name: &str, title: &str, description: &str, schema: Value, read_only: bool) -> Value { + json!({ + "name": name, + "title": title, + "description": description, + "inputSchema": schema, + "annotations": { + "readOnlyHint": read_only, + "destructiveHint": false, + "openWorldHint": false + } + }) +} + +fn string_schema(description: &str) -> Value { + json!({"type":"string","description":description}) +} + +fn nullable_string_schema(description: &str) -> Value { + json!({"type":["string","null"],"description":description}) +} + +fn string_array_schema(description: &str) -> Value { + json!({"type":"array","items":{"type":"string"},"description":description}) +} + +fn object_schema(properties: Value, required: &[&str]) -> Value { + let mut schema = json!({ + "type": "object", + "properties": properties, + "additionalProperties": false + }); + if !required.is_empty() { + schema["required"] = serde_json::to_value(required).unwrap_or_default(); + } + schema +} + +fn id_schema(field: &str, description: &str) -> Value { + let mut properties = Map::new(); + properties.insert(field.into(), string_schema(description)); + object_schema(Value::Object(properties), &[field]) +} + +fn post_query_schema(query_required: bool) -> Value { + object_schema( + json!({ + "query": string_schema("Full-text query"), + "category": string_schema("Category filter"), + "tags": string_array_schema("All required tags"), + "language": string_schema("Available language"), + "missingTranslationLanguage": string_schema("Missing translation language"), + "year": {"type":"integer"}, + "month": {"type":"integer","minimum":1,"maximum":12}, + "status": {"type":"string","enum":["draft","published","archived"]}, + "offset": {"type":"integer","minimum":0}, + "limit": {"type":"integer","minimum":1,"maximum":MAX_PAGE_SIZE} + }), + if query_required { &["query"] } else { &[] }, + ) +} + +fn count_schema() -> Value { + let mut schema = post_query_schema(false); + schema["properties"]["groupBy"] = json!({"type":"array","items":{"type":"string","enum":["year","month","tag","category","status"]},"minItems":1}); + schema["required"] = json!(["groupBy"]); + schema +} + +fn check_term(conn: &DbConnection, params: &Value) -> EngineResult { + let term = required_string(params, "term")?; + let (project, _) = active_project(conn)?; + let posts = post_q::list_posts_by_project(conn, &project.id)?; + let normalized = term.to_lowercase(); + let tag_count = posts + .iter() + .filter(|post| post.tags.iter().any(|tag| tag.to_lowercase() == normalized)) + .count(); + let category_count = posts + .iter() + .filter(|post| { + post.categories + .iter() + .any(|category| category.to_lowercase() == normalized) + }) + .count(); + Ok(json!({ + "is_category": category_count > 0, + "category_post_count": category_count, + "is_tag": tag_count > 0, + "tag_post_count": tag_count + })) +} + +fn filtered_posts(conn: &DbConnection, params: &Value) -> EngineResult> { + let (project, _) = active_project(conn)?; + let mut posts = post_q::list_posts_by_project(conn, &project.id)?; + let query = optional_string(params, "query") + .unwrap_or_default() + .trim() + .to_lowercase(); + let fts_matches = if query.is_empty() { + None + } else { + let language = optional_string(params, "language").unwrap_or("en"); + Some( + crate::db::fts::search_posts(conn, &query, language)? + .into_iter() + .collect::>(), + ) + }; + let category = optional_string(params, "category").map(str::to_lowercase); + let tags = string_array(params, "tags") + .into_iter() + .map(|tag| tag.to_lowercase()) + .collect::>(); + let language = optional_string(params, "language").map(str::to_lowercase); + let missing_language = + optional_string(params, "missingTranslationLanguage").map(str::to_lowercase); + let status = optional_string(params, "status"); + let year = integer(params, "year")?.map(|value| value as i32); + let month = integer(params, "month")?.map(|value| value as u32); + if month.is_some() && year.is_none() { + return Err(EngineError::Validation("month requires year".into())); + } + posts.retain(|post| { + if fts_matches + .as_ref() + .is_some_and(|matches| !matches.contains(&post.id)) + { + return false; + } + if category.as_ref().is_some_and(|wanted| { + !post + .categories + .iter() + .any(|value| value.to_lowercase() == *wanted) + }) { + return false; + } + if tags.iter().any(|wanted| { + !post + .tags + .iter() + .any(|value| value.to_lowercase() == *wanted) + }) { + return false; + } + if status.is_some_and(|wanted| post.status.as_str() != wanted) { + return false; + } + if let Some(wanted) = &language { + let canonical = post + .language + .as_deref() + .is_some_and(|value| value.eq_ignore_ascii_case(wanted)); + let translated = + post_translation::get_post_translation_by_post_and_language(conn, &post.id, wanted) + .is_ok(); + if !canonical && !translated { + return false; + } + } + if let Some(wanted) = &missing_language { + let canonical = post + .language + .as_deref() + .is_some_and(|value| value.eq_ignore_ascii_case(wanted)); + let translated = + post_translation::get_post_translation_by_post_and_language(conn, &post.id, wanted) + .is_ok(); + if canonical || translated { + return false; + } + } + if let Some(wanted_year) = year { + let Some(date) = Utc.timestamp_millis_opt(post.created_at).single() else { + return false; + }; + if date.year() != wanted_year || month.is_some_and(|wanted| date.month() != wanted) { + return false; + } + } + true + }); + Ok(posts) +} + +fn search_posts(conn: &DbConnection, params: &Value) -> EngineResult { + let posts = filtered_posts(conn, params)?; + let total = posts.len(); + let offset = unsigned(params, "offset")?.unwrap_or(0); + let limit = unsigned(params, "limit")?.unwrap_or(MAX_PAGE_SIZE); + if limit == 0 || limit > MAX_PAGE_SIZE { + return Err(EngineError::Validation(format!( + "limit must be between 1 and {MAX_PAGE_SIZE}" + ))); + } + let posts = posts + .into_iter() + .skip(offset) + .take(limit) + .map(|post| post_summary(conn, &post)) + .collect::>>()?; + Ok(json!({ + "total": total, + "offset": offset, + "limit": limit, + "hasMore": offset.saturating_add(limit) < total, + "posts": posts + })) +} + +fn count_posts(conn: &DbConnection, params: &Value) -> EngineResult { + let group_by = string_array(params, "groupBy"); + if group_by.is_empty() + || group_by.iter().any(|dimension| { + !["year", "month", "tag", "category", "status"].contains(&dimension.as_str()) + }) + { + return Err(EngineError::Validation("invalid groupBy".into())); + } + let posts = filtered_posts(conn, params)?; + let total = posts.len(); + let mut counts = BTreeMap::, usize)>::new(); + for post in posts { + for row in group_rows(&post, &group_by) { + let key = serde_json::to_string(&row)?; + counts + .entry(key) + .and_modify(|(_, count)| *count += 1) + .or_insert((row, 1)); + } + } + let groups = counts + .into_values() + .map(|(mut row, count)| { + row.insert("count".into(), json!(count)); + Value::Object(row) + }) + .collect::>(); + Ok(json!({"groups": groups, "totalPosts": total})) +} + +fn group_rows(post: &Post, dimensions: &[String]) -> Vec> { + let Some((dimension, rest)) = dimensions.split_first() else { + return vec![Map::new()]; + }; + let values = match dimension.as_str() { + "year" => Utc + .timestamp_millis_opt(post.created_at) + .single() + .map(|date| vec![json!(date.year())]) + .unwrap_or_else(|| vec![Value::Null]), + "month" => Utc + .timestamp_millis_opt(post.created_at) + .single() + .map(|date| vec![json!(date.month())]) + .unwrap_or_else(|| vec![Value::Null]), + "tag" => values_or_null(&post.tags), + "category" => values_or_null(&post.categories), + "status" => vec![json!(post.status.as_str())], + _ => vec![Value::Null], + }; + values + .into_iter() + .flat_map(|value| { + group_rows(post, rest).into_iter().map({ + let dimension = dimension.clone(); + move |mut row| { + row.insert(dimension.clone(), value.clone()); + row + } + }) + }) + .collect() +} + +fn values_or_null(values: &[String]) -> Vec { + if values.is_empty() { + vec![Value::Null] + } else { + values.iter().map(|value| json!(value)).collect() + } +} + +fn read_post_by_slug(conn: &DbConnection, params: &Value) -> EngineResult { + let slug = required_string(params, "slug")?; + let (project, data_dir) = active_project(conn)?; + let post = post_q::get_post_by_project_and_slug(conn, &project.id, slug) + .map_err(|_| EngineError::NotFound(format!("post slug {slug}")))?; + let Some(language) = optional_string(params, "language") else { + return Ok(json!({"post": post_detail(conn, &data_dir, &post)?})); + }; + if post + .language + .as_deref() + .is_some_and(|canonical| canonical.eq_ignore_ascii_case(language)) + { + return Ok(json!({"post": post_detail(conn, &data_dir, &post)?})); + } + let translation = + post_translation::get_post_translation_by_post_and_language(conn, &post.id, language) + .map_err(|_| EngineError::NotFound(format!("post translation {language}")))?; + let mut detail = post_detail(conn, &data_dir, &post)?; + detail["title"] = json!(translation.title); + detail["excerpt"] = json!(translation.excerpt); + detail["content"] = json!(translation_content(&data_dir, &translation)); + detail["language"] = json!(translation.language); + detail["canonicalLanguage"] = json!(post.language); + Ok(json!({"post": detail})) +} + +fn translation_content( + data_dir: &std::path::Path, + translation: &crate::model::PostTranslation, +) -> String { + if let Some(content) = &translation.content { + return content.clone(); + } + std::fs::read_to_string(data_dir.join(&translation.file_path)) + .ok() + .and_then(|source| { + crate::util::frontmatter::read_translation_file(&source) + .ok() + .map(|(_, body)| body) + }) + .unwrap_or_default() +} + +fn get_post_translations(conn: &DbConnection, params: &Value) -> EngineResult { + let id = required_string(params, "postId")?; + let (project, data_dir) = active_project(conn)?; + let post = post_q::get_post_by_id(conn, id) + .map_err(|_| EngineError::NotFound(format!("post {id}")))?; + if post.project_id != project.id { + return Err(EngineError::NotFound(format!("post {id}"))); + } + let translations = post_translation::list_post_translations_by_post(conn, id)? + .into_iter() + .map(|translation| { + let mut value = serde_json::to_value(&translation)?; + value["content"] = json!(translation_content(&data_dir, &translation)); + Ok(value) + }) + .collect::>>()?; + Ok(json!({"translations": translations})) +} + +fn get_media_translations(conn: &DbConnection, params: &Value) -> EngineResult { + let id = required_string(params, "mediaId")?; + let (project, _) = active_project(conn)?; + let media = media_q::get_media_by_id(conn, id) + .map_err(|_| EngineError::NotFound(format!("media {id}")))?; + if media.project_id != project.id { + return Err(EngineError::NotFound(format!("media {id}"))); + } + Ok(json!({ + "translations": media_translation::list_media_translations_by_media(conn, id)? + })) +} + +fn propose_media_translation(conn: &DbConnection, params: &Value) -> EngineResult { + let id = required_string(params, "mediaId")?; + required_string(params, "language")?; + let (project, _) = active_project(conn)?; + let media = media_q::get_media_by_id(conn, id) + .map_err(|_| EngineError::NotFound(format!("media {id}")))?; + if media.project_id != project.id { + return Err(EngineError::NotFound(format!("media {id}"))); + } + propose( + conn, + ProposalKind::ProposeMediaTranslation, + Some(id), + params, + ) +} + +fn propose_script(conn: &DbConnection, params: &Value) -> EngineResult { + required_string(params, "title")?; + let content = required_string(params, "content")?; + required_string(params, "kind")? + .parse::() + .map_err(EngineError::Validation)?; + crate::engine::script::validate_script_syntax(content).map_err(EngineError::Validation)?; + propose(conn, ProposalKind::ProposeScript, None, params) +} + +fn propose_template(conn: &DbConnection, params: &Value) -> EngineResult { + required_string(params, "title")?; + let content = required_string(params, "content")?; + required_string(params, "kind")? + .parse::() + .map_err(EngineError::Validation)?; + crate::engine::template::validate_template(content).map_err(EngineError::Validation)?; + propose(conn, ProposalKind::ProposeTemplate, None, params) +} + +fn propose_media_metadata(conn: &DbConnection, params: &Value) -> EngineResult { + let id = required_string(params, "mediaId")?; + let (project, _) = active_project(conn)?; + let media = media_q::get_media_by_id(conn, id) + .map_err(|_| EngineError::NotFound(format!("media {id}")))?; + if media.project_id != project.id { + return Err(EngineError::NotFound(format!("media {id}"))); + } + propose(conn, ProposalKind::ProposeMediaMetadata, Some(id), params) +} + +fn propose_post_metadata(conn: &DbConnection, params: &Value) -> EngineResult { + let id = required_string(params, "postId")?; + let (project, _) = active_project(conn)?; + let post = post_q::get_post_by_id(conn, id) + .map_err(|_| EngineError::NotFound(format!("post {id}")))?; + if post.project_id != project.id { + return Err(EngineError::NotFound(format!("post {id}"))); + } + propose(conn, ProposalKind::ProposePostMetadata, Some(id), params) +} + +fn propose( + conn: &DbConnection, + kind: ProposalKind, + entity_id: Option<&str>, + params: &Value, +) -> EngineResult { + if kind == ProposalKind::DraftPost { + required_string(params, "title")?; + required_string(params, "content")?; + } + let (project, _) = active_project(conn)?; + let proposal = create_proposal(conn, kind, &project.id, entity_id, params)?; + Ok(json!({ + "proposalId": proposal.id, + "status": proposal.status, + "expiresAt": proposal.expires_at, + "message": "Pending explicit approval in RuDS Settings" + })) +} + +fn integer(value: &Value, key: &str) -> EngineResult> { + match value.get(key) { + None => Ok(None), + Some(value) => value + .as_i64() + .map(Some) + .ok_or_else(|| EngineError::Validation(format!("{key} must be an integer"))), + } +} + +fn unsigned(value: &Value, key: &str) -> EngineResult> { + integer(value, key)?.map_or(Ok(None), |value| { + usize::try_from(value) + .map(Some) + .map_err(|_| EngineError::Validation(format!("{key} cannot be negative"))) + }) +} diff --git a/crates/bds-core/src/engine/media.rs b/crates/bds-core/src/engine/media.rs index 5d0db77..54b1eb1 100644 --- a/crates/bds-core/src/engine/media.rs +++ b/crates/bds-core/src/engine/media.rs @@ -595,6 +595,7 @@ pub fn upsert_media_translation( // Re-index FTS for parent media fts_index_media(conn, &media)?; + emit_media(&media, NotificationAction::Updated); Ok(translation) } diff --git a/crates/bds-core/src/engine/mod.rs b/crates/bds-core/src/engine/mod.rs index a8afe88..48b9831 100644 --- a/crates/bds-core/src/engine/mod.rs +++ b/crates/bds-core/src/engine/mod.rs @@ -9,6 +9,7 @@ pub mod error; pub mod gallery_import; pub mod generation; pub mod git; +pub mod mcp; pub mod media; pub mod menu; pub mod meta; diff --git a/crates/bds-core/src/model/mcp.rs b/crates/bds-core/src/model/mcp.rs new file mode 100644 index 0000000..1313410 --- /dev/null +++ b/crates/bds-core/src/model/mcp.rs @@ -0,0 +1,128 @@ +use serde::{Deserialize, Serialize}; + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Serialize, + Deserialize, + diesel::AsExpression, + diesel::FromSqlRow, +)] +#[diesel(sql_type = diesel::sql_types::Text)] +#[serde(rename_all = "snake_case")] +pub enum ProposalKind { + DraftPost, + ProposeScript, + ProposeTemplate, + ProposeMediaTranslation, + ProposeMediaMetadata, + ProposePostMetadata, +} + +impl ProposalKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::DraftPost => "draft_post", + Self::ProposeScript => "propose_script", + Self::ProposeTemplate => "propose_template", + Self::ProposeMediaTranslation => "propose_media_translation", + Self::ProposeMediaMetadata => "propose_media_metadata", + Self::ProposePostMetadata => "propose_post_metadata", + } + } +} + +impl std::str::FromStr for ProposalKind { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "draft_post" => Ok(Self::DraftPost), + "propose_script" => Ok(Self::ProposeScript), + "propose_template" => Ok(Self::ProposeTemplate), + "propose_media_translation" => Ok(Self::ProposeMediaTranslation), + "propose_media_metadata" => Ok(Self::ProposeMediaMetadata), + "propose_post_metadata" => Ok(Self::ProposePostMetadata), + _ => Err(format!("invalid proposal kind: {value}")), + } + } +} + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Serialize, + Deserialize, + diesel::AsExpression, + diesel::FromSqlRow, +)] +#[diesel(sql_type = diesel::sql_types::Text)] +#[serde(rename_all = "lowercase")] +pub enum ProposalStatus { + Pending, + Executing, + Accepted, + Rejected, + Expired, +} + +impl ProposalStatus { + pub const fn as_str(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Executing => "executing", + Self::Accepted => "accepted", + Self::Rejected => "rejected", + Self::Expired => "expired", + } + } +} + +impl std::str::FromStr for ProposalStatus { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "pending" => Ok(Self::Pending), + "executing" => Ok(Self::Executing), + "accepted" => Ok(Self::Accepted), + "rejected" => Ok(Self::Rejected), + "expired" => Ok(Self::Expired), + _ => Err(format!("invalid proposal status: {value}")), + } + } +} + +#[derive( + Debug, + Clone, + PartialEq, + Eq, + Serialize, + Deserialize, + diesel::Queryable, + diesel::Selectable, + diesel::Insertable, +)] +#[diesel( + table_name = crate::db::schema::mcp_proposals, + check_for_backend(diesel::sqlite::Sqlite) +)] +pub struct McpProposal { + pub id: String, + pub project_id: String, + pub kind: ProposalKind, + pub status: ProposalStatus, + pub entity_id: Option, + pub data: String, + pub result: Option, + pub created_at: i64, + pub expires_at: i64, + pub resolved_at: Option, +} diff --git a/crates/bds-core/src/model/mod.rs b/crates/bds-core/src/model/mod.rs index c084c07..6f5e6b7 100644 --- a/crates/bds-core/src/model/mod.rs +++ b/crates/bds-core/src/model/mod.rs @@ -1,6 +1,7 @@ mod event; mod generation; mod import; +mod mcp; mod media; pub mod metadata; mod post; @@ -19,6 +20,7 @@ pub use import::{ ImportExecutionResult, ImportItemKind, ImportItemStatus, ImportMacroUsage, ImportPhase, ImportProgress, ImportReport, ImportResolution, ImportedSite, TaxonomyCandidate, TaxonomyKind, }; +pub use mcp::{McpProposal, ProposalKind, ProposalStatus}; pub use media::{Media, MediaTranslation}; pub use metadata::{CategorySettings, ProjectMetadata, TagEntry}; pub use post::{Post, PostLink, PostMedia, PostStatus, PostTranslation}; diff --git a/crates/bds-core/tests/mcp.rs b/crates/bds-core/tests/mcp.rs new file mode 100644 index 0000000..d7e8455 --- /dev/null +++ b/crates/bds-core/tests/mcp.rs @@ -0,0 +1,714 @@ +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":"

{{ 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 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::>(); + 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(); +} diff --git a/crates/bds-mcp/Cargo.toml b/crates/bds-mcp/Cargo.toml new file mode 100644 index 0000000..5328781 --- /dev/null +++ b/crates/bds-mcp/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "bds-mcp" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +bds-core = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/bds-mcp/src/main.rs b/crates/bds-mcp/src/main.rs new file mode 100644 index 0000000..7f33314 --- /dev/null +++ b/crates/bds-mcp/src/main.rs @@ -0,0 +1,45 @@ +use std::io::{BufRead as _, Write as _}; +use std::process::ExitCode; + +use bds_core::engine::mcp::{McpContext, handle_rpc}; +use serde_json::{Value, json}; + +fn main() -> ExitCode { + if let Err(error) = run_stdio() { + eprintln!("MCP server error: {error}"); + ExitCode::from(1) + } else { + ExitCode::SUCCESS + } +} + +fn run_stdio() -> Result<(), Box> { + let database_path = bds_core::util::application_database_path(); + if let Some(parent) = database_path.parent() { + std::fs::create_dir_all(parent)?; + } + let context = McpContext::new(database_path); + context.prepare()?; + let stdin = std::io::stdin(); + let mut stdout = std::io::stdout().lock(); + for line in stdin.lock().lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let response = match serde_json::from_str::(&line) { + Ok(request) => handle_rpc(&context, &request), + Err(_) => Some(json!({ + "jsonrpc":"2.0", + "id":null, + "error":{"code":-32700,"message":"Parse error"} + })), + }; + if let Some(response) = response { + serde_json::to_writer(&mut stdout, &response)?; + stdout.write_all(b"\n")?; + stdout.flush()?; + } + } + Ok(()) +} diff --git a/crates/bds-mcp/tests/process.rs b/crates/bds-mcp/tests/process.rs new file mode 100644 index 0000000..38708ef --- /dev/null +++ b/crates/bds-mcp/tests/process.rs @@ -0,0 +1,38 @@ +use std::io::Write as _; +use std::process::{Command, Stdio}; + +#[test] +fn stdio_process_initializes_lists_capabilities_and_reports_parse_errors() { + let home = tempfile::tempdir().unwrap(); + let mut child = Command::new(env!("CARGO_BIN_EXE_bds-mcp")) + .env("HOME", home.path()) + .env("XDG_DATA_HOME", home.path().join("data")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let stdin = child.stdin.as_mut().unwrap(); + writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap(); + writeln!(stdin, r#"{{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#).unwrap(); + writeln!(stdin, "not-json").unwrap(); + drop(child.stdin.take()); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let lines = String::from_utf8(output.stdout).unwrap(); + let responses = lines + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + assert_eq!(responses.len(), 3); + assert_eq!(responses[0]["result"]["protocolVersion"], "2025-06-18"); + assert_eq!( + responses[1]["result"]["tools"].as_array().unwrap().len(), + 12 + ); + assert_eq!(responses[2]["error"]["code"], -32700); +} diff --git a/crates/bds-ui/Cargo.toml b/crates/bds-ui/Cargo.toml index 40cf7f7..bb3d9b4 100644 --- a/crates/bds-ui/Cargo.toml +++ b/crates/bds-ui/Cargo.toml @@ -37,10 +37,11 @@ winresource = "0.1" product-name = "Blogging Desktop Server" identifier = "de.rfc1437.ruds" description = "A desktop application for writing and publishing static blogs." -before-packaging-command = "cargo build --release -p bds-ui -p bds-cli" +before-packaging-command = "cargo build --release -p bds-ui -p bds-cli -p bds-mcp" binaries = [ { path = "bds-ui", main = true }, { path = "bds-cli" }, + { path = "bds-mcp" }, ] deep-link-protocols = [{ schemes = ["ruds"] }] icons = [ diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 42c8484..b0ba842 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -873,6 +873,7 @@ pub struct BdsApp { // Local preview preview_session: Option, + mcp_server: Option, embedded_preview: Option, main_window_id: Option, @@ -923,6 +924,17 @@ impl BdsApp { let search_index_rebuild_required = db .as_ref() .is_some_and(|db| engine::search::prepare_search_index(db.conn()).unwrap_or(true)); + let mcp_enabled = db.as_ref().is_some_and(|db| { + engine::settings::get(db.conn(), "mcp.http.enabled") + .ok() + .flatten() + .is_some_and(|value| value == "true") + }); + let mcp_server = mcp_enabled + .then(|| { + engine::mcp::McpHttpServer::start(db_path.clone(), engine::mcp::DEFAULT_HTTP_PORT) + }) + .and_then(Result::ok); // Load projects let projects = db @@ -1020,6 +1032,7 @@ impl BdsApp { active_modal: search_index_rebuild_required .then_some(modal::ModalState::SearchIndexRepair), preview_session: None, + mcp_server, embedded_preview: None, main_window_id: None, post_editors: HashMap::new(), @@ -1093,6 +1106,7 @@ impl BdsApp { toasts: Vec::new(), active_modal: None, preview_session: None, + mcp_server: None, embedded_preview: None, main_window_id: None, post_editors: HashMap::new(), @@ -5142,6 +5156,34 @@ impl BdsApp { state.title_model = ai_settings.title_model.unwrap_or_default(); state.image_model = ai_settings.image_model.unwrap_or_default(); } + state.mcp_enabled = engine::settings::get(db.conn(), "mcp.http.enabled") + .ok() + .flatten() + .is_some_and(|value| value == "true"); + state.mcp_running = self.mcp_server.is_some(); + state.mcp_endpoint = self + .mcp_server + .as_ref() + .map(engine::mcp::McpHttpServer::endpoint) + .unwrap_or_else(|| { + format!("http://127.0.0.1:{}/mcp", engine::mcp::DEFAULT_HTTP_PORT) + }); + if let Some(project) = &self.active_project { + state.mcp_proposals = + engine::mcp::list_pending_proposals(db.conn(), &project.id).unwrap_or_default(); + } + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + state.mcp_agents = engine::mcp::McpAgent::all() + .into_iter() + .map(|agent| crate::views::settings_view::SettingsMcpAgentRow { + agent, + label: agent.label().to_string(), + configured: engine::mcp::is_agent_configured(agent, &home), + config_path: engine::mcp::agent_config_path(agent, &home) + .to_string_lossy() + .into_owned(), + }) + .collect(); } state.offline_mode = self.offline_mode; state @@ -5982,6 +6024,100 @@ impl BdsApp { } } } + SettingsMsg::McpEnabledChanged(enabled) => { + let Some(db) = &self.db else { + return Task::none(); + }; + if enabled { + if self.mcp_server.is_none() { + match engine::mcp::McpHttpServer::start( + self.db_path.clone(), + engine::mcp::DEFAULT_HTTP_PORT, + ) { + Ok(server) => self.mcp_server = Some(server), + Err(error) => { + self.notify_operation_failed( + "settings.mcpEnable", + error.to_string(), + ); + return Task::none(); + } + } + } + } else if let Some(server) = self.mcp_server.take() + && let Err(error) = server.stop() + { + self.notify_operation_failed("settings.mcpEnable", error.to_string()); + return Task::none(); + } + match engine::settings::set( + db.conn(), + "mcp.http.enabled", + if enabled { "true" } else { "false" }, + ) { + Ok(()) => { + self.settings_state = Some(self.hydrate_settings_state()); + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + } + Err(error) => { + self.notify_operation_failed("settings.mcpEnable", error.to_string()) + } + } + } + SettingsMsg::McpProposalAccepted(proposal_id) => { + if let (Some(db), Some(data_dir)) = (&self.db, &self.data_dir) { + match engine::mcp::accept_proposal(db.conn(), data_dir, &proposal_id) { + Ok(_) => { + self.settings_state = Some(self.hydrate_settings_state()); + self.notify( + ToastLevel::Success, + &t(self.ui_locale, "settings.mcpProposalApproved"), + ); + } + Err(error) => { + self.notify_operation_failed("settings.mcpApprove", error.to_string()) + } + } + } + } + SettingsMsg::McpProposalRejected(proposal_id) => { + if let (Some(db), Some(data_dir)) = (&self.db, &self.data_dir) { + match engine::mcp::reject_proposal(db.conn(), data_dir, &proposal_id) { + Ok(_) => { + self.settings_state = Some(self.hydrate_settings_state()); + self.notify( + ToastLevel::Success, + &t(self.ui_locale, "settings.mcpProposalRejected"), + ); + } + Err(error) => { + self.notify_operation_failed("settings.mcpReject", error.to_string()) + } + } + } + } + SettingsMsg::McpAgentToggled(agent) => { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + let result = if engine::mcp::is_agent_configured(agent, &home) { + engine::mcp::remove_agent_config(agent, &home) + } else { + engine::mcp::packaged_mcp_executable().and_then(|executable| { + engine::mcp::install_agent_config(agent, &home, &executable) + }) + }; + match result { + Ok(_) => { + self.settings_state = Some(self.hydrate_settings_state()); + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + } + Err(error) => { + self.notify_operation_failed("settings.mcpAgents", error.to_string()) + } + } + } + SettingsMsg::McpRefresh => { + self.settings_state = Some(self.hydrate_settings_state()); + } SettingsMsg::FocusSection(section) => { state.focus_section(section); } @@ -8974,4 +9110,63 @@ mod tests { assert!(app.script_editors[&created.id].is_dirty); assert!(app.toasts.is_empty()); } + + #[test] + fn mcp_settings_review_applies_or_rejects_inert_proposals() { + let (db, mut project, tmp) = setup(); + project.data_path = Some(tmp.path().to_string_lossy().into_owned()); + bds_core::db::queries::project::update_project(db.conn(), &project).unwrap(); + let now = bds_core::util::now_unix_ms(); + let accepted = bds_core::model::McpProposal { + id: "accept-me".into(), + project_id: project.id.clone(), + kind: bds_core::model::ProposalKind::DraftPost, + status: bds_core::model::ProposalStatus::Pending, + entity_id: None, + data: serde_json::json!({"title":"Approved","content":"Body"}).to_string(), + result: None, + created_at: now, + expires_at: now + 60_000, + resolved_at: None, + }; + let mut rejected = accepted.clone(); + rejected.id = "reject-me".into(); + rejected.data = serde_json::json!({"title":"Rejected","content":"Body"}).to_string(); + bds_core::db::queries::mcp_proposal::insert_proposal(db.conn(), &accepted).unwrap(); + bds_core::db::queries::mcp_proposal::insert_proposal(db.conn(), &rejected).unwrap(); + + let mut app = make_app(db, project.clone(), &tmp); + app.settings_state = Some(app.hydrate_settings_state()); + assert_eq!(app.settings_state.as_ref().unwrap().mcp_proposals.len(), 2); + let _ = app.handle_settings_msg(SettingsMsg::McpProposalAccepted("accept-me".into())); + let _ = app.handle_settings_msg(SettingsMsg::McpProposalRejected("reject-me".into())); + + let posts = bds_core::db::queries::post::list_posts_by_project( + app.db.as_ref().unwrap().conn(), + &project.id, + ) + .unwrap(); + assert_eq!(posts.len(), 1); + assert_eq!(posts[0].title, "Approved"); + assert_eq!(posts[0].status, PostStatus::Published); + assert_eq!( + bds_core::engine::mcp::get_proposal(app.db.as_ref().unwrap().conn(), "accept-me") + .unwrap() + .status, + bds_core::model::ProposalStatus::Accepted + ); + assert_eq!( + bds_core::engine::mcp::get_proposal(app.db.as_ref().unwrap().conn(), "reject-me") + .unwrap() + .status, + bds_core::model::ProposalStatus::Rejected + ); + assert!( + app.settings_state + .as_ref() + .unwrap() + .mcp_proposals + .is_empty() + ); + } } diff --git a/crates/bds-ui/src/views/settings_view.rs b/crates/bds-ui/src/views/settings_view.rs index fd7cba1..9bc5705 100644 --- a/crates/bds-ui/src/views/settings_view.rs +++ b/crates/bds-ui/src/views/settings_view.rs @@ -45,6 +45,14 @@ pub struct SettingsCategoryRow { pub is_protected: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SettingsMcpAgentRow { + pub agent: bds_core::engine::mcp::McpAgent, + pub label: String, + pub configured: bool, + pub config_path: String, +} + impl std::fmt::Display for SettingsCategoryRow { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.name) @@ -143,6 +151,12 @@ pub struct SettingsViewState { pub system_prompt: text_editor::Content, // Technology pub semantic_similarity_enabled: bool, + // MCP + pub mcp_enabled: bool, + pub mcp_running: bool, + pub mcp_endpoint: String, + pub mcp_proposals: Vec, + pub mcp_agents: Vec, } impl std::fmt::Debug for SettingsViewState { @@ -195,6 +209,11 @@ impl Clone for SettingsViewState { image_model: self.image_model.clone(), system_prompt: text_editor::Content::with_text(&self.system_prompt.text()), semantic_similarity_enabled: self.semantic_similarity_enabled, + mcp_enabled: self.mcp_enabled, + mcp_running: self.mcp_running, + mcp_endpoint: self.mcp_endpoint.clone(), + mcp_proposals: self.mcp_proposals.clone(), + mcp_agents: self.mcp_agents.clone(), } } } @@ -247,6 +266,11 @@ impl Default for SettingsViewState { image_model: String::new(), system_prompt: text_editor::Content::new(), semantic_similarity_enabled: false, + mcp_enabled: false, + mcp_running: false, + mcp_endpoint: String::new(), + mcp_proposals: Vec::new(), + mcp_agents: Vec::new(), } } } @@ -345,6 +369,11 @@ pub enum SettingsMsg { RegenerateThumbnails, OpenDataFolder, InstallCli, + McpEnabledChanged(bool), + McpProposalAccepted(String), + McpProposalRejected(String), + McpAgentToggled(bds_core::engine::mcp::McpAgent), + McpRefresh, /// Navigate to a specific section from sidebar; expand it, collapse all others. FocusSection(SettingsSection), } @@ -436,7 +465,7 @@ fn render_section<'a>( SettingsSection::Technology => section_technology(state, locale), SettingsSection::Publishing => section_publishing(state, locale), SettingsSection::Data => section_data(locale), - SettingsSection::MCP => section_mcp(locale), + SettingsSection::MCP => section_mcp(state, locale), }; inputs::card(column![header, content].spacing(8)).into() @@ -1008,12 +1037,131 @@ fn section_data<'a>(locale: UiLocale) -> Element<'a, Message> { .into() } -fn section_mcp<'a>(locale: UiLocale) -> Element<'a, Message> { - // MCP status and agent toggles — placeholder for M3 - text(t(locale, "settings.mcpPlaceholder")) - .size(13) - .color(Color::from_rgb(0.5, 0.5, 0.5)) +fn section_mcp<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> { + let status = if state.mcp_running { + t(locale, "settings.mcpRunning") + } else { + t(locale, "settings.mcpStopped") + }; + let status_color = if state.mcp_running { + Color::from_rgb(0.35, 0.78, 0.48) + } else { + inputs::LABEL_COLOR + }; + let server = column![ + iced::widget::checkbox(t(locale, "settings.mcpEnable"), state.mcp_enabled) + .on_toggle(|value| Message::Settings(SettingsMsg::McpEnabledChanged(value))), + row![ + text(status).size(13).color(status_color), + text(state.mcp_endpoint.clone()) + .size(12) + .color(inputs::LABEL_COLOR), + button(text(t(locale, "settings.mcpRefresh")).size(12)) + .on_press(Message::Settings(SettingsMsg::McpRefresh)) + .style(inputs::secondary_button) + .padding([5, 10]), + ] + .spacing(10) + .align_y(Alignment::Center), + ] + .spacing(8); + + let proposal_rows = if state.mcp_proposals.is_empty() { + column![ + text(t(locale, "settings.mcpNoProposals")) + .size(13) + .color(inputs::LABEL_COLOR) + ] + } else { + column(state.mcp_proposals.iter().map(|proposal| { + let kind = t(locale, proposal_kind_i18n_key(proposal.kind)); + let summary = proposal + .entity_id + .as_deref() + .map_or(kind.clone(), |entity| format!("{kind} · {entity}")); + inputs::card( + row![ + column![ + text(summary).size(13), + text(t(locale, proposal_status_i18n_key(proposal.status))) + .size(11) + .color(inputs::LABEL_COLOR), + text(proposal.data.clone()) + .size(11) + .color(inputs::LABEL_COLOR), + ] + .spacing(2) + .width(Length::Fill), + button(text(t(locale, "settings.mcpApprove")).size(12)) + .on_press(Message::Settings(SettingsMsg::McpProposalAccepted( + proposal.id.clone() + ))) + .style(inputs::primary_button) + .padding([5, 10]), + button(text(t(locale, "settings.mcpReject")).size(12)) + .on_press(Message::Settings(SettingsMsg::McpProposalRejected( + proposal.id.clone() + ))) + .style(inputs::danger_button) + .padding([5, 10]), + ] + .spacing(8) + .align_y(Alignment::Center), + ) + .into() + })) + .spacing(6) + }; + + let agents = column(state.mcp_agents.iter().map(|agent| { + let configured = agent.configured; + column![ + iced::widget::checkbox(agent.label.clone(), configured).on_toggle({ + let agent = agent.agent; + move |_| Message::Settings(SettingsMsg::McpAgentToggled(agent)) + }), + text(agent.config_path.clone()) + .size(11) + .color(inputs::LABEL_COLOR), + ] + .spacing(2) .into() + })) + .spacing(8); + + column![ + server, + text(t(locale, "settings.mcpProposals")).size(14), + proposal_rows, + text(t(locale, "settings.mcpAgents")).size(14), + agents, + ] + .spacing(12) + .padding([0, 16]) + .into() +} + +fn proposal_kind_i18n_key(kind: bds_core::model::ProposalKind) -> &'static str { + match kind { + bds_core::model::ProposalKind::DraftPost => "settings.mcpKindDraftPost", + bds_core::model::ProposalKind::ProposeScript => "settings.mcpKindScript", + bds_core::model::ProposalKind::ProposeTemplate => "settings.mcpKindTemplate", + bds_core::model::ProposalKind::ProposeMediaTranslation => { + "settings.mcpKindMediaTranslation" + } + bds_core::model::ProposalKind::ProposeMediaMetadata => "settings.mcpKindMediaMetadata", + bds_core::model::ProposalKind::ProposePostMetadata => "settings.mcpKindPostMetadata", + } +} + +fn proposal_status_i18n_key(status: bds_core::model::ProposalStatus) -> &'static str { + match status { + bds_core::model::ProposalStatus::Pending => "settings.mcpStatusPending", + bds_core::model::ProposalStatus::Executing => "settings.mcpStatusExecuting", + bds_core::model::ProposalStatus::Accepted => "settings.mcpStatusAccepted", + bds_core::model::ProposalStatus::Rejected => "settings.mcpStatusRejected", + bds_core::model::ProposalStatus::Expired => "settings.mcpStatusExpired", + } } #[cfg(test)] diff --git a/crates/bds-ui/tests/packaging_assets.rs b/crates/bds-ui/tests/packaging_assets.rs index c1d4544..a71b193 100644 --- a/crates/bds-ui/tests/packaging_assets.rs +++ b/crates/bds-ui/tests/packaging_assets.rs @@ -25,6 +25,8 @@ fn desktop_packages_have_native_icons_and_cargo_commands() { "identifier = \"de.rfc1437.ruds\"", "deep-link-protocols = [{ schemes = [\"ruds\"] }]", "signing-identity = \"-\"", + "cargo build --release -p bds-ui -p bds-cli -p bds-mcp", + "{ path = \"bds-mcp\" }", ] { assert!(manifest.contains(required), "missing {required}"); } diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index 3c18478..79a694a 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -395,7 +395,28 @@ settings-installCli = CLI installieren settings-cliInstalled = CLI unter { $path } installiert settings-contentPlaceholder = Inhaltseinstellungen erscheinen hier settings-technologyPlaceholder = Technologieeinstellungen erscheinen hier -settings-mcpPlaceholder = MCP-Server-Einstellungen erscheinen hier +settings-mcpEnable = Lokalen MCP-Server aktivieren +settings-mcpRunning = Läuft +settings-mcpStopped = Angehalten +settings-mcpRefresh = Aktualisieren +settings-mcpProposals = Ausstehende Vorschläge +settings-mcpNoProposals = Keine Vorschläge warten auf Genehmigung. +settings-mcpApprove = Genehmigen +settings-mcpReject = Ablehnen +settings-mcpAgents = Agent-Konfiguration +settings-mcpProposalApproved = MCP-Vorschlag genehmigt und angewendet. +settings-mcpProposalRejected = MCP-Vorschlag abgelehnt. +settings-mcpKindDraftPost = Neuer Beitrag +settings-mcpKindScript = Neues Skript +settings-mcpKindTemplate = Neue Vorlage +settings-mcpKindMediaTranslation = Medienübersetzung +settings-mcpKindMediaMetadata = Medienmetadaten +settings-mcpKindPostMetadata = Beitragsmetadaten +settings-mcpStatusPending = Ausstehend +settings-mcpStatusExecuting = Wird angewendet +settings-mcpStatusAccepted = Genehmigt +settings-mcpStatusRejected = Abgelehnt +settings-mcpStatusExpired = Abgelaufen dashboard-overview = Übersicht dashboard-posts = Beiträge dashboard-media = Medien diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index 8afbf58..987a000 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -395,7 +395,28 @@ settings-installCli = Install CLI settings-cliInstalled = CLI installed at { $path } settings-contentPlaceholder = Content settings will appear here settings-technologyPlaceholder = Technology settings will appear here -settings-mcpPlaceholder = MCP server settings will appear here +settings-mcpEnable = Enable local MCP server +settings-mcpRunning = Running +settings-mcpStopped = Stopped +settings-mcpRefresh = Refresh +settings-mcpProposals = Pending proposals +settings-mcpNoProposals = No proposals are waiting for approval. +settings-mcpApprove = Approve +settings-mcpReject = Reject +settings-mcpAgents = Agent configuration +settings-mcpProposalApproved = MCP proposal approved and applied. +settings-mcpProposalRejected = MCP proposal rejected. +settings-mcpKindDraftPost = New post +settings-mcpKindScript = New script +settings-mcpKindTemplate = New template +settings-mcpKindMediaTranslation = Media translation +settings-mcpKindMediaMetadata = Media metadata +settings-mcpKindPostMetadata = Post metadata +settings-mcpStatusPending = Pending +settings-mcpStatusExecuting = Applying +settings-mcpStatusAccepted = Approved +settings-mcpStatusRejected = Rejected +settings-mcpStatusExpired = Expired dashboard-overview = Overview dashboard-posts = Posts dashboard-media = Media diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index eb77b19..476a7ac 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -395,7 +395,28 @@ settings-installCli = Instalar CLI settings-cliInstalled = CLI instalada en { $path } settings-contentPlaceholder = La configuración de contenido aparecerá aquí settings-technologyPlaceholder = La configuración tecnológica aparecerá aquí -settings-mcpPlaceholder = La configuración del servidor MCP aparecerá aquí +settings-mcpEnable = Activar el servidor MCP local +settings-mcpRunning = En ejecución +settings-mcpStopped = Detenido +settings-mcpRefresh = Actualizar +settings-mcpProposals = Propuestas pendientes +settings-mcpNoProposals = No hay propuestas pendientes de aprobación. +settings-mcpApprove = Aprobar +settings-mcpReject = Rechazar +settings-mcpAgents = Configuración de agentes +settings-mcpProposalApproved = Propuesta MCP aprobada y aplicada. +settings-mcpProposalRejected = Propuesta MCP rechazada. +settings-mcpKindDraftPost = Nueva entrada +settings-mcpKindScript = Nuevo script +settings-mcpKindTemplate = Nueva plantilla +settings-mcpKindMediaTranslation = Traducción del recurso +settings-mcpKindMediaMetadata = Metadatos del recurso +settings-mcpKindPostMetadata = Metadatos de la entrada +settings-mcpStatusPending = Pendiente +settings-mcpStatusExecuting = Aplicando +settings-mcpStatusAccepted = Aprobada +settings-mcpStatusRejected = Rechazada +settings-mcpStatusExpired = Caducada dashboard-overview = Resumen dashboard-posts = Artículos dashboard-media = Medios diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index 02bab9f..1de2d96 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -395,7 +395,28 @@ settings-installCli = Installer la CLI settings-cliInstalled = CLI installée dans { $path } settings-contentPlaceholder = Les paramètres de contenu apparaîtront ici settings-technologyPlaceholder = Les paramètres technologiques apparaîtront ici -settings-mcpPlaceholder = Les paramètres du serveur MCP apparaîtront ici +settings-mcpEnable = Activer le serveur MCP local +settings-mcpRunning = En cours d’exécution +settings-mcpStopped = Arrêté +settings-mcpRefresh = Actualiser +settings-mcpProposals = Propositions en attente +settings-mcpNoProposals = Aucune proposition n’attend d’approbation. +settings-mcpApprove = Approuver +settings-mcpReject = Rejeter +settings-mcpAgents = Configuration des agents +settings-mcpProposalApproved = Proposition MCP approuvée et appliquée. +settings-mcpProposalRejected = Proposition MCP rejetée. +settings-mcpKindDraftPost = Nouvel article +settings-mcpKindScript = Nouveau script +settings-mcpKindTemplate = Nouveau modèle +settings-mcpKindMediaTranslation = Traduction du média +settings-mcpKindMediaMetadata = Métadonnées du média +settings-mcpKindPostMetadata = Métadonnées de l’article +settings-mcpStatusPending = En attente +settings-mcpStatusExecuting = Application en cours +settings-mcpStatusAccepted = Approuvée +settings-mcpStatusRejected = Rejetée +settings-mcpStatusExpired = Expirée dashboard-overview = Aperçu dashboard-posts = Articles dashboard-media = Médias diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index 502470a..dbd7c10 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -395,7 +395,28 @@ settings-installCli = Installa CLI settings-cliInstalled = CLI installata in { $path } settings-contentPlaceholder = Le impostazioni del contenuto appariranno qui settings-technologyPlaceholder = Le impostazioni tecnologiche appariranno qui -settings-mcpPlaceholder = Le impostazioni del server MCP appariranno qui +settings-mcpEnable = Abilita il server MCP locale +settings-mcpRunning = In esecuzione +settings-mcpStopped = Arrestato +settings-mcpRefresh = Aggiorna +settings-mcpProposals = Proposte in attesa +settings-mcpNoProposals = Nessuna proposta è in attesa di approvazione. +settings-mcpApprove = Approva +settings-mcpReject = Rifiuta +settings-mcpAgents = Configurazione degli agenti +settings-mcpProposalApproved = Proposta MCP approvata e applicata. +settings-mcpProposalRejected = Proposta MCP rifiutata. +settings-mcpKindDraftPost = Nuovo articolo +settings-mcpKindScript = Nuovo script +settings-mcpKindTemplate = Nuovo modello +settings-mcpKindMediaTranslation = Traduzione del media +settings-mcpKindMediaMetadata = Metadati del media +settings-mcpKindPostMetadata = Metadati dell’articolo +settings-mcpStatusPending = In attesa +settings-mcpStatusExecuting = Applicazione in corso +settings-mcpStatusAccepted = Approvata +settings-mcpStatusRejected = Rifiutata +settings-mcpStatusExpired = Scaduta dashboard-overview = Panoramica dashboard-posts = Articoli dashboard-media = Media diff --git a/specs/mcp.allium b/specs/mcp.allium index d8e6fb9..82a0e4f 100644 --- a/specs/mcp.allium +++ b/specs/mcp.allium @@ -10,6 +10,9 @@ use "./template.allium" as template enum ProposalStatus { pending + executing + accepted + rejected expired } @@ -31,12 +34,14 @@ surface McpServerSurface { } entity Proposal { - kind: draft_post | propose_script | propose_template | propose_media_metadata | propose_post_metadata + kind: draft_post | propose_script | propose_template | propose_media_translation | propose_media_metadata | propose_post_metadata status: ProposalStatus entity_id: String data: String created_at: Timestamp expires_at: Timestamp + resolved_at: Timestamp? + result: String? draft_post: post/Post? proposed_script: script/Script? proposed_template: template/Template? @@ -47,7 +52,10 @@ entity Proposal { is_expired: expires_at <= now transitions status { + pending -> executing pending -> expired + executing -> accepted + executing -> rejected } } @@ -61,6 +69,8 @@ surface ProposalSurface { proposal.data proposal.created_at proposal.expires_at + proposal.resolved_at when proposal.resolved_at != null + proposal.result when proposal.result != null proposal.draft_post when proposal.draft_post != null proposal.proposed_script when proposal.proposed_script != null proposal.proposed_template when proposal.proposed_template != null @@ -101,12 +111,33 @@ invariant LocalhostOnlyHttp { } invariant StatelessHttpHandling { - -- Each HTTP request creates a fresh McpServer instance - -- No session state between requests + -- Each HTTP request opens a fresh shared database connection through a + -- path-only application context. No MCP session id or client state is kept. +} + +invariant InertProposalPayloads { + -- Write tools validate their request and persist only a serialized proposal. + -- They do not create or update posts, media translations, scripts, templates, + -- sidecars, or other content before explicit desktop approval. } -- Read-only resources (bds:// scheme) +surface ProjectResource { + facing viewer: McpClient + context project: Project + exposes: + project.id + project.name + project.slug + project.description + project.public_url + project.main_language + project.blog_languages + @guidance + -- bds://project. Local filesystem paths are not exposed. +} + surface PostsResource { facing viewer: McpClient context posts: Posts @@ -258,13 +289,19 @@ rule GetMediaTranslations { rule UpsertMediaTranslation { when: McpToolInvoked("upsert_media_translation", params) - -- Creates or updates translated media metadata for a language. - ensures: media/UpsertMediaTranslationRequested( - params.media_id, - params.language, - params.title, - params.alt, - params.caption + -- Persists an inert translated-metadata proposal for desktop review. + ensures: Proposal.created( + kind: propose_media_translation, + entity_id: params.media_id, + data: serialize(params), + created_at: now, + expires_at: now + config.proposal_ttl, + draft_post: null, + proposed_script: null, + proposed_template: null, + target_media: params.media, + target_post: null, + status: pending ) } @@ -272,21 +309,15 @@ rule UpsertMediaTranslation { rule DraftPost { when: McpToolInvoked("draft_post", params) - -- Creates a draft post in DB - -- Returns proposalId for accept/discard lifecycle + -- Validates and stores an inert post payload; returns proposalId. ensures: - let new_post = post/Post.created( - title: params.title, - content: params.content, - status: draft - ) let proposal = Proposal.created( kind: draft_post, - entity_id: new_post.id, - data: "", + entity_id: params.proposal_entity_id, + data: serialize(params), created_at: now, expires_at: now + config.proposal_ttl, - draft_post: new_post, + draft_post: null, proposed_script: null, proposed_template: null, target_media: null, @@ -300,20 +331,14 @@ rule ProposeScript { when: McpToolInvoked("propose_script", params) requires: ValidateScript(params.content) = valid ensures: - let new_script = script/Script.created( - title: params.title, - kind: params.kind, - content: params.content, - status: draft - ) let proposal = Proposal.created( kind: propose_script, - entity_id: new_script.id, - data: "", + entity_id: params.proposal_entity_id, + data: serialize(params), created_at: now, expires_at: now + config.proposal_ttl, draft_post: null, - proposed_script: new_script, + proposed_script: null, proposed_template: null, target_media: null, target_post: null, @@ -326,21 +351,15 @@ rule ProposeTemplate { when: McpToolInvoked("propose_template", params) requires: ValidateLiquid(params.content) = valid ensures: - let new_template = template/Template.created( - title: params.title, - kind: params.kind, - content: params.content, - status: draft - ) let proposal = Proposal.created( kind: propose_template, - entity_id: new_template.id, - data: "", + entity_id: params.proposal_entity_id, + data: serialize(params), created_at: now, expires_at: now + config.proposal_ttl, draft_post: null, proposed_script: null, - proposed_template: new_template, + proposed_template: null, target_media: null, target_post: null, status: pending @@ -393,42 +412,42 @@ rule AcceptProposal { requires: not proposal.is_expired ensures: if proposal.kind = draft_post: - post/PublishPostRequested(proposal.draft_post) + post/PublishPostRequested(deserialize_post(proposal.data)) if proposal.kind = propose_script: - script/PublishScriptRequested(proposal.proposed_script) + script/PublishScriptRequested(deserialize_script(proposal.data)) if proposal.kind = propose_template: - template/PublishTemplateRequested(proposal.proposed_template) + template/PublishTemplateRequested(deserialize_template(proposal.data)) + if proposal.kind = propose_media_translation: + media/UpsertMediaTranslationRequested(deserialize_media_translation(proposal.data)) if proposal.kind = propose_media_metadata: media/UpdateMediaRequested(proposal.target_media, deserialize_media_changes(proposal.data)) if proposal.kind = propose_post_metadata: post/UpdatePostRequested(proposal.target_post, deserialize_post_changes(proposal.data)) - not exists proposal + proposal.status = accepted + proposal.result = serialize(result) + proposal.resolved_at = now } rule DiscardProposal { when: DiscardProposalRequested(proposal) - ensures: - if proposal.kind = draft_post: - post/DeletePostRequested(proposal.draft_post) - if proposal.kind = propose_script: - script/DeleteScriptRequested(proposal.proposed_script) - if proposal.kind = propose_template: - template/DeleteTemplateRequested(proposal.proposed_template) - not exists proposal + -- There are no temporary content entities to clean up. + ensures: proposal.status = rejected + ensures: proposal.result = serialize(rejected) + ensures: proposal.resolved_at = now } rule ExpireProposal { when: proposal: Proposal.is_expired becomes true - -- On expiry: clean up draft DB rows + -- Expiry marks the inert proposal terminal; no content cleanup is needed. ensures: proposal.status = expired - ensures: DiscardProposalRequested(proposal) + ensures: proposal.result = serialize(expired) + ensures: proposal.resolved_at = now } -- Agent configuration value McpAgentKind { - -- Supported: claude_code, claude_desktop, github_copilot, - -- gemini_cli, opencode, mistral_vibe, openai_codex + -- Currently supported and shown: claude_code, github_copilot. kind: String } @@ -462,7 +481,6 @@ rule UninstallAgentConfig { } invariant ProposalPayloadEncoding { - -- Proposal.data stores a serialized payload for metadata proposals. - -- draft_post / propose_script / propose_template proposals keep the - -- created entity reference directly on the proposal record. + -- Proposal.data stores the validated serialized payload for every write. + -- Terminal status/result rows remain available for result reporting. }