@dypai-ai/mcp 1.2.4 → 1.3.1

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,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 }
@@ -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" }
@@ -46,10 +52,11 @@ function triggersToDb(trigger = {}) {
46
52
  // Accept `http_api: true` (shorthand) by treating it as `http_api: {}`.
47
53
  // Accept `auth_mode` or `mode` for forward/backward compat with older docs.
48
54
  const httpApi = trigger.http_api === true ? {} : trigger.http_api
55
+ const hasNonHttpTrigger = trigger.webhook || trigger.schedule || trigger.manual || trigger.telegram
49
56
  if (httpApi) {
50
57
  const authMode = httpApi.auth_mode || httpApi.mode || "jwt"
51
58
  out.http_api = { enabled: true, auth_mode: authMode }
52
- } else if (trigger.webhook || trigger.schedule || trigger.manual) {
59
+ } else if (hasNonHttpTrigger) {
53
60
  // Non-HTTP trigger: disable http_api
54
61
  out.http_api = { enabled: false, auth_mode: "jwt" }
55
62
  }
@@ -63,6 +70,14 @@ function triggersToDb(trigger = {}) {
63
70
  if (trigger.manual) {
64
71
  out.manual = { enabled: true }
65
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
+ }
66
81
  return out
67
82
  }
68
83
 
@@ -94,7 +109,12 @@ function edgesToDb(edges = []) {
94
109
  }
95
110
  const out = { source, target }
96
111
  if (e.condition) out.condition = e.condition
97
- 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)
98
118
  if (e.target_handle) out.target_handle = e.target_handle
99
119
  return out
100
120
  }).filter(Boolean)
@@ -160,10 +180,76 @@ export function serializeEndpoint(row, mapsCtx) {
160
180
  return { doc, extractedFiles }
161
181
  }
162
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
+
163
242
  export function deserializeEndpoint(doc, mapsCtx) {
164
243
  const readFile = mapsCtx.readFile || (() => "")
165
244
 
166
- const nodes = (doc.workflow?.nodes || []).map(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 => {
167
253
  // Tolerate both YAML-canonical `type` and engine-style `node_type`. The
168
254
  // canonical form is `type`, but agents writing YAML by hand sometimes use
169
255
  // `node_type` because that's what the engine returns. We accept either,
@@ -218,11 +304,11 @@ export function deserializeEndpoint(doc, mapsCtx) {
218
304
 
219
305
  const workflow_code = {
220
306
  nodes,
221
- edges: edgesToDb(doc.workflow?.edges),
222
- execution_config: { triggers: triggersToDb(doc.trigger) },
307
+ edges: edgesToDb(effectiveWorkflow.edges),
308
+ execution_config: { triggers: triggersToDb(effectiveTrigger) },
223
309
  schema_version: "2.0",
224
310
  }
225
- 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
226
312
 
227
313
  return {
228
314
  name: doc.name,
@@ -12,7 +12,10 @@
12
12
  import { proxyToolCall } from "../proxy.js"
13
13
 
14
14
  async function execSql(projectId, sql) {
15
- const args = projectId ? { project_id: projectId, sql } : { sql }
15
+ // Bypass remote execute_sql auto-LIMIT 20 so describe counts match reality.
16
+ const hasLimit = /\bLIMIT\b/i.test(sql)
17
+ const finalSql = hasLimit ? sql : `${sql.replace(/;?\s*$/, "")} LIMIT 100000`
18
+ const args = projectId ? { project_id: projectId, sql: finalSql } : { sql: finalSql }
16
19
  const result = await proxyToolCall("execute_sql", args)
17
20
  if (result?.error) return null
18
21
  if (!result?.rows) return null
@@ -54,7 +57,6 @@ export const dypaiDescribeTool = {
54
57
  jsonb_array_length(e.workflow_code->'nodes') AS node_count
55
58
  FROM system.endpoints e
56
59
  LEFT JOIN system.endpoints_group g ON g.id = e.group_id
57
- WHERE e.is_active = true
58
60
  ORDER BY e.name
59
61
  `),
60
62
  execSql(project_id, "SELECT name, type FROM system.credentials ORDER BY name"),
@@ -17,7 +17,13 @@ import { deserializeEndpoint, serializeEndpoint } from "./codec.js"
17
17
  // ─── Remote ────────────────────────────────────────────────────────────────
18
18
 
19
19
  async function execSql(projectId, sql) {
20
- const args = projectId ? { project_id: projectId, sql } : { sql }
20
+ // Bypass the remote execute_sql safety that injects `LIMIT 20` when no LIMIT
21
+ // is present. Planner needs the FULL endpoint/credential/group lists to
22
+ // compute an accurate diff — truncation here would mean invisible deletes
23
+ // (local thinks "remote has only X" when it actually has X+N).
24
+ const hasLimit = /\bLIMIT\b/i.test(sql)
25
+ const finalSql = hasLimit ? sql : `${sql.replace(/;?\s*$/, "")} LIMIT 100000`
26
+ const args = projectId ? { project_id: projectId, sql: finalSql } : { sql: finalSql }
21
27
  const result = await proxyToolCall("execute_sql", args)
22
28
  if (result?.error) throw new Error(`SQL error: ${result.error}`)
23
29
  if (!result?.rows) throw new Error(`Unexpected SQL response: ${JSON.stringify(result).slice(0, 300)}`)
@@ -116,9 +122,8 @@ export async function fetchRemoteState(projectId) {
116
122
  const [endpoints, credentials, groups] = await Promise.all([
117
123
  execSql(projectId, `
118
124
  SELECT id, name, method, description, workflow_code, input, output,
119
- allowed_roles, is_tool, tool_description, group_id, updated_at
125
+ allowed_roles, is_tool, tool_description, group_id, is_active, updated_at
120
126
  FROM system.endpoints
121
- WHERE is_active = true
122
127
  ORDER BY name
123
128
  `),
124
129
  execSql(projectId, "SELECT id, name, type FROM system.credentials"),
@@ -178,9 +183,15 @@ async function findEndpointYamls(endpointsDir) {
178
183
  const sub = relFromRoot ? `${relFromRoot}/${e.name}` : e.name
179
184
  await walk(join(dir, e.name), sub, group || e.name)
180
185
  } else if (e.isFile() && (e.name.endsWith(".yaml") || e.name.endsWith(".yml"))) {
186
+ // Skip files ending in .disabled (e.g. _example.yaml.disabled — the
187
+ // reference template that ships with empty projects). Anything ending
188
+ // in .disabled BEFORE the .yaml suffix is also treated as disabled.
189
+ if (e.name.endsWith(".disabled.yaml") || e.name.endsWith(".disabled.yml")) continue
181
190
  const rel = relFromRoot ? `${relFromRoot}/${e.name}` : e.name
182
191
  out.push({ rel, group })
183
192
  }
193
+ // Note: files ending in .disabled (with no .yaml/.yml after) are also
194
+ // skipped naturally because the isFile branch only matches .yaml/.yml.
184
195
  }
185
196
  }
186
197
  await walk(endpointsDir, "", null)