// MCP tools for the saltcorn-mcp server (its docs/pluginTools.md is the // contract): an AI client drives site-sync the way an admin drives its UI -- pair // peers, pull a peer's state into a plan, review it, apply it, roll it back, set // the policies and the table data modes. Every tool is an adapter over the // operations the admin routes use (operations.js), so the rules live in one place. // // Scopes: looking (status, peers, a peer's health, tables, plans, this site's // state) is read; pulling a plan is write -- it records a plan here and changes // nothing on either site; applying and rolling back (they rewrite this site, // code pages -- server-side JavaScript -- included), settings, peers (credentials) // and table data modes are admin. The machine API (peers calling peers) is never a // tool, and there is no push: pulling is the path. // // Secrets: a peer's shared secret is never returned, except the one this site // GENERATES when pairing or rotating, returned once by that tool (as the UI shows // it once). A stored plan holds the source's full configurations and managed rows, // so a plan is shown trimmed (names, counts, gates) or one entity at a time, both // through ctx.redact; the managed rows themselves are never shown. const ops = require("./operations"); const diff = require("./diff"); const planLib = require("./plan"); const peers = require("./peers"); const { requireEnv } = require("./env"); const { PLAN_STATUS, DATA_MODES, DELETE_POLICY, SYNCABLE_CONFIG_KEYS } = require("./constants"); const PREFIX = "sitesync_"; // get_plan's entity kind for the config settings a plan overwrites. const CONFIG_KIND = "config"; const PEER_ARG = { type: ["string", "integer"], description: "The peer: its id, env_id or label (sitesync_list_peers lists them)." }; const PLAN_ARG = { type: "string", minLength: 1, description: "The plan id (sitesync_plan_pull returns it; sitesync_list_plans lists plans)." }; const PRUNABLE_ARG = { type: "array", items: { type: "string", minLength: 1 }, description: "Names of the tables whose DATA may be destroyed (sitesync_get_plan data_drops.tables): a drop of a table with rows, or of a field of a populated table, happens only for a table named here. Never pre-fill it: name only the tables you mean to lose." }; const CONFIRM_ARG = { type: "boolean", description: "The data-loss acknowledgement the on_delete=confirm policy requires besides prunable (sitesync_get_plan data_drops.confirm_data_drops_needed)." }; const tool = (name, scope, title, description, properties, required, handler, flags) => { return { name: PREFIX + name, title: title, description: description, scope: scope, inputSchema: { type: "object", properties, required: required || [], additionalProperties: false }, handler: handler, ...(flags || {}) }; }; // An OpError as a tool error the model sees; a failed apply whose plan has a // restore point says how to revert it. const run = async (ctx, fn) => { try { return await fn(); } catch (e) { if (e instanceof ops.OpError) { const extra = e.extra || {}; ctx.fail(extra.canRollback ? `${e.message} (plan ${extra.planId} has a restore point: sitesync_rollback reverts what its apply changed)` : e.message); } throw e; } }; // ---------------- Views ---------------- const envView = (env) => { return { env_id: env.env_id, label: env.env_label, sync_mode: env.sync_mode, on_delete: env.on_delete, on_apply: env.on_apply, require_tls: !!env.require_tls }; }; const peerView = (peer) => { return { peer_id: peer.peer_id, label: peer.label, env_id: peer.env_id, base_url: peer.base_url, require_tls: peer.require_tls, created_at: peer.created_at, last_seen_at: peer.last_seen_at }; }; // The name an entity of a plan kind is listed under (and found by, besides its // identity key): roles by role name, models as .. const entityName = (kind, entry) => { if (kind === "roles") { return entry.role || String(entry.id); } if (kind === "models") { return `${entry.table_name}.${entry.name}`; } return entry.name; }; // Names per change of every kind the plan touches (tables with their field and // constraint changes; renames are the plan's rename_candidates). const changesView = (plan) => { const out = {}; for (const kind of diff.PLAN_KINDS.filter((k) => plan.summary[k] > 0)) { const b = plan.entities[kind]; if (kind === "tables") { out.tables = { create: b.create.map((t) => t.name), update: b.update.map((u) => ({ name: u.name, props_changed: u.propsChanged, fields_added: u.fields.create.map((f) => f.name), fields_changed: u.fields.update.map((f) => f.name), fields_dropped: u.fields.delete.map((f) => f.name), constraints_added: u.constraints.add.length, constraints_removed: u.constraints.remove.length, row_count: u.rowCount })), delete: b.delete.map((d) => ({ name: d.name, row_count: d.rowCount })) }; continue; } out[kind] = { create: b.create.map((e) => entityName(kind, e)), update: b.update.map((u) => entityName(kind, u.to)), delete: b.delete.map((e) => entityName(kind, e)) }; } return out; }; // A plan, trimmed: what it does (names and counts, never definitions or rows), // what it destroys, the gates an apply must pass, and what can be done with it now. const planView = (info) => { const { loaded, peer, env, standing } = info; const plan = loaded.plan; const dropTables = planLib.dataDropTables(plan); return { plan_id: loaded.plan_id, status: loaded.status, direction: loaded.direction, peer: peer ? peerView(peer) : null, source_env_id: loaded.source_env_id, created_at: loaded.created_at, ...standing, in_sync: diff.isEmptyPlan(plan), sync_mode: plan.sync_mode, summary: plan.summary, changes: changesView(plan), destructive: plan.destructive.map((d) => ({ text: d.text, data_bearing: d.dataBearing })), rename_candidates: plan.entities.tables.rename.map((r) => ({ from: r.from, to: r.to, row_count: r.rowCount })), data_drops: { tables: dropTables, on_delete: env.on_delete, confirm_data_drops_needed: dropTables.length > 0 && env.on_delete === DELETE_POLICY.CONFIRM, refused_by_policy: dropTables.length > 0 && env.on_delete === DELETE_POLICY.REFUSE }, config_changes: Object.keys(plan.config.sets), rows: plan.rows.perTable, rows_skipped: plan.rows.skipped, warnings: plan.warnings }; }; // One entity of a plan: what the plan does to it, the source's definition (to) // and this site's definition now (current; null when absent). const entityView = (info, kind, name, ctx) => { const { loaded, current } = info; const plan = loaded.plan; const fail = () => ctx.fail(`plan ${loaded.plan_id} does not change ${kind} "${name}" (sitesync_get_plan without entity lists what it changes)`); if (kind === CONFIG_KIND) { if (!Object.prototype.hasOwnProperty.call(plan.config.sets, name)) { return fail(); } const now = current.pack.config; return { kind, name, action: "update", to: plan.config.sets[name], current: Object.prototype.hasOwnProperty.call(now, name) ? now[name] : null }; } const byKey = (pack, key) => pack[kind].find((e) => diff.keyOfKind(kind, e) === key) || null; const named = (e) => entityName(kind, e) === name || diff.keyOfKind(kind, e) === name; const b = plan.entities[kind]; const created = b.create.find(named); if (created) { const key = diff.keyOfKind(kind, created); return { kind, name: entityName(kind, created), action: "create", to: created, current: byKey(current.pack, key) }; } if (kind === "tables") { const u = b.update.find((x) => x.name === name); if (u) { return { kind, name, action: "update", props_changed: u.propsChanged, fields: { added: u.fields.create.map((f) => f.name), changed: u.fields.update, dropped: u.fields.delete.map((f) => f.name) }, constraints: u.constraints, row_count: u.rowCount, to: byKey(loaded.desired, name), current: byKey(current.pack, name) }; } const d = b.delete.find((x) => x.name === name); if (d) { return { kind, name, action: "delete", row_count: d.rowCount, to: null, current: byKey(current.pack, name) }; } const r = b.rename.find((x) => x.from === name || x.to === name); if (r) { return { kind, name, action: "rename", from: r.from, row_count: r.rowCount, to: byKey(loaded.desired, r.to), current: byKey(current.pack, r.from) }; } return fail(); } const u = b.update.find((x) => named(x.to)); if (u) { return { kind, name: entityName(kind, u.to), action: "update", to: u.to, current: byKey(current.pack, u.key) }; } const d = b.delete.find(named); if (d) { return { kind, name: entityName(kind, d), action: "delete", to: null, current: byKey(current.pack, diff.keyOfKind(kind, d)) }; } return fail(); }; // ---------------- Handlers ---------------- const getStatus = async () => { const s = await ops.status(); return { ...envView(s.env), created_at: s.env.created_at, version: s.version, peer_count: s.peerCount, synced_tables: s.syncedTables, session_secret_configured: s.sessionSecretConfigured, apply_lock: s.applyLock }; }; const listPeers = async () => { const env = await requireEnv(); return { this_env_id: env.env_id, peers: (await peers.listPeers()).map(peerView) }; }; const peerHealth = async (args) => { const h = await ops.peerHealth(args.peer); if (!h.reachable) { return { peer: peerView(h.peer), reachable: false, error: h.error }; } return { peer: peerView(h.peer), reachable: true, plugin: h.health.plugin, version: h.health.version, env_id: h.health.env_id, label: h.health.label, parity_warnings: h.parityWarnings }; }; const getPlan = async (args, ctx) => { const info = await ops.inspectPlan(args.plan_id); return ctx.redact(args.entity ? entityView(info, args.entity.kind, args.entity.name, ctx) : planView(info)); }; const pullPlan = async (args, ctx) => { const pulled = await ops.pullPlan(args.peer); return getPlan({ plan_id: pulled.plan_id }, ctx); }; const applyPlan = async (args, ctx) => { const r = await ops.applyPlan(args.plan_id, { prunable: args.prunable, confirmDataDrops: args.confirm_data_drops, confirmRenames: args.confirm_renames }); return ctx.redact({ plan_id: r.plan_id, applied: r.applied, snapshot_taken: r.snapshotTaken, summary: r.summary, renamed: r.renames.renamed, deleted: r.deletes.deleted, deletes_skipped: r.deletes.skipped, rows: r.rows.perTable, warnings: r.warnings, can_rollback: r.canRollback }); }; const rollbackPlan = async (args, ctx) => { const r = await ops.rollbackPlan(args.plan_id, { prunable: args.prunable, confirmDataDrops: args.confirm_data_drops }); return ctx.redact({ plan_id: r.plan_id, rolled_back: r.rolledBack, restored_tables: r.restoredTables, warnings: r.warnings }); }; // A pairing or rotation result: the secret only when this site generated it. const secretResult = async (r, generatedStep, pastedStep) => { const env = await requireEnv(); const out = { peer: peerView(r.peer), this_env_id: env.env_id }; if (r.generated) { out.shared_secret = r.secret; } out.next_step = r.generated ? generatedStep : pastedStep; return out; }; const addPeer = async (args) => { const r = await ops.addPeer({ envId: args.env_id, label: args.label, baseUrl: args.base_url, requireTls: args.require_tls, existingSecret: args.existing_secret }); return secretResult(r, "shared_secret is shown only now: enter it, with this_env_id, in the peer's own site-sync pairing form for this site.", "The peer must pair with this site's this_env_id and the same secret."); }; const rotatePeerSecret = async (args) => { return secretResult(await ops.rotatePeerSecret(args.peer, args.secret), "shared_secret is shown only now: on the peer, replace this site's pairing secret with it (Peers -> Replace a peer's secret); until then the pairing cannot authenticate.", "The given secret is installed; the pairing works once the peer uses the same one."); }; const deletePeer = async (args) => { const r = await ops.deletePeer(args.peer); return { deleted: peerView(r.peer), plans_kept_for_rollback: r.keptForRollback }; }; const setTableMode = async (args) => { const r = await ops.setTableMode(args.table, args.mode); return { table: r.table, mode: r.mode, previous_mode: r.previous }; }; const localState = async () => { const s = await ops.localState(); return { hash: s.hash, counts: s.counts, config_settings: s.config, rows: s.rows, warnings: s.warnings }; }; // ---------------- Definitions ---------------- const SETTING_ENUM = (key) => ops.SETTING_OPTIONS[key].map((o) => o.value); const KIND_ENUM = diff.PLAN_KINDS.concat([CONFIG_KIND]); const tools = () => { return [ tool("status", "read", "site-sync status", "This site's site-sync identity and policies: env_id (a peer pairs with it), label, sync_mode (mirror prunes what the source dropped; additive never prunes), on_delete (how drops that destroy data are gated), on_apply (whether paired peers may apply remotely), require_tls, the plugin version, how many peers are paired, how many tables are managed / starter (row data synced), whether the session secret that seals peer secrets is configured, and apply_lock: idle, running (holder: the apply or rollback running now, its plan, since when, and when its lease lapses if its process stops) or interrupted (plans an apply or rollback left in flight when its process stopped -- this site may be partly changed; sitesync_rollback reverts a plan with a restore point, and any next apply or rollback releases them).", {}, [], () => getStatus(), { idempotent: true }), tool("list_peers", "read", "List peers", "The paired peers (site-sync instances this site can pull from): id, label, env_id, base_url, require_tls, when paired and last seen; plus this site's own env_id. Shared secrets are never shown.", {}, [], () => listPeers(), { idempotent: true }), tool("peer_health", "read", "Check a peer", "Call a peer's signed health endpoint: whether it answers and authenticates (a wrong or rotated secret shows as unreachable with the reason), its site-sync version, env_id and label, and plugin parity warnings (plugins missing on either side or at other versions -- applying state that needs a plugin this site lacks can fail). Changes nothing.", { peer: PEER_ARG }, ["peer"], (a, ctx) => run(ctx, () => peerHealth(a)), { idempotent: true, openWorld: true }), tool("list_tables", "read", "List tables and data modes", "Every application table with its row count and data mode: user (default: rows are this site's own and never synced), starter (rows seeded once from the source into a fresh table, then this site owns them; starter_shipped_at says when), managed (the source is canonical: rows are mirrored, added / updated / removed). locked tables (users) are always user.", {}, [], async () => ({ tables: await ops.listTables() }), { idempotent: true }), tool("list_plans", "read", "List plans", "Recorded plans, newest first: status (pending = previewed, not applied; applied; rolled_back; applying / rolling_back = in flight; interrupted = its process stopped part-way, and can_apply / can_rollback then tell what the next apply or rollback can do once it recovers the plan), direction (pull; push = sent by a peer; rollback = the internal plan of a rollback), the source peer, has_restore_point, can_apply (pending or rolled_back and this site unchanged since the preview), can_rollback, and stale (this site changed since the preview, so an apply would be refused: pull again).", { status: { type: "string", enum: Object.values(PLAN_STATUS), description: "Only plans in this status." }, limit: { type: "integer", minimum: 1, maximum: planLib.PLAN_RETENTION, description: `At most this many (default ${ops.PLAN_LIST_DEFAULT}).` } }, [], (a, ctx) => run(ctx, async () => ({ plans: await ops.listPlans({ status: a.status, limit: a.limit }) })), { idempotent: true }), tool("get_plan", "read", "Review a plan", "A plan, trimmed: status, standing (can_apply, stale, can_rollback, has_restore_point, interrupted = left applying / rolling_back by a process that stopped), in_sync (nothing to do), summary counts, changes (entity names per kind and per create / update / delete; table field and constraint changes), destructive (every drop, data_bearing ones destroy rows), rename_candidates (detected table renames: pass their from names in confirm_renames to keep the rows), data_drops (the tables apply_plan's prunable must name to destroy their data, and whether confirm_data_drops is needed under on_delete), config_changes, rows (managed / starter row inserts, updates, deletes per table), rows_skipped and warnings (code pages = server-side JavaScript it installs, plugin parity, pre-flight checks). With entity: that one entity -- the action, the source's definition (to) and this site's (current), secret-looking keys masked.", { plan_id: PLAN_ARG, entity: { type: "object", description: "Show one entity of the plan instead of the summary.", properties: { kind: { type: "string", enum: KIND_ENUM, description: `The kind: ${KIND_ENUM.join(", ")} (config = a config setting the plan overwrites).` }, name: { type: "string", minLength: 1, description: "Its name as the plan's changes list it (roles by role name, models as
.), or the setting name for config." } }, required: ["kind", "name"], additionalProperties: false } }, ["plan_id"], (a, ctx) => run(ctx, () => getPlan(a, ctx)), { idempotent: true }), tool("local_state", "read", "This site's desired state", "This site's design state as a peer pulling from it would get it: the content hash (equal hashes = identical state), entity counts per kind, the syncable config settings present (only " + SYNCABLE_CONFIG_KEYS.join(", ") + " ever sync), and the row count of each managed / starter table it ships.", {}, [], () => localState(), { idempotent: true }), tool("plan_pull", "write", "Pull a plan from a peer", "Fetch a peer's current design state (signed; can take minutes for a large site) and record a plan for making THIS site match it. Nothing changes on either site: review the plan (returned as sitesync_get_plan shows it), then sitesync_apply_plan applies it (admin).", { peer: PEER_ARG }, ["peer"], (a, ctx) => run(ctx, () => pullPlan(a, ctx)), { openWorld: true }), tool("apply_plan", "admin", "Apply a plan", "Make this site match a pulled plan: creates / updates / (under sync_mode mirror) deletes tables, fields, views, pages, triggers, roles, library, tags, models and code pages (server-side JavaScript that then runs on this site), overwrites the synced config settings and syncs managed / starter rows. Refused if this site changed since the plan was pulled (pull again). A restore point is saved first (sitesync_rollback reverts). Data is destroyed only for tables named in prunable (plus confirm_data_drops under on_delete=confirm; never under refuse); a detected table rename keeps its rows only when confirmed, else it is a create + gated drop. Only one apply or rollback runs on this site at a time (across every server process); while one runs, this is refused with what is running.", { plan_id: PLAN_ARG, confirm_renames: { type: "array", items: { type: "string", minLength: 1 }, description: "Old names (rename_candidates from) of the detected table renames to apply as data-preserving renames." }, prunable: PRUNABLE_ARG, confirm_data_drops: CONFIRM_ARG }, ["plan_id"], (a, ctx) => run(ctx, () => applyPlan(a, ctx)), { destructive: true }), tool("rollback", "admin", "Roll back an apply", "Revert an applied plan (or one whose apply failed or was interrupted part-way) to the restore point saved before it: metadata, config and code pages back to the pre-apply state (in mirror mode), inverse renames, rows it inserted into managed / starter tables removed, captured rows restored. Once per plan. A table the apply created that has rows by now is dropped only when named in prunable (plus confirm_data_drops under on_delete=confirm).", { plan_id: PLAN_ARG, prunable: { ...PRUNABLE_ARG, description: "Names of tables the apply created that hold rows by now and may be dropped with them." }, confirm_data_drops: CONFIRM_ARG }, ["plan_id"], (a, ctx) => run(ctx, () => rollbackPlan(a, ctx)), { destructive: true }), tool("update_settings", "admin", "Change site-sync settings", "Change some of this site's site-sync settings (the others are kept). on_apply=auto lets paired peers apply plans they push here remotely (never dropping data or renaming tables); sync_mode=mirror makes applies prune what the source dropped; on_delete governs drops that destroy data. Returns the settings.", { env_label: { type: ["string", "null"], description: "This site's label (dev, test, prod ...); empty or null clears it." }, sync_mode: { type: "string", enum: SETTING_ENUM("sync_mode"), description: "mirror = prune entities the source dropped; additive = never prune." }, on_delete: { type: "string", enum: SETTING_ENUM("on_delete"), description: "confirm = destroy data only for tables named in prunable plus confirm_data_drops; refuse = never drop a table or field holding data; auto = tables named in prunable, no acknowledgement." }, on_apply: { type: "string", enum: SETTING_ENUM("on_apply"), description: "confirm = only an admin applies (here or in the UI); auto = paired peers may also apply plans they pushed, remotely." }, require_tls: { type: "boolean", description: "Refuse inbound peer traffic that did not arrive over HTTPS." } }, [], (a, ctx) => run(ctx, async () => envView(await ops.updateSettings(a))), { idempotent: true }), tool("add_peer", "admin", "Pair with a peer", "Pair with another site-sync instance (to pull from it). Without existing_secret a shared secret is generated and returned ONCE as shared_secret, as the admin UI shows it once: enter it with this site's env_id in the peer's pairing form. With existing_secret (the secret the peer generated for this pairing) nothing secret is returned.", { env_id: { type: "string", minLength: 1, description: "The peer's env_id (its sitesync_status / dashboard shows it)." }, base_url: { type: "string", minLength: 1, description: "The peer's base URL, http(s)://host[:port]." }, label: { type: "string", description: "A label for the peer (dev, prod ...)." }, require_tls: { type: "boolean", description: "Only talk to this peer over HTTPS." }, existing_secret: { type: "string", description: "The shared secret the peer already generated for this pairing (64 hex characters); omit to generate one." } }, ["env_id", "base_url"], (a, ctx) => run(ctx, () => addPeer(a))), tool("rotate_peer_secret", "admin", "Replace a peer's secret", "Replace a pairing's shared secret. Without secret a new one is generated and returned ONCE as shared_secret (the pairing then fails until the peer installs it); with secret (what the peer displayed after rotating its side) that secret is installed and nothing secret is returned.", { peer: PEER_ARG, secret: { type: "string", description: "The secret the peer generated (64 hex characters); omit to generate one here." } }, ["peer"], (a, ctx) => run(ctx, () => rotatePeerSecret(a)), { destructive: true }), tool("delete_peer", "admin", "Delete a peer", "Unpair a peer: its pairing and the plans pulled from it are deleted, except applied plans that can still be rolled back (kept, detached from any peer).", { peer: PEER_ARG }, ["peer"], (a, ctx) => run(ctx, () => deletePeer(a)), { destructive: true }), tool("set_table_mode", "admin", "Set a table's data mode", "Choose whether site-sync syncs a table's ROWS: user (never), starter (seeded once from the source into a fresh table) or managed (mirrored from the source: later applies add, update and REMOVE rows here to match it). Use starter / managed only for reference / lookup tables, never user-entered data. starter / managed adds a hidden _ss_row_uuid identity field. The users table and _-prefixed tables cannot be synced.", { table: { type: "string", minLength: 1, description: "The table name (sitesync_list_tables)." }, mode: { type: "string", enum: Object.values(DATA_MODES), description: "user, starter or managed." } }, ["table", "mode"], (a, ctx) => run(ctx, () => setTableMode(a)), { destructive: true, idempotent: true }), ]; }; module.exports = { tools, PREFIX };