@dypai-ai/mcp 1.0.9 → 1.1.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.
@@ -0,0 +1,414 @@
1
+ /**
2
+ * dypai_pull — snapshot remote project state to local YAML + SQL + MD files.
3
+ *
4
+ * Thin wrapper: reads DB via execute_sql, calls codec.serializeEndpoint,
5
+ * writes the resulting doc + extractedFiles to disk. All transformations
6
+ * live in transforms.js (shared with push).
7
+ *
8
+ * Also writes:
9
+ * - schema.sql — DDL of public.* (reference for writing SQL)
10
+ * - .gitignore — excludes .dypai/ cache from git
11
+ * - .dypai/state.json — per-endpoint updated_at for conflict detection on push
12
+ */
13
+
14
+ import { writeFile, mkdir, rm, access } from "fs/promises"
15
+ import { existsSync } from "fs"
16
+ import { join, resolve as resolvePath, dirname, isAbsolute, delimiter, sep } from "path"
17
+ import { homedir } from "os"
18
+ import YAML from "yaml"
19
+ import { proxyToolCall } from "../proxy.js"
20
+ import { serializeEndpoint } from "./codec.js"
21
+ import { dumpPublicSchema } from "./schema-dump.js"
22
+ import { regenerateTypes } from "../codegen.js"
23
+
24
+ /**
25
+ * Resolve a user-supplied out_dir. When an IDE spawns this MCP over stdio, the
26
+ * process cwd is usually the user's home dir, NOT the workspace the user sees.
27
+ * A relative `./foo` can land in ~/foo instead of the project root. We try
28
+ * several signals in order and warn the caller if the result still looks off.
29
+ */
30
+ function resolveOutDir(outDir) {
31
+ if (isAbsolute(outDir)) return { path: outDir, source: "absolute" }
32
+
33
+ // 1. Env vars set by common agent hosts (Claude Code, Cursor, Cline, etc.)
34
+ const envCandidates = [
35
+ ["CLAUDE_PROJECT_DIR", process.env.CLAUDE_PROJECT_DIR],
36
+ ["DYPAI_WORKSPACE_ROOT", process.env.DYPAI_WORKSPACE_ROOT],
37
+ ["WORKSPACE_FOLDER_PATHS", process.env.WORKSPACE_FOLDER_PATHS?.split(delimiter)[0]],
38
+ ["PROJECT_ROOT", process.env.PROJECT_ROOT],
39
+ ]
40
+ for (const [name, val] of envCandidates) {
41
+ if (val && isAbsolute(val)) return { path: resolvePath(val, outDir), source: `env:${name}` }
42
+ }
43
+
44
+ // 2. Walk up from cwd looking for a project marker (.git, package.json, dypai/)
45
+ let cursor = process.cwd()
46
+ for (let i = 0; i < 6; i++) {
47
+ if (existsSync(join(cursor, ".git")) ||
48
+ existsSync(join(cursor, "package.json")) ||
49
+ existsSync(join(cursor, "dypai"))) {
50
+ return { path: resolvePath(cursor, outDir), source: "project_marker" }
51
+ }
52
+ const parent = dirname(cursor)
53
+ if (parent === cursor) break
54
+ cursor = parent
55
+ }
56
+
57
+ // 3. Last resort — use cwd (probably ~/ when launched by IDE)
58
+ return { path: resolvePath(process.cwd(), outDir), source: "cwd_fallback" }
59
+ }
60
+
61
+ /** Return a warning string if the resolved path looks suspicious (e.g. sits directly in $HOME). */
62
+ function suspiciousPathWarning(resolvedPath, source) {
63
+ if (source === "absolute") return null
64
+ const home = homedir()
65
+ // If output_dir is ~/X (one level under home) and we had to fall back to cwd,
66
+ // the agent almost certainly didn't mean to write there.
67
+ if (source === "cwd_fallback" && resolvedPath.startsWith(home + sep)) {
68
+ const rel = resolvedPath.slice(home.length + 1)
69
+ if (!rel.includes(sep) || rel.split(sep).length <= 2) {
70
+ return (
71
+ `Output landed at ${resolvedPath}. The MCP process cwd is your home dir, not your project. ` +
72
+ `Re-run with an ABSOLUTE out_dir (e.g. ${home}/path/to/your-project/dypai) ` +
73
+ `or set the CLAUDE_PROJECT_DIR env var on the MCP entry.`
74
+ )
75
+ }
76
+ }
77
+ return null
78
+ }
79
+
80
+ // Subfolders that are always created so the layout is predictable. An agent
81
+ // never has to check "does this folder exist?" before writing a new SQL/prompt/JS file.
82
+ const CANONICAL_SUBDIRS = ["endpoints", "sql", "prompts", "code"]
83
+
84
+ const README_CONTENT = `# dypai/
85
+
86
+ Declarative snapshot of your DYPAI project's backend.
87
+
88
+ ## Layout
89
+
90
+ - \`endpoints/\` — one YAML per endpoint (the workflow definition).
91
+ Subfolders represent endpoint groups, e.g. \`endpoints/Admin/foo.yaml\` → group "Admin".
92
+ - \`sql/\` — SQL queries extracted from \`dypai_database\` nodes when longer than 500 chars.
93
+ - \`prompts/\` — system prompts extracted from \`agent\` nodes when longer than 800 chars.
94
+ - \`code/\` — JavaScript / Python extracted from \`javascript_code\` / \`python_code\` nodes when longer than 500 chars.
95
+
96
+ ## Workflow
97
+
98
+ 1. \`dypai_pull\` to snapshot the remote state into this folder
99
+ 2. Edit YAML, SQL, prompts, code with your editor or AI agent
100
+ 3. \`dypai_diff\` to preview changes
101
+ 4. \`dypai_push\` to apply to the remote
102
+
103
+ Paths inside YAML (e.g. \`query_file: sql/create_invoice.sql\`) are always relative
104
+ to this folder's root, regardless of where the YAML lives.
105
+ `
106
+
107
+ async function execSql(projectId, sql) {
108
+ const args = projectId ? { project_id: projectId, sql } : { sql }
109
+ const result = await proxyToolCall("execute_sql", args)
110
+ if (result?.error) throw new Error(`SQL error: ${result.error}`)
111
+ if (!result?.rows) {
112
+ throw new Error(`Unexpected SQL response: ${JSON.stringify(result).slice(0, 300)}`)
113
+ }
114
+ return result.rows
115
+ }
116
+
117
+ function parseMaybeJson(v) {
118
+ if (v == null || typeof v !== "string") return v
119
+ try { return JSON.parse(v) } catch { return v }
120
+ }
121
+
122
+ function hydrateRow(row) {
123
+ return {
124
+ ...row,
125
+ workflow_code: parseMaybeJson(row.workflow_code) || {},
126
+ input: parseMaybeJson(row.input),
127
+ output: parseMaybeJson(row.output),
128
+ allowed_roles: parseMaybeJson(row.allowed_roles) || row.allowed_roles,
129
+ }
130
+ }
131
+
132
+ async function writeFileEnsured(filePath, content) {
133
+ await mkdir(dirname(filePath), { recursive: true })
134
+ await writeFile(filePath, content, "utf8")
135
+ }
136
+
137
+ function renderYaml(doc) {
138
+ return YAML.stringify(doc, {
139
+ blockQuote: "literal",
140
+ lineWidth: 0,
141
+ singleQuote: false,
142
+ })
143
+ }
144
+
145
+ // schema.sql dump lives in ./schema-dump.js (shared with execute_sql auto-refresh)
146
+
147
+ export const dypaiPullTool = {
148
+ name: "dypai_pull",
149
+ description:
150
+ "Serializes the remote project state to local YAML files under ./dypai/. " +
151
+ "Writes endpoints/<name>.yaml + sql/ and prompts/ for extracted content. " +
152
+ "Canvas positions are stripped (regenerated by visual editor). Safe to run repeatedly. " +
153
+ "Use this to start editing a project locally with your editor + AI agent. " +
154
+ "IMPORTANT: when called by an IDE-hosted MCP, the process cwd is often the user's home dir — " +
155
+ "pass an ABSOLUTE path in out_dir (e.g. /Users/me/projects/my-app/dypai) to avoid writing to the wrong place. " +
156
+ "If out_dir is relative, the tool tries to auto-detect the workspace via env vars and git markers, " +
157
+ "and will surface a `warning` if the result looks suspicious.",
158
+ inputSchema: {
159
+ type: "object",
160
+ properties: {
161
+ project_id: {
162
+ type: "string",
163
+ description: "Project UUID. Optional if your token is project-scoped.",
164
+ },
165
+ out_dir: {
166
+ type: "string",
167
+ description:
168
+ "Output directory. PREFER ABSOLUTE PATHS (e.g. /Users/me/projects/x/dypai) because IDE-hosted " +
169
+ "MCP servers often have the user's home dir as cwd. If relative, the tool auto-detects the " +
170
+ "workspace via CLAUDE_PROJECT_DIR / WORKSPACE_FOLDER_PATHS env vars or by walking up to a .git/package.json marker.",
171
+ default: "./dypai",
172
+ },
173
+ clean: {
174
+ type: "boolean",
175
+ description: "If true, removes existing endpoints/, sql/, prompts/ before writing.",
176
+ default: false,
177
+ },
178
+ },
179
+ },
180
+
181
+ async execute({ project_id, out_dir = "./dypai", clean = false } = {}) {
182
+ const { path: outDir, source: outDirSource } = resolveOutDir(out_dir)
183
+ const suspiciousWarning = suspiciousPathWarning(outDir, outDirSource)
184
+
185
+ if (clean) {
186
+ await Promise.all(
187
+ CANONICAL_SUBDIRS.map(sub =>
188
+ rm(join(outDir, sub), { recursive: true, force: true })
189
+ )
190
+ )
191
+ }
192
+
193
+ // Always create the canonical subdirs (empty or not) so the layout is predictable
194
+ await Promise.all(
195
+ CANONICAL_SUBDIRS.map(async sub => {
196
+ const dir = join(outDir, sub)
197
+ await mkdir(dir, { recursive: true })
198
+ // .gitkeep lets empty folders survive git. Non-destructive: only create if missing.
199
+ const keepPath = join(dir, ".gitkeep")
200
+ try { await access(keepPath) } catch { await writeFile(keepPath, "", "utf8") }
201
+ })
202
+ )
203
+
204
+ // Top-level README (only write if missing — don't clobber user edits)
205
+ const readmePath = join(outDir, "README.md")
206
+ try { await access(readmePath) } catch { await writeFile(readmePath, README_CONTENT, "utf8") }
207
+
208
+ // .gitignore so the .dypai/ cache + OS junk doesn't leak to git
209
+ const gitignorePath = join(outDir, ".gitignore")
210
+ try { await access(gitignorePath) } catch {
211
+ await writeFile(gitignorePath, "# Auto-generated by dypai_pull\n.dypai/\n.DS_Store\n", "utf8")
212
+ }
213
+
214
+ const [endpoints, credentials, groups, schemaSql, nodeCatalogResult, realtimePolicies] = await Promise.all([
215
+ execSql(project_id, `
216
+ SELECT id, name, method, description, workflow_code, input, output,
217
+ allowed_roles, is_tool, tool_description, group_id, updated_at
218
+ FROM system.endpoints
219
+ WHERE is_active = true
220
+ ORDER BY name
221
+ `),
222
+ execSql(project_id, "SELECT id, name, type FROM system.credentials"),
223
+ execSql(project_id, "SELECT id, name FROM system.endpoints_group"),
224
+ dumpPublicSchema(project_id),
225
+ // Dump the global node catalog (central control plane) in one call.
226
+ // The per-project system.node_catalog is a stripped copy with empty schemas.
227
+ // The remote endpoints_sdk is project_aware, so it needs project_id even
228
+ // though the catalog itself is global.
229
+ proxyToolCall("list_node_catalog", project_id ? { project_id } : {}).catch(e => {
230
+ console.error(`[dypai_pull] list_node_catalog failed: ${e.message}`)
231
+ return { nodes: [] }
232
+ }),
233
+ // Realtime policies — deny-safe by default. Table may not exist yet on
234
+ // older engines; the catch keeps pull working.
235
+ execSql(project_id, `
236
+ SELECT target_type, target_name, subscribe_filter,
237
+ events, required_role, auth_required
238
+ FROM system.realtime_policies
239
+ ORDER BY target_type, target_name
240
+ `).catch(() => []),
241
+ ])
242
+
243
+ // schema.sql: always regenerated (read-only reference)
244
+ await writeFile(join(outDir, "schema.sql"), schemaSql, "utf8")
245
+
246
+ // node-catalog.json: full schemas of every node this engine exposes.
247
+ // Committed so the validator works offline right after a git clone.
248
+ const catalogNodes = nodeCatalogResult?.nodes || []
249
+ const catalogDoc = {
250
+ cached_at: new Date().toISOString(),
251
+ source: "public.node_catalog (central control plane)",
252
+ total: catalogNodes.length,
253
+ nodes: Object.fromEntries(catalogNodes.map(n => [n.node_type || n.id, {
254
+ label: n.name,
255
+ description: n.description,
256
+ category: n.category,
257
+ provider: n.provider,
258
+ inputs: parseMaybeJson(n.input_schema) || {},
259
+ outputs: parseMaybeJson(n.output_schema) || {},
260
+ }])),
261
+ }
262
+ await writeFile(join(outDir, "node-catalog.json"), JSON.stringify(catalogDoc, null, 2) + "\n", "utf8")
263
+
264
+ const mapsCtx = {
265
+ credIdToName: Object.fromEntries(credentials.map(c => [c.id, c.name])),
266
+ groupIdToName: Object.fromEntries(groups.map(g => [g.id, g.name])),
267
+ endpointIdToName: Object.fromEntries(endpoints.map(e => [e.id, e.name])),
268
+ }
269
+
270
+ const filesWritten = []
271
+ const errors = []
272
+
273
+ // realtime.yaml: declarative access policies for WebSocket subscriptions.
274
+ // Committed so team members / CI can see who's allowed to subscribe to what.
275
+ // Missing → realtime is deny-by-default on the engine (only service_role).
276
+ if (Array.isArray(realtimePolicies) && realtimePolicies.length > 0) {
277
+ const realtimeDoc = { tables: {}, channels: {} }
278
+ for (const p of realtimePolicies) {
279
+ const entry = {}
280
+ if (p.subscribe_filter) entry.subscribe_filter = p.subscribe_filter
281
+ if (Array.isArray(p.events) && p.events.length) entry.events = p.events
282
+ if (p.required_role) entry.required_role = p.required_role
283
+ if (p.auth_required === false) entry.auth_required = false
284
+ const bucket = p.target_type === "channel" ? "channels" : "tables"
285
+ realtimeDoc[bucket][p.target_name] = entry
286
+ }
287
+ if (!Object.keys(realtimeDoc.channels).length) delete realtimeDoc.channels
288
+ if (!Object.keys(realtimeDoc.tables).length) delete realtimeDoc.tables
289
+ await writeFile(join(outDir, "realtime.yaml"),
290
+ "# Declarative realtime access policies. Tables / channels NOT listed here are deny-by-default.\n" +
291
+ "# Placeholders: ${current_user_id}, ${current_user_role}, ${channel_param} (for wildcard channels).\n" +
292
+ renderYaml(realtimeDoc),
293
+ "utf8")
294
+ filesWritten.push("realtime.yaml")
295
+ }
296
+
297
+ for (const rawRow of endpoints) {
298
+ const row = hydrateRow(rawRow)
299
+ try {
300
+ const { doc, extractedFiles } = serializeEndpoint(row, mapsCtx)
301
+
302
+ // Group → folder convention: path encodes the group, so strip the field
303
+ const groupName = doc.group
304
+ delete doc.group
305
+
306
+ for (const f of extractedFiles) {
307
+ await writeFileEnsured(join(outDir, f.path), f.content)
308
+ filesWritten.push(f.path)
309
+ }
310
+
311
+ const relPath = groupName
312
+ ? `endpoints/${groupName}/${row.name}.yaml`
313
+ : `endpoints/${row.name}.yaml`
314
+ await writeFileEnsured(join(outDir, relPath), renderYaml(doc))
315
+ filesWritten.push(relPath)
316
+ } catch (e) {
317
+ errors.push({ endpoint: row.name, error: e.message })
318
+ }
319
+ }
320
+
321
+ // Fetch project metadata to persist identity in the committed config
322
+ let projectInfo = null
323
+ if (project_id) {
324
+ try {
325
+ projectInfo = await proxyToolCall("get_project", { project_id })
326
+ } catch { /* non-fatal; we still pin the id we know */ }
327
+ }
328
+ const resolvedProjectId = projectInfo?.id || project_id || null
329
+
330
+ // dypai.config.yaml: COMMITTED — identifies which project this folder belongs to.
331
+ // Serves two purposes:
332
+ // 1. A cloner/CI knows what project to push to.
333
+ // 2. dypai_push refuses to push to a different project_id (hallucination guard).
334
+ const configPath = join(outDir, "dypai.config.yaml")
335
+ const configDoc = { project_id: resolvedProjectId }
336
+ if (projectInfo?.name) configDoc.project_name = projectInfo.name
337
+ if (projectInfo?.organization_name) configDoc.organization = projectInfo.organization_name
338
+ const configYaml =
339
+ "# DYPAI project configuration — commit this file.\n" +
340
+ "# It identifies which remote project this dypai/ folder belongs to.\n" +
341
+ "# Never edit by hand unless you KNOW you're retargeting to a different project.\n" +
342
+ renderYaml(configDoc)
343
+ await writeFile(configPath, configYaml, "utf8")
344
+
345
+ // .dypai/state.json: GITIGNORED — per-endpoint updated_at for conflict detection.
346
+ const state = {
347
+ pulled_at: new Date().toISOString(),
348
+ project_id: resolvedProjectId,
349
+ endpoints: Object.fromEntries(
350
+ endpoints.map(e => [e.name, { id: e.id, updated_at: e.updated_at }])
351
+ ),
352
+ }
353
+ await writeFileEnsured(join(outDir, ".dypai", "state.json"), JSON.stringify(state, null, 2) + "\n")
354
+
355
+ // Auto-regenerate TS types for the frontend (src/dypai/). Non-fatal on failure.
356
+ let codegen
357
+ try { codegen = await regenerateTypes(outDir) }
358
+ catch (e) { codegen = { skipped: true, reason: e.message } }
359
+
360
+ // Compact overview — replaces what dypai_describe used to print, but
361
+ // derived from the data we already have in memory (zero extra queries).
362
+ // Lets the agent skip the "call describe then pull" dance.
363
+ const byGroup = (endpoints || []).reduce((acc, e) => {
364
+ const groupName = e.group_id ? (mapsCtx.groupIdToName[e.group_id] || "(unknown)") : "(no group)"
365
+ if (!acc[groupName]) acc[groupName] = []
366
+ const nodeCount = Array.isArray(e.workflow_code?.nodes) ? e.workflow_code.nodes.length : 0
367
+ acc[groupName].push({ name: e.name, method: e.method, nodes: nodeCount, is_tool: !!e.is_tool })
368
+ return acc
369
+ }, {})
370
+ const toolEndpoints = (endpoints || [])
371
+ .filter(e => e.is_tool)
372
+ .map(e => ({ name: e.name, description: e.tool_description || null }))
373
+
374
+ const overview = {
375
+ project: projectInfo ? {
376
+ id: projectInfo.id,
377
+ name: projectInfo.name,
378
+ plan: projectInfo.plan,
379
+ } : { id: resolvedProjectId || "(from token)" },
380
+ endpoints: {
381
+ total: (endpoints || []).length,
382
+ groups: Object.keys(byGroup).filter(g => g !== "(no group)").sort(),
383
+ by_group: byGroup,
384
+ tool_endpoints: toolEndpoints,
385
+ },
386
+ credentials: (credentials || []).map(c => ({ name: c.name, type: c.type })),
387
+ realtime_policies: (realtimePolicies || []).length,
388
+ next_steps: (endpoints || []).length === 0
389
+ ? ["Empty project. Create tables via execute_sql, then write dypai/endpoints/<name>.yaml and dypai_push."]
390
+ : ["Read dypai/schema.sql before writing queries.", "Edit YAML in dypai/endpoints/, then dypai_diff → dypai_push."],
391
+ }
392
+
393
+ return {
394
+ success: errors.length === 0,
395
+ endpoints: endpoints.length,
396
+ files_written: filesWritten.length,
397
+ output_dir: outDir,
398
+ out_dir_resolved_via: outDirSource,
399
+ overview,
400
+ codegen: codegen?.regenerated
401
+ ? { output_dir: codegen.output_dir, files: codegen.files_written.length }
402
+ : (codegen?.reason || undefined),
403
+ errors: errors.length ? errors : undefined,
404
+ warning: suspiciousWarning || undefined,
405
+ hint: errors.length
406
+ ? "Some endpoints failed to serialize. Check errors[] — usually malformed workflow_code."
407
+ : suspiciousWarning
408
+ ? "Files were written but the path looks wrong. See `warning` above and re-run with an absolute out_dir."
409
+ : endpoints.length === 0
410
+ ? "Empty project. Create tables with execute_sql, then write endpoints/<name>.yaml and dypai_push."
411
+ : undefined,
412
+ }
413
+ },
414
+ }