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