@dypai-ai/mcp 1.2.3 → 1.3.0

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.
@@ -13,9 +13,16 @@
13
13
  * mean push will refuse (unless skip_validation is passed).
14
14
  */
15
15
 
16
- import { readFile } from "fs/promises"
16
+ import { readFile, writeFile, stat } from "fs/promises"
17
17
  import { join, resolve as resolvePath } from "path"
18
18
  import { fetchRemoteState, readLocalState, readLocalConfig, readLocalRealtime } from "./planner.js"
19
+ import { proxyToolCall } from "../proxy.js"
20
+ import { dumpPublicSchema } from "./schema-dump.js"
21
+
22
+ /** schema.sql is considered "fresh" if it was written less than this ago.
23
+ * Within the freshness window, we trust it without re-checking the remote.
24
+ * Outside the window, sql_table_not_found triggers a remote verification. */
25
+ const SCHEMA_FRESHNESS_MS = 5 * 60 * 1000 // 5 minutes
19
26
 
20
27
  /**
21
28
  * Validate dypai/realtime.yaml against known schemas/tables. Rules:
@@ -133,13 +140,15 @@ async function loadNodeCatalog(rootDir) {
133
140
  const parsed = JSON.parse(raw)
134
141
  const schemas = {}
135
142
  const knownTypes = new Set()
143
+ const isNonEmptyObj = (o) => o && typeof o === "object" && !Array.isArray(o) && Object.keys(o).length > 0
136
144
  for (const [nodeType, data] of Object.entries(parsed.nodes || {})) {
145
+ if (!data || typeof data !== "object") continue
137
146
  knownTypes.add(nodeType)
138
147
  schemas[nodeType] = {
139
148
  label: data.label,
140
149
  description: data.description,
141
- inputs: data.inputs && Object.keys(data.inputs).length ? data.inputs : null,
142
- outputs: data.outputs && Object.keys(data.outputs).length ? data.outputs : null,
150
+ inputs: isNonEmptyObj(data.inputs) ? data.inputs : null,
151
+ outputs: isNonEmptyObj(data.outputs) ? data.outputs : null,
143
152
  }
144
153
  }
145
154
  return { schemas, knownTypes, missing: false }
@@ -321,19 +330,27 @@ function validateEndpoint(entry, ctx) {
321
330
  // Heuristic: look like SQL (contains SELECT/INSERT/UPDATE/DELETE/WITH)
322
331
  if (/\b(SELECT|INSERT|UPDATE|DELETE|WITH)\b/i.test(value)) {
323
332
  for (const t of extractSqlTables(value)) referencedTables.add(t)
333
+
334
+ // NOTE: an earlier version of this validator warned about manual
335
+ // `'${current_user_id}'::uuid` casts as "redundant", under the assumption
336
+ // that the engine auto-cast UUID-shaped values. That auto-cast was
337
+ // removed because it broke postgres.js binding in production. Manual
338
+ // ::uuid casts are now legitimate again, so no warning is emitted here.
324
339
  }
325
340
  }
326
341
 
327
342
  // --- SQL table existence (if schema.sql available) ---
343
+ // Defer the actual error emission to runValidation: it batch-checks all
344
+ // missing tables against the remote in ONE query before deciding what's
345
+ // a real error vs a stale-local-schema. We just collect the misses here
346
+ // along with enough context for runValidation to emit per-endpoint errors.
328
347
  if (ctx.schemaTables) {
329
348
  for (const table of referencedTables) {
330
349
  if (!ctx.schemaTables.has(table)) {
331
- diagnostics.push({
332
- severity: "error",
333
- rule: "sql_table_not_found",
334
- endpoint: name, file,
335
- message: `SQL references public.${table}, but that table does not exist in schema.sql.`,
336
- fix_hint: `Check typos, or create the table first with execute_sql. Known tables: ${[...ctx.schemaTables].slice(0, 8).join(", ")}${ctx.schemaTables.size > 8 ? "…" : ""}`,
350
+ ctx.suspectedMissingTables.push({
351
+ table,
352
+ endpoint: name,
353
+ file,
337
354
  })
338
355
  }
339
356
  }
@@ -341,14 +358,144 @@ function validateEndpoint(entry, ctx) {
341
358
 
342
359
  // --- Per-node catalog-based validation (unknown type, missing/unknown params) ---
343
360
  for (const node of doc.workflow?.nodes || []) {
361
+ if (!node || typeof node !== "object") continue
362
+ // Tolerate both `type` and `node_type` (the codec accepts either).
363
+ const nodeType = node.type ?? node.node_type
364
+ // Legacy `start_trigger` node — the codec lifts it transparently, but
365
+ // surface a warning so the agent learns the canonical form (top-level trigger:).
366
+ if (nodeType === "start_trigger") {
367
+ diagnostics.push({
368
+ severity: "warn",
369
+ rule: "obsolete_start_trigger_node",
370
+ endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
371
+ message: `'start_trigger' is no longer a node. The codec lifted it to the top-level trigger: block automatically — but please remove this node from the YAML.`,
372
+ fix_hint: "Move trigger config to top-level `trigger: { http_api: { auth_mode: jwt } }` (or webhook/schedule/telegram) and delete this node + any edges that referenced it.",
373
+ })
374
+ continue
375
+ }
376
+ if (!nodeType) {
377
+ diagnostics.push({
378
+ severity: "error",
379
+ rule: "missing_node_type",
380
+ endpoint: name, file, loc: `workflow.nodes[${node.id || "?"}]`,
381
+ message: `Node '${node.id || "(no id)"}' is missing 'type'.`,
382
+ fix_hint: "Add `type: <node_type>` to this node. Use `dypai_pull` to refresh `node-catalog.json` for the list of valid types.",
383
+ })
384
+ continue
385
+ }
386
+
387
+ // dypai_database — coherence checks for the new canonical operations.
388
+ if (nodeType === "dypai_database") {
389
+ const op = node.operation
390
+ const LEGACY_OPS = new Set(["select", "insert", "update", "delete", "upsert", "aggregate", "copy_to", "custom_query"])
391
+ if (op && LEGACY_OPS.has(op)) {
392
+ const suggested = (op === "custom_query") ? "query" : (op === "select") ? "query" : "mutation"
393
+ const example = suggested === "query"
394
+ ? `operation: query, query: "..."`
395
+ : `operation: mutation, table: "...", ${op}: { ... }${op === "delete" ? "" : ", where: { ... }"}`
396
+ diagnostics.push({
397
+ severity: "warn",
398
+ rule: "legacy_database_operation",
399
+ endpoint: name, file, loc: `workflow.nodes[${node.id}].operation`,
400
+ message: `Operation '${op}' is legacy. The canonical form is '${suggested}'.`,
401
+ fix_hint: `Replace with: ${example}. Both still execute today; use the canonical form for new endpoints.`,
402
+ })
403
+ }
404
+
405
+ // ── operation: mutation coherence ─────────────────────────────────────
406
+ // mutation requires `table:` and exactly one of insert/update/delete.
407
+ // Fields like `query`, `query_file`, `params` belong to `operation: query`
408
+ // and are silently ignored here — surface as ERROR so the agent reroutes.
409
+ if (op === "mutation") {
410
+ if (!node.table) {
411
+ diagnostics.push({
412
+ severity: "error",
413
+ rule: "mutation_missing_table",
414
+ endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
415
+ message: `Node '${node.id}' uses 'operation: mutation' but is missing the required 'table:' field.`,
416
+ fix_hint: `Add 'table: <name>'. mutation also needs exactly one of: insert / update / delete.`,
417
+ })
418
+ }
419
+ const wantsInsert = node.insert !== undefined && node.insert !== null
420
+ const wantsUpdate = node.update !== undefined && node.update !== null
421
+ const wantsDelete = node.delete === true
422
+ const opCount = [wantsInsert, wantsUpdate, wantsDelete].filter(Boolean).length
423
+ if (opCount === 0) {
424
+ diagnostics.push({
425
+ severity: "error",
426
+ rule: "mutation_missing_action",
427
+ endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
428
+ message: `Node '${node.id}' uses 'operation: mutation' but declares none of: insert / update / delete.`,
429
+ fix_hint: `Add ONE of: 'insert: { ... }', 'update: { ... } + where: { ... }', or 'delete: true + where: { ... }'.`,
430
+ })
431
+ } else if (opCount > 1) {
432
+ diagnostics.push({
433
+ severity: "error",
434
+ rule: "mutation_multiple_actions",
435
+ endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
436
+ message: `Node '${node.id}' uses 'operation: mutation' with multiple actions set. Use one per node.`,
437
+ fix_hint: `Pick exactly one: insert OR update OR delete (split into separate nodes if you need more).`,
438
+ })
439
+ }
440
+ if ((wantsUpdate || wantsDelete) && !node.where) {
441
+ diagnostics.push({
442
+ severity: "error",
443
+ rule: "mutation_missing_where",
444
+ endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
445
+ message: `Node '${node.id}' (mutation ${wantsUpdate ? "update" : "delete"}) is missing 'where:' — refusing to operate on every row.`,
446
+ fix_hint: `Add 'where: { id: \${input.id}, user_id: \${current_user_id} }' (or whatever filter applies).`,
447
+ })
448
+ }
449
+ // Foreign fields that belong to `operation: query`
450
+ const QUERY_ONLY = ["query", "query_file", "params"]
451
+ for (const k of QUERY_ONLY) {
452
+ if (node[k] !== undefined) {
453
+ diagnostics.push({
454
+ severity: "error",
455
+ rule: "mutation_with_query_field",
456
+ endpoint: name, file, loc: `workflow.nodes[${node.id}].${k}`,
457
+ message: `Node '${node.id}' uses 'operation: mutation' but declares '${k}' (only valid for 'operation: query').`,
458
+ fix_hint: `If this is raw SQL, change to 'operation: query'. If it's declarative CRUD, remove '${k}' and use insert/update/delete + where.`,
459
+ })
460
+ }
461
+ }
462
+ }
463
+
464
+ // ── operation: query coherence ────────────────────────────────────────
465
+ // query needs SQL: either inline `query:` or external `query_file:`.
466
+ // Fields like `table`, `insert`, `update`, `delete`, `where`, `on_conflict`,
467
+ // `returning` belong to `operation: mutation` and are ignored here.
468
+ if (op === "query") {
469
+ if (!node.query && !node.query_file) {
470
+ diagnostics.push({
471
+ severity: "error",
472
+ rule: "query_missing_sql",
473
+ endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
474
+ message: `Node '${node.id}' uses 'operation: query' but is missing both 'query:' and 'query_file:'.`,
475
+ fix_hint: `Add 'query: "SELECT ..."' for inline SQL, or 'query_file: sql/<name>.sql' for an external file.`,
476
+ })
477
+ }
478
+ const MUTATION_ONLY = ["table", "insert", "update", "delete", "where", "on_conflict", "returning"]
479
+ const foreign = MUTATION_ONLY.filter(k => node[k] !== undefined && node[k] !== false)
480
+ if (foreign.length > 0) {
481
+ diagnostics.push({
482
+ severity: "error",
483
+ rule: "query_with_mutation_field",
484
+ endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
485
+ message: `Node '${node.id}' uses 'operation: query' but declares mutation-only fields: ${foreign.join(", ")}.`,
486
+ fix_hint: `If this is declarative CRUD, change to 'operation: mutation'. If it's raw SQL, remove ${foreign.join(", ")} from the node.`,
487
+ })
488
+ }
489
+ }
490
+ }
344
491
  // node_type exists in catalog?
345
- if (ctx.knownTypes.size && !ctx.knownTypes.has(node.type)) {
346
- const suggestions = [...ctx.knownTypes].filter(t => levenshteinSmall(t, node.type) <= 2).slice(0, 3)
492
+ if (ctx.knownTypes.size && !ctx.knownTypes.has(nodeType)) {
493
+ const suggestions = [...ctx.knownTypes].filter(t => levenshteinSmall(t, nodeType) <= 2).slice(0, 3)
347
494
  diagnostics.push({
348
495
  severity: "error",
349
496
  rule: "unknown_node_type",
350
497
  endpoint: name, file, loc: `workflow.nodes[${node.id}].type`,
351
- message: `Node type '${node.type}' is not registered.`,
498
+ message: `Node type '${nodeType}' is not registered.`,
352
499
  fix_hint: suggestions.length
353
500
  ? `Did you mean: ${suggestions.join(", ")}?`
354
501
  : `Run dypai_pull to refresh node-catalog.json. Or call search_nodes to discover.`,
@@ -357,19 +504,46 @@ function validateEndpoint(entry, ctx) {
357
504
  }
358
505
 
359
506
  // Schema-based parameter validation (only when we have a schema for this node_type)
360
- const schema = ctx.catalog[node.type]
507
+ const schema = ctx.catalog[nodeType]
361
508
  if (schema?.inputs?.properties) {
362
509
  const { properties, required = [] } = schema.inputs
363
510
  // Ignore node metadata keys (id, type, variable, return, credential, *_file) — they're not "params"
364
511
  const META_KEYS = new Set(["id", "type", "variable", "return", "credential", "query_file", "code_file", "system_prompt_file"])
365
512
  const paramKeys = Object.keys(node).filter(k => !META_KEYS.has(k))
366
513
 
367
- // Required params present? Severity: warn (not error) because current
368
- // engine node_catalog schemas list all conditional params as "required"
369
- // (e.g. dypai_database flags `data` AND `query` both required regardless
370
- // of `operation`). Once schemas model conditional required properly
371
- // (oneOf / dependentRequired), we can bump this back to error.
372
- for (const req of required) {
514
+ // Required-param check.
515
+ //
516
+ // We respect the schema as-is: top-level `required` are universally
517
+ // required; `allOf[].if/then` blocks express conditional requirements
518
+ // driven by sibling fields (e.g. `query` is required only when
519
+ // `operation: query`). The engine's parametersToJsonSchema emits these
520
+ // automatically from displayOptions.show — so the catalog is the single
521
+ // source of truth and we don't hardcode node names anywhere.
522
+ const allRequired = []
523
+
524
+ // Universal requirements
525
+ for (const req of required) allRequired.push({ name: req, condition: null })
526
+
527
+ // Conditional requirements from allOf[]
528
+ const allOf = Array.isArray(schema.inputs?.allOf) ? schema.inputs.allOf : []
529
+ for (const rule of allOf) {
530
+ const ifProps = rule?.if?.properties || {}
531
+ const thenRequired = rule?.then?.required || []
532
+ const selectorField = Object.keys(ifProps)[0]
533
+ const selectorValues = ifProps[selectorField]?.enum || []
534
+ if (!selectorField || selectorValues.length === 0) continue
535
+ // Activates only if the node's value for selectorField matches one of selectorValues.
536
+ const nodeValue = node[selectorField]
537
+ if (!selectorValues.includes(nodeValue)) continue
538
+ for (const req of thenRequired) {
539
+ allRequired.push({
540
+ name: req,
541
+ condition: `${selectorField}=${nodeValue}`,
542
+ })
543
+ }
544
+ }
545
+
546
+ for (const { name: req, condition } of allRequired) {
373
547
  if (!paramKeys.includes(req)) {
374
548
  const hasFileEquivalent = META_KEYS.has(`${req}_file`) && node[`${req}_file`]
375
549
  if (!hasFileEquivalent) {
@@ -377,8 +551,12 @@ function validateEndpoint(entry, ctx) {
377
551
  severity: "warn",
378
552
  rule: "missing_required_param",
379
553
  endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
380
- message: `Node '${node.id}' (type '${node.type}') may be missing parameter '${req}'.`,
381
- fix_hint: `Schema lists required: [${required.join(", ")}]. Verify this param is actually needed for your operation.`,
554
+ message: condition
555
+ ? `Node '${node.id}' (type '${nodeType}') is missing parameter '${req}' required when ${condition}.`
556
+ : `Node '${node.id}' (type '${nodeType}') is missing required parameter '${req}'.`,
557
+ fix_hint: condition
558
+ ? `Add '${req}: ...' to this node, or change the value of '${condition.split("=")[0]}'.`
559
+ : `Add '${req}: ...' to this node.`,
382
560
  })
383
561
  }
384
562
  }
@@ -393,7 +571,7 @@ function validateEndpoint(entry, ctx) {
393
571
  severity: "warn",
394
572
  rule: "unknown_param",
395
573
  endpoint: name, file, loc: `workflow.nodes[${node.id}].${key}`,
396
- message: `Node '${node.id}' (type '${node.type}') has unknown parameter '${key}'.`,
574
+ message: `Node '${node.id}' (type '${nodeType}') has unknown parameter '${key}'.`,
397
575
  fix_hint: suggestions.length
398
576
  ? `Did you mean: ${suggestions.join(", ")}?`
399
577
  : `Valid params: ${knownKeys.slice(0, 8).join(", ")}${knownKeys.length > 8 ? "…" : ""}`,
@@ -438,6 +616,7 @@ function validateEndpoint(entry, ctx) {
438
616
 
439
617
  // --- Credential references ---
440
618
  for (const node of doc.workflow?.nodes || []) {
619
+ if (!node || typeof node !== "object") continue
441
620
  const cred = node.credential
442
621
  if (cred && !ctx.remoteCredentials.has(cred)) {
443
622
  diagnostics.push({
@@ -452,7 +631,8 @@ function validateEndpoint(entry, ctx) {
452
631
  }
453
632
 
454
633
  // Agent tool references
455
- if (node.type === "agent" && Array.isArray(node.tools)) {
634
+ const nodeType = node.type ?? node.node_type
635
+ if (nodeType === "agent" && Array.isArray(node.tools)) {
456
636
  for (const toolName of node.tools) {
457
637
  if (!ctx.toolEndpoints.has(toolName)) {
458
638
  diagnostics.push({
@@ -472,6 +652,65 @@ function validateEndpoint(entry, ctx) {
472
652
  return diagnostics
473
653
  }
474
654
 
655
+ // ─── Schema staleness detection ─────────────────────────────────────────────
656
+
657
+ /**
658
+ * Returns mtime of dypai/schema.sql as Date, or null if missing.
659
+ */
660
+ async function getSchemaFileAge(rootDir) {
661
+ try {
662
+ const s = await stat(join(rootDir, "schema.sql"))
663
+ return s.mtime
664
+ } catch {
665
+ return null
666
+ }
667
+ }
668
+
669
+ /**
670
+ * Returns true if schema.sql was written less than SCHEMA_FRESHNESS_MS ago.
671
+ * Within the freshness window we skip remote staleness checks.
672
+ */
673
+ async function isSchemaFresh(rootDir) {
674
+ const mtime = await getSchemaFileAge(rootDir)
675
+ if (!mtime) return false
676
+ return (Date.now() - mtime.getTime()) < SCHEMA_FRESHNESS_MS
677
+ }
678
+
679
+ /**
680
+ * Batch-check which of the given tables exist in the remote DB. Returns the
681
+ * subset that DO exist. Single SQL round-trip via execute_sql.
682
+ *
683
+ * Tolerant on failure: if the check itself fails (auth, network), returns null
684
+ * and the caller treats every missing table as a real "table not found" error.
685
+ */
686
+ async function verifyTablesInRemote(missingTables, projectId) {
687
+ if (!missingTables.length) return new Set()
688
+ if (!projectId) return null
689
+ try {
690
+ const list = missingTables.map(t => `'${String(t).replace(/'/g, "''")}'`).join(",")
691
+ const sql = `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name IN (${list})`
692
+ const result = await proxyToolCall("execute_sql", { project_id: projectId, sql })
693
+ const rows = Array.isArray(result?.rows) ? result.rows : []
694
+ return new Set(rows.map(r => r.table_name).filter(Boolean))
695
+ } catch {
696
+ return null
697
+ }
698
+ }
699
+
700
+ /**
701
+ * Refresh dypai/schema.sql from the remote. Best-effort — if it fails the
702
+ * caller continues with the existing local schema (just logs the warning).
703
+ */
704
+ async function refreshSchemaSql(rootDir, projectId) {
705
+ try {
706
+ const fresh = await dumpPublicSchema(projectId)
707
+ await writeFile(join(rootDir, "schema.sql"), fresh, "utf8")
708
+ return true
709
+ } catch {
710
+ return false
711
+ }
712
+ }
713
+
475
714
  // ─── Tool ───────────────────────────────────────────────────────────────────
