Implement MCP automation and approvals.

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

View File

@@ -873,6 +873,7 @@ pub struct BdsApp {
// Local preview
preview_session: Option<PreviewSession>,
mcp_server: Option<engine::mcp::McpHttpServer>,
embedded_preview: Option<EmbeddedPreviewState>,
main_window_id: Option<window::Id>,
@@ -923,6 +924,17 @@ impl BdsApp {
let search_index_rebuild_required = db
.as_ref()
.is_some_and(|db| engine::search::prepare_search_index(db.conn()).unwrap_or(true));
let mcp_enabled = db.as_ref().is_some_and(|db| {
engine::settings::get(db.conn(), "mcp.http.enabled")
.ok()
.flatten()
.is_some_and(|value| value == "true")
});
let mcp_server = mcp_enabled
.then(|| {
engine::mcp::McpHttpServer::start(db_path.clone(), engine::mcp::DEFAULT_HTTP_PORT)
})
.and_then(Result::ok);
// Load projects
let projects = db
@@ -1020,6 +1032,7 @@ impl BdsApp {
active_modal: search_index_rebuild_required
.then_some(modal::ModalState::SearchIndexRepair),
preview_session: None,
mcp_server,
embedded_preview: None,
main_window_id: None,
post_editors: HashMap::new(),
@@ -1093,6 +1106,7 @@ impl BdsApp {
toasts: Vec::new(),
active_modal: None,
preview_session: None,
mcp_server: None,
embedded_preview: None,
main_window_id: None,
post_editors: HashMap::new(),
@@ -5142,6 +5156,34 @@ impl BdsApp {
state.title_model = ai_settings.title_model.unwrap_or_default();
state.image_model = ai_settings.image_model.unwrap_or_default();
}
state.mcp_enabled = engine::settings::get(db.conn(), "mcp.http.enabled")
.ok()
.flatten()
.is_some_and(|value| value == "true");
state.mcp_running = self.mcp_server.is_some();
state.mcp_endpoint = self
.mcp_server
.as_ref()
.map(engine::mcp::McpHttpServer::endpoint)
.unwrap_or_else(|| {
format!("http://127.0.0.1:{}/mcp", engine::mcp::DEFAULT_HTTP_PORT)
});
if let Some(project) = &self.active_project {
state.mcp_proposals =
engine::mcp::list_pending_proposals(db.conn(), &project.id).unwrap_or_default();
}
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
state.mcp_agents = engine::mcp::McpAgent::all()
.into_iter()
.map(|agent| crate::views::settings_view::SettingsMcpAgentRow {
agent,
label: agent.label().to_string(),
configured: engine::mcp::is_agent_configured(agent, &home),
config_path: engine::mcp::agent_config_path(agent, &home)
.to_string_lossy()
.into_owned(),
})
.collect();
}
state.offline_mode = self.offline_mode;
state
@@ -5982,6 +6024,100 @@ impl BdsApp {
}
}
}
SettingsMsg::McpEnabledChanged(enabled) => {
let Some(db) = &self.db else {
return Task::none();
};
if enabled {
if self.mcp_server.is_none() {
match engine::mcp::McpHttpServer::start(
self.db_path.clone(),
engine::mcp::DEFAULT_HTTP_PORT,
) {
Ok(server) => self.mcp_server = Some(server),
Err(error) => {
self.notify_operation_failed(
"settings.mcpEnable",
error.to_string(),
);
return Task::none();
}
}
}
} else if let Some(server) = self.mcp_server.take()
&& let Err(error) = server.stop()
{
self.notify_operation_failed("settings.mcpEnable", error.to_string());
return Task::none();
}
match engine::settings::set(
db.conn(),
"mcp.http.enabled",
if enabled { "true" } else { "false" },
) {
Ok(()) => {
self.settings_state = Some(self.hydrate_settings_state());
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
}
Err(error) => {
self.notify_operation_failed("settings.mcpEnable", error.to_string())
}
}
}
SettingsMsg::McpProposalAccepted(proposal_id) => {
if let (Some(db), Some(data_dir)) = (&self.db, &self.data_dir) {
match engine::mcp::accept_proposal(db.conn(), data_dir, &proposal_id) {
Ok(_) => {
self.settings_state = Some(self.hydrate_settings_state());
self.notify(
ToastLevel::Success,
&t(self.ui_locale, "settings.mcpProposalApproved"),
);
}
Err(error) => {
self.notify_operation_failed("settings.mcpApprove", error.to_string())
}
}
}
}
SettingsMsg::McpProposalRejected(proposal_id) => {
if let (Some(db), Some(data_dir)) = (&self.db, &self.data_dir) {
match engine::mcp::reject_proposal(db.conn(), data_dir, &proposal_id) {
Ok(_) => {
self.settings_state = Some(self.hydrate_settings_state());
self.notify(
ToastLevel::Success,
&t(self.ui_locale, "settings.mcpProposalRejected"),
);
}
Err(error) => {
self.notify_operation_failed("settings.mcpReject", error.to_string())
}
}
}
}
SettingsMsg::McpAgentToggled(agent) => {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let result = if engine::mcp::is_agent_configured(agent, &home) {
engine::mcp::remove_agent_config(agent, &home)
} else {
engine::mcp::packaged_mcp_executable().and_then(|executable| {
engine::mcp::install_agent_config(agent, &home, &executable)
})
};
match result {
Ok(_) => {
self.settings_state = Some(self.hydrate_settings_state());
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
}
Err(error) => {
self.notify_operation_failed("settings.mcpAgents", error.to_string())
}
}
}
SettingsMsg::McpRefresh => {
self.settings_state = Some(self.hydrate_settings_state());
}
SettingsMsg::FocusSection(section) => {
state.focus_section(section);
}
@@ -8974,4 +9110,63 @@ mod tests {
assert!(app.script_editors[&created.id].is_dirty);
assert!(app.toasts.is_empty());
}
#[test]
fn mcp_settings_review_applies_or_rejects_inert_proposals() {
let (db, mut project, tmp) = setup();
project.data_path = Some(tmp.path().to_string_lossy().into_owned());
bds_core::db::queries::project::update_project(db.conn(), &project).unwrap();
let now = bds_core::util::now_unix_ms();
let accepted = bds_core::model::McpProposal {
id: "accept-me".into(),
project_id: project.id.clone(),
kind: bds_core::model::ProposalKind::DraftPost,
status: bds_core::model::ProposalStatus::Pending,
entity_id: None,
data: serde_json::json!({"title":"Approved","content":"Body"}).to_string(),
result: None,
created_at: now,
expires_at: now + 60_000,
resolved_at: None,
};
let mut rejected = accepted.clone();
rejected.id = "reject-me".into();
rejected.data = serde_json::json!({"title":"Rejected","content":"Body"}).to_string();
bds_core::db::queries::mcp_proposal::insert_proposal(db.conn(), &accepted).unwrap();
bds_core::db::queries::mcp_proposal::insert_proposal(db.conn(), &rejected).unwrap();
let mut app = make_app(db, project.clone(), &tmp);
app.settings_state = Some(app.hydrate_settings_state());
assert_eq!(app.settings_state.as_ref().unwrap().mcp_proposals.len(), 2);
let _ = app.handle_settings_msg(SettingsMsg::McpProposalAccepted("accept-me".into()));
let _ = app.handle_settings_msg(SettingsMsg::McpProposalRejected("reject-me".into()));
let posts = bds_core::db::queries::post::list_posts_by_project(
app.db.as_ref().unwrap().conn(),
&project.id,
)
.unwrap();
assert_eq!(posts.len(), 1);
assert_eq!(posts[0].title, "Approved");
assert_eq!(posts[0].status, PostStatus::Published);
assert_eq!(
bds_core::engine::mcp::get_proposal(app.db.as_ref().unwrap().conn(), "accept-me")
.unwrap()
.status,
bds_core::model::ProposalStatus::Accepted
);
assert_eq!(
bds_core::engine::mcp::get_proposal(app.db.as_ref().unwrap().conn(), "reject-me")
.unwrap()
.status,
bds_core::model::ProposalStatus::Rejected
);
assert!(
app.settings_state
.as_ref()
.unwrap()
.mcp_proposals
.is_empty()
);
}
}