Implement MCP automation and approvals.

This commit is contained in:
2026-07-19 15:10:43 +02:00
parent fdfae200a0
commit fb5cae2131
35 changed files with 3654 additions and 80 deletions

9
Cargo.lock generated
View File

@@ -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"

View File

@@ -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" }

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
DROP TABLE mcp_proposals;

View File

@@ -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);

View File

@@ -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,
);
}

View File

@@ -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<McpProposal> {
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<Vec<McpProposal>> {
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<Vec<McpProposal>> {
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<usize> {
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<bool> {
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<bool> {
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
);
}
}

View File

@@ -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;

View File

@@ -141,6 +141,21 @@ diesel::table! {
}
}
diesel::table! {
mcp_proposals (id) {
id -> Text,
project_id -> Text,
kind -> Text,
status -> Text,
entity_id -> Nullable<Text>,
data -> Text,
result -> Nullable<Text>,
created_at -> BigInt,
expires_at -> BigInt,
resolved_at -> Nullable<BigInt>,
}
}
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,

View File

@@ -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);

View File

@@ -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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<String, Value>> {
config.get(server_key(agent)).and_then(Value::as_object)
}
fn read_config(path: &Path) -> EngineResult<Value> {
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"));
}
}
}

View File

@@ -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<tokio::sync::oneshot::Sender<()>>,
thread: Option<JoinHandle<()>>,
}
impl McpHttpServer {
pub fn start(database_path: PathBuf, port: u16) -> EngineResult<Self> {
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<McpContext>,
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::<Value>(&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");
}
}

View File

@@ -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<Value> {
resources::list()
}
pub fn list_resource_templates(&self) -> Vec<Value> {
resources::templates()
}
pub fn read_resource(&self, uri: &str) -> EngineResult<resources::ResourceContent> {
let db = self.open_database()?;
resources::read(db.conn(), uri)
}
pub fn list_tools(&self) -> Vec<Value> {
tools::list()
}
pub fn call_tool(&self, name: &str, params: Value) -> EngineResult<Value> {
let db = self.open_database()?;
tools::call(db.conn(), name, params)
}
pub(crate) fn open_database(&self) -> EngineResult<Database> {
Ok(Database::open(&self.database_path)?)
}
}
pub fn get_proposal(conn: &DbConnection, proposal_id: &str) -> EngineResult<McpProposal> {
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<Vec<McpProposal>> {
expire_proposals(conn)?;
Ok(proposal_q::list_proposals(conn, project_id)?)
}
pub fn list_pending_proposals(
conn: &DbConnection,
project_id: &str,
) -> EngineResult<Vec<McpProposal>> {
expire_proposals(conn)?;
Ok(proposal_q::list_pending_proposals(conn, project_id)?)
}
pub fn expire_proposals(conn: &DbConnection) -> EngineResult<usize> {
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<McpProposal> {
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<McpProposal> {
resolve_proposal(conn, data_dir, proposal_id, true)
}
pub fn reject_proposal(
conn: &DbConnection,
data_dir: &Path,
proposal_id: &str,
) -> EngineResult<McpProposal> {
resolve_proposal(conn, data_dir, proposal_id, false)
}
fn resolve_proposal(
conn: &DbConnection,
data_dir: &Path,
proposal_id: &str,
accept: bool,
) -> EngineResult<McpProposal> {
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<Value> {
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<Option<&'a str>> {
value.get(key).map(|value| value.as_str())
}
pub(crate) fn string_array(value: &Value, key: &str) -> Vec<String> {
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);
}

View File

@@ -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<Value> {
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(&params),
"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, &params),
"resources/list" => Ok(json!({"resources": context.list_resources()})),
"resources/templates/list" => Ok(json!({
"resourceTemplates": context.list_resource_templates()
})),
"resources/read" => read_resource(context, &params),
_ => 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<Value> {
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<Value> {
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
);
}
}

View File

@@ -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<String>,
pub blob: Option<String>,
}
impl ResourceContent {
fn json(uri: &str, value: &Value) -> EngineResult<Self> {
Ok(Self {
uri: uri.to_string(),
mime_type: "application/json".into(),
text: Some(serde_json::to_string(value)?),
blob: None,
})
}
}
pub fn list() -> Vec<Value> {
[
("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<Value> {
[
("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<ResourceContent> {
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::<Vec<_>>();
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::<Vec<_>>();
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<Value> {
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<usize> {
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<Value>, 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<Value> {
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::<EngineResult<Vec<_>>>()?;
Ok(page(items, total, offset))
}
fn media_page(conn: &DbConnection, project_id: &str, offset: usize) -> EngineResult<Value> {
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<Value> {
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<Vec<Value>> {
let links = if outgoing {
post_link::list_links_by_source(conn, post_id)?
.into_iter()
.map(|link| link.target_post_id)
.collect::<Vec<_>>()
} else {
post_link::list_links_by_target(conn, post_id)?
.into_iter()
.map(|link| link.source_post_id)
.collect::<Vec<_>>()
};
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<Value> {
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::<Vec<_>>();
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<Value> {
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<Value> {
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<Value> {
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::<Vec<_>>();
Ok(json!({"items": items}))
}
fn media_image(
conn: &DbConnection,
project_id: &str,
data_dir: &Path,
media_id: &str,
uri: &str,
) -> EngineResult<ResourceContent> {
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<Value> {
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::<Vec<_>>();
Ok(json!({"items": items}))
}
fn categories(conn: &DbConnection, project_id: &str, data_dir: &Path) -> EngineResult<Value> {
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::<Vec<_>>();
Ok(json!({"items": items}))
}
fn stats(conn: &DbConnection, project_id: &str, data_dir: &Path) -> EngineResult<Value> {
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}")))
}
}

View File

@@ -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<Value> {
[
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<Value> {
if !params.is_object() {
return Err(EngineError::Validation(
"tool arguments must be an object".into(),
));
}
match name {
"check_term" => check_term(conn, &params),
"search_posts" => search_posts(conn, &params),
"count_posts" => count_posts(conn, &params),
"read_post_by_slug" => read_post_by_slug(conn, &params),
"get_post_translations" => get_post_translations(conn, &params),
"get_media_translations" => get_media_translations(conn, &params),
"upsert_media_translation" => propose_media_translation(conn, &params),
"draft_post" => propose(conn, ProposalKind::DraftPost, None, &params),
"propose_script" => propose_script(conn, &params),
"propose_template" => propose_template(conn, &params),
"propose_media_metadata" => propose_media_metadata(conn, &params),
"propose_post_metadata" => propose_post_metadata(conn, &params),
_ => 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<Value> {
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<Vec<Post>> {
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::<std::collections::HashSet<_>>(),
)
};
let category = optional_string(params, "category").map(str::to_lowercase);
let tags = string_array(params, "tags")
.into_iter()
.map(|tag| tag.to_lowercase())
.collect::<Vec<_>>();
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<Value> {
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::<EngineResult<Vec<_>>>()?;
Ok(json!({
"total": total,
"offset": offset,
"limit": limit,
"hasMore": offset.saturating_add(limit) < total,
"posts": posts
}))
}
fn count_posts(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
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::<String, (Map<String, Value>, 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::<Vec<_>>();
Ok(json!({"groups": groups, "totalPosts": total}))
}
fn group_rows(post: &Post, dimensions: &[String]) -> Vec<Map<String, Value>> {
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<Value> {
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<Value> {
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<Value> {
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::<EngineResult<Vec<_>>>()?;
Ok(json!({"translations": translations}))
}
fn get_media_translations(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
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<Value> {
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<Value> {
required_string(params, "title")?;
let content = required_string(params, "content")?;
required_string(params, "kind")?
.parse::<crate::model::ScriptKind>()
.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<Value> {
required_string(params, "title")?;
let content = required_string(params, "content")?;
required_string(params, "kind")?
.parse::<crate::model::TemplateKind>()
.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<Value> {
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<Value> {
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<Value> {
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<Option<i64>> {
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<Option<usize>> {
integer(value, key)?.map_or(Ok(None), |value| {
usize::try_from(value)
.map(Some)
.map_err(|_| EngineError::Validation(format!("{key} cannot be negative")))
})
}

View File

@@ -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)
}

View File

@@ -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;

View File

@@ -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<Self, Self::Err> {
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<Self, Self::Err> {
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<String>,
pub data: String,
pub result: Option<String>,
pub created_at: i64,
pub expires_at: i64,
pub resolved_at: Option<i64>,
}

View File

@@ -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};

View File

@@ -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":"<p>{{ post.title }}</p>"}),
),
(
"propose_media_metadata",
json!({"mediaId":media.id,"title":"Updated media","tags":["updated"]}),
),
(
"propose_post_metadata",
json!({"postId":post.id,"title":"Updated post","categories":["news"]}),
),
];
let mut proposal_ids = Vec::new();
for (tool, params) in requests {
let response = context.call_tool(tool, params).unwrap();
proposal_ids.push(response["proposalId"].as_str().unwrap().to_string());
}
assert_eq!(
bds_core::db::queries::script::list_scripts_by_project(db.conn(), &fixture.project_id)
.unwrap()
.len(),
0
);
assert_eq!(
bds_core::db::queries::template::list_templates_by_project(db.conn(), &fixture.project_id)
.unwrap()
.len(),
0
);
assert_eq!(
bds_core::db::queries::post::get_post_by_id(db.conn(), &post.id)
.unwrap()
.title,
"Existing"
);
assert_eq!(
bds_core::db::queries::media::get_media_by_id(db.conn(), &media.id)
.unwrap()
.title
.as_deref(),
Some("Close")
);
let events = bds_core::engine::domain_events::subscribe();
for proposal_id in proposal_ids.iter().take(4) {
mcp::accept_proposal(db.conn(), &fixture.data_dir, proposal_id).unwrap();
}
mcp::reject_proposal(db.conn(), &fixture.data_dir, &proposal_ids[4]).unwrap();
assert_eq!(
bds_core::db::queries::media_translation::list_media_translations_by_media(
db.conn(),
&media.id
)
.unwrap()
.len(),
1
);
assert_eq!(
bds_core::db::queries::script::list_scripts_by_project(db.conn(), &fixture.project_id)
.unwrap()[0]
.status,
bds_core::model::ScriptStatus::Published
);
assert_eq!(
bds_core::db::queries::template::list_templates_by_project(db.conn(), &fixture.project_id)
.unwrap()[0]
.status,
bds_core::model::TemplateStatus::Published
);
assert_eq!(
bds_core::db::queries::media::get_media_by_id(db.conn(), &media.id)
.unwrap()
.title
.as_deref(),
Some("Updated media")
);
assert_eq!(
bds_core::db::queries::post::get_post_by_id(db.conn(), &post.id)
.unwrap()
.title,
"Existing"
);
assert_eq!(
mcp::get_proposal(db.conn(), &proposal_ids[4])
.unwrap()
.status,
ProposalStatus::Rejected
);
let changed_entities = events
.drain()
.into_iter()
.filter_map(|event| match event {
DomainEvent::EntityChanged {
project_id, entity, ..
} if project_id == fixture.project_id => Some(entity),
_ => None,
})
.collect::<Vec<_>>();
for entity in [
DomainEntity::Media,
DomainEntity::Script,
DomainEntity::Template,
] {
assert!(
changed_entities.contains(&entity),
"approved {entity:?} proposal did not emit a domain event"
);
}
assert!(
context
.call_tool(
"propose_script",
json!({"title":"Bad","kind":"utility","content":"function ("}),
)
.is_err()
);
assert!(
context
.call_tool(
"propose_template",
json!({"title":"Bad","kind":"post","content":"{% if x %}"}),
)
.is_err()
);
}
#[test]
fn expiry_invalid_ids_unavailable_projects_and_concurrent_acceptance_are_safe() {
let fixture = Fixture::new();
let db = fixture.database();
let expired = McpProposal {
id: "expired".into(),
project_id: fixture.project_id.clone(),
kind: ProposalKind::DraftPost,
status: ProposalStatus::Pending,
entity_id: None,
data: json!({"title":"Expired","content":"Body"}).to_string(),
result: None,
created_at: 1,
expires_at: 2,
resolved_at: None,
};
bds_core::db::queries::mcp_proposal::insert_proposal(db.conn(), &expired).unwrap();
assert!(
mcp::list_pending_proposals(db.conn(), &fixture.project_id)
.unwrap()
.is_empty()
);
assert_eq!(
mcp::get_proposal(db.conn(), "expired").unwrap().status,
ProposalStatus::Expired
);
assert!(mcp::accept_proposal(db.conn(), &fixture.data_dir, "missing").is_err());
let response = fixture
.context()
.call_tool("draft_post", json!({"title":"Once","content":"Body"}))
.unwrap();
let proposal_id = response["proposalId"].as_str().unwrap().to_string();
let barrier = Arc::new(Barrier::new(2));
let threads = (0..2)
.map(|_| {
let barrier = Arc::clone(&barrier);
let database_path = fixture.database_path.clone();
let data_dir = fixture.data_dir.clone();
let proposal_id = proposal_id.clone();
std::thread::spawn(move || {
let db = Database::open(&database_path).unwrap();
barrier.wait();
mcp::accept_proposal(db.conn(), &data_dir, &proposal_id).is_ok()
})
})
.collect::<Vec<_>>();
assert_eq!(
threads
.into_iter()
.map(|thread| thread.join().unwrap())
.filter(|accepted| *accepted)
.count(),
1
);
assert_eq!(
bds_core::db::queries::post::list_posts_by_project(db.conn(), &fixture.project_id)
.unwrap()
.len(),
1
);
let empty_root = tempfile::tempdir().unwrap();
let empty_path = empty_root.path().join("empty.db");
let empty_db = Database::open(&empty_path).unwrap();
empty_db.migrate().unwrap();
assert!(
mcp::McpContext::new(empty_path)
.call_tool("check_term", json!({"term":"rust"}))
.is_err()
);
}
#[test]
fn protocol_routes_every_family_and_http_enforces_loopback_origin_and_cors() {
let fixture = Fixture::new();
let post = fixture.post("Protocol post", "Protocol body");
let media = fixture.media();
let db = fixture.database();
bds_core::engine::post_media::link_media_to_post(
db.conn(),
&fixture.data_dir,
&fixture.project_id,
&post.id,
&media.id,
0,
)
.unwrap();
let context = fixture.context();
for (id, method, params, result_key) in [
(
1,
"initialize",
json!({"protocolVersion":"2025-06-18"}),
"serverInfo",
),
(2, "tools/list", json!({}), "tools"),
(3, "resources/list", json!({}), "resources"),
(
4,
"resources/templates/list",
json!({}),
"resourceTemplates",
),
] {
let response = mcp::handle_rpc(
&context,
&json!({"jsonrpc":"2.0","id":id,"method":method,"params":params}),
)
.unwrap();
assert!(response["result"].get(result_key).is_some(), "{method}");
}
for (id, uri) in [
"bds://project".to_string(),
"bds://posts".to_string(),
"bds://media".to_string(),
"bds://tags".to_string(),
"bds://categories".to_string(),
"bds://stats".to_string(),
format!("bds://posts/{}/media", post.id),
format!("bds://media/{}/image", media.id),
]
.into_iter()
.enumerate()
{
let response = mcp::handle_rpc(
&context,
&json!({"jsonrpc":"2.0","id":10+id,"method":"resources/read","params":{"uri":uri}}),
)
.unwrap();
assert!(response["result"]["contents"].is_array(), "{uri}");
}
let tool_calls = [
("check_term", json!({"term":"rust"})),
("search_posts", json!({"query":"protocol"})),
("count_posts", json!({"groupBy":["status"]})),
("read_post_by_slug", json!({"slug":post.slug})),
("get_post_translations", json!({"postId":post.id})),
("get_media_translations", json!({"mediaId":media.id})),
(
"upsert_media_translation",
json!({"mediaId":media.id,"language":"de","title":"Bild"}),
),
(
"draft_post",
json!({"title":"Protocol proposal","content":"Body"}),
),
(
"propose_script",
json!({"title":"Protocol script","kind":"utility","content":"function main() end"}),
),
(
"propose_template",
json!({"title":"Protocol template","kind":"partial","content":"{{ post.title }}"}),
),
(
"propose_media_metadata",
json!({"mediaId":media.id,"title":"Changed"}),
),
(
"propose_post_metadata",
json!({"postId":post.id,"title":"Changed"}),
),
];
for (id, (name, arguments)) in tool_calls.into_iter().enumerate() {
let call = mcp::handle_rpc(
&context,
&json!({"jsonrpc":"2.0","id":30+id,"method":"tools/call","params":{"name":name,"arguments":arguments}}),
)
.unwrap();
assert_eq!(call["result"]["isError"], false, "{name}: {call}");
}
assert_eq!(
mcp::handle_rpc(
&context,
&json!({"jsonrpc":"2.0","id":7,"method":"missing"}),
)
.unwrap()["error"]["code"],
-32601
);
let server = mcp::McpHttpServer::start(fixture.database_path.clone(), 0).unwrap();
let client = reqwest::blocking::Client::new();
let response = client
.post(server.endpoint())
.header("accept", "application/json, text/event-stream")
.header("origin", "http://localhost:3000")
.json(&json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}))
.send()
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::OK);
assert_eq!(
response
.headers()
.get("access-control-allow-origin")
.unwrap(),
"http://localhost:3000"
);
assert!(response.headers().get("mcp-session-id").is_none());
let forbidden = client
.post(server.endpoint())
.header("accept", "application/json, text/event-stream")
.header("origin", "https://attacker.example")
.json(&json!({"jsonrpc":"2.0","id":2,"method":"ping"}))
.send()
.unwrap();
assert_eq!(forbidden.status(), reqwest::StatusCode::FORBIDDEN);
let get = client.get(server.endpoint()).send().unwrap();
assert_eq!(get.status(), reqwest::StatusCode::METHOD_NOT_ALLOWED);
server.stop().unwrap();
}
#[test]
fn http_server_binds_loopback_and_rejects_remote_origins() {
let fixture = Fixture::new();
let server = mcp::McpHttpServer::start(fixture.database_path.clone(), 0).unwrap();
assert_eq!(server.address().ip(), IpAddr::V4(Ipv4Addr::LOCALHOST));
assert_eq!(
server.endpoint(),
format!("http://{}/mcp", server.address())
);
server.stop().unwrap();
}

12
crates/bds-mcp/Cargo.toml Normal file
View File

@@ -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"

View File

@@ -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<dyn std::error::Error>> {
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::<Value>(&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(())
}

View File

@@ -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::<serde_json::Value>(line).unwrap())
.collect::<Vec<_>>();
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);
}

View File

@@ -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 = [

View File

@@ -873,6 +873,7 @@ pub struct BdsApp {
// Local preview
preview_session: Option<PreviewSession>,
mcp_server: Option<engine::mcp::McpHttpServer>,
embedded_preview: Option<EmbeddedPreviewState>,
main_window_id: Option<window::Id>,
@@ -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()
);
}
}

View File

@@ -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<bds_core::model::McpProposal>,
pub mcp_agents: Vec<SettingsMcpAgentRow>,
}
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)]

View File

@@ -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}");
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 dexécution
settings-mcpStopped = Arrêté
settings-mcpRefresh = Actualiser
settings-mcpProposals = Propositions en attente
settings-mcpNoProposals = Aucune proposition nattend dapprobation.
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 larticle
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

View File

@@ -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 dellarticolo
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

View File

@@ -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.
}