Enforce unique MCP proposal identities

This commit is contained in:
2026-07-22 18:25:19 +02:00
parent 2dcaf7f7f7
commit 70503c8bc2
12 changed files with 353 additions and 15 deletions

View File

@@ -17,7 +17,7 @@ The project is under active development. Core blogging workflows are broadly ava
- A localized OPML menu editor manages pages, submenus, and category archives with protected Home ordering, keyboard-accessible tree controls, drag-and-drop, and bDS2-compatible persistence.
- 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, effective shared settings with secret-presence redaction, 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.
- Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, uniquely identified inert write proposals, clean duplicate-pending rejection, explicit desktop approval, and opt-in Claude Code/Copilot configuration.
- A fully localized Ratatui terminal workspace, available locally through `bds-cli tui`/`BDS_MODE=tui` and remotely through authenticated SSH shell sessions, with shared post/template/script editing and publishing, project/search/command overlays, settings, tags, Git, reports, task progress, live multi-client locale updates, and airplane-mode AI gating.
- `bds-cli server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection.
- bDS2-compatible Markdown/Liquid rendering with built-in macros rendered from bundled Liquid templates in isolated scopes (customizable with `macros/*` partial-template slugs), descriptive category archive titles, canonical multilingual and flat page routes for every configured blog language, recursive menus, calendar archives, feeds, a root hreflang sitemap, Pagefind, and incremental site generation and validation repair through cancellable section task groups.

View File

@@ -0,0 +1,38 @@
DROP INDEX mcp_proposals_entity_idx;
CREATE TABLE mcp_proposals_old (
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
);
INSERT INTO mcp_proposals_old (
id, project_id, kind, status, entity_id, data, result,
created_at, expires_at, resolved_at
)
SELECT
id, project_id, kind, status, entity_id, data, result,
created_at, expires_at, resolved_at
FROM mcp_proposals;
DROP TABLE mcp_proposals;
ALTER TABLE mcp_proposals_old RENAME TO mcp_proposals;
CREATE INDEX mcp_proposals_project_status_idx
ON mcp_proposals(project_id, status, created_at);

View File

@@ -0,0 +1,55 @@
CREATE TABLE mcp_proposals_new (
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 NOT NULL,
data TEXT NOT NULL,
result TEXT,
created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
resolved_at BIGINT
);
INSERT INTO mcp_proposals_new (
id, project_id, kind, status, entity_id, data, result,
created_at, expires_at, resolved_at
)
SELECT
id, project_id, kind, status, COALESCE(entity_id, id), data, result,
created_at, expires_at, resolved_at
FROM mcp_proposals;
DROP TABLE mcp_proposals;
ALTER TABLE mcp_proposals_new RENAME TO mcp_proposals;
-- Keep the newest record if a pre-migration database already contains an
-- invalid duplicate. NULL-backed proposals were assigned their own IDs above
-- and therefore remain distinct.
DELETE FROM mcp_proposals AS duplicate
WHERE EXISTS (
SELECT 1
FROM mcp_proposals AS keeper
WHERE keeper.kind = duplicate.kind
AND keeper.entity_id = duplicate.entity_id
AND keeper.status = duplicate.status
AND (
keeper.created_at > duplicate.created_at
OR (keeper.created_at = duplicate.created_at AND keeper.id > duplicate.id)
)
);
CREATE INDEX mcp_proposals_project_status_idx
ON mcp_proposals(project_id, status, created_at);
CREATE UNIQUE INDEX mcp_proposals_entity_idx
ON mcp_proposals(kind, entity_id, status);

View File

@@ -62,6 +62,15 @@ impl DbConnection {
)
}
#[cfg(test)]
pub(crate) fn insert_legacy_null_mcp_proposal_for_test(&self) -> diesel::QueryResult<()> {
self.0.borrow_mut().batch_execute(
"INSERT INTO mcp_proposals \
(id, project_id, kind, status, entity_id, data, created_at, expires_at) \
VALUES ('null-entity', 'p1', 'draft_post', 'pending', NULL, '{}', 3, 99)",
)
}
/// Filesystem database path for sibling surfaces that must open their own
/// short-lived connection (gallery workers, preview servers, Lua hosts).
pub fn database_path(&self) -> diesel::QueryResult<std::path::PathBuf> {

View File

@@ -36,7 +36,60 @@ mod tests {
let applied = db
.conn()
.with_migrations(|conn| conn.applied_migrations().unwrap().len());
assert_eq!(applied, 8);
assert_eq!(applied, 9);
}
#[test]
fn mcp_proposal_upgrade_normalizes_entities_and_enforces_uniqueness() {
use crate::db::queries::{mcp_proposal, project};
use crate::model::{McpProposal, ProposalKind, ProposalStatus};
let db = Database::open_in_memory().unwrap();
db.conn().with_migrations(|conn| {
for _ in 0..8 {
conn.run_next_migration(MIGRATIONS).unwrap();
}
});
project::insert_project(db.conn(), &project::make_test_project("p1", "blog")).unwrap();
let targeted = |id: &str, created_at| McpProposal {
id: id.into(),
project_id: "p1".into(),
kind: ProposalKind::ProposePostMetadata,
status: ProposalStatus::Pending,
entity_id: "post-1".into(),
data: "{}".into(),
result: None,
created_at,
expires_at: 99,
resolved_at: None,
};
mcp_proposal::insert_proposal(db.conn(), &targeted("older", 1)).unwrap();
mcp_proposal::insert_proposal(db.conn(), &targeted("newer", 2)).unwrap();
db.conn()
.insert_legacy_null_mcp_proposal_for_test()
.unwrap();
run_migrations(db.conn()).unwrap();
let proposals = mcp_proposal::list_proposals(db.conn(), "p1").unwrap();
assert_eq!(proposals.len(), 2);
assert!(proposals.iter().any(|proposal| proposal.id == "newer"));
assert_eq!(
proposals
.iter()
.find(|proposal| proposal.id == "null-entity")
.unwrap()
.entity_id,
"null-entity"
);
assert!(matches!(
mcp_proposal::insert_proposal(db.conn(), &targeted("duplicate", 4)),
Err(diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UniqueViolation,
_
))
));
}
#[test]

View File

@@ -48,6 +48,21 @@ pub fn list_pending_proposals(
pub fn expire_pending(conn: &DbConnection, now: i64) -> QueryResult<usize> {
conn.with(|connection| {
let expiring = mcp_proposals::table
.filter(mcp_proposals::status.eq(ProposalStatus::Pending))
.filter(mcp_proposals::expires_at.le(now))
.select(McpProposal::as_select())
.load::<McpProposal>(connection)?;
for proposal in &expiring {
diesel::delete(
mcp_proposals::table
.filter(mcp_proposals::id.ne(&proposal.id))
.filter(mcp_proposals::kind.eq(proposal.kind))
.filter(mcp_proposals::entity_id.eq(&proposal.entity_id))
.filter(mcp_proposals::status.eq(ProposalStatus::Expired)),
)
.execute(connection)?;
}
diesel::update(
mcp_proposals::table
.filter(mcp_proposals::status.eq(ProposalStatus::Pending))
@@ -62,6 +77,23 @@ pub fn expire_pending(conn: &DbConnection, now: i64) -> QueryResult<usize> {
})
}
pub fn delete_status_collision(
conn: &DbConnection,
proposal: &McpProposal,
status: ProposalStatus,
) -> QueryResult<usize> {
conn.with(|connection| {
diesel::delete(
mcp_proposals::table
.filter(mcp_proposals::id.ne(&proposal.id))
.filter(mcp_proposals::kind.eq(proposal.kind))
.filter(mcp_proposals::entity_id.eq(&proposal.entity_id))
.filter(mcp_proposals::status.eq(status)),
)
.execute(connection)
})
}
pub fn claim_pending(conn: &DbConnection, id: &str, now: i64) -> QueryResult<bool> {
conn.with(|connection| {
diesel::update(
@@ -123,7 +155,7 @@ mod tests {
project_id: "p1".into(),
kind: ProposalKind::DraftPost,
status: ProposalStatus::Pending,
entity_id: None,
entity_id: id.into(),
data: "{}".into(),
result: None,
created_at: 1,

View File

@@ -161,7 +161,7 @@ diesel::table! {
project_id -> Text,
kind -> Text,
status -> Text,
entity_id -> Nullable<Text>,
entity_id -> Text,
data -> Text,
result -> Nullable<Text>,
created_at -> BigInt,

View File

@@ -98,7 +98,17 @@ pub fn list_pending_proposals(
}
pub fn expire_proposals(conn: &DbConnection) -> EngineResult<usize> {
let expired = proposal_q::expire_pending(conn, now_unix_ms())?;
conn.begin_savepoint()?;
let expired = match proposal_q::expire_pending(conn, now_unix_ms()) {
Ok(expired) => {
conn.release_savepoint()?;
expired
}
Err(error) => {
let _ = conn.rollback_savepoint();
return Err(error.into());
}
};
if expired > 0 {
notify_proposals_changed();
}
@@ -114,19 +124,37 @@ pub(crate) fn create_proposal(
) -> EngineResult<McpProposal> {
expire_proposals(conn)?;
let now = now_unix_ms();
let entity_id = entity_id
.map(str::to_string)
.unwrap_or_else(|| Uuid::new_v4().to_string());
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),
entity_id,
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)?;
if let Err(error) = proposal_q::insert_proposal(conn, &proposal) {
if matches!(
error,
diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UniqueViolation,
_
)
) {
return Err(EngineError::Conflict(format!(
"proposal already pending for {} entity {}",
proposal.kind.as_str(),
proposal.entity_id
)));
}
return Err(error.into());
}
cli_sync::record_cli_event(
conn,
&DomainEvent::SettingsChanged {
@@ -182,6 +210,7 @@ fn resolve_proposal(
} else {
ProposalStatus::Rejected
};
proposal_q::delete_status_collision(conn, &proposal, status)?;
if !proposal_q::resolve_claimed(
conn,
proposal_id,

View File

@@ -119,7 +119,7 @@ pub struct McpProposal {
pub project_id: String,
pub kind: ProposalKind,
pub status: ProposalStatus,
pub entity_id: Option<String>,
pub entity_id: String,
pub data: String,
pub result: Option<String>,
pub created_at: i64,

View File

@@ -2,7 +2,7 @@ use std::net::{IpAddr, Ipv4Addr};
use std::sync::{Arc, Barrier};
use bds_core::db::Database;
use bds_core::engine::{mcp, project};
use bds_core::engine::{EngineError, mcp, project};
use bds_core::model::{DomainEntity, DomainEvent, McpProposal, ProposalKind, ProposalStatus};
use serde_json::json;
@@ -523,7 +523,7 @@ fn expiry_invalid_ids_unavailable_projects_and_concurrent_acceptance_are_safe()
project_id: fixture.project_id.clone(),
kind: ProposalKind::DraftPost,
status: ProposalStatus::Pending,
entity_id: None,
entity_id: "expired-entity".into(),
data: json!({"title":"Expired","content":"Body"}).to_string(),
result: None,
created_at: 1,
@@ -587,6 +587,130 @@ fn expiry_invalid_ids_unavailable_projects_and_concurrent_acceptance_are_safe()
);
}
#[test]
fn duplicate_pending_entity_proposals_are_rejected_cleanly() {
let fixture = Fixture::new();
let post = fixture.post("One target", "Body");
let context = fixture.context();
let arguments = json!({"postId": post.id, "title": "First change"});
context
.call_tool("propose_post_metadata", arguments.clone())
.unwrap();
let error = context
.call_tool("propose_post_metadata", arguments)
.unwrap_err();
assert!(matches!(error, EngineError::Conflict(_)));
assert_eq!(
error.to_string(),
format!(
"conflict: proposal already pending for propose_post_metadata entity {}",
post.id
)
);
let db = fixture.database();
assert_eq!(
mcp::list_pending_proposals(db.conn(), &fixture.project_id)
.unwrap()
.len(),
1
);
}
#[test]
fn untargeted_proposals_receive_distinct_entity_ids() {
let fixture = Fixture::new();
let context = fixture.context();
context
.call_tool("draft_post", json!({"title": "First", "content": "Body"}))
.unwrap();
context
.call_tool("draft_post", json!({"title": "Second", "content": "Body"}))
.unwrap();
let db = fixture.database();
let proposals = mcp::list_pending_proposals(db.conn(), &fixture.project_id).unwrap();
assert_eq!(proposals.len(), 2);
assert_ne!(proposals[0].entity_id, proposals[1].entity_id);
}
#[test]
fn newer_terminal_proposals_replace_older_status_history() {
let fixture = Fixture::new();
let post = fixture.post("Original", "Body");
let context = fixture.context();
let first = context
.call_tool(
"propose_post_metadata",
json!({"postId": post.id, "title": "First change"}),
)
.unwrap();
let db = fixture.database();
mcp::accept_proposal(
db.conn(),
&fixture.data_dir,
first["proposalId"].as_str().unwrap(),
)
.unwrap();
let second = context
.call_tool(
"propose_post_metadata",
json!({"postId": post.id, "title": "Second change"}),
)
.unwrap();
mcp::accept_proposal(
db.conn(),
&fixture.data_dir,
second["proposalId"].as_str().unwrap(),
)
.unwrap();
let accepted = mcp::list_proposals(db.conn(), &fixture.project_id)
.unwrap()
.into_iter()
.filter(|proposal| proposal.status == ProposalStatus::Accepted)
.collect::<Vec<_>>();
assert_eq!(accepted.len(), 1);
assert_eq!(accepted[0].id, second["proposalId"]);
}
#[test]
fn newer_expired_proposals_replace_older_expired_history() {
let fixture = Fixture::new();
let db = fixture.database();
for (id, status, expires_at) in [
("old-expired", ProposalStatus::Expired, 1),
("new-expiring", ProposalStatus::Pending, 2),
] {
bds_core::db::queries::mcp_proposal::insert_proposal(
db.conn(),
&McpProposal {
id: id.into(),
project_id: fixture.project_id.clone(),
kind: ProposalKind::ProposePostMetadata,
status,
entity_id: "same-post".into(),
data: "{}".into(),
result: None,
created_at: expires_at,
expires_at,
resolved_at: (status == ProposalStatus::Expired).then_some(1),
},
)
.unwrap();
}
assert_eq!(mcp::expire_proposals(db.conn()).unwrap(), 1);
let proposals = mcp::list_proposals(db.conn(), &fixture.project_id).unwrap();
assert_eq!(proposals.len(), 1);
assert_eq!(proposals[0].id, "new-expiring");
assert_eq!(proposals[0].status, ProposalStatus::Expired);
}
#[test]
fn protocol_routes_every_family_and_http_enforces_loopback_origin_and_cors() {
let fixture = Fixture::new();

View File

@@ -13196,7 +13196,7 @@ mod tests {
project_id: project.id.clone(),
kind: bds_core::model::ProposalKind::DraftPost,
status: bds_core::model::ProposalStatus::Pending,
entity_id: None,
entity_id: "accept-entity".into(),
data: serde_json::json!({"title":"Approved","content":"Body"}).to_string(),
result: None,
created_at: now,
@@ -13205,6 +13205,7 @@ mod tests {
};
let mut rejected = accepted.clone();
rejected.id = "reject-me".into();
rejected.entity_id = "reject-entity".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();

View File

@@ -1064,10 +1064,7 @@ fn section_mcp<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a
} 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}"));
let summary = format!("{kind} · {}", proposal.entity_id);
inputs::card(
row![
column![