@dypai-ai/mcp 1.2.4 → 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.
- package/package.json +1 -1
- package/src/index.js +525 -14
- package/src/tools/frontend.js +37 -11
- package/src/tools/scaffold.js +33 -3
- package/src/tools/sync/codec.js +93 -7
- package/src/tools/sync/planner.js +6 -0
- package/src/tools/sync/pull.js +363 -3
- package/src/tools/sync/test-endpoint.js +68 -2
- package/src/tools/sync/validate.js +300 -15
- package/src/tools/sync.js +133 -0
- package/src/tools/trace-summarize.js +8 -0
package/src/tools/frontend.js
CHANGED
|
@@ -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
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
" -
|
|
25
|
-
"
|
|
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
|
|
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 }
|
package/src/tools/scaffold.js
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
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
|
|
package/src/tools/sync/codec.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
222
|
-
execution_config: { triggers: triggersToDb(
|
|
307
|
+
edges: edgesToDb(effectiveWorkflow.edges),
|
|
308
|
+
execution_config: { triggers: triggersToDb(effectiveTrigger) },
|
|
223
309
|
schema_version: "2.0",
|
|
224
310
|
}
|
|
225
|
-
if (
|
|
311
|
+
if (effectiveWorkflow.on_error) workflow_code.on_error = effectiveWorkflow.on_error
|
|
226
312
|
|
|
227
313
|
return {
|
|
228
314
|
name: doc.name,
|
|
@@ -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)
|