@dypai-ai/mcp 1.4.3 → 1.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,7 +16,6 @@ import {
16
16
  readLocalStateSnapshot,
17
17
  readLocalConfig,
18
18
  readLocalRealtime,
19
- fetchRemoteRealtime,
20
19
  computePlan,
21
20
  localToCanonical,
22
21
  } from "./planner.js"
@@ -66,6 +65,12 @@ function endpointPayload(row) {
66
65
  * least one of the markers we expect from a real mutation. Anything else
67
66
  * (empty object, error string masquerading as data, missing id) becomes
68
67
  * an error so the push doesn't lie to the agent.
68
+ *
69
+ * Layer 2: by default the API stages mutations as drafts and returns
70
+ * `{applied_to: "draft", draft: {...}, message: "..."}`. That counts as a
71
+ * successful mutation — the change was saved, just not applied to live yet.
72
+ * Power-user direct-write projects skip the draft stage and return the
73
+ * legacy live shape; both are valid successes.
69
74
  */
70
75
  function assertMutationOK(result, op, name) {
71
76
  if (!result || typeof result !== "object") {
@@ -75,15 +80,29 @@ function assertMutationOK(result, op, name) {
75
80
  throw new Error(`${op} ${name}: ${typeof result.error === "string" ? result.error : JSON.stringify(result.error)}`)
76
81
  }
77
82
  // Successful create returns { id, name } or { ok: true }; update/delete return
78
- // { message } or { ok: true }. If none of these markers are present the call
79
- // probably failed silently.
80
- const hasMarker = result.id || result.ok === true || result.message || result.endpoint_id || result.success === true
83
+ // { message } or { ok: true }. Draft responses return { applied_to: "draft", draft, message }.
84
+ const hasMarker = result.id
85
+ || result.ok === true
86
+ || result.message
87
+ || result.endpoint_id
88
+ || result.success === true
89
+ || result.applied_to === "draft"
81
90
  if (!hasMarker) {
82
91
  throw new Error(`${op} ${name}: response missing success markers — ${JSON.stringify(result).slice(0, 200)}`)
83
92
  }
84
93
  return result
85
94
  }
86
95
 
96
+ /**
97
+ * Returns true when the API staged the change as a draft (default behavior)
98
+ * instead of applying it directly to live. Both the draft branch and the
99
+ * direct-write branch are valid successful outcomes — drafts just need to
100
+ * be reported back to the user so they know to run manage_drafts(publish).
101
+ */
102
+ function isDraftResponse(result) {
103
+ return result && typeof result === "object" && result.applied_to === "draft"
104
+ }
105
+
87
106
  async function applyCreate(canonicalRow, projectId) {
88
107
  const res = await proxyToolCall("create_endpoint", {
89
108
  ...(projectId ? { project_id: projectId } : {}),
@@ -150,101 +169,66 @@ async function applyGroupDelete(name, projectId, mapsCtx) {
150
169
  }
151
170
 
152
171
  // ─── Realtime policies sync ────────────────────────────────────────────────
172
+ //
173
+ // Layer 2 update: we no longer compute the diff client-side and write
174
+ // directly via execute_sql. The whole bulk-sync goes through the API
175
+ // endpoint POST /{project_id}/realtime/sync (called via the cloud MCP
176
+ // tool `sync_realtime_policies`). The API:
177
+ // - computes the diff against live (and pending drafts in production)
178
+ // - branches dev/prod: production queues drafts, development applies
179
+ // - returns `{ applied_to: "live"|"draft", summary, details, drafts_queued? }`
180
+ // Cache invalidation in the engine is automatic via the SQL trigger
181
+ // `_dypai_notify_policy_change` (pg_notify → engine LISTEN), so neither
182
+ // dev nor draft-publish need anything extra here.
153
183
 
154
184
  /**
155
- * Reconcile local realtime.yaml with system.realtime_policies remotely.
156
- * Returns a summary of what was applied (or would be applied in dry_run).
185
+ * Reconcile local realtime.yaml with the engine via the API. Returns the
186
+ * API response unchanged (with `dry_run: true` short-circuit when requested
187
+ * — the API itself doesn't have dry-run yet, so we just return the local
188
+ * count without making the call).
157
189
  */
158
190
  async function syncRealtimePolicies(projectId, rootDir, dryRun = false) {
159
191
  const local = await readLocalRealtime(rootDir)
160
192
  if (!local) return { skipped: true, reason: "no realtime.yaml found" }
161
193
 
162
- const remote = await fetchRemoteRealtime(projectId)
163
- if (remote === null) {
164
- return { skipped: true, reason: "engine has no system.realtime_policies table yet (backward compat)" }
165
- }
166
-
167
- const key = (p) => `${p.target_type}::${p.target_name}`
168
- const remoteMap = Object.fromEntries(remote.map(p => [key(p), p]))
169
- const localMap = Object.fromEntries(local.rows.map(p => [key(p), p]))
170
-
171
- const toUpsert = []
172
- const toDelete = []
173
- for (const k of Object.keys(localMap)) {
174
- const l = localMap[k]
175
- const r = remoteMap[k]
176
- if (!r || !policiesEqual(l, r)) toUpsert.push(l)
177
- }
178
- for (const k of Object.keys(remoteMap)) {
179
- if (!localMap[k]) toDelete.push(remoteMap[k])
180
- }
181
-
182
194
  if (dryRun) {
183
- return { upsert: toUpsert.length, delete: toDelete.length, dry_run: true }
195
+ // API doesn't expose dry-run today. We can still report the local count;
196
+ // the actual upsert/delete breakdown is computed server-side, so for the
197
+ // purposes of `dypai_diff` we just say "N local policies on disk".
198
+ return { local_count: local.rows.length, dry_run: true }
184
199
  }
185
200
 
186
- // execute_sql on the remote is parameterless (only takes a raw `sql` string).
187
- // To avoid hand-escaping values, we embed the rows as JSON and let Postgres
188
- // parse them via jsonb_to_recordset — JSON.stringify handles quote escaping
189
- // correctly, and we only need to double single-quotes for the SQL literal.
190
- if (toUpsert.length > 0) {
191
- const rowsJson = JSON.stringify(toUpsert.map(p => ({
201
+ // Forward the FULL local list server diffs against live + drafts.
202
+ const result = await proxyToolCall("sync_realtime_policies", {
203
+ project_id: projectId,
204
+ policies: local.rows.map(p => ({
192
205
  target_type: p.target_type,
193
206
  target_name: p.target_name,
194
- subscribe_filter: p.subscribe_filter,
195
- events: p.events,
196
- required_role: p.required_role,
197
- auth_required: p.auth_required,
198
- }))).replace(/'/g, "''") // escape for SQL string literal
199
-
200
- await proxyToolCall("execute_sql", {
201
- project_id: projectId,
202
- sql: `
203
- INSERT INTO system.realtime_policies
204
- (target_type, target_name, subscribe_filter, events, required_role, auth_required, updated_at)
205
- SELECT target_type, target_name, subscribe_filter, events, required_role, auth_required, now()
206
- FROM jsonb_to_recordset('${rowsJson}'::jsonb) AS r(
207
- target_type text,
208
- target_name text,
209
- subscribe_filter text,
210
- events text[],
211
- required_role text,
212
- auth_required boolean
213
- )
214
- ON CONFLICT (target_type, target_name) DO UPDATE SET
215
- subscribe_filter = EXCLUDED.subscribe_filter,
216
- events = EXCLUDED.events,
217
- required_role = EXCLUDED.required_role,
218
- auth_required = EXCLUDED.auth_required,
219
- updated_at = now()
220
- `,
221
- })
222
- }
207
+ subscribe_filter: p.subscribe_filter ?? null,
208
+ events: p.events ?? null,
209
+ required_role: p.required_role ?? null,
210
+ auth_required: p.auth_required !== false,
211
+ })),
212
+ delete_orphans: true,
213
+ })
223
214
 
224
- if (toDelete.length > 0) {
225
- // Build a safe tuple list. target_type is always 'table' or 'channel', target_name is user-provided.
226
- const pairs = toDelete.map(p =>
227
- `('${p.target_type}', '${String(p.target_name).replace(/'/g, "''")}')`
228
- ).join(",")
229
- await proxyToolCall("execute_sql", {
230
- project_id: projectId,
231
- sql: `
232
- DELETE FROM system.realtime_policies
233
- WHERE (target_type, target_name) IN (${pairs})
234
- `,
235
- })
215
+ if (result?.error) {
216
+ throw new Error(`sync_realtime_policies failed: ${result.error}`)
217
+ }
218
+ // Backward-compat: surface the legacy `{ upsert, delete }` shape that
219
+ // older code paths (and the diff tool) expect, in addition to the full
220
+ // API response.
221
+ const summary = result?.summary || { upserts: 0, deletes: 0, unchanged: 0 }
222
+ return {
223
+ upsert: summary.upserts ?? 0,
224
+ delete: summary.deletes ?? 0,
225
+ unchanged: summary.unchanged ?? 0,
226
+ applied_to: result?.applied_to ?? "live",
227
+ drafts_queued: result?.drafts_queued || undefined,
228
+ skipped: result?.skipped || undefined,
229
+ reason: result?.reason || undefined,
230
+ details: result?.details || undefined,
236
231
  }
237
-
238
- return { upsert: toUpsert.length, delete: toDelete.length }
239
- }
240
-
241
- function policiesEqual(a, b) {
242
- return a.target_type === b.target_type
243
- && a.target_name === b.target_name
244
- && (a.subscribe_filter || null) === (b.subscribe_filter || null)
245
- && JSON.stringify(a.events || []) === JSON.stringify(b.events || [])
246
- && (a.required_role || null) === (b.required_role || null)
247
- && !!a.auth_required === !!b.auth_required
248
232
  }
249
233
 
250
234
  // ─── Tool ──────────────────────────────────────────────────────────────────
@@ -252,9 +236,11 @@ function policiesEqual(a, b) {
252
236
  export const dypaiPushTool = {
253
237
  name: "dypai_push",
254
238
  description:
255
- "Apply the local ./dypai/ state to the remote project: creates, updates, and optionally deletes endpoints. " +
256
- "ALWAYS run dypai_diff first to preview the plan. This tool mutates remote state. " +
257
- "By default, endpoints in remote but missing locally are kept (safe). Pass delete_orphans: true to delete them.",
239
+ "BACKEND ONLY — applies the local ./dypai/ state (endpoints, realtime policies) to the remote project as DRAFTS. " +
240
+ "Does NOT touch the live site by itself: changes are staged in `system.config_drafts` and only become live after `manage_drafts(operation:'publish', confirm:true)`. Safe to iterate freely. " +
241
+ "ALWAYS run dypai_diff first to preview what will be staged. " +
242
+ "Frontend code is not affected — use `manage_frontend` for that. " +
243
+ "By default, endpoints in remote but missing locally are kept (safe). Pass delete_orphans: true to stage their deletion as a draft as well.",
258
244
  inputSchema: {
259
245
  type: "object",
260
246
  properties: {
@@ -397,7 +383,16 @@ export const dypaiPushTool = {
397
383
  // Group operations are sequential because they're fast (a handful of
398
384
  // groups typically) and we need mapsCtx mutations to be race-free.
399
385
  const CONCURRENCY = 5
400
- const applied = { created: [], updated: [], deleted: [], groups_created: [], groups_deleted: [] }
386
+ // `applied.*_drafts` tracks the per-op subset that the API staged as
387
+ // drafts (default behavior). The lists in `created/updated/deleted`
388
+ // include all successful ops — drafts and direct-write — so existing
389
+ // callers and tests don't break. Drafts are also broken out for the
390
+ // summary and `next_step` so the user knows to publish.
391
+ const applied = {
392
+ created: [], updated: [], deleted: [],
393
+ created_drafts: [], updated_drafts: [], deleted_drafts: [],
394
+ groups_created: [], groups_deleted: [],
395
+ }
401
396
  const errors = []
402
397
 
403
398
  // Phase 1 — create missing groups BEFORE any endpoint is created,
@@ -414,8 +409,9 @@ export const dypaiPushTool = {
414
409
  await runWithConcurrency(plan.create, CONCURRENCY, async (item) => {
415
410
  try {
416
411
  const canonical = localToCanonical(local.byName[item.name], remote.mapsCtx)
417
- await applyCreate(canonical, targetProjectId)
412
+ const res = await applyCreate(canonical, targetProjectId)
418
413
  applied.created.push(item.name)
414
+ if (isDraftResponse(res)) applied.created_drafts.push(item.name)
419
415
  } catch (e) {
420
416
  errors.push({ op: "create", endpoint: item.name, error: e.message })
421
417
  }
@@ -426,8 +422,9 @@ export const dypaiPushTool = {
426
422
  const canonical = localToCanonical(local.byName[item.name], remote.mapsCtx)
427
423
  const endpointId = remote.byName[item.name]?.id
428
424
  if (!endpointId) throw new Error("endpoint_id missing from remote state")
429
- await applyUpdate(canonical, endpointId, targetProjectId)
425
+ const res = await applyUpdate(canonical, endpointId, targetProjectId)
430
426
  applied.updated.push({ name: item.name, changed_fields: item.changedFields })
427
+ if (isDraftResponse(res)) applied.updated_drafts.push(item.name)
431
428
  } catch (e) {
432
429
  errors.push({ op: "update", endpoint: item.name, error: e.message })
433
430
  }
@@ -437,8 +434,9 @@ export const dypaiPushTool = {
437
434
  try {
438
435
  const endpointId = remote.byName[item.name]?.id
439
436
  if (!endpointId) throw new Error("endpoint_id missing from remote state")
440
- await applyDelete(endpointId, targetProjectId)
437
+ const res = await applyDelete(endpointId, targetProjectId)
441
438
  applied.deleted.push(item.name)
439
+ if (isDraftResponse(res)) applied.deleted_drafts.push(item.name)
442
440
  } catch (e) {
443
441
  errors.push({ op: "delete", endpoint: item.name, error: e.message })
444
442
  }
@@ -474,13 +472,44 @@ export const dypaiPushTool = {
474
472
  ...applied.updated.map(u => u.name),
475
473
  ]
476
474
 
475
+ // How many ops landed in draft state. When `draftCount > 0` the user
476
+ // must run `manage_drafts(operation:'publish')` to expose the changes
477
+ // on live; tests against live won't see them yet. Realtime drafts are
478
+ // also counted — the API returns `applied_to: "draft"` and a
479
+ // `drafts_queued` array when realtime policies were staged.
480
+ const realtimeDraftCount = realtime && realtime.applied_to === "draft"
481
+ ? (realtime.drafts_queued?.length ?? ((realtime.upsert ?? 0) + (realtime.delete ?? 0)))
482
+ : 0
483
+ const draftCount =
484
+ applied.created_drafts.length +
485
+ applied.updated_drafts.length +
486
+ applied.deleted_drafts.length +
487
+ realtimeDraftCount
488
+ const endpointTotal =
489
+ applied.created.length + applied.updated.length + applied.deleted.length
490
+ const realtimeTotal = realtime ? ((realtime.upsert ?? 0) + (realtime.delete ?? 0)) : 0
491
+ const isDraftMode = draftCount > 0
492
+ && draftCount === endpointTotal + realtimeTotal
493
+
477
494
  return {
478
495
  success: errors.length === 0,
479
496
  applied: true,
497
+ // By default every endpoint mutation is staged as a draft, so the
498
+ // same push that "created 3 endpoints" actually queued 3 drafts.
499
+ // Surfacing this at the top level makes the difference unmissable
500
+ // for both human callers and the agent.
501
+ // - "draft": every mutation in this push was staged as a draft
502
+ // - "mixed": some drafts, some direct writes (rare)
503
+ // - "live": direct-write mode, mutation hit live immediately
504
+ mode: isDraftMode ? "draft" : draftCount > 0 ? "mixed" : "live",
505
+ pending_drafts: draftCount,
480
506
  summary: {
481
507
  created: applied.created.length,
508
+ created_drafts: applied.created_drafts.length,
482
509
  updated: applied.updated.length,
510
+ updated_drafts: applied.updated_drafts.length,
483
511
  deleted: applied.deleted.length,
512
+ deleted_drafts: applied.deleted_drafts.length,
484
513
  unchanged: plan.unchanged.length,
485
514
  groups_created: applied.groups_created.length,
486
515
  groups_deleted: applied.groups_deleted.length,
@@ -489,12 +518,16 @@ export const dypaiPushTool = {
489
518
  },
490
519
  details: applied,
491
520
  errors: errors.length ? errors : undefined,
492
- // Only one next_step — and only when it's non-obvious
521
+ // Only one next_step — and only when it's non-obvious. Drafts win
522
+ // over the test suggestion because tests against live won't see
523
+ // the change until the drafts are promoted.
493
524
  next_step: errors.length
494
525
  ? "Fix the offending YAMLs and push again."
495
- : changedNames.length
496
- ? `Test changed endpoint(s) with dypai_test_endpoint: ${changedNames.slice(0, 3).join(", ")}${changedNames.length > 3 ? "…" : ""}`
497
- : undefined,
526
+ : draftCount > 0
527
+ ? `${draftCount} change(s) saved as draft(s) — they're not live yet. Review with manage_drafts(operation:'list'), verify with dypai_test_endpoint(mode:'draft', endpoint:'<name>'), then publish with manage_drafts(operation:'publish', confirm:true) (or discard with manage_drafts(operation:'discard', confirm:true) to throw them away).`
528
+ : changedNames.length
529
+ ? `Test changed endpoint(s) with dypai_test_endpoint: ${changedNames.slice(0, 3).join(", ")}${changedNames.length > 3 ? "…" : ""}`
530
+ : undefined,
498
531
  hint: errors.some(e => /credential/i.test(e.error))
499
532
  ? "A referenced credential doesn't exist remotely. Create it in the dashboard (same name), then retry."
500
533
  : errors.some(e => /validation/i.test(e.error))