@dypai-ai/mcp 1.5.16 → 1.5.18

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dypai-ai/mcp",
3
- "version": "1.5.16",
3
+ "version": "1.5.18",
4
4
  "description": "DYPAI MCP Server — AI agent toolkit for building and deploying full-stack apps",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -26,7 +26,7 @@
26
26
  */
27
27
 
28
28
  import { createHash } from "crypto"
29
- import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdirSync } from "fs"
29
+ import { readFileSync, readdirSync, statSync, lstatSync, existsSync, writeFileSync, mkdirSync } from "fs"
30
30
  import { join, basename, dirname, resolve } from "path"
31
31
  import { api } from "../api.js"
32
32
 
@@ -37,7 +37,8 @@ const MAX_BUNDLED_FILE = 25 * 1024 * 1024
37
37
 
38
38
  // ─── Directories to skip ────────────────────────────────────────────────────
39
39
 
40
- const IGNORE_DIRS = new Set([
40
+ // Skipped at any depth in the tree — these never carry user code.
41
+ const IGNORE_DIRS_ANYWHERE = new Set([
41
42
  "node_modules", ".git",
42
43
  // Build outputs
43
44
  "dist", "build", "out", ".output", ".vercel", ".netlify",
@@ -46,8 +47,16 @@ const IGNORE_DIRS = new Set([
46
47
  ".cache", ".turbo", ".vite", ".parcel-cache", ".wrangler",
47
48
  // Test / misc
48
49
  "coverage", "storybook-static", "__pycache__", ".idea", ".vscode",
49
- // DYPAI backend metadata — handled by dypai_pull/push, not shipped to the
50
- // frontend build.
50
+ ])
51
+
52
+ // Skipped ONLY at the project root. `dypai/` at root holds backend metadata
53
+ // (endpoint YAMLs, schema.sql, prompts) materialized by dypai_pull and
54
+ // shipped via dypai_push — it must NOT enter the frontend deploy. But a
55
+ // nested folder named `dypai/` (e.g. `src/integrations/dypai/`) is legitimate
56
+ // user code — the SDK client setup lives there. Matching by name alone (the
57
+ // previous behavior) silently dropped that folder and produced
58
+ // "Could not resolve '../integrations/dypai/client'" build errors.
59
+ const IGNORE_DIRS_AT_ROOT = new Set([
51
60
  "dypai",
52
61
  ])
53
62
 
@@ -220,7 +229,7 @@ function classifySkip(path, ext, size) {
220
229
  return null
221
230
  }
222
231
 
223
- function collectSource(dir) {
232
+ export function collectSource(dir) {
224
233
  const allFiles = []
225
234
  const skipped = []
226
235
  const textByPath = new Map()
@@ -231,10 +240,18 @@ function collectSource(dir) {
231
240
  let entries
232
241
  try { entries = readdirSync(d) } catch { return }
233
242
  for (const entry of entries) {
234
- if (IGNORE_DIRS.has(entry)) continue
243
+ if (IGNORE_DIRS_ANYWHERE.has(entry)) continue
244
+ if (rel === "" && IGNORE_DIRS_AT_ROOT.has(entry)) continue
235
245
  const full = join(d, entry)
236
246
  try {
237
- const stat = statSync(full)
247
+ // lstat (not stat): a symlink should be classified as a symlink, NOT
248
+ // as the target it points to. Otherwise a folder like `src/sneaky →
249
+ // /Users/me/something` would be walked and code outside the project
250
+ // root would be quietly committed to the user's repo on deploy.
251
+ // Symlinks are skipped outright — DYPAI projects don't need them
252
+ // and there is no safe automatic policy for following them.
253
+ const stat = lstatSync(full)
254
+ if (stat.isSymbolicLink()) continue
238
255
  if (stat.isDirectory()) {
239
256
  if (entry.startsWith(".")) continue
240
257
  walk(full, rel ? `${rel}/${entry}` : entry)
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { writeFileSync, mkdirSync, existsSync } from "fs"
9
- import { join } from "path"
9
+ import { join, dirname } from "path"
10
10
  import { api } from "../api.js"
11
11
 
12
12
  export const scaffoldTool = {
@@ -70,7 +70,7 @@ Use only visible Studio catalog template slugs; do not invent legacy slugs.`,
70
70
  name: directory.split("/").pop() || "my-app",
71
71
  private: true, version: "0.0.1", type: "module",
72
72
  scripts: { dev: "vite", build: "vite build", preview: "vite preview" },
73
- dependencies: { "@dypai-ai/client-sdk": "latest", react: "^19.0.0", "react-dom": "^19.0.0" },
73
+ dependencies: { "@dypai-ai/client-sdk": "1.11.0", react: "^19.0.0", "react-dom": "^19.0.0" },
74
74
  devDependencies: { "@vitejs/plugin-react": "^4.3.0", vite: "^6.0.0", typescript: "^5.6.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0" },
75
75
  }, null, 2) },
76
76
  { path: "vite.config.ts", content: `import { defineConfig } from 'vite'\nimport react from '@vitejs/plugin-react'\n\nexport default defineConfig({ plugins: [react()] })\n` },
@@ -116,12 +116,13 @@ Use only visible Studio catalog template slugs; do not invent legacy slugs.`,
116
116
  // SDK client helper (lib/dypai.ts)
117
117
  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` })
118
118
 
119
- // Write files to disk
119
+ // Write files to disk. Use `dirname()` rather than slicing on "/" — on
120
+ // Windows the path separator is "\" so the slice approach produced a
121
+ // garbage parent dir and the writeFileSync below failed with ENOENT.
120
122
  let created = 0
121
123
  for (const file of files) {
122
124
  const fullPath = join(directory, file.path)
123
- const dir = fullPath.substring(0, fullPath.lastIndexOf("/"))
124
- mkdirSync(dir, { recursive: true })
125
+ mkdirSync(dirname(fullPath), { recursive: true })
125
126
  writeFileSync(fullPath, file.content)
126
127
  created++
127
128
  }
@@ -503,6 +503,28 @@ function renderYaml(doc) {
503
503
  })
504
504
  }
505
505
 
506
+ /**
507
+ * Build the state.json payload, restricted to endpoints that were actually
508
+ * written to disk during this pull. The push planner uses this snapshot to
509
+ * detect remote drift since pull (compares remote.updated_at against
510
+ * snapshot.updated_at per endpoint). Including endpoints that failed to
511
+ * serialize would create a phantom "we have a fresh copy of this" claim and
512
+ * silently mask real conflicts on those rows.
513
+ *
514
+ * Exported for unit testing.
515
+ */
516
+ export function buildStateSnapshot({ endpoints, successfullyPulledIds, projectId, now = new Date() }) {
517
+ return {
518
+ pulled_at: now.toISOString(),
519
+ project_id: projectId,
520
+ endpoints: Object.fromEntries(
521
+ endpoints
522
+ .filter(e => successfullyPulledIds.has(e.id))
523
+ .map(e => [e.name, { id: e.id, updated_at: e.updated_at }])
524
+ ),
525
+ }
526
+ }
527
+
506
528
  // schema.sql dump lives in ./schema-dump.js (shared with execute_sql auto-refresh)
507
529
 
508
530
  export const dypaiPullTool = {
@@ -668,6 +690,15 @@ export const dypaiPullTool = {
668
690
  filesWritten.push("realtime.yaml")
669
691
  }
670
692
 
693
+ // Track which endpoints survived serialization → only those go into
694
+ // state.json. Including failed ones would lie to the push planner about
695
+ // what's locally in sync with remote (the conflict detector compares
696
+ // remote.updated_at against the snapshot per endpoint, and a phantom
697
+ // snapshot row for an endpoint that never made it to disk hides a real
698
+ // conflict). Tracked by row id so endpoint renames at the same name
699
+ // can't accidentally inherit a stale snapshot.
700
+ const successfullyPulled = new Set()
701
+
671
702
  for (const rawRow of endpoints) {
672
703
  const row = hydrateRow(rawRow)
673
704
  try {
@@ -702,6 +733,7 @@ export const dypaiPullTool = {
702
733
 
703
734
  await writeFileEnsured(join(outDir, relPath), content)
704
735
  filesWritten.push(relPath)
736
+ successfullyPulled.add(row.id)
705
737
  } catch (e) {
706
738
  errors.push({ endpoint: row.name, error: e.message })
707
739
  }
@@ -756,13 +788,11 @@ export const dypaiPullTool = {
756
788
  await writeFile(configPath, configYaml, "utf8")
757
789
 
758
790
  // .dypai/state.json: GITIGNORED — per-endpoint updated_at for conflict detection.
759
- const state = {
760
- pulled_at: new Date().toISOString(),
761
- project_id: resolvedProjectId,
762
- endpoints: Object.fromEntries(
763
- endpoints.map(e => [e.name, { id: e.id, updated_at: e.updated_at }])
764
- ),
765
- }
791
+ const state = buildStateSnapshot({
792
+ endpoints,
793
+ successfullyPulledIds: successfullyPulled,
794
+ projectId: resolvedProjectId,
795
+ })
766
796
  await writeFileEnsured(join(outDir, ".dypai", "state.json"), JSON.stringify(state, null, 2) + "\n")
767
797
 
768
798
  // Codegen removed from v1. If we reintroduce it, this is where it wires in.
@@ -316,6 +316,53 @@ async function readSchemaColumns(rootDir) {
316
316
  }
317
317
 
318
318
  /** Extract referenced table names from a SQL string: `FROM public.X`, `JOIN public.X`, `INTO public.X`, `UPDATE public.X`. */
319
+ function skipSqlBalancedParens(sql, start) {
320
+ let depth = 0
321
+ for (let i = start; i < sql.length; i++) {
322
+ const ch = sql[i]
323
+ if (ch === "\"") {
324
+ i++
325
+ while (i < sql.length && sql[i] !== "\"") i++
326
+ continue
327
+ }
328
+ if (ch === "(") depth++
329
+ else if (ch === ")") {
330
+ depth--
331
+ if (depth === 0) return i + 1
332
+ }
333
+ }
334
+ return sql.length
335
+ }
336
+
337
+ function extractSqlCteNames(cleanSql) {
338
+ const names = new Set()
339
+ const withMatch = /^\s*WITH\s+(?:RECURSIVE\s+)?/i.exec(cleanSql)
340
+ if (!withMatch) return names
341
+ let i = withMatch[0].length
342
+ while (i < cleanSql.length) {
343
+ while (/\s|,/.test(cleanSql[i] || "")) i++
344
+ const nameMatch = /^"?([A-Za-z_][A-Za-z0-9_]*)"?/.exec(cleanSql.slice(i))
345
+ if (!nameMatch) break
346
+ const cteName = nameMatch[1]
347
+ if (SQL_KEYWORDS_AFTER_FROM.has(cteName.toUpperCase())) break
348
+ i += nameMatch[0].length
349
+ while (/\s/.test(cleanSql[i] || "")) i++
350
+ if (cleanSql[i] === "(") {
351
+ i = skipSqlBalancedParens(cleanSql, i)
352
+ while (/\s/.test(cleanSql[i] || "")) i++
353
+ }
354
+ if (!/^AS\b/i.test(cleanSql.slice(i))) break
355
+ i += 2
356
+ while (/\s/.test(cleanSql[i] || "")) i++
357
+ if (cleanSql[i] !== "(") break
358
+ names.add(cteName)
359
+ i = skipSqlBalancedParens(cleanSql, i)
360
+ while (/\s/.test(cleanSql[i] || "")) i++
361
+ if (cleanSql[i] !== ",") break
362
+ }
363
+ return names
364
+ }
365
+
319
366
  function extractSqlTables(sql) {
320
367
  const tables = new Set()
321
368
  if (typeof sql !== "string" || sql.length === 0) return tables
@@ -325,6 +372,7 @@ function extractSqlTables(sql) {
325
372
  .replace(/--[^\n]*/g, " ")
326
373
  .replace(/\/\*[\s\S]*?\*\//g, " ")
327
374
  .replace(/'(?:[^']|'')*'/g, "''")
375
+ const cteNames = extractSqlCteNames(clean)
328
376
  // Single regex that captures BOTH a possible schema and the table name,
329
377
  // optionally preceded by `ONLY` (Postgres inheritance modifier). Splitting
330
378
  // schema/table into separate groups makes filtering by schema trivial.
@@ -339,6 +387,7 @@ function extractSqlTables(sql) {
339
387
  // when the regex was greedy enough (defensive — the optional `ONLY` above
340
388
  // already handles the common case).
341
389
  if (SQL_KEYWORDS_AFTER_FROM.has(tableName.toUpperCase())) continue
390
+ if (cteNames.has(tableName)) continue
342
391
  // Only validate tables in the user-managed `public` schema. System
343
392
  // schemas (auth, system, ext, pg_catalog, information_schema) are
344
393
  // managed by the engine and not present in dypai/schema.sql.