Implement MCP automation and approvals.
This commit is contained in:
9
Cargo.lock
generated
9
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE mcp_proposals;
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
153
crates/bds-core/src/db/queries/mcp_proposal.rs
Normal file
153
crates/bds-core/src/db/queries/mcp_proposal.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
182
crates/bds-core/src/engine/mcp/agent_config.rs
Normal file
182
crates/bds-core/src/engine/mcp/agent_config.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
234
crates/bds-core/src/engine/mcp/http.rs
Normal file
234
crates/bds-core/src/engine/mcp/http.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
379
crates/bds-core/src/engine/mcp/mod.rs
Normal file
379
crates/bds-core/src/engine/mcp/mod.rs
Normal 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);
|
||||
}
|
||||
130
crates/bds-core/src/engine/mcp/protocol.rs
Normal file
130
crates/bds-core/src/engine/mcp/protocol.rs
Normal 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(¶ms),
|
||||
"capabilities": {
|
||||
"tools": {"listChanged": false},
|
||||
"resources": {"subscribe": false, "listChanged": false}
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "Blogging Desktop Server",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
})),
|
||||
"ping" => Ok(json!({})),
|
||||
"tools/list" => Ok(json!({"tools": context.list_tools()})),
|
||||
"tools/call" => call_tool(context, ¶ms),
|
||||
"resources/list" => Ok(json!({"resources": context.list_resources()})),
|
||||
"resources/templates/list" => Ok(json!({
|
||||
"resourceTemplates": context.list_resource_templates()
|
||||
})),
|
||||
"resources/read" => read_resource(context, ¶ms),
|
||||
_ => return Some(error(id, -32601, "Method not found")),
|
||||
};
|
||||
Some(match result {
|
||||
Ok(result) => success(id, result),
|
||||
Err(EngineError::NotFound(message)) => error(id, -32004, &message),
|
||||
Err(EngineError::Validation(message) | EngineError::Conflict(message)) => {
|
||||
error(id, -32602, &message)
|
||||
}
|
||||
Err(error_value) => error(id, -32000, &error_value.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn negotiated_version(params: &Value) -> &str {
|
||||
match params.get("protocolVersion").and_then(Value::as_str) {
|
||||
Some("2025-03-26") => "2025-03-26",
|
||||
Some("2025-06-18") => "2025-06-18",
|
||||
_ => MCP_PROTOCOL_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
fn call_tool(context: &McpContext, params: &Value) -> EngineResult<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
|
||||
);
|
||||
}
|
||||
}
|
||||
382
crates/bds-core/src/engine/mcp/resources.rs
Normal file
382
crates/bds-core/src/engine/mcp/resources.rs
Normal 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}")))
|
||||
}
|
||||
}
|
||||
646
crates/bds-core/src/engine/mcp/tools.rs
Normal file
646
crates/bds-core/src/engine/mcp/tools.rs
Normal 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, ¶ms),
|
||||
"search_posts" => search_posts(conn, ¶ms),
|
||||
"count_posts" => count_posts(conn, ¶ms),
|
||||
"read_post_by_slug" => read_post_by_slug(conn, ¶ms),
|
||||
"get_post_translations" => get_post_translations(conn, ¶ms),
|
||||
"get_media_translations" => get_media_translations(conn, ¶ms),
|
||||
"upsert_media_translation" => propose_media_translation(conn, ¶ms),
|
||||
"draft_post" => propose(conn, ProposalKind::DraftPost, None, ¶ms),
|
||||
"propose_script" => propose_script(conn, ¶ms),
|
||||
"propose_template" => propose_template(conn, ¶ms),
|
||||
"propose_media_metadata" => propose_media_metadata(conn, ¶ms),
|
||||
"propose_post_metadata" => propose_post_metadata(conn, ¶ms),
|
||||
_ => Err(EngineError::NotFound(format!("MCP tool {name}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn tool(name: &str, title: &str, description: &str, schema: Value, read_only: bool) -> Value {
|
||||
json!({
|
||||
"name": name,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"inputSchema": schema,
|
||||
"annotations": {
|
||||
"readOnlyHint": read_only,
|
||||
"destructiveHint": false,
|
||||
"openWorldHint": false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn string_schema(description: &str) -> Value {
|
||||
json!({"type":"string","description":description})
|
||||
}
|
||||
|
||||
fn nullable_string_schema(description: &str) -> Value {
|
||||
json!({"type":["string","null"],"description":description})
|
||||
}
|
||||
|
||||
fn string_array_schema(description: &str) -> Value {
|
||||
json!({"type":"array","items":{"type":"string"},"description":description})
|
||||
}
|
||||
|
||||
fn object_schema(properties: Value, required: &[&str]) -> Value {
|
||||
let mut schema = json!({
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"additionalProperties": false
|
||||
});
|
||||
if !required.is_empty() {
|
||||
schema["required"] = serde_json::to_value(required).unwrap_or_default();
|
||||
}
|
||||
schema
|
||||
}
|
||||
|
||||
fn id_schema(field: &str, description: &str) -> Value {
|
||||
let mut properties = Map::new();
|
||||
properties.insert(field.into(), string_schema(description));
|
||||
object_schema(Value::Object(properties), &[field])
|
||||
}
|
||||
|
||||
fn post_query_schema(query_required: bool) -> Value {
|
||||
object_schema(
|
||||
json!({
|
||||
"query": string_schema("Full-text query"),
|
||||
"category": string_schema("Category filter"),
|
||||
"tags": string_array_schema("All required tags"),
|
||||
"language": string_schema("Available language"),
|
||||
"missingTranslationLanguage": string_schema("Missing translation language"),
|
||||
"year": {"type":"integer"},
|
||||
"month": {"type":"integer","minimum":1,"maximum":12},
|
||||
"status": {"type":"string","enum":["draft","published","archived"]},
|
||||
"offset": {"type":"integer","minimum":0},
|
||||
"limit": {"type":"integer","minimum":1,"maximum":MAX_PAGE_SIZE}
|
||||
}),
|
||||
if query_required { &["query"] } else { &[] },
|
||||
)
|
||||
}
|
||||
|
||||
fn count_schema() -> Value {
|
||||
let mut schema = post_query_schema(false);
|
||||
schema["properties"]["groupBy"] = json!({"type":"array","items":{"type":"string","enum":["year","month","tag","category","status"]},"minItems":1});
|
||||
schema["required"] = json!(["groupBy"]);
|
||||
schema
|
||||
}
|
||||
|
||||
fn check_term(conn: &DbConnection, params: &Value) -> EngineResult<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")))
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
128
crates/bds-core/src/model/mcp.rs
Normal file
128
crates/bds-core/src/model/mcp.rs
Normal 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>,
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
714
crates/bds-core/tests/mcp.rs
Normal file
714
crates/bds-core/tests/mcp.rs
Normal 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
12
crates/bds-mcp/Cargo.toml
Normal 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"
|
||||
45
crates/bds-mcp/src/main.rs
Normal file
45
crates/bds-mcp/src/main.rs
Normal 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(())
|
||||
}
|
||||
38
crates/bds-mcp/tests/process.rs
Normal file
38
crates/bds-mcp/tests/process.rs
Normal 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);
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -395,7 +395,28 @@ settings-installCli = Installer la CLI
|
||||
settings-cliInstalled = CLI installée dans { $path }
|
||||
settings-contentPlaceholder = Les paramètres de contenu apparaîtront ici
|
||||
settings-technologyPlaceholder = Les paramètres technologiques apparaîtront ici
|
||||
settings-mcpPlaceholder = Les paramètres du serveur MCP apparaîtront ici
|
||||
settings-mcpEnable = Activer le serveur MCP local
|
||||
settings-mcpRunning = En cours d’exécution
|
||||
settings-mcpStopped = Arrêté
|
||||
settings-mcpRefresh = Actualiser
|
||||
settings-mcpProposals = Propositions en attente
|
||||
settings-mcpNoProposals = Aucune proposition n’attend d’approbation.
|
||||
settings-mcpApprove = Approuver
|
||||
settings-mcpReject = Rejeter
|
||||
settings-mcpAgents = Configuration des agents
|
||||
settings-mcpProposalApproved = Proposition MCP approuvée et appliquée.
|
||||
settings-mcpProposalRejected = Proposition MCP rejetée.
|
||||
settings-mcpKindDraftPost = Nouvel article
|
||||
settings-mcpKindScript = Nouveau script
|
||||
settings-mcpKindTemplate = Nouveau modèle
|
||||
settings-mcpKindMediaTranslation = Traduction du média
|
||||
settings-mcpKindMediaMetadata = Métadonnées du média
|
||||
settings-mcpKindPostMetadata = Métadonnées de l’article
|
||||
settings-mcpStatusPending = En attente
|
||||
settings-mcpStatusExecuting = Application en cours
|
||||
settings-mcpStatusAccepted = Approuvée
|
||||
settings-mcpStatusRejected = Rejetée
|
||||
settings-mcpStatusExpired = Expirée
|
||||
dashboard-overview = Aperçu
|
||||
dashboard-posts = Articles
|
||||
dashboard-media = Médias
|
||||
|
||||
@@ -395,7 +395,28 @@ settings-installCli = Installa CLI
|
||||
settings-cliInstalled = CLI installata in { $path }
|
||||
settings-contentPlaceholder = Le impostazioni del contenuto appariranno qui
|
||||
settings-technologyPlaceholder = Le impostazioni tecnologiche appariranno qui
|
||||
settings-mcpPlaceholder = Le impostazioni del server MCP appariranno qui
|
||||
settings-mcpEnable = Abilita il server MCP locale
|
||||
settings-mcpRunning = In esecuzione
|
||||
settings-mcpStopped = Arrestato
|
||||
settings-mcpRefresh = Aggiorna
|
||||
settings-mcpProposals = Proposte in attesa
|
||||
settings-mcpNoProposals = Nessuna proposta è in attesa di approvazione.
|
||||
settings-mcpApprove = Approva
|
||||
settings-mcpReject = Rifiuta
|
||||
settings-mcpAgents = Configurazione degli agenti
|
||||
settings-mcpProposalApproved = Proposta MCP approvata e applicata.
|
||||
settings-mcpProposalRejected = Proposta MCP rifiutata.
|
||||
settings-mcpKindDraftPost = Nuovo articolo
|
||||
settings-mcpKindScript = Nuovo script
|
||||
settings-mcpKindTemplate = Nuovo modello
|
||||
settings-mcpKindMediaTranslation = Traduzione del media
|
||||
settings-mcpKindMediaMetadata = Metadati del media
|
||||
settings-mcpKindPostMetadata = Metadati dell’articolo
|
||||
settings-mcpStatusPending = In attesa
|
||||
settings-mcpStatusExecuting = Applicazione in corso
|
||||
settings-mcpStatusAccepted = Approvata
|
||||
settings-mcpStatusRejected = Rifiutata
|
||||
settings-mcpStatusExpired = Scaduta
|
||||
dashboard-overview = Panoramica
|
||||
dashboard-posts = Articoli
|
||||
dashboard-media = Media
|
||||
|
||||
132
specs/mcp.allium
132
specs/mcp.allium
@@ -10,6 +10,9 @@ use "./template.allium" as template
|
||||
|
||||
enum ProposalStatus {
|
||||
pending
|
||||
executing
|
||||
accepted
|
||||
rejected
|
||||
expired
|
||||
}
|
||||
|
||||
@@ -31,12 +34,14 @@ surface McpServerSurface {
|
||||
}
|
||||
|
||||
entity Proposal {
|
||||
kind: draft_post | propose_script | propose_template | propose_media_metadata | propose_post_metadata
|
||||
kind: draft_post | propose_script | propose_template | propose_media_translation | propose_media_metadata | propose_post_metadata
|
||||
status: ProposalStatus
|
||||
entity_id: String
|
||||
data: String
|
||||
created_at: Timestamp
|
||||
expires_at: Timestamp
|
||||
resolved_at: Timestamp?
|
||||
result: String?
|
||||
draft_post: post/Post?
|
||||
proposed_script: script/Script?
|
||||
proposed_template: template/Template?
|
||||
@@ -47,7 +52,10 @@ entity Proposal {
|
||||
is_expired: expires_at <= now
|
||||
|
||||
transitions status {
|
||||
pending -> executing
|
||||
pending -> expired
|
||||
executing -> accepted
|
||||
executing -> rejected
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +69,8 @@ surface ProposalSurface {
|
||||
proposal.data
|
||||
proposal.created_at
|
||||
proposal.expires_at
|
||||
proposal.resolved_at when proposal.resolved_at != null
|
||||
proposal.result when proposal.result != null
|
||||
proposal.draft_post when proposal.draft_post != null
|
||||
proposal.proposed_script when proposal.proposed_script != null
|
||||
proposal.proposed_template when proposal.proposed_template != null
|
||||
@@ -101,12 +111,33 @@ invariant LocalhostOnlyHttp {
|
||||
}
|
||||
|
||||
invariant StatelessHttpHandling {
|
||||
-- Each HTTP request creates a fresh McpServer instance
|
||||
-- No session state between requests
|
||||
-- Each HTTP request opens a fresh shared database connection through a
|
||||
-- path-only application context. No MCP session id or client state is kept.
|
||||
}
|
||||
|
||||
invariant InertProposalPayloads {
|
||||
-- Write tools validate their request and persist only a serialized proposal.
|
||||
-- They do not create or update posts, media translations, scripts, templates,
|
||||
-- sidecars, or other content before explicit desktop approval.
|
||||
}
|
||||
|
||||
-- Read-only resources (bds:// scheme)
|
||||
|
||||
surface ProjectResource {
|
||||
facing viewer: McpClient
|
||||
context project: Project
|
||||
exposes:
|
||||
project.id
|
||||
project.name
|
||||
project.slug
|
||||
project.description
|
||||
project.public_url
|
||||
project.main_language
|
||||
project.blog_languages
|
||||
@guidance
|
||||
-- bds://project. Local filesystem paths are not exposed.
|
||||
}
|
||||
|
||||
surface PostsResource {
|
||||
facing viewer: McpClient
|
||||
context posts: Posts
|
||||
@@ -258,13 +289,19 @@ rule GetMediaTranslations {
|
||||
|
||||
rule UpsertMediaTranslation {
|
||||
when: McpToolInvoked("upsert_media_translation", params)
|
||||
-- Creates or updates translated media metadata for a language.
|
||||
ensures: media/UpsertMediaTranslationRequested(
|
||||
params.media_id,
|
||||
params.language,
|
||||
params.title,
|
||||
params.alt,
|
||||
params.caption
|
||||
-- Persists an inert translated-metadata proposal for desktop review.
|
||||
ensures: Proposal.created(
|
||||
kind: propose_media_translation,
|
||||
entity_id: params.media_id,
|
||||
data: serialize(params),
|
||||
created_at: now,
|
||||
expires_at: now + config.proposal_ttl,
|
||||
draft_post: null,
|
||||
proposed_script: null,
|
||||
proposed_template: null,
|
||||
target_media: params.media,
|
||||
target_post: null,
|
||||
status: pending
|
||||
)
|
||||
}
|
||||
|
||||
@@ -272,21 +309,15 @@ rule UpsertMediaTranslation {
|
||||
|
||||
rule DraftPost {
|
||||
when: McpToolInvoked("draft_post", params)
|
||||
-- Creates a draft post in DB
|
||||
-- Returns proposalId for accept/discard lifecycle
|
||||
-- Validates and stores an inert post payload; returns proposalId.
|
||||
ensures:
|
||||
let new_post = post/Post.created(
|
||||
title: params.title,
|
||||
content: params.content,
|
||||
status: draft
|
||||
)
|
||||
let proposal = Proposal.created(
|
||||
kind: draft_post,
|
||||
entity_id: new_post.id,
|
||||
data: "",
|
||||
entity_id: params.proposal_entity_id,
|
||||
data: serialize(params),
|
||||
created_at: now,
|
||||
expires_at: now + config.proposal_ttl,
|
||||
draft_post: new_post,
|
||||
draft_post: null,
|
||||
proposed_script: null,
|
||||
proposed_template: null,
|
||||
target_media: null,
|
||||
@@ -300,20 +331,14 @@ rule ProposeScript {
|
||||
when: McpToolInvoked("propose_script", params)
|
||||
requires: ValidateScript(params.content) = valid
|
||||
ensures:
|
||||
let new_script = script/Script.created(
|
||||
title: params.title,
|
||||
kind: params.kind,
|
||||
content: params.content,
|
||||
status: draft
|
||||
)
|
||||
let proposal = Proposal.created(
|
||||
kind: propose_script,
|
||||
entity_id: new_script.id,
|
||||
data: "",
|
||||
entity_id: params.proposal_entity_id,
|
||||
data: serialize(params),
|
||||
created_at: now,
|
||||
expires_at: now + config.proposal_ttl,
|
||||
draft_post: null,
|
||||
proposed_script: new_script,
|
||||
proposed_script: null,
|
||||
proposed_template: null,
|
||||
target_media: null,
|
||||
target_post: null,
|
||||
@@ -326,21 +351,15 @@ rule ProposeTemplate {
|
||||
when: McpToolInvoked("propose_template", params)
|
||||
requires: ValidateLiquid(params.content) = valid
|
||||
ensures:
|
||||
let new_template = template/Template.created(
|
||||
title: params.title,
|
||||
kind: params.kind,
|
||||
content: params.content,
|
||||
status: draft
|
||||
)
|
||||
let proposal = Proposal.created(
|
||||
kind: propose_template,
|
||||
entity_id: new_template.id,
|
||||
data: "",
|
||||
entity_id: params.proposal_entity_id,
|
||||
data: serialize(params),
|
||||
created_at: now,
|
||||
expires_at: now + config.proposal_ttl,
|
||||
draft_post: null,
|
||||
proposed_script: null,
|
||||
proposed_template: new_template,
|
||||
proposed_template: null,
|
||||
target_media: null,
|
||||
target_post: null,
|
||||
status: pending
|
||||
@@ -393,42 +412,42 @@ rule AcceptProposal {
|
||||
requires: not proposal.is_expired
|
||||
ensures:
|
||||
if proposal.kind = draft_post:
|
||||
post/PublishPostRequested(proposal.draft_post)
|
||||
post/PublishPostRequested(deserialize_post(proposal.data))
|
||||
if proposal.kind = propose_script:
|
||||
script/PublishScriptRequested(proposal.proposed_script)
|
||||
script/PublishScriptRequested(deserialize_script(proposal.data))
|
||||
if proposal.kind = propose_template:
|
||||
template/PublishTemplateRequested(proposal.proposed_template)
|
||||
template/PublishTemplateRequested(deserialize_template(proposal.data))
|
||||
if proposal.kind = propose_media_translation:
|
||||
media/UpsertMediaTranslationRequested(deserialize_media_translation(proposal.data))
|
||||
if proposal.kind = propose_media_metadata:
|
||||
media/UpdateMediaRequested(proposal.target_media, deserialize_media_changes(proposal.data))
|
||||
if proposal.kind = propose_post_metadata:
|
||||
post/UpdatePostRequested(proposal.target_post, deserialize_post_changes(proposal.data))
|
||||
not exists proposal
|
||||
proposal.status = accepted
|
||||
proposal.result = serialize(result)
|
||||
proposal.resolved_at = now
|
||||
}
|
||||
|
||||
rule DiscardProposal {
|
||||
when: DiscardProposalRequested(proposal)
|
||||
ensures:
|
||||
if proposal.kind = draft_post:
|
||||
post/DeletePostRequested(proposal.draft_post)
|
||||
if proposal.kind = propose_script:
|
||||
script/DeleteScriptRequested(proposal.proposed_script)
|
||||
if proposal.kind = propose_template:
|
||||
template/DeleteTemplateRequested(proposal.proposed_template)
|
||||
not exists proposal
|
||||
-- There are no temporary content entities to clean up.
|
||||
ensures: proposal.status = rejected
|
||||
ensures: proposal.result = serialize(rejected)
|
||||
ensures: proposal.resolved_at = now
|
||||
}
|
||||
|
||||
rule ExpireProposal {
|
||||
when: proposal: Proposal.is_expired becomes true
|
||||
-- On expiry: clean up draft DB rows
|
||||
-- Expiry marks the inert proposal terminal; no content cleanup is needed.
|
||||
ensures: proposal.status = expired
|
||||
ensures: DiscardProposalRequested(proposal)
|
||||
ensures: proposal.result = serialize(expired)
|
||||
ensures: proposal.resolved_at = now
|
||||
}
|
||||
|
||||
-- Agent configuration
|
||||
|
||||
value McpAgentKind {
|
||||
-- Supported: claude_code, claude_desktop, github_copilot,
|
||||
-- gemini_cli, opencode, mistral_vibe, openai_codex
|
||||
-- Currently supported and shown: claude_code, github_copilot.
|
||||
kind: String
|
||||
}
|
||||
|
||||
@@ -462,7 +481,6 @@ rule UninstallAgentConfig {
|
||||
}
|
||||
|
||||
invariant ProposalPayloadEncoding {
|
||||
-- Proposal.data stores a serialized payload for metadata proposals.
|
||||
-- draft_post / propose_script / propose_template proposals keep the
|
||||
-- created entity reference directly on the proposal record.
|
||||
-- Proposal.data stores the validated serialized payload for every write.
|
||||
-- Terminal status/result rows remain available for result reporting.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user