476
715
 
477
716
  export async function runValidation(rootDir, projectId) {
@@ -502,6 +741,9 @@ export async function runValidation(rootDir, projectId) {
502
741
  catalog: nodeCatalog.schemas,
503
742
  knownTypes: nodeCatalog.knownTypes,
504
743
  fileByName: Object.fromEntries(Object.values(local.byName).map(e => [e.doc.name, `endpoints/${e.doc.name}.yaml`])),
744
+ // Collected during per-endpoint pass; resolved against the remote AFTER
745
+ // all endpoints are checked (one batch query instead of N).
746
+ suspectedMissingTables: [],
505
747
  }
506
748
 
507
749
  const diagnostics = []
@@ -509,6 +751,66 @@ export async function runValidation(rootDir, projectId) {
509
751
  diagnostics.push(...validateEndpoint(entry, ctx))
510
752
  }
511
753
 
754
+ // ── Resolve suspected missing tables against the remote ──────────────────
755
+ // The local schema.sql may be stale (someone pushed DDL outside our flow).
756
+ // Verify each missing table against information_schema BEFORE crying error.
757
+ // Skip the remote check entirely if schema.sql is fresh (recently refreshed).
758
+ let schemaWasRefreshed = false
759
+ if (ctx.suspectedMissingTables.length > 0) {
760
+ const uniqueTables = [...new Set(ctx.suspectedMissingTables.map(s => s.table))]
761
+ const fresh = await isSchemaFresh(rootDir)
762
+ let realMissing = new Set(uniqueTables)
763
+
764
+ if (!fresh && targetProjectId) {
765
+ // Schema is old → ask the remote which of these tables actually exist.
766
+ const existsInRemote = await verifyTablesInRemote(uniqueTables, targetProjectId)
767
+ if (existsInRemote !== null) {
768
+ // Remove tables that exist remotely from the "real missing" set.
769
+ const nowKnown = [...uniqueTables].filter(t => existsInRemote.has(t))
770
+ realMissing = new Set(uniqueTables.filter(t => !existsInRemote.has(t)))
771
+
772
+ if (nowKnown.length > 0) {
773
+ // Local schema was stale — refresh it so subsequent runs are accurate.
774
+ schemaWasRefreshed = await refreshSchemaSql(rootDir, targetProjectId)
775
+ // Add the now-known tables to ctx.schemaTables so any callers that
776
+ // re-use this validation result see a consistent picture.
777
+ if (ctx.schemaTables) for (const t of nowKnown) ctx.schemaTables.add(t)
778
+ diagnostics.push({
779
+ severity: "warn",
780
+ rule: "schema_was_stale",
781
+ message: `Local dypai/schema.sql was out of date — auto-refreshed from remote (added: ${nowKnown.join(", ")}).`,
782
+ fix_hint: "No action needed. The local file is now in sync.",
783
+ })
784
+ }
785
+ }
786
+ }
787
+
788
+ // Emit one error per (endpoint, missing_table) for the tables that really don't exist.
789
+ const knownTablesList = ctx.schemaTables
790
+ ? [...ctx.schemaTables].slice(0, 8).join(", ") + (ctx.schemaTables.size > 8 ? "…" : "")
791
+ : "(none yet)"
792
+ for (const { table, endpoint, file } of ctx.suspectedMissingTables) {
793
+ if (realMissing.has(table)) {
794
+ diagnostics.push({
795
+ severity: "error",
796
+ rule: "sql_table_not_found",
797
+ endpoint, file,
798
+ message: `SQL references public.${table}, but that table does not exist in the database.`,
799
+ fix_hint:
800
+ `Create it first via execute_sql. Example:\n` +
801
+ ` execute_sql({ sql: "CREATE TABLE public.${table} (\\n` +
802
+ ` id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\\n` +
803
+ ` user_id TEXT NOT NULL,\\n` +
804
+ ` created_at TIMESTAMPTZ DEFAULT NOW()\\n` +
805
+ ` );" })\n` +
806
+ `IMPORTANT: user_id must be TEXT (not UUID) — better-auth's auth.user.id is a 32-char nanoid stored as TEXT. ` +
807
+ `(Add other columns matching what your endpoint INSERTs.) ` +
808
+ `schema.sql will auto-refresh after the DDL. Existing tables: ${knownTablesList}`,
809
+ })
810
+ }
811
+ }
812
+ }
813
+
512
814
  // Realtime YAML rules
513
815
  diagnostics.push(...await validateRealtime(rootDir, ctx))
514
816
 
@@ -556,12 +858,23 @@ export const dypaiValidateTool = {
556
858
  },
557
859
  async execute({ project_id, root_dir = "./dypai" } = {}) {
558
860
  const rootDir = resolvePath(process.cwd(), root_dir)
559
- const result = await runValidation(rootDir, project_id)
560
- return {
561
- ...result,
562
- hint: result.success
563
- ? undefined
564
- : `${result.summary.errors} error(s) would cause runtime failures. Fix them, or push with skip_validation: true to override (not recommended).`,
861
+ try {
862
+ const result = await runValidation(rootDir, project_id)
863
+ return {
864
+ ...result,
865
+ hint: result.success
866
+ ? undefined
867
+ : `${result.summary.errors} error(s) would cause runtime failures. Fix them, or push with skip_validation: true to override (not recommended).`,
868
+ }
869
+ } catch (e) {
870
+ // Surface the real stack so failures during validation don't look like
871
+ // a generic "undefined.length" mystery to the agent.
872
+ return {
873
+ success: false,
874
+ error: `Validation crashed: ${e.message}`,
875
+ stack: (e.stack || "").split("\n").slice(0, 5).join("\n"),
876
+ hint: "This is a bug in the local MCP. Report the stack above; in the meantime you can pass skip_validation: true to dypai_push to bypass.",
877
+ }
565
878
  }
566
879
  },
567
880
  }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * syncFromRemote — Mirror of `deployFromSource`.
3
+ *
4
+ * Fetches the project's source from the API (which reads it from GitHub
5
+ * server-side and returns a JSON `{ files: [{ path, content }] }` payload —
6
+ * same shape the deploy uses, just reversed direction) and writes each file
7
+ * to disk under `targetDirectory`.
8
+ *
9
+ * Safety: refuses to write into a directory that already contains a
10
+ * `package.json` unless `overwrite: true`. When overwriting, only files
11
+ * present in the remote ZIP are touched — local-only files (.env,
12
+ * node_modules, .vscode, etc.) are preserved.
13
+ */
14
+
15
+ import { writeFileSync, mkdirSync, existsSync } from "fs"
16
+ import { join, dirname } from "path"
17
+ import { api } from "../api.js"
18
+
19
+ export async function syncFromRemote({ project_id, targetDirectory, overwrite = false }) {
20
+ if (!project_id) {
21
+ return { success: false, error: "project_id is required." }
22
+ }
23
+ if (!targetDirectory) {
24
+ return { success: false, error: "targetDirectory is required (absolute path where the source will be written)." }
25
+ }
26
+
27
+ // Refuse to clobber an existing project unless asked.
28
+ // Heuristic: presence of package.json (or any file at the root) means "in use".
29
+ if (existsSync(join(targetDirectory, "package.json")) && !overwrite) {
30
+ return {
31
+ success: false,
32
+ error: `targetDirectory '${targetDirectory}' already contains a package.json. ` +
33
+ `Pass overwrite: true to replace files (local-only files like .env are preserved). ` +
34
+ `Or pick an empty directory.`,
35
+ }
36
+ }
37
+
38
+ // Pull the JSON payload — same shape the deploy uses, just reversed.
39
+ let payload
40
+ try {
41
+ payload = await api.get(`/api/engine/${project_id}/frontend/source`)
42
+ } catch (e) {
43
+ return { success: false, error: `Failed to fetch source from API: ${e.message}` }
44
+ }
45
+
46
+ if (!payload || !Array.isArray(payload.files) || payload.files.length === 0) {
47
+ return {
48
+ success: false,
49
+ error: "API returned no files. The project may be empty or the GitHub repo unreachable.",
50
+ raw: payload,
51
+ }
52
+ }
53
+
54
+ // Make sure the target directory exists. mkdir is recursive, idempotent.
55
+ try {
56
+ mkdirSync(targetDirectory, { recursive: true })
57
+ } catch (e) {
58
+ return { success: false, error: `Could not create targetDirectory '${targetDirectory}': ${e.message}` }
59
+ }
60
+
61
+ let written = 0
62
+ const failures = []
63
+
64
+ for (const file of payload.files) {
65
+ if (!file?.path || typeof file.content !== "string") {
66
+ failures.push({ path: file?.path ?? "<unknown>", reason: "Malformed file entry from API" })
67
+ continue
68
+ }
69
+
70
+ // Defense in depth: refuse anything that tries to escape targetDirectory.
71
+ // The API strips the GitHub wrapper but we re-validate so a future bug
72
+ // there can't lead to writes outside the intended folder.
73
+ if (file.path.startsWith("/") || file.path.includes("..")) {
74
+ failures.push({ path: file.path, reason: "Suspicious path (absolute or contains '..'); skipped." })
75
+ continue
76
+ }
77
+
78
+ const fullPath = join(targetDirectory, file.path)
79
+ try {
80
+ mkdirSync(dirname(fullPath), { recursive: true })
81
+ // `content` is base64 from the API (uniform encoding, binary-safe —
82
+ // same convention as the deploy). Decode to a Buffer so binaries
83
+ // (images, fonts, etc.) round-trip cleanly.
84
+ writeFileSync(fullPath, Buffer.from(file.content, "base64"))
85
+ written++
86
+ } catch (e) {
87
+ failures.push({ path: file.path, reason: e.message })
88
+ }
89
+ }
90
+
91
+ // Structured next_steps so the agent can act without re-prompting the LLM.
92
+ // `npm install` is always suggested (idempotent and cheap). When we overwrote
93
+ // an existing project the user's local files removed upstream are NOT
94
+ // deleted by sync — flag that so the agent can surface it.
95
+ const next_steps = ["Run `npm install` in the target directory if dependencies changed."]
96
+ if (overwrite) {
97
+ next_steps.push(
98
+ "Sync does not delete local files that were removed upstream. Review the target directory for stale files and remove them manually if needed."
99
+ )
100
+ }
101
+
102
+ // .env is gitignored → the source API does NOT return it → the frontend won't
103
+ // know the engine URL. If it's missing after sync, tell the agent to create it.
104
+ // Engine URL convention: https://<project_id>.dypai.app (override with DYPAI_ENGINE_BASE).
105
+ const envMissing = !existsSync(join(targetDirectory, ".env"))
106
+ if (envMissing) {
107
+ const engineBase = process.env.DYPAI_ENGINE_BASE || "dypai.app"
108
+ const engineUrl = `https://${project_id}.${engineBase}`
109
+ next_steps.push(
110
+ `Create .env in ${targetDirectory} with \`VITE_DYPAI_URL=${engineUrl}\` (and \`VITE_PROJECT_ID=${project_id}\`). ` +
111
+ `Use NEXT_PUBLIC_DYPAI_URL instead of VITE_DYPAI_URL for Next.js projects. ` +
112
+ `The frontend SDK reads this to reach the engine — without .env, API calls will fail with network errors.`
113
+ )
114
+ }
115
+
116
+ return {
117
+ success: written > 0,
118
+ targetDirectory,
119
+ files_written: written,
120
+ files_failed: failures.length,
121
+ failures: failures.length > 0 ? failures : undefined,
122
+ total_bytes: payload.total_bytes ?? null,
123
+ ref: payload.ref ?? "main",
124
+ repo: payload.repo ?? null,
125
+ overwrite,
126
+ env_file_missing: envMissing,
127
+ next_steps,
128
+ message:
129
+ `Synced ${written} file(s) to ${targetDirectory}` +
130
+ (failures.length > 0 ? ` (${failures.length} failed — see failures[])` : "") +
131
+ ". See next_steps[].",
132
+ }
133
+ }
@@ -113,6 +113,14 @@ export function summarizeTrace(trace, mode = "smart") {
113
113
  type: n.error?.type,
114
114
  stack: truncateString(n.error?.stack, 800),
115
115
  }
116
+ // Preserve the engine's pre-computed fix_hint and category so the
117
+ // agent gets actionable guidance without us re-deriving it.
118
+ if (n.fix_hint || n.error?.fix_hint) {
119
+ base.fix_hint = truncateString(n.fix_hint || n.error?.fix_hint)
120
+ }
121
+ if (n.error_category || n.error?.category) {
122
+ base.error_category = n.error_category || n.error?.category
123
+ }
116
124
  // failure_snapshot: reduce to a key outline — not full values
117
125
  if (n.failure_snapshot) {
118
126
  const snap = n.failure_snapshot