@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.
@@ -11,6 +11,59 @@ import { readFileSync, existsSync } from "fs"
11
11
  import { basename } from "path"
12
12
  import { proxyToolCall } from "./proxy.js"
13
13
 
14
+ /**
15
+ * Minimal RFC 4180-compliant CSV parser.
16
+ * Handles: quoted fields, embedded commas, escaped quotes (""), CRLF, embedded newlines in quoted fields.
17
+ * Returns an array of rows, each row an array of string fields.
18
+ *
19
+ * The previous implementation did `line.split(",")` which silently corrupted
20
+ * any CSV with quoted commas (e.g. `"Smith, Jr.",100`).
21
+ */
22
+ function parseCsv(text) {
23
+ const rows = []
24
+ let row = []
25
+ let field = ""
26
+ let i = 0
27
+ let inQuotes = false
28
+ while (i < text.length) {
29
+ const c = text[i]
30
+ if (inQuotes) {
31
+ if (c === '"') {
32
+ if (text[i + 1] === '"') { field += '"'; i += 2; continue }
33
+ inQuotes = false
34
+ i++
35
+ continue
36
+ }
37
+ field += c
38
+ i++
39
+ continue
40
+ }
41
+ if (c === '"') { inQuotes = true; i++; continue }
42
+ if (c === ",") { row.push(field); field = ""; i++; continue }
43
+ if (c === "\r") {
44
+ // Treat CRLF as one line break
45
+ if (text[i + 1] === "\n") i++
46
+ row.push(field); rows.push(row); row = []; field = ""
47
+ i++
48
+ continue
49
+ }
50
+ if (c === "\n") {
51
+ row.push(field); rows.push(row); row = []; field = ""
52
+ i++
53
+ continue
54
+ }
55
+ field += c
56
+ i++
57
+ }
58
+ // Tail row (no trailing newline)
59
+ if (field.length > 0 || row.length > 0) {
60
+ row.push(field); rows.push(row)
61
+ }
62
+ // Drop trailing empty rows (common when the file ends with \n)
63
+ while (rows.length && rows[rows.length - 1].length === 1 && rows[rows.length - 1][0] === "") rows.pop()
64
+ return rows
65
+ }
66
+
14
67
  export const bulkUpsertTool = {
15
68
  name: "bulk_upsert",
16
69
  description: `Import data from a local CSV or JSON file into a database table.
@@ -63,21 +116,20 @@ Great for seeding initial data: products, categories, config, etc.`,
63
116
  // Read and parse data
64
117
  let rows
65
118
  try {
66
- const raw = readFileSync(file_path, "utf-8")
119
+ // Strip UTF-8 BOM if present — otherwise the first header column ends up
120
+ // as "\uFEFFname" instead of "name" and silently misaligns everything.
121
+ const raw = readFileSync(file_path, "utf-8").replace(/^\uFEFF/, "")
67
122
 
68
123
  if (ext === "json") {
69
124
  const parsed = JSON.parse(raw)
70
125
  rows = Array.isArray(parsed) ? parsed : [parsed]
71
126
  } else {
72
- // Parse CSV
73
- const lines = raw.trim().split("\n")
74
- if (lines.length < 2) return { error: "CSV must have a header row and at least one data row" }
75
-
76
- const headers = lines[0].split(",").map(h => h.trim().replace(/^"|"$/g, ""))
77
- rows = lines.slice(1).map(line => {
78
- const values = line.split(",").map(v => v.trim().replace(/^"|"$/g, ""))
127
+ const records = parseCsv(raw)
128
+ if (records.length < 2) return { error: "CSV must have a header row and at least one data row" }
129
+ const headers = records[0].map(h => h.trim())
130
+ rows = records.slice(1).map(values => {
79
131
  const obj = {}
80
- headers.forEach((h, i) => { obj[h] = values[i] || null })
132
+ headers.forEach((h, i) => { obj[h] = (values[i] === undefined || values[i] === "") ? null : values[i] })
81
133
  return obj
82
134
  })
83
135
  }
@@ -110,11 +162,16 @@ Great for seeding initial data: products, categories, config, etc.`,
110
162
  let inserted = 0
111
163
  let errors = []
112
164
 
165
+ // Quote a Postgres identifier safely. Doubles any embedded quotes.
166
+ const qIdent = (s) => `"${String(s).replace(/"/g, '""')}"`
167
+ const qTable = qIdent(table)
168
+
113
169
  // Batch in chunks of 50 rows
114
170
  const BATCH_SIZE = 50
115
171
  for (let i = 0; i < rows.length; i += BATCH_SIZE) {
116
172
  const batch = rows.slice(i, i + BATCH_SIZE)
117
173
  const batchCols = Object.keys(batch[0])
174
+ const quotedCols = batchCols.map(qIdent).join(", ")
118
175
  const batchValues = batch.map(row =>
119
176
  `(${batchCols.map(col => {
120
177
  const v = row[col]
@@ -126,10 +183,10 @@ Great for seeding initial data: products, categories, config, etc.`,
126
183
 
127
184
  let batchSql
128
185
  if (upsert_key && batchCols.includes(upsert_key)) {
129
- const updateCols = batchCols.filter(c => c !== upsert_key).map(c => `${c} = EXCLUDED.${c}`).join(", ")
130
- batchSql = `INSERT INTO ${table} (${batchCols.join(", ")}) VALUES\n${batchValues}\nON CONFLICT (${upsert_key}) DO UPDATE SET ${updateCols}`
186
+ const updateCols = batchCols.filter(c => c !== upsert_key).map(c => `${qIdent(c)} = EXCLUDED.${qIdent(c)}`).join(", ")
187
+ batchSql = `INSERT INTO ${qTable} (${quotedCols}) VALUES\n${batchValues}\nON CONFLICT (${qIdent(upsert_key)}) DO UPDATE SET ${updateCols}`
131
188
  } else {
132
- batchSql = `INSERT INTO ${table} (${batchCols.join(", ")}) VALUES\n${batchValues}`
189
+ batchSql = `INSERT INTO ${qTable} (${quotedCols}) VALUES\n${batchValues}`
133
190
  }
134
191
 
135
192
  try {
@@ -13,33 +13,53 @@
13
13
 
14
14
  import { api } from "../api.js"
15
15
  import { deployFromSource } from "./deploy.js"
16
+ import { syncFromRemote } from "./sync.js"
16
17
 
17
18
  export const manageFrontendTool = {
18
19
  name: "manage_frontend",
19
20
  description:
20
- "Manage the project's frontend deploy lifecycle. Operations:\n" +
21
- " - deploy: upload source files from a local directory and queue a build. Returns immediately with build_status=\"queued\" poll with build_status until \"success\" or \"failure\".\n" +
22
- " - status: current live deploy info (URL, last deploy time, size).\n" +
23
- " - build_status: progress of the current/latest build (queued/building/success/failure + stage + %).\n" +
24
- " - list_deployments: recent deploy history (status, commit, duration, URL).\n" +
25
- " - logs: build logs for a specific deployment (needs deployment_id from list_deployments).",
21
+ "Manage the project's frontend: download the source code to disk AND the deploy lifecycle.\n\n" +
22
+ "Use `sync` FIRST whenever you start working on a project whose frontend code isn't already on this machine " +
23
+ "without it you have no React/Vite source to read or edit. Call `deploy` to ship your changes.\n\n" +
24
+ "Operations:\n" +
25
+ " - sync: Download the project's frontend source code (React/Vite/etc.) into a local directory. " +
26
+ "Use when: starting a session on an existing project, onboarding on a new machine, or refreshing after edits made from elsewhere. " +
27
+ "Also known as: clone, download source, get source, initial setup. " +
28
+ "Writes only; does NOT delete local files that were removed upstream — you may have stale files after sync (call them out to the user). " +
29
+ "By default refuses to overwrite a directory that already has a package.json — pass overwrite:true to allow it (local-only files like .env, node_modules, .vscode are always preserved). " +
30
+ "AFTER SYNC: .env is gitignored so it's NOT included in the download. If the target directory has no .env, the response sets `env_file_missing: true` and adds a `next_steps` line with the exact VITE_DYPAI_URL / NEXT_PUBLIC_DYPAI_URL value to write. Follow it — without .env the SDK can't reach the engine.\n" +
31
+ " - deploy: Upload source files from a local directory and queue a build. Returns immediately with build_status=\"queued\" — poll with `build_status` until \"success\" or \"failure\".\n" +
32
+ " - status: Current live deploy info (URL, last deploy time, size).\n" +
33
+ " - build_status: Progress of the current/latest build (queued/building/success/failure + stage + %).\n" +
34
+ " - list_deployments: Recent deploy history (status, commit, duration, URL).\n" +
35
+ " - logs: Build logs for a specific deployment (needs deployment_id from list_deployments).\n\n" +
36
+ "Related: `dypai_pull` brings BACKEND state (YAML endpoints, SQL, prompts). The two are independent — run both when starting fresh on a full-stack project.",
26
37
 
27
38
  inputSchema: {
28
39
  type: "object",
29
40
  properties: {
30
41
  operation: {
31
42
  type: "string",
32
- enum: ["deploy", "status", "build_status", "list_deployments", "logs"],
43
+ enum: ["deploy", "sync", "status", "build_status", "list_deployments", "logs"],
33
44
  description: "Which action to run.",
34
45
  },
35
46
  project_id: {
36
47
  type: "string",
37
- description: "Project UUID. Auto-injected from dypai.config.yaml when omitted (except `deploy`, which needs it explicit).",
48
+ description: "Project UUID. Auto-injected from dypai.config.yaml when omitted (except `deploy` and `sync`, which need it explicit).",
38
49
  },
39
50
  sourceDirectory: {
40
51
  type: "string",
41
52
  description: "deploy only. Absolute path to the project source directory (must contain package.json).",
42
53
  },
54
+ targetDirectory: {
55
+ type: "string",
56
+ description: "sync only. Absolute path where the project source will be written. If it already contains a package.json, pass overwrite: true to allow replacing files.",
57
+ },
58
+ overwrite: {
59
+ type: "boolean",
60
+ description: "sync only. When true, allows writing into a directory that already has a project. Local-only files (.env, node_modules, etc.) are NOT touched. Default: false.",
61
+ default: false,
62
+ },
43
63
  deployment_id: {
44
64
  type: "string",
45
65
  description: "logs only. Deployment UUID obtained from list_deployments.",
@@ -52,9 +72,9 @@ export const manageFrontendTool = {
52
72
  required: ["operation"],
53
73
  },
54
74
 
55
- async execute({ operation, project_id, sourceDirectory, deployment_id, limit } = {}) {
75
+ async execute({ operation, project_id, sourceDirectory, targetDirectory, overwrite, deployment_id, limit } = {}) {
56
76
  if (!operation) {
57
- return { success: false, error: "operation is required (deploy | status | build_status | list_deployments | logs)." }
77
+ return { success: false, error: "operation is required (deploy | sync | status | build_status | list_deployments | logs)." }
58
78
  }
59
79
  if (!project_id) {
60
80
  return { success: false, error: "project_id is required. Set it in dypai.config.yaml or pass it explicitly." }
@@ -68,6 +88,12 @@ export const manageFrontendTool = {
68
88
  }
69
89
  return await deployFromSource({ sourceDirectory, project_id })
70
90
 
91
+ case "sync":
92
+ if (!targetDirectory) {
93
+ return { success: false, error: "operation 'sync' requires 'targetDirectory' (absolute path where the source will be written)." }
94
+ }
95
+ return await syncFromRemote({ project_id, targetDirectory, overwrite: !!overwrite })
96
+
71
97
  case "status":
72
98
  return await api.get(`/api/engine/${project_id}/frontend`)
73
99
 
@@ -84,7 +110,7 @@ export const manageFrontendTool = {
84
110
  return await api.get(`/api/engine/${project_id}/frontend/deployments/${deployment_id}/logs`)
85
111
 
86
112
  default:
87
- return { success: false, error: `Unknown operation '${operation}'. Use deploy | status | build_status | list_deployments | logs.` }
113
+ return { success: false, error: `Unknown operation '${operation}'. Use deploy | sync | status | build_status | list_deployments | logs.` }
88
114
  }
89
115
  } catch (e) {
90
116
  return { success: false, error: e.message, operation }
@@ -76,28 +76,41 @@ function mcpRequest(body) {
76
76
  })
77
77
  }
78
78
 
79
+ // Single in-flight init promise so concurrent first-calls (e.g. push with
80
+ // concurrency=5) don't all race to initialize and clobber each other's
81
+ // session id mid-handshake.
82
+ let initPromise = null
83
+
79
84
  async function ensureInitialized() {
80
85
  if (sessionId) return
81
- try {
82
- await mcpRequest({
83
- jsonrpc: "2.0",
84
- id: "init-proxy",
85
- method: "initialize",
86
- params: {
87
- protocolVersion: "2024-11-05",
88
- capabilities: {},
89
- clientInfo: { name: "dypai-mcp-local", version: "1.0.0" },
90
- },
91
- })
92
- // Send initialized notification
93
- await mcpRequest({
94
- jsonrpc: "2.0",
95
- method: "notifications/initialized",
96
- })
97
- } catch (e) {
98
- // Non-fatal — some servers don't require init
99
- process.stderr.write(`MCP proxy init warning: ${e.message}\n`)
100
- }
86
+ if (initPromise) return initPromise
87
+ initPromise = (async () => {
88
+ try {
89
+ await mcpRequest({
90
+ jsonrpc: "2.0",
91
+ id: "init-proxy",
92
+ method: "initialize",
93
+ params: {
94
+ protocolVersion: "2024-11-05",
95
+ capabilities: {},
96
+ clientInfo: { name: "dypai-mcp-local", version: "1.0.0" },
97
+ },
98
+ })
99
+ // Send initialized notification
100
+ await mcpRequest({
101
+ jsonrpc: "2.0",
102
+ method: "notifications/initialized",
103
+ })
104
+ } catch (e) {
105
+ // Don't swallow silently — calls right after will appear to "work"
106
+ // returning empty results and we'd never know init failed.
107
+ process.stderr.write(`MCP proxy init failed: ${e.message}\n`)
108
+ // Reset so a future call gets a chance to retry.
109
+ initPromise = null
110
+ throw e
111
+ }
112
+ })()
113
+ return initPromise
101
114
  }
102
115
 
103
116
  export async function proxyToolCall(toolName, args) {
@@ -47,9 +47,10 @@ Or use "blank" for an empty starter project.`,
47
47
  return { error: `Directory already has a package.json. Pick an empty directory or a new name.` }
48
48
  }
49
49
 
50
- const token = process.env.DYPAI_TOKEN || ""
51
- const mcpUrl = process.env.DYPAI_MCP_URL || "https://mcp.dypai.dev/mcp"
52
- const engineUrl = `https://${project_id}.dypai.dev`
50
+ // Production engine URL: https://<project-id>.dypai.app (NOT .dypai.dev that's the dev tier).
51
+ // Override with DYPAI_ENGINE_BASE for self-hosted / staging setups.
52
+ const engineBase = process.env.DYPAI_ENGINE_BASE || "dypai.app"
53
+ const engineUrl = `https://${project_id}.${engineBase}`
53
54
 
54
55
  // Try to download template from API
55
56
  let files = []
@@ -79,6 +80,35 @@ Or use "blank" for an empty starter project.`,
79
80
  // .env with engine URL
80
81
  files.push({ path: ".env", content: `VITE_DYPAI_URL=${engineUrl}\nVITE_PROJECT_ID=${project_id}\n` })
81
82
 
83
+ // .gitignore — keep node_modules / .dypai cache / build outputs / secrets out of git.
84
+ files.push({ path: ".gitignore", content: [
85
+ "# Dependencies",
86
+ "node_modules/",
87
+ "",
88
+ "# Build outputs",
89
+ "dist/",
90
+ "build/",
91
+ ".vite/",
92
+ ".turbo/",
93
+ "",
94
+ "# Logs",
95
+ "*.log",
96
+ "",
97
+ "# Secrets (never commit)",
98
+ ".env",
99
+ ".env.local",
100
+ ".env.*.local",
101
+ "",
102
+ "# DYPAI local cache (committed: dypai/, gitignored: dypai/.dypai/)",
103
+ "dypai/.dypai/",
104
+ "",
105
+ "# OS / editor",
106
+ ".DS_Store",
107
+ ".vscode/",
108
+ ".idea/",
109
+ "",
110
+ ].join("\n") })
111
+
82
112
  // SDK client helper (lib/dypai.ts)
83
113
  files.push({ path: "src/lib/dypai.ts", content: `import { createClient } from "@dypai-ai/client-sdk";\n\nexport const dypai = createClient(import.meta.env.VITE_DYPAI_URL);\n` })
84
114
 
@@ -18,11 +18,14 @@ import { pullNodeParams, pushNodeParams } from "./transforms.js"
18
18
 
19
19
  function triggersToYaml(triggers = {}) {
20
20
  const out = {}
21
+ // Telegram takes precedence over webhook because the engine enables both
22
+ // (telegram piggybacks on webhook plumbing) but conceptually it's one trigger.
23
+ const telegramEnabled = triggers.telegram?.enabled
21
24
  for (const [kind, cfg] of Object.entries(triggers)) {
22
25
  if (!cfg) continue
23
26
  if (kind === "http_api" && cfg.enabled !== false) {
24
27
  out.http_api = { auth_mode: cfg.auth_mode || "jwt" }
25
- } else if (kind === "webhook" && cfg.enabled) {
28
+ } else if (kind === "webhook" && cfg.enabled && !telegramEnabled) {
26
29
  out.webhook = { path: cfg.path || "" }
27
30
  } else if (kind === "schedule" && cfg.enabled) {
28
31
  const s = { cron: cfg.cron }
@@ -30,6 +33,9 @@ function triggersToYaml(triggers = {}) {
30
33
  out.schedule = s
31
34
  } else if (kind === "manual" && cfg.enabled) {
32
35
  out.manual = true
36
+ } else if (kind === "telegram" && cfg.enabled) {
37
+ const { enabled, ...rest } = cfg
38
+ out.telegram = Object.keys(rest).length ? rest : {}
33
39
  }
34
40
  }
35
41
  if (!Object.keys(out).length) out.http_api = { auth_mode: "jwt" }
@@ -43,14 +49,20 @@ function triggersToDb(trigger = {}) {
43
49
  http_api: { enabled: true, auth_mode: "jwt" },
44
50
  schedule: { enabled: false },
45
51
  }
46
- if (trigger.http_api) {
47
- out.http_api = { enabled: true, auth_mode: trigger.http_api.auth_mode || "jwt" }
48
- } else if (trigger.webhook || trigger.schedule || trigger.manual) {
52
+ // Accept `http_api: true` (shorthand) by treating it as `http_api: {}`.
53
+ // Accept `auth_mode` or `mode` for forward/backward compat with older docs.
54
+ const httpApi = trigger.http_api === true ? {} : trigger.http_api
55
+ const hasNonHttpTrigger = trigger.webhook || trigger.schedule || trigger.manual || trigger.telegram
56
+ if (httpApi) {
57
+ const authMode = httpApi.auth_mode || httpApi.mode || "jwt"
58
+ out.http_api = { enabled: true, auth_mode: authMode }
59
+ } else if (hasNonHttpTrigger) {
49
60
  // Non-HTTP trigger: disable http_api
50
61
  out.http_api = { enabled: false, auth_mode: "jwt" }
51
62
  }
52
63
  if (trigger.webhook) {
53
- out.webhook = { path: trigger.webhook.path || "", enabled: true }
64
+ const wh = trigger.webhook === true ? {} : trigger.webhook
65
+ out.webhook = { path: wh.path || "", enabled: true }
54
66
  }
55
67
  if (trigger.schedule) {
56
68
  out.schedule = { ...trigger.schedule, enabled: true }
@@ -58,6 +70,14 @@ function triggersToDb(trigger = {}) {
58
70
  if (trigger.manual) {
59
71
  out.manual = { enabled: true }
60
72
  }
73
+ if (trigger.telegram) {
74
+ // Telegram triggers piggyback on the webhook plumbing (the engine sets up
75
+ // the telegram webhook URL automatically). We mark webhook as enabled so
76
+ // the engine routes the inbound HTTP correctly.
77
+ const tg = trigger.telegram === true ? {} : trigger.telegram
78
+ out.telegram = { enabled: true, ...tg }
79
+ out.webhook = { path: "", enabled: true }
80
+ }
61
81
  return out
62
82
  }
63
83
 
@@ -74,13 +94,30 @@ function edgesToYaml(edges = []) {
74
94
  }
75
95
 
76
96
  function edgesToDb(edges = []) {
77
- return edges.map(e => {
78
- const out = { source: e.from, target: e.to }
97
+ return (edges || []).map(e => {
98
+ if (!e || typeof e !== "object") return null
99
+ // Tolerate both YAML-canonical (`from`/`to`) and engine-style (`source`/`target`).
100
+ // Without this, an agent that wrote `source/target` in YAML would silently
101
+ // produce edges with `source: undefined`, breaking workflow execution.
102
+ const source = e.from ?? e.source
103
+ const target = e.to ?? e.target
104
+ if (!source || !target) {
105
+ throw new Error(
106
+ `Edge is missing source/target: ${JSON.stringify(e).slice(0, 120)}. ` +
107
+ `Edges must have either 'from'/'to' (canonical) or 'source'/'target' (engine-style).`
108
+ )
109
+ }
110
+ const out = { source, target }
79
111
  if (e.condition) out.condition = e.condition
80
- if (e.source_handle) out.source_handle = e.source_handle
112
+ // Accept `source_handle` (canonical) plus a `handle` shorthand. The `handle`
113
+ // shorthand mirrors how branching looks in a UI ("which branch did this come
114
+ // out of?") and shows up in agent-written YAML often. Engine only knows
115
+ // source_handle, so we always emit that key.
116
+ const sh = e.source_handle ?? e.handle
117
+ if (sh != null && sh !== "") out.source_handle = String(sh)
81
118
  if (e.target_handle) out.target_handle = e.target_handle
82
119
  return out
83
- })
120
+ }).filter(Boolean)
84
121
  }
85
122
 
86
123
  // ─── Public codec ───────────────────────────────────────────────────────────
@@ -143,11 +180,114 @@ export function serializeEndpoint(row, mapsCtx) {
143
180
  return { doc, extractedFiles }
144
181
  }
145
182
 
183
+ /**
184
+ * Detect legacy `start_trigger` nodes and extract their config to a top-level
185
+ * trigger if one isn't already declared. Returns:
186
+ * - cleanedNodes: workflow.nodes minus any start_trigger entries
187
+ * - cleanedEdges: workflow.edges minus any edge that referenced a removed node
188
+ * - inferredTrigger: the trigger object to use if doc.trigger is missing
189
+ *
190
+ * This makes the codec tolerant of an agent that copied an old-format example
191
+ * (where the trigger was a node) into a YAML workflow. Without this they'd
192
+ * get "unknown_node_type: start_trigger" because the canonical form has no
193
+ * such node — trigger lives at the top level.
194
+ */
195
+ function liftStartTriggerNodes(workflow) {
196
+ const allNodes = workflow?.nodes || []
197
+ const allEdges = workflow?.edges || []
198
+ const startNodes = allNodes.filter(n => {
199
+ const t = n?.type ?? n?.node_type
200
+ return t === "start_trigger"
201
+ })
202
+ if (!startNodes.length) {
203
+ return { cleanedNodes: allNodes, cleanedEdges: allEdges, inferredTrigger: null, lifted: false }
204
+ }
205
+
206
+ const startIds = new Set(startNodes.map(n => n.id))
207
+ const cleanedNodes = allNodes.filter(n => !startIds.has(n?.id))
208
+ const cleanedEdges = allEdges.filter(e =>
209
+ !startIds.has(e?.from ?? e?.source) && !startIds.has(e?.to ?? e?.target)
210
+ )
211
+
212
+ // Build a trigger from the FIRST start_trigger node's parameters. If multiple
213
+ // are present (rare), the first wins — we don't try to merge.
214
+ const first = startNodes[0]
215
+ const params = first.parameters || first
216
+ const triggerType = params.trigger_type || params.triggerType || "http_api"
217
+ let inferredTrigger
218
+ switch (triggerType) {
219
+ case "http_api":
220
+ inferredTrigger = { http_api: { auth_mode: params.auth_mode || params.mode || "jwt" } }
221
+ break
222
+ case "webhook":
223
+ inferredTrigger = { webhook: params.path ? { path: params.path } : {} }
224
+ break
225
+ case "schedule": {
226
+ const s = {}
227
+ if (params.cron) s.cron = params.cron
228
+ if (params.timezone) s.timezone = params.timezone
229
+ inferredTrigger = { schedule: s }
230
+ break
231
+ }
232
+ case "telegram":
233
+ inferredTrigger = { telegram: {} }
234
+ break
235
+ default:
236
+ inferredTrigger = { http_api: { auth_mode: "jwt" } }
237
+ }
238
+
239
+ return { cleanedNodes, cleanedEdges, inferredTrigger, lifted: true }
240
+ }
241
+
146
242
  export function deserializeEndpoint(doc, mapsCtx) {
147
243
  const readFile = mapsCtx.readFile || (() => "")
148
244
 
149
- const nodes = (doc.workflow?.nodes || []).map(clean => {
150
- const { id, type, variable, return: isReturn, ...params } = clean
245
+ // Lift legacy `start_trigger` nodes into the canonical top-level trigger.
246
+ // If the doc already has a `trigger:` block, we trust that one and just
247
+ // strip the redundant nodes (no merge — top-level is canonical).
248
+ const { cleanedNodes, cleanedEdges, inferredTrigger } = liftStartTriggerNodes(doc.workflow)
249
+ const effectiveTrigger = doc.trigger || inferredTrigger
250
+ const effectiveWorkflow = { ...doc.workflow, nodes: cleanedNodes, edges: cleanedEdges }
251
+
252
+ const nodes = (effectiveWorkflow.nodes || []).map(clean => {
253
+ // Tolerate both YAML-canonical `type` and engine-style `node_type`. The
254
+ // canonical form is `type`, but agents writing YAML by hand sometimes use
255
+ // `node_type` because that's what the engine returns. We accept either,
256
+ // but FAIL LOUD if neither is present — silently sending undefined to the
257
+ // engine causes accepted-but-not-created ghost endpoints (the bug from 1.2.2).
258
+ if (!clean || typeof clean !== "object") {
259
+ throw new Error(`workflow.nodes contains a non-object entry in endpoint '${doc.name}'.`)
260
+ }
261
+ // Tolerate both YAML-canonical (`type`, `return`) and engine-style
262
+ // (`node_type`, `is_return`) for the keys that have a "renamed" form.
263
+ // Anything still in `rest` after this is treated as a node parameter
264
+ // (inline form). This is the layer that prevents silent data loss when
265
+ // an agent mixes the two conventions.
266
+ const {
267
+ id,
268
+ type: typeA, node_type: typeB,
269
+ variable,
270
+ return: isReturnA, is_return: isReturnB,
271
+ parameters: explicitParams,
272
+ ...rest
273
+ } = clean
274
+ const type = typeA || typeB
275
+ const isReturn = isReturnA || isReturnB
276
+ if (!id) {
277
+ throw new Error(
278
+ `A node in endpoint '${doc.name}' is missing 'id'. Every node needs a unique id within the workflow.`
279
+ )
280
+ }
281
+ if (!type) {
282
+ throw new Error(
283
+ `Node '${id}' in endpoint '${doc.name}' is missing 'type'. ` +
284
+ `Each node in workflow.nodes must declare a node type, e.g. \`type: dypai_database\`. ` +
285
+ `(Both 'type' and 'node_type' are accepted.)`
286
+ )
287
+ }
288
+ // Params: prefer an explicit `parameters:` object, otherwise fall back to
289
+ // the inline shape (everything that isn't id/type/variable/return is a param).
290
+ const params = explicitParams && typeof explicitParams === "object" ? explicitParams : rest
151
291
  const nodeCtx = {
152
292
  ...mapsCtx,
153
293
  endpointName: doc.name,
@@ -164,21 +304,25 @@ export function deserializeEndpoint(doc, mapsCtx) {
164
304
 
165
305
  const workflow_code = {
166
306
  nodes,
167
- edges: edgesToDb(doc.workflow?.edges),
168
- execution_config: { triggers: triggersToDb(doc.trigger) },
307
+ edges: edgesToDb(effectiveWorkflow.edges),
308
+ execution_config: { triggers: triggersToDb(effectiveTrigger) },
169
309
  schema_version: "2.0",
170
310
  }
171
- if (doc.workflow?.on_error) workflow_code.on_error = doc.workflow.on_error
311
+ if (effectiveWorkflow.on_error) workflow_code.on_error = effectiveWorkflow.on_error
172
312
 
173
313
  return {
174
314
  name: doc.name,
175
- method: doc.method || "GET",
315
+ // Engine expects uppercase HTTP methods. Normalize so YAML can use any case.
316
+ method: String(doc.method || "GET").toUpperCase(),
176
317
  description: doc.description || null,
177
318
  workflow_code,
178
319
  input: doc.input || null,
179
320
  output: doc.output || null,
180
321
  allowed_roles: doc.allowed_roles || [],
181
- is_tool: !!doc.tool,
322
+ // Accept both `tool: true` (canonical) and `is_tool: true` (engine-style).
323
+ // Without this, an agent that wrote `is_tool` in YAML would silently get
324
+ // a non-tool endpoint and `allowed_roles` would not be enforced as expected.
325
+ is_tool: !!(doc.tool || doc.is_tool),
182
326
  tool_description: doc.tool_description || null,
183
327
  group_id: doc.group && mapsCtx.groupNameToId ? (mapsCtx.groupNameToId[doc.group] || null) : null,
184
328
  }
@@ -52,6 +52,14 @@ export const dypaiDiffTool = {
52
52
  }
53
53
  }
54
54
 
55
+ if (!remote || !remote.mapsCtx) {
56
+ return {
57
+ success: false,
58
+ phase: "fetch_remote",
59
+ error: "Remote state could not be fetched (mapsCtx missing). Check your DYPAI_TOKEN and project_id.",
60
+ }
61
+ }
62
+
55
63
  const plan = computePlan(local, remote, remote.mapsCtx, {
56
64
  deleteOrphans: delete_orphans,
57
65
  stateSnapshot,
@@ -178,9 +178,15 @@ async function findEndpointYamls(endpointsDir) {
178
178
  const sub = relFromRoot ? `${relFromRoot}/${e.name}` : e.name
179
179
  await walk(join(dir, e.name), sub, group || e.name)
180
180
  } else if (e.isFile() && (e.name.endsWith(".yaml") || e.name.endsWith(".yml"))) {
181
+ // Skip files ending in .disabled (e.g. _example.yaml.disabled — the
182
+ // reference template that ships with empty projects). Anything ending
183
+ // in .disabled BEFORE the .yaml suffix is also treated as disabled.
184
+ if (e.name.endsWith(".disabled.yaml") || e.name.endsWith(".disabled.yml")) continue
181
185
  const rel = relFromRoot ? `${relFromRoot}/${e.name}` : e.name
182
186
  out.push({ rel, group })
183
187
  }
188
+ // Note: files ending in .disabled (with no .yaml/.yml after) are also
189
+ // skipped naturally because the isFile branch only matches .yaml/.yml.
184
190
  }
185
191
  }
186
192
  await walk(endpointsDir, "", null)
@@ -232,8 +238,17 @@ export async function readLocalState(rootDir) {
232
238
  * This is what we'd send to update_endpoint.
233
239
  */
234
240
  export function localToCanonical(entry, mapsCtx) {
241
+ if (!entry || !entry.doc) {
242
+ throw new Error("localToCanonical: entry must have a `doc` field (parsed YAML).")
243
+ }
244
+ if (!mapsCtx || typeof mapsCtx !== "object") {
245
+ throw new Error(
246
+ "localToCanonical: mapsCtx required to resolve credential/group/tool references. " +
247
+ "If you see this, fetchRemoteState probably returned an empty/failed response."
248
+ )
249
+ }
235
250
  const { doc, fileMap } = entry
236
- const ctx = { ...mapsCtx, readFile: (p) => fileMap[p] ?? "" }
251
+ const ctx = { ...mapsCtx, readFile: (p) => (fileMap || {})[p] ?? "" }
237
252
  return deserializeEndpoint(doc, ctx)
238
253
  }
239
254