@dypai-ai/mcp 1.5.7 → 1.5.9

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.7",
3
+ "version": "1.5.9",
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",
package/src/index.js CHANGED
@@ -115,8 +115,38 @@ const REMOTE_TOOLS = [
115
115
  // ── Project ───────────────────────────────────────────────────────────────
116
116
  { name: "list_projects", description: "Lists all projects you have access to across your organizations. Returns project id, name, description, organization, subscription plan, and status. Use this as the first step to discover which projects are available, then pass project_id to other tools.", inputSchema: { type: "object", properties: { organization_id: { type: "string", description: "Optional. Filter projects by organization UUID." } }, required: [] } },
117
117
  { name: "get_project", description: "Gets detailed information about a specific project. Returns project name, description, organization, plan, status, engine URL, frontend slug, and timestamps.", inputSchema: { type: "object", properties: { project_id: { type: "string" } }, required: ["project_id"] } },
118
- { name: "list_ai_models", description: "List only the DYPAI Managed AI models that are active for a project. Returns the project-gated OpenRouter model catalog, monthly included AI credits, monthly hard cap, RPM limit, max output tokens, active/available counts, and the exact node parameters to use. Call this before creating or editing an AI Agent node with DYPAI Managed models. Agents must not invent or use inactive model ids. Use provider='openrouter' and do NOT set credential_id; DYPAI uses the platform OpenRouter key and bills usage to the organization.", inputSchema: { type: "object", properties: { project_id: { type: "string", description: "Project UUID whose plan and Model Gateway settings determine the active Managed AI catalog." } }, required: ["project_id"] } },
119
- { name: "create_project", description: "Create a new DYPAI project (free plan). Creates a full project with database, engine, GitHub repo, and frontend hosting. BLOCKS by default until provisioning finishes (~60s typical, 120s max) — when it returns, the project_id is ready to use with execute_sql, endpoint tools, etc. Pass wait_until_ready:false for batch flows.\n\nName collision: if another project in the same org already uses the name (case-insensitive), returns {error:'name_taken', existing_project_id, suggestions:[...]}. Pick a different name or use the existing project.\n\nIMPORTANT: before calling, check for a matching template with `search_project_templates`. Passing a `template_slug` drops in a ready-made schema + endpoints + UI that cover 70% of common app types. Only create a blank project if nothing matches.", inputSchema: { type: "object", properties: { name: { type: "string", description: "Project name (e.g. 'My Veterinary App')" }, organization_id: { type: "string", description: "Optional. Uses default org if omitted." }, description: { type: "string" }, template_slug: { type: "string", description: "RECOMMENDED. Project template slug to start from (e.g. 'clinic', 'gym', 'waitlist', 'blank'). Always call search_project_templates first to find the best match." }, wait_until_ready: { type: "boolean", description: "If true (default), blocks until provisioning completes and the project is ready for all operations. If false, returns immediately with status='provisioning' — caller must poll get_project before using.", default: true } }, required: ["name"] } },
118
+ {
119
+ name: "manage_project_access_profile",
120
+ description: `Read or update the project's product access profile.
121
+
122
+ Use this when you know what kind of app is being built:
123
+ - private/admin tool: app_visibility='private', auth_scope='admin_only', has_admin_area=true, root_requires_auth=true
124
+ - public landing with admin panel: app_visibility='mixed', auth_scope='admin_only', has_public_area=true, has_admin_area=true
125
+ - customer/user portal: app_visibility='private' or 'mixed', auth_scope='end_users' or 'admin_and_end_users', has_end_user_accounts=true
126
+ - public-only site: app_visibility='public', auth_scope='none', role_model='none', root_requires_auth=false
127
+
128
+ This stores classification metadata only. It does not create users, roles, login UI, tables, endpoints, or publish anything.`,
129
+ inputSchema: {
130
+ type: "object",
131
+ properties: {
132
+ project_id: { type: "string", description: "Project UUID. Required for user tokens; auto-detected for project tokens." },
133
+ operation: { type: "string", enum: ["get", "update"], description: "Read or update the project access classification." },
134
+ app_visibility: { type: "string", enum: ["public", "private", "mixed"], description: "Public surface of the app." },
135
+ auth_scope: { type: "string", enum: ["none", "admin_only", "internal_users", "end_users", "admin_and_end_users"], description: "Who needs authentication." },
136
+ role_model: { type: "string", enum: ["none", "single_role", "multi_role"], description: "Whether the app has no roles, one role, or multiple roles." },
137
+ has_admin_area: { type: "boolean", description: "Whether the app should include an admin/private management area." },
138
+ has_public_area: { type: "boolean", description: "Whether the app should include pages usable without login." },
139
+ has_end_user_accounts: { type: "boolean", description: "Whether non-admin end users have their own accounts." },
140
+ root_requires_auth: { type: "boolean", description: "Whether the root route should require login." },
141
+ metadata_patch: { type: "object", description: "Optional non-sensitive notes to merge into access_metadata. Sensitive-looking keys are dropped." },
142
+ reason: { type: "string", description: "Short reason for the update." },
143
+ source: { type: "string", description: "Optional caller label. Defaults to mcp." },
144
+ },
145
+ required: ["operation"],
146
+ },
147
+ },
148
+ { name: "list_ai_models", description: "List only the DYPAI Managed AI models that are active for a project. Returns the project-gated OpenRouter model catalog priced in AI Credits per 1M tokens, RPM limit, max output tokens, active/available counts, billing metadata, and the exact node parameters to use. Call this before creating or editing an AI Agent node with DYPAI Managed models. Agents must not invent or use inactive model ids. Use provider='openrouter' and do NOT set credential_id; DYPAI uses the platform OpenRouter key and deducts usage from the organization's AI Credits.", inputSchema: { type: "object", properties: { project_id: { type: "string", description: "Project UUID whose plan and Model Gateway settings determine the active Managed AI catalog." } }, required: ["project_id"] } },
149
+ { name: "create_project", description: "Create a new DYPAI project (free plan). Creates a full project with database, engine, GitHub repo, and frontend hosting. BLOCKS by default until provisioning finishes (~60s typical, 120s max) — when it returns, the project_id is ready to use with execute_sql, endpoint tools, etc. Pass wait_until_ready:false for batch flows.\n\nName collision: if another project in the same org already uses the name (case-insensitive), returns {error:'name_taken', existing_project_id, suggestions:[...]}. Pick a different name or use the existing project.\n\nProject limits are enforced by the DYPAI API at organization/workspace scope according to the workspace plan. If it returns {error:'project_limit_reached'}, do not retry create_project; show list_projects for that organization and ask the user to reuse, archive/pause, upgrade the workspace to Pro, or add capacity.\n\nIMPORTANT: before calling, check for a matching template with `search_project_templates`. Passing a `template_slug` drops in a ready-made schema + endpoints + UI that cover 70% of common app types. Use built-in bases when appropriate: `private-admin` for private internal tools, `user-accounts` for apps with signup/login users, `landing-admin` for public landing plus admin, and `blank` only when no base fits.", inputSchema: { type: "object", properties: { name: { type: "string", description: "Project name (e.g. 'My Veterinary App')" }, organization_id: { type: "string", description: "Optional. Uses default org if omitted." }, description: { type: "string" }, template_slug: { type: "string", description: "RECOMMENDED. Project template slug to start from (e.g. 'clinic', 'gym', 'private-admin', 'user-accounts', 'landing-admin', 'blank'). Always call search_project_templates first to find the best match." }, wait_until_ready: { type: "boolean", description: "If true (default), blocks until provisioning completes and the project is ready for all operations. If false, returns immediately with status='provisioning' — caller must poll get_project before using.", default: true } }, required: ["name"] } },
120
150
  { name: "get_app_credentials", description: "Lists available credentials in the current application. Returns API keys, anon key, service role key, and engine URL needed for SDK configuration.", inputSchema: { type: "object", properties: { project_id: { type: "string" } }, required: [] } },
121
151
 
122
152
  // ── Database ──────────────────────────────────────────────────────────────
@@ -437,15 +467,16 @@ endpoint YAML and \`dypai_push\`. This tool does NOT modify the definition.`,
437
467
  // ── Observability ─────────────────────────────────────────────────────────
438
468
  {
439
469
  name: "search_logs",
440
- description: "Search recent errors and warnings for the current project. ALWAYS call this FIRST when the user reports any error, bug, or 'this isn't working' — don't guess from the code; check what actually broke. Returns a unified, time-ordered list mixing failed workflow executions and warn/error log lines from the engine. Defaults to the last 24h. Data retention: 7 days.\n\nWorkflow:\n 1) Call with no args (or just `since:'1h'`) → see recent failures.\n 2) Pick the relevant entry → call again with `endpoint` + tighter `query` to narrow down.\n 3) For the full step-by-step debug trace of a specific failure, set `include_trace:true` (response is much larger; you'll likely get a `file_path` to read the full JSON from disk).\n\nUse `environment:'live'` when investigating a production user complaint (excludes draft test runs). Use `environment:'draft'` when the user says 'I just tested X locally and it failed' (their local UI hits the draft overlay).",
470
+ description: "Search recent backend activity for the current project. ALWAYS call this FIRST when the user reports any error, bug, 'this isn't working', or a click/action that appears to do nothing — don't guess from the code; check what actually happened. Returns a unified, time-ordered list mixing workflow executions and warn/error log lines from the engine. Defaults to recent problems only (`status:'problem'`) over the last 24h. Raw data is retained 7 days and cleaned automatically by metrics-db retention policies.\n\nWorkflow:\n 1) Call with no args (or just `since:'1h'`) → see recent failures/warnings.\n 2) If the user says a button/action did nothing and there is no visible error → call `search_logs({ since:'30m', status:'all', endpoint:'...' })` or `status:'success'` to see successful backend calls too.\n 3) Pick the relevant entry → call again with `endpoint` + tighter `query` to narrow down.\n 4) For the full step-by-step debug trace of a specific failure, set `include_trace:true` (response is much larger; you'll likely get a `file_path` to read the full JSON from disk).\n\nUse `environment:'live'` when investigating a production user complaint (excludes draft overlay test runs). Use `environment:'draft'` when the user says 'I just tested X locally and it failed' (their local UI hits the draft overlay).",
441
471
  inputSchema: {
442
472
  type: "object",
443
473
  properties: {
444
474
  project_id: { type: "string", description: "Project UUID. Auto-detected for project tokens." },
445
- query: { type: "string", description: "Optional substring to match (case-insensitive) in error messages and log lines. e.g. 'timeout', 'OpenAI', 'permission denied'." },
475
+ query: { type: "string", description: "Optional substring to match (case-insensitive) in messages, endpoint names, statuses, request IDs, and log lines. e.g. 'timeout', 'OpenAI', 'permission denied'." },
446
476
  endpoint: { type: "string", description: "Optional endpoint name filter (e.g. 'create-order')." },
447
477
  since: { type: "string", default: "24h", description: "Time window: relative ('15m', '1h', '24h', '7d') or ISO 8601 timestamp. Default 24h. Hard cap: 7d (retention)." },
448
- level: { type: "string", enum: ["error", "warn", "all"], default: "all", description: "Filter by severity. 'error' includes failed/timeout executions + error logs. 'warn' is warning logs. 'all' (default) returns both." },
478
+ level: { type: "string", enum: ["error", "warn", "all"], default: "all", description: "Filter by severity. 'error' includes failed/timeout executions + error logs. 'warn' returns warning logs only. 'all' returns every severity allowed by `status`." },
479
+ status: { type: "string", enum: ["problem", "success", "error", "timeout", "all"], default: "problem", description: "Workflow execution status filter. 'problem' (default) keeps the old lightweight behavior: failed/timed-out executions plus warn/error logs. 'success' shows successful executions only. 'all' shows successful and failed executions plus warn/error logs. Use 'success' or 'all' when debugging a user action that produced no visible backend error." },
449
480
  environment: { type: "string", enum: ["live", "draft", "all"], default: "all", description: "live = production traffic only (excludes draft overlay test runs). draft = only requests through dev-<project_id>.dypai.dev. all = both. Use 'live' for real user bug reports." },
450
481
  limit: { type: "integer", default: 50, minimum: 1, maximum: 200, description: "Max items to return. Default 50, max 200." },
451
482
  include_trace: { type: "boolean", default: false, description: "Attach the full step-by-step debug trace per failed execution. Verbose — combine with `query`/`endpoint` filters and a low `limit`. If the response gets large, the local proxy writes it to disk and returns a file_path you can Read." }
@@ -456,8 +487,9 @@ endpoint YAML and \`dypai_push\`. This tool does NOT modify the definition.`,
456
487
 
457
488
  // ── Knowledge ─────────────────────────────────────────────────────────────
458
489
  { name: "search_docs", description: "Search DYPAI documentation. Use this when unsure about SDK usage, auth patterns, workflow nodes, or platform features. Returns relevant documentation chunks.", inputSchema: { type: "object", properties: { query: { type: "string", description: "What you want to learn about" } }, required: ["query"] } },
490
+ { name: "search_design_patterns", description: "Search compact DYPAI UI/design recipes. Use before designing substantial screens.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Design need, with starter/domain/screen/style context when known." }, starter_slug: { type: "string", description: "Optional: private-admin, user-accounts, landing-admin, or blank." }, app_type: { type: "string", description: "Optional domain/app type." }, screen_type: { type: "string", description: "Optional screen/workflow." }, visual_style: { type: "string", description: "Optional style." }, category: { type: "string", description: "Optional category." }, limit: { type: "integer", default: 3, minimum: 1, maximum: 4 } }, required: ["query"] } },
459
491
  { name: "search_workflow_templates", description: "Search workflow templates by description. Returns ready-to-use workflow code for common patterns: CRUD operations, payment gateways, email sending, AI chatbots, data pipelines, etc.", inputSchema: { type: "object", properties: { query: { type: "string", description: "What the workflow should do (e.g. 'send email', 'stripe payment')" }, category: { type: "string", description: "Optional: AI, Database, Payments, Communication, Logic, Storage" } }, required: ["query"] } },
460
- { name: "search_project_templates", description: "Search project starter templates by description. Returns template metadata and slugs for starters like clinic, gym, waitlist, blank, auth, or landing.", inputSchema: { type: "object", properties: { query: { type: "string", description: "What kind of project starter you need (e.g. 'gym app', 'landing page', 'auth starter')" }, category: { type: "string", description: "Optional category filter" } }, required: ["query"] } },
492
+ { name: "search_project_templates", description: "Search project starter templates by description. Returns template metadata and slugs for marketplace templates plus built-in bases: private-admin, user-accounts, landing-admin, and blank.", inputSchema: { type: "object", properties: { query: { type: "string", description: "What kind of project starter you need (e.g. 'gym app', 'private admin dashboard', 'user accounts portal', 'landing plus admin')" }, category: { type: "string", description: "Optional category filter" } }, required: ["query"] } },
461
493
  ]
462
494
 
463
495
  // ── Server Instructions ──────────────────────────────────────────────────────
@@ -486,16 +518,26 @@ First reflex, always:
486
518
 
487
519
  1. **Acknowledge briefly** what they want to build (one short line, their language).
488
520
  2. **\`search_project_templates(query: "<keywords from their request>")\`** — keywords in their language. Templates cover common app types (gym, clinic, waitlist, saas dashboard, etc.).
489
- 3. **Decide: template or blank?** Default is **blank**. A template is only the right pick when the match is OBVIOUS and STRONG:
521
+ 3. **Decide: marketplace template, built-in base, or blank.** Marketplace templates are only right when the match is OBVIOUS and STRONG:
490
522
  - ✅ User says *"app para mi gimnasio"* + there's \`gym-manager\` (exact domain + feature overlap) → template.
491
- - ❌ User says *"algo para gestionar reservas"* + there's \`gym-manager\` (soft match, many interpretations) → **blank**. Don't assume they want the gym's specific schema (classes, memberships, check-ins) — they didn't ask for it.
492
- - User is a dev with a concrete spec (*"crea un proyecto con estas 3 tablas y estos endpoints"*) → **blank**, always. Respect their design.
493
- - No template returned at all **blank**.
494
- 4. **Call it** → \`create_project(name: "<their name>", template_slug: "<matched_slug>" | "blank")\`.
523
+ - ❌ User says *"algo para gestionar reservas"* + there's \`gym-manager\` (soft match, many interpretations) → use a built-in base or **blank**. Don't assume they want the gym's specific schema (classes, memberships, check-ins) — they didn't ask for it.
524
+ - Built-in bases are safe defaults:
525
+ - private/internal/admin/dashboard/backoffice/business management\`private-admin\`
526
+ - end-user signup/login/customer/member portal/marketplace/SaaS accounts → \`user-accounts\`
527
+ - public landing/marketing site plus private admin → \`landing-admin\`
528
+ - no clear access pattern or explicitly custom/from scratch → \`blank\`
529
+ - ❌ User is a dev with a concrete spec (*"crea un proyecto con estas 3 tablas y estos endpoints"*) → usually **blank**, unless they explicitly want one of the built-in bases.
530
+ - ❌ No marketplace or built-in base fits → **blank**.
531
+ 4. **Call it** → \`create_project(name: "<their name>", template_slug: "<matched_slug>" | "private-admin" | "user-accounts" | "landing-admin" | "blank")\`.
495
532
  If you went with a template, acknowledge in ONE line what's included so the user can push back: *"Lo arranco con la plantilla X, que trae socios, clases y pagos. ¿Te vale o prefieres algo más simple?"*
496
533
  If you went blank, just say: *"Arranco un proyecto en blanco y lo construimos a medida."*
497
534
  5. **After \`create_project\`** → ask for an absolute workspace path, then \`dypai_pull\` + \`manage_frontend(sync)\` (see next section).
498
535
 
536
+ Before designing substantial UI (app shell, dashboard, login, tables/lists,
537
+ forms, calendars, or domain-specific screens), use \`search_design_patterns\`
538
+ with the app/starter/screen/style context. It returns curated recipes; adapt
539
+ them to the project instead of inventing generic starter UI.
540
+
499
541
  **The template system exists to save time when the fit is obvious, not to force-match every request.** When in doubt → blank is always correct. Iterating up from blank is cheaper than deleting 80% of a mismatched template.
500
542
 
501
543
  ## The one legit follow-up question
@@ -520,6 +562,34 @@ DYPAI builds the **backend** (DB, auth, API, storage, realtime) for any client.
520
562
 
521
563
  For everything else, **the stack is decided. Start building.**
522
564
 
565
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
566
+ # PROJECT ACCESS PROFILE — classify the app once you know the product shape
567
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
568
+
569
+ DYPAI projects store a lightweight product access profile: whether the app is
570
+ public, private, mixed, admin-only, internal-user based, end-user-account based,
571
+ single-role, or multi-role. This helps later agents and platform UI reason
572
+ about login, first-run credentials, preview instructions, and route protection.
573
+
574
+ When you know the product shape, call
575
+ \`manage_project_access_profile(operation:"update", ...)\`. Do this as metadata
576
+ only; it does NOT replace implementing the actual auth UI, roles, protected
577
+ routes, tables, or endpoints.
578
+
579
+ Defaults:
580
+ - Private/admin app: \`app_visibility:"private"\`, \`auth_scope:"admin_only"\`,
581
+ \`role_model:"single_role"\`, \`has_admin_area:true\`, \`has_public_area:false\`,
582
+ \`has_end_user_accounts:false\`, \`root_requires_auth:true\`.
583
+ - Public site with admin panel: \`app_visibility:"mixed"\`,
584
+ \`auth_scope:"admin_only"\`, \`has_public_area:true\`, \`has_admin_area:true\`,
585
+ \`root_requires_auth:false\`.
586
+ - User portal / marketplace / SaaS: use \`auth_scope:"admin_and_end_users"\`
587
+ when both admins and customers log in, \`role_model:"multi_role"\` if there
588
+ are materially different permissions, and \`has_end_user_accounts:true\`.
589
+ - Public-only landing/docs/blog: \`app_visibility:"public"\`,
590
+ \`auth_scope:"none"\`, \`role_model:"none"\`, \`has_admin_area:false\`,
591
+ \`root_requires_auth:false\`.
592
+
523
593
  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
524
594
  # BEFORE YOU DO ANYTHING — materialize the project locally
525
595
  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -689,7 +759,7 @@ Use phrases like:
689
759
  Default is **no tool names in user-facing text**.
690
760
 
691
761
  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
692
- # SEARCH BEFORE YOU GUESS — \`search_docs\` is your reference manual
762
+ # SEARCH BEFORE YOU GUESS — \`search_docs\` and \`search_design_patterns\`
693
763
  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
694
764
 
695
765
  This prompt is the MAP of the DYPAI platform. The detailed docs live in
@@ -732,6 +802,17 @@ synonym.
732
802
  - \`"agent ai"\` — agent node: providers, tools, memory, streaming, tool endpoints
733
803
  - \`"javascript code node"\` — custom code escape hatch when native nodes don't fit
734
804
 
805
+ ### Managed AI Models
806
+ - Before creating or editing any AI Agent node that uses DYPAI Managed AI,
807
+ always call \`list_ai_models\` first.
808
+ - Use only model IDs from \`list_ai_models.models[].id\`; never invent OpenRouter
809
+ model IDs.
810
+ - For DYPAI Managed AI, set \`provider: "openrouter"\` and do not set
811
+ \`credential_id\`; DYPAI uses the platform key server-side.
812
+ - Treat model pricing as AI Credits, using
813
+ \`input_ai_credits_per_million\` / \`output_ai_credits_per_million\` when you
814
+ need to explain cost.
815
+
735
816
  ### Auth
736
817
  - \`"auth flows"\` — signup / login / reset / magic link / role upgrade canonical flows
737
818
  - \`"auth defaults"\` — what auth_mode to pick when the user doesn't specify
@@ -800,12 +881,13 @@ Editing files inside \`dypai/\` only changes YOUR DISK. The platform doesn't see
800
881
  \`\`\`
801
882
 
802
883
  Practical consequences — internalize these:
803
- - **After EVERY meaningful change set, call \`dypai_push\`.** Don't batch a session's worth of edits hoping to push at the end — if you forget, the user tests the local UI and sees the OLD behavior, gets confused, and you waste a debug round-trip blaming the code. The push is cheap, idempotent, and creates ONE draft per resource (subsequent pushes overwrite the draft, not stack new ones).
804
- - **\`dypai_push\` is the "save" button. It is NOT a publish.** Live traffic is untouched. You can push 20 times in a row without affecting a single user. Tell the user that explicitly when they ask "did it ship?" push = staged draft, publish = live.
805
- - **The draft overlay (\`dev-<project_id>.dypai.dev\`) only sees what you've pushed.** A change still on disk is invisible to the local frontend. If the user says "I tested it locally and nothing changed", your first check is "did I run \`dypai_push\` after the last edit?".
884
+ - **Never publish backend changes just to test them.** Backend changes are testable before production: save them to preview, verify with \`dypai_test_endpoint(mode:'draft')\` when possible, then tell the user exactly what to try in preview.
885
+ - **After EVERY meaningful backend change set, call \`dypai_push\`.** Don't batch a session's worth of edits hoping to push at the end if you forget, the user tests the preview and sees the OLD behavior. The push is cheap, idempotent, and creates ONE preview version per resource (subsequent pushes overwrite the pending preview version, not stack new ones).
886
+ - **\`dypai_push\` is the internal save-to-preview step. It is NOT a production publish.** Live traffic is untouched. You can run it repeatedly without affecting real users. In user-facing prose, say "listo para probar" or "en previsualización", not "pushed" or "draft".
887
+ - **The preview host (\`dev-<project_id>.dypai.dev\`) only sees what you've saved to preview.** A change still only on disk is invisible to the user's preview. If the user says "I tested it and nothing changed", first check whether the backend change was saved to preview after the last edit.
806
888
  - **\`dypai_validate\` before \`dypai_push\`** — push runs validate as a pre-flight, but running it explicitly first gives you the lint output without committing. Cheap insurance.
807
- - **Order during a multi-step feature**: edit → \`dypai_validate\` → \`dypai_push\` → \`dypai_test_endpoint(mode:'draft')\` (or tell the user to test their local UI). Repeat per change. ONLY at the end, when the user signs off, do \`manage_drafts(operation:'list')\` → \`manage_drafts(operation:'publish', confirm:true)\`.
808
- - **DDL is the exception**: \`execute_sql\` with CREATE / ALTER / DROP TABLE applies to live IMMEDIATELY (no draft stage for schema). Drafts only exist for endpoints / webhooks / crons / realtime policies. Summarize destructive DDL to the user before running it.
889
+ - **Order during a multi-step backend feature**: edit → \`dypai_validate\` → \`dypai_push\` → \`dypai_test_endpoint(mode:'draft')\` (or tell the user to test preview). Repeat per change. ONLY when the user explicitly approves production do \`manage_drafts(operation:'list')\` → \`manage_drafts(operation:'publish', confirm:true)\`.
890
+ - **DDL is the exception**: \`execute_sql\` with CREATE / ALTER / DROP TABLE applies to live IMMEDIATELY (no preview layer for schema). Preview only exists for endpoints / webhooks / crons / realtime policies. Summarize destructive DDL to the user before running it.
809
891
 
810
892
  ## User intent → tool to call (decision table)
811
893
 
@@ -815,13 +897,14 @@ Use this BEFORE picking a tool. If unsure which row matches, ask the user.
815
897
  |---|---|---|
816
898
  | "Create a new project" | \`search_project_templates\` (find a starter) | \`create_project(template_slug: ...)\` |
817
899
  | "Show me what we have" / "I want to work on existing project X" | \`list_projects\` → \`dypai_pull\` (backend) + \`manage_frontend(sync)\` (frontend) | Read \`dypai/\` files + \`src/\` |
900
+ | "This is a private admin app / public site / user portal / multi-role app" | \`manage_project_access_profile(operation:'update')\` | Then implement the actual auth/UI/data behavior normally |
818
901
  | "Add/change a backend endpoint, table, cron, webhook, agent, integration" | Edit files in \`dypai/\` | \`dypai_validate\` → \`dypai_push\` |
819
902
  | "Publish my backend changes" / "make it live" | \`manage_drafts(operation:'list')\` to show what's pending | \`manage_drafts(operation:'publish', confirm:true)\` |
820
903
  | "Test an endpoint before publishing" | \`dypai_test_endpoint(mode:'local')\` (your edits) or \`(mode:'draft')\` (after push) | — |
821
904
  | "Test the new endpoint from my local frontend, end-to-end, before publishing" | Tell user: their local frontend already points to \`https://dev-<project_id>.dypai.dev\` (set by \`manage_frontend(sync)\`), which serves drafts on top of live. So after \`dypai_push\` the local UI hits the draft overlay automatically — nothing else to do. | — |
822
905
  | "Throw away my backend changes" | \`manage_drafts(operation:'discard', confirm:true)\` | — |
823
- | "Change the UI / change colors / add a page" | Edit files in \`src/\` | \`manage_frontend(deploy, confirm:true)\` |
824
- | "Publish the new UI" / "ship the frontend" | \`manage_frontend(deploy, confirm:true)\` | (deploy is the publish there is no separate step) |
906
+ | "Change the UI / change colors / add a page" | Edit files in \`src/\` | Test locally/with browser when possible. \`manage_frontend(deploy, confirm:true)\` only when the user approves publishing the UI. |
907
+ | "Publish the new UI" / "ship the frontend" | If backend changes are needed by the new UI, publish those backend changes first with explicit approval | Then \`manage_frontend(deploy, confirm:true)\` deploy is live, not a preview. |
825
908
  | "Roll back" | Backend: \`get_endpoint_versions\` then write old code back. Frontend: re-deploy older source. | — |
826
909
  | "Upload a file / a CSV / seed data" | \`bulk_upsert\` (data) or \`manage_storage(upload_file)\` (binary) | — |
827
910
  | "X is broken" / "I'm getting an error" / "this doesn't work" / "users are reporting Y" | \`search_logs\` FIRST (don't guess from the code) | If a specific failure is found → \`search_logs(include_trace:true, query:'...')\` for the full step-by-step trace |
@@ -847,19 +930,21 @@ User: "Add a /api/list-tasks endpoint that returns the current user's tasks, and
847
930
  2. manage_frontend(operation:'sync', ...) # materialize frontend if not already on disk
848
931
  3. # Backend: create the endpoint
849
932
  Write dypai/endpoints/list-tasks.yaml # trigger.http_api auth_mode:jwt + dypai_database query
850
- 4. dypai_validate # catch typos before push
851
- 5. dypai_push # stages as draft, NOT live yet
852
- 6. dypai_test_endpoint(name:'list-tasks', mode:'draft', as_user:'<uuid>') # verify the staged version
853
- 7. manage_drafts(operation:'list') # show pending changes to user
854
- 8. # ASK USER: "Ready to publish list-tasks to live?"
855
- 9. manage_drafts(operation:'publish', confirm:true) # backend now live ✅
856
- 10. # Frontend: call the new endpoint from React
857
- Edit src/pages/Dashboard.tsx # useEndpoint('list-tasks')
858
- 11. # ASK USER: "Ready to deploy the dashboard UI?"
859
- 12. manage_frontend(operation:'deploy', sourceDirectory, confirm:true) # frontend now live
933
+ 4. dypai_validate # catch typos before saving to preview
934
+ 5. dypai_push # saves to preview, NOT production
935
+ 6. dypai_test_endpoint(endpoint:'list-tasks', mode:'draft', as_user:'<user_id>')
936
+ # verifies the preview version; do NOT publish just to test
937
+ 7. # Frontend: call the new endpoint from React
938
+ Edit src/pages/Dashboard.tsx # useEndpoint('list-tasks')
939
+ 8. # Test locally/browser if available. Then tell the user in plain language:
940
+ # "Ya está listo para probar. Abre la previsualización y revisa la lista de tareas. Todavía no está publicado para tus usuarios."
941
+ 9. # ONLY after the user confirms it is good:
942
+ manage_drafts(operation:'list') # internal: inspect what will publish
943
+ 10. manage_drafts(operation:'publish', confirm:true) # backend live after explicit approval
944
+ 11. manage_frontend(operation:'deploy', sourceDirectory, confirm:true) # frontend live after explicit approval
860
945
  \`\`\`
861
946
 
862
- **Order matters**: publish backend BEFORE deploying frontend. Otherwise the new UI calls an endpoint that doesn't exist on live yet 404s for users. The \`manage_frontend(deploy)\` confirmation hint will warn you if backend drafts are still pending.
947
+ **Testing rule**: never publish backend changes just to test them. Backend can be verified from the preview version. **Production order rule**: when you are truly publishing a full-stack change, publish backend BEFORE deploying the frontend; otherwise the live UI may call backend functionality that is not live yet.
863
948
 
864
949
  ## Debugging user-reported errors — \`search_logs\` is your starting point
865
950
 
@@ -876,10 +961,16 @@ User: "Add a /api/list-tasks endpoint that returns the current user's tasks, and
876
961
  # Did they say "production users are reporting..."?
877
962
  # → add environment: "live" (excludes their own draft test runs)
878
963
 
879
- 3. # Found the relevant entry? Narrow down:
964
+ 3. # Did they say "I clicked the button and it disappeared / nothing happened"
965
+ # and the error scan is empty?
966
+ search_logs({ since: "30m", status: "all", endpoint: "<likely-endpoint>" })
967
+ → This shows successful executions too, so you can tell whether the backend
968
+ was called, returned 200, was slow, or was never reached.
969
+
970
+ 4. # Found the relevant entry? Narrow down:
880
971
  search_logs({ endpoint: "create-order", query: "stripe", since: "1h" })
881
972
 
882
- 4. # For the full step-by-step trace of one specific failure:
973
+ 5. # For the full step-by-step trace of one specific failure:
883
974
  search_logs({
884
975
  endpoint: "create-order",
885
976
  query: "<a unique substring from the error message>",
@@ -890,12 +981,12 @@ User: "Add a /api/list-tasks endpoint that returns the current user's tasks, and
890
981
  and returns a \`file_path\`. Read that file with the Read tool ONLY
891
982
  when you need fields beyond the inline summary.
892
983
 
893
- 5. # Now you know exactly which node failed and why → fix the code.
984
+ 6. # Now you know exactly which node failed, succeeded, or was never called → fix the code.
894
985
  \`\`\`
895
986
 
896
987
  ### What \`search_logs\` returns
897
988
 
898
- Each item has \`type\` (\`execution_failed\` | \`log\`), \`level\` (\`error\` | \`warn\`), \`time\`, \`endpoint\`, \`message\`, and \`environment\` (\`live\` | \`draft\` | null for legacy rows). Failed executions also include \`status\` (\`error\` | \`timeout\`) and \`duration_ms\`. With \`include_trace:true\` they also include \`trace\` — a per-node log of inputs, outputs, errors, and stacks.
989
+ Each item has \`type\` (\`execution\` | \`execution_failed\` | \`log\`), \`level\` (\`info\` | \`error\` | \`warn\`), \`time\`, \`endpoint\`, \`message\`, and \`environment\` (\`live\` | \`draft\` | null for legacy rows). Workflow executions include \`status\` (\`success\` | \`error\` | \`timeout\`) and \`duration_ms\`. With \`include_trace:true\`, failed executions can include \`trace\` — a per-node log of inputs, outputs, errors, and stacks.
899
990
 
900
991
  ### Common pitfalls
901
992
 
@@ -944,6 +1035,7 @@ Mental translations: "edge function" → workflow with one code node; "cron" →
944
1035
  4. **\`public\` auth_mode with \`\${current_user_id}\`** — no JWT → placeholder empty → SQL fails or returns wrong data. Use \`jwt\` if you need the user.
945
1036
  5. **Missing \`return: true\`** — endpoint returns \`null\`. Every path that should produce an HTTP response needs one node with \`return: true\`.
946
1037
  6. **\`tool_ids\` in YAML instead of \`tools\`** — write \`tools: [name1, name2]\`. \`tool_ids\` bypasses the codec and fails silently in prod.
1038
+ 7. **Putting workflow placeholders inside \`javascript_code.code\`** — code is raw JavaScript, so JS template literals like \`\${where.join(" AND ")}\` are safe and not rendered by DYPAI. Pass workflow values via \`input_data\`, \`ctx.nodes\`, \`ctx.user\`, or \`ctx.env\`; do not write \`\${input.email}\` inside code or set \`code\` from another node output.
947
1039
 
948
1040
  → Longer list of common pitfalls + fixes: \`search_docs("troubleshooting")\`.
949
1041
 
@@ -32,7 +32,7 @@ export const manageFrontendTool = {
32
32
  "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" +
33
33
  " - deploy: Upload source files from a local directory and queue a build. **DESTRUCTIVE: replaces the LIVE site immediately, no draft stage, no rollback button.** " +
34
34
  "Requires `confirm: true` — without it the tool returns a confirmation_required hint instead of deploying. " +
35
- "If backend drafts are pending, the hint includes a warning to publish backend FIRST (otherwise the new frontend may call endpoints that don't exist yet). " +
35
+ "If backend drafts are pending, the hint warns that publishing the frontend is a live production action; only publish backend FIRST when the user has explicitly approved going live. Do NOT publish backend just to test it — use preview/draft testing instead. " +
36
36
  "Returns immediately with build_status=\"queued\" — poll with `build_status` until \"success\" or \"failure\". " +
37
37
  "The response includes `build_quota` with remaining monthly build minutes. If minutes_remaining is low, tell the user. If 0, DO NOT retry — suggest upgrading the plan.\n" +
38
38
  " - status: Current live deploy info (URL, last deploy time, size).\n" +
@@ -41,7 +41,7 @@ export const manageFrontendTool = {
41
41
  " - list_deployments: Recent deploy history (status, commit, duration, URL).\n" +
42
42
  " - logs: Build logs for a specific deployment (needs deployment_id from list_deployments).\n\n" +
43
43
  "Related: `dypai_pull` brings BACKEND state (YAML endpoints, SQL, prompts). The two are independent — run both when starting fresh on a full-stack project.\n\n" +
44
- "Order rule when both backend AND frontend changed: 1) dypai_push (saves backend as draft) → 2) manage_drafts(publish, confirm:true) → 3) manage_frontend(deploy, confirm:true). Inverting steps 2 and 3 may serve a frontend that calls non-existent endpoints.",
44
+ "Testing rule: backend changes can be tested in preview after dypai_push; do NOT publish backend just to test. Production order rule when both backend AND frontend changed and the user approved going live: 1) publish backend drafts with manage_drafts(publish, confirm:true) → 2) deploy frontend with manage_frontend(deploy, confirm:true). Inverting production order may serve a live frontend that calls backend functionality not live yet.",
45
45
 
46
46
  inputSchema: {
47
47
  type: "object",
@@ -117,7 +117,7 @@ export const manageFrontendTool = {
117
117
  const draftsTotal = draftsResult?.total || 0
118
118
  if (draftsTotal > 0) {
119
119
  warnings.push(
120
- `${draftsTotal} backend draft(s) pending. Publish them BEFORE deploying the frontend with manage_drafts(operation:'publish', confirm:true) otherwise the new frontend may call endpoints that don't exist on live yet.`,
120
+ `${draftsTotal} backend preview change(s) pending. This frontend deploy is LIVE. Only publish the backend first if the user explicitly approved production; for testing, keep backend in preview and test without deploying live frontend.`,
121
121
  )
122
122
  }
123
123
  } catch {
@@ -20,7 +20,8 @@ Scaffolds a project directory with:
20
20
  - .env with engine URL
21
21
 
22
22
  Use search_project_templates first to find available templates, then pass the template slug here.
23
- Or use "blank" for an empty starter project.`,
23
+ Use "private-admin" for private internal tools, "user-accounts" for apps with signup/login users,
24
+ "landing-admin" for public landing plus admin, or "blank" only when no base fits.`,
24
25
 
25
26
  inputSchema: {
26
27
  type: "object",
@@ -35,7 +36,7 @@ Or use "blank" for an empty starter project.`,
35
36
  },
36
37
  template: {
37
38
  type: "string",
38
- description: 'Template slug (e.g. "clinic", "gym", "blank"). Use search_project_templates to find available templates.',
39
+ description: 'Template slug (e.g. "clinic", "gym", "private-admin", "user-accounts", "landing-admin", "blank"). Use search_project_templates to find available templates.',
39
40
  default: "blank",
40
41
  },
41
42
  },
@@ -130,6 +130,7 @@ export function maybeOffloadSearchLogs(result) {
130
130
  project_id: result.project_id,
131
131
  since: result.since,
132
132
  level: result.level,
133
+ status: result.status,
133
134
  environment: result.environment,
134
135
  endpoint: result.endpoint,
135
136
  query: result.query,
@@ -524,7 +524,7 @@ export const dypaiPushTool = {
524
524
  next_step: errors.length
525
525
  ? "Fix the offending YAMLs and push again."
526
526
  : draftCount > 0
527
- ? `${draftCount} change(s) saved as draft(s) — they're not live yet. Review with manage_drafts(operation:'list'), verify with dypai_test_endpoint(mode:'draft', endpoint:'<name>'), then publish with manage_drafts(operation:'publish', confirm:true) (or discard with manage_drafts(operation:'discard', confirm:true) to throw them away).`
527
+ ? `${draftCount} change(s) saved to preview — they're not live yet. Verify with dypai_test_endpoint(mode:'draft', endpoint:'<name>') or ask the user to test the preview. Publish with manage_drafts(operation:'publish', confirm:true) ONLY after explicit approval.`
528
528
  : changedNames.length
529
529
  ? `Test changed endpoint(s) with dypai_test_endpoint: ${changedNames.slice(0, 3).join(", ")}${changedNames.length > 3 ? "…" : ""}`
530
530
  : undefined,
@@ -361,6 +361,9 @@ export const dypaiTestEndpointTool = {
361
361
  data: input,
362
362
  trace_mode, // used by the local MCP enrichment layer
363
363
  }
364
+ if (mode === "local") execArgs.draft_mode = true
365
+ if (mode === "draft") execArgs.draft_mode = true
366
+ if (mode === "live") execArgs.draft_mode = false
364
367
  if (as_user) execArgs.impersonated_user_id = as_user
365
368
 
366
369
  // Build a compact source-meta block for the response so the agent can see
@@ -8,6 +8,7 @@
8
8
  * - `credential: foo` pointing to a credential that doesn't exist remotely
9
9
  * - Agent `tools: [foo]` pointing to endpoints that aren't tools
10
10
  * - SQL queries referencing tables that don't exist in schema.sql
11
+ * - dypai_storage/static storage bucket references that don't exist remotely
11
12
  *
12
13
  * Returns a list of diagnostics with severity + fix_hint. Non-zero errors
13
14
  * mean push will refuse (unless skip_validation is passed).
@@ -179,6 +180,44 @@ function* walkStrings(node, path = "") {
179
180
  }
180
181
  }
181
182
 
183
+ const BUCKET_LOC_RE = /(?:^|\.)(?:bucket|bucket_name|bucketName|storage_bucket|storageBucket)$/
184
+ const STATIC_BUCKET_RE = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/
185
+
186
+ function isStaticBucketName(value) {
187
+ const trimmed = String(value || "").trim()
188
+ if (!trimmed || trimmed.includes("${") || trimmed.includes("{{")) return false
189
+ return STATIC_BUCKET_RE.test(trimmed)
190
+ }
191
+
192
+ function collectStorageBucketRefs(entry, endpoint, file) {
193
+ const refs = new Map()
194
+ const add = (bucket, loc) => {
195
+ const name = String(bucket || "").trim()
196
+ if (!isStaticBucketName(name)) return
197
+ refs.set(`${name}|${loc}`, { bucket: name, endpoint, file, loc })
198
+ }
199
+
200
+ for (const { path, value } of walkStrings(entry.doc)) {
201
+ if (BUCKET_LOC_RE.test(path)) add(value, path)
202
+
203
+ // JS helper pattern from workflow code files/inline code:
204
+ // storage.upload("documents", ...), storage.read("documents", ...), etc.
205
+ if (/(^|\.|])code$/.test(path)) {
206
+ const re = /\bstorage\.(?:upload|read|list|getUrl|delete)\(\s*["']([a-z0-9][a-z0-9-]{1,61}[a-z0-9])["']/g
207
+ let m
208
+ while ((m = re.exec(value)) !== null) add(m[1], path)
209
+ }
210
+ }
211
+
212
+ for (const [filePath, content] of Object.entries(entry.fileMap || {})) {
213
+ const re = /\bstorage\.(?:upload|read|list|getUrl|delete)\(\s*["']([a-z0-9][a-z0-9-]{1,61}[a-z0-9])["']/g
214
+ let m
215
+ while ((m = re.exec(content)) !== null) add(m[1], filePath)
216
+ }
217
+
218
+ return [...refs.values()]
219
+ }
220
+
182
221
  /** Extract all ${...} placeholder expressions from a string. */
183
222
  function extractPlaceholders(s) {
184
223
  const out = []
@@ -188,6 +227,13 @@ function extractPlaceholders(s) {
188
227
  return out
189
228
  }
190
229
 
230
+ function isRawCodePlaceholderSource(source, loc) {
231
+ if (source === "file") {
232
+ return loc.startsWith("code/") || /\.(js|ts|py)$/.test(loc)
233
+ }
234
+ return /(^|\.|])code$/.test(loc)
235
+ }
236
+
191
237
  /**
192
238
  * Extract the first identifier (a-z, A-Z, _, 0-9 — JS-ident shape) from the
193
239
  * head of an expression, ignoring any trailing template filter or coalesce
@@ -289,6 +335,31 @@ const LEGACY_OPS_THAT_USE_TABLE_FIELD = new Set([
289
335
  "select", "insert", "update", "delete", "upsert", "aggregate",
290
336
  ])
291
337
 
338
+ function nodeParams(node) {
339
+ return node?.parameters && typeof node.parameters === "object" && !Array.isArray(node.parameters)
340
+ ? node.parameters
341
+ : null
342
+ }
343
+
344
+ function nodeField(node, params, key) {
345
+ return node?.[key] ?? params?.[key]
346
+ }
347
+
348
+ function lookupFileMapContent(fileMap, ref) {
349
+ if (!fileMap || typeof ref !== "string" || !ref.trim()) return ""
350
+ const key = ref.trim()
351
+ return fileMap[key] || fileMap[key.replace(/^dypai\//, "")] || fileMap[`dypai/${key}`] || ""
352
+ }
353
+
354
+ function collectMissingTableCandidates(ctx, referencedTables, endpoint, file) {
355
+ if (!ctx.schemaTables) return
356
+ for (const table of referencedTables) {
357
+ if (!ctx.schemaTables.has(table)) {
358
+ ctx.suspectedMissingTables.push({ table, endpoint, file })
359
+ }
360
+ }
361
+ }
362
+
292
363
  // ─── Rules ──────────────────────────────────────────────────────────────────
293
364
 
294
365
  function ruleUsesJwt(trigger) {
@@ -307,17 +378,24 @@ function validateEndpoint(entry, ctx) {
307
378
 
308
379
  const jwt = ruleUsesJwt(doc.trigger)
309
380
 
310
- // Collect all strings INCLUDING file contents (query_file, system_prompt_file, code_file)
381
+ // Collect all template-rendered strings, including query_file and prompts.
382
+ // Raw code is intentionally skipped: javascript_code.code/code_file may
383
+ // contain normal JS template literals such as `${where.join(" AND ")}`.
311
384
  const sources = []
312
385
  for (const { path, value } of walkStrings(doc)) {
386
+ if (isRawCodePlaceholderSource("yaml", path)) continue
313
387
  sources.push({ source: "yaml", loc: path, value })
314
388
  }
315
389
  for (const [filePath, content] of Object.entries(fileMap || {})) {
390
+ if (isRawCodePlaceholderSource("file", filePath)) continue
316
391
  sources.push({ source: "file", loc: filePath, value: content })
317
392
  }
318
393
 
319
394
  // Collect SQL tables referenced (before checking each individually)
320
395
  const referencedTables = new Set()
396
+ for (const ref of collectStorageBucketRefs(entry, name, file)) {
397
+ ctx.suspectedStorageBuckets.push(ref)
398
+ }
321
399
 
322
400
  // Aggregate missing input.X refs across the whole endpoint so we emit ONE
323
401
  // diagnostic per endpoint instead of N (an endpoint with 11 stray refs
@@ -457,23 +535,6 @@ function validateEndpoint(entry, ctx) {
457
535
  }
458
536
  }
459
537
 
460
- // --- SQL table existence (if schema.sql available) ---
461
- // Defer the actual error emission to runValidation: it batch-checks all
462
- // missing tables against the remote in ONE query before deciding what's
463
- // a real error vs a stale-local-schema. We just collect the misses here
464
- // along with enough context for runValidation to emit per-endpoint errors.
465
- if (ctx.schemaTables) {
466
- for (const table of referencedTables) {
467
- if (!ctx.schemaTables.has(table)) {
468
- ctx.suspectedMissingTables.push({
469
- table,
470
- endpoint: name,
471
- file,
472
- })
473
- }
474
- }
475
- }
476
-
477
538
  // --- Per-node catalog-based validation (unknown type, missing/unknown params) ---
478
539
  for (const node of doc.workflow?.nodes || []) {
479
540
  if (!node || typeof node !== "object") continue
@@ -504,30 +565,35 @@ function validateEndpoint(entry, ctx) {
504
565
 
505
566
  // dypai_database — coherence checks for the new canonical operations.
506
567
  if (nodeType === "dypai_database") {
507
- const op = node.operation
568
+ const params = nodeParams(node)
569
+ const op = nodeField(node, params, "operation")
570
+ const table = nodeField(node, params, "table") ?? nodeField(node, params, "table_name")
571
+ const query = nodeField(node, params, "query")
572
+ const queryFile = nodeField(node, params, "query_file")
573
+ const insertValue = nodeField(node, params, "insert")
574
+ const updateValue = nodeField(node, params, "update")
575
+ const deleteValue = nodeField(node, params, "delete")
576
+ const whereValue = nodeField(node, params, "where")
508
577
 
509
578
  // Extract referenced tables from real SQL fields ONLY. Doing this here
510
579
  // (instead of in the generic walkStrings pass) eliminates the class of
511
580
  // false positive where a prompt/comment/label happens to contain words
512
581
  // like "INSERT" or "SELECT". Also covers `mutation` (table: <name>)
513
582
  // since that's a guaranteed table reference.
514
- if (op === "query" || (op && LEGACY_OPS_THAT_USE_QUERY.has(op))) {
515
- const sqlText = typeof node.query === "string" ? node.query : ""
516
- if (sqlText) {
517
- for (const t of extractSqlTables(sqlText)) referencedTables.add(t)
518
- }
583
+ const sqlTexts = []
584
+ if (typeof query === "string" && query.trim()) sqlTexts.push(query)
585
+ const queryFileSql = lookupFileMapContent(fileMap, queryFile)
586
+ if (queryFileSql) sqlTexts.push(queryFileSql)
587
+ for (const sqlText of sqlTexts) {
588
+ for (const t of extractSqlTables(sqlText)) referencedTables.add(t)
519
589
  }
520
- if (op === "mutation" && typeof node.table === "string") {
521
- referencedTables.add(node.table)
590
+ if (op === "mutation" && typeof table === "string") {
591
+ referencedTables.add(table)
522
592
  }
523
593
  // Legacy ops like `select` / `insert` / `update` / `delete` use `table:`
524
594
  // as the target table directly.
525
- if (op && LEGACY_OPS_THAT_USE_TABLE_FIELD.has(op) && typeof node.table === "string") {
526
- referencedTables.add(node.table)
527
- }
528
- // Resolved query_file content also counts as SQL (loaded by the codec).
529
- if (typeof node.query === "string" && node.query.length > 0) {
530
- for (const t of extractSqlTables(node.query)) referencedTables.add(t)
595
+ if (op && LEGACY_OPS_THAT_USE_TABLE_FIELD.has(op) && typeof table === "string") {
596
+ referencedTables.add(table)
531
597
  }
532
598
  const LEGACY_OPS = new Set(["select", "insert", "update", "delete", "upsert", "aggregate", "copy_to", "custom_query"])
533
599
  if (op && LEGACY_OPS.has(op)) {
@@ -549,7 +615,7 @@ function validateEndpoint(entry, ctx) {
549
615
  // Fields like `query`, `query_file`, `params` belong to `operation: query`
550
616
  // and are silently ignored here — surface as ERROR so the agent reroutes.
551
617
  if (op === "mutation") {
552
- if (!node.table) {
618
+ if (!table) {
553
619
  diagnostics.push({
554
620
  severity: "error",
555
621
  rule: "mutation_missing_table",
@@ -558,9 +624,9 @@ function validateEndpoint(entry, ctx) {
558
624
  fix_hint: `Add 'table: <name>'. mutation also needs exactly one of: insert / update / delete.`,
559
625
  })
560
626
  }
561
- const wantsInsert = node.insert !== undefined && node.insert !== null
562
- const wantsUpdate = node.update !== undefined && node.update !== null
563
- const wantsDelete = node.delete === true
627
+ const wantsInsert = insertValue !== undefined && insertValue !== null
628
+ const wantsUpdate = updateValue !== undefined && updateValue !== null
629
+ const wantsDelete = deleteValue === true
564
630
  const opCount = [wantsInsert, wantsUpdate, wantsDelete].filter(Boolean).length
565
631
  if (opCount === 0) {
566
632
  diagnostics.push({
@@ -582,7 +648,7 @@ function validateEndpoint(entry, ctx) {
582
648
  if (wantsUpdate || wantsDelete) {
583
649
  // `where: {}` is just as dangerous as omitting `where:` entirely — both
584
650
  // produce an unconstrained UPDATE/DELETE in the engine.
585
- const whereVal = node.where
651
+ const whereVal = whereValue
586
652
  const whereIsEmpty =
587
653
  whereVal === undefined ||
588
654
  whereVal === null ||
@@ -603,7 +669,7 @@ function validateEndpoint(entry, ctx) {
603
669
  // Foreign fields that belong to `operation: query`
604
670
  const QUERY_ONLY = ["query", "query_file", "params"]
605
671
  for (const k of QUERY_ONLY) {
606
- if (node[k] !== undefined) {
672
+ if (node[k] !== undefined || params?.[k] !== undefined) {
607
673
  diagnostics.push({
608
674
  severity: "error",
609
675
  rule: "mutation_with_query_field",
@@ -620,7 +686,7 @@ function validateEndpoint(entry, ctx) {
620
686
  // Fields like `table`, `insert`, `update`, `delete`, `where`, `on_conflict`,
621
687
  // `returning` belong to `operation: mutation` and are ignored here.
622
688
  if (op === "query") {
623
- if (!node.query && !node.query_file) {
689
+ if (!query && !queryFile) {
624
690
  diagnostics.push({
625
691
  severity: "error",
626
692
  rule: "query_missing_sql",
@@ -630,7 +696,10 @@ function validateEndpoint(entry, ctx) {
630
696
  })
631
697
  }
632
698
  const MUTATION_ONLY = ["table", "insert", "update", "delete", "where", "on_conflict", "returning"]
633
- const foreign = MUTATION_ONLY.filter(k => node[k] !== undefined && node[k] !== false)
699
+ const foreign = MUTATION_ONLY.filter(k => {
700
+ const value = node[k] ?? params?.[k]
701
+ return value !== undefined && value !== false
702
+ })
634
703
  if (foreign.length > 0) {
635
704
  diagnostics.push({
636
705
  severity: "error",
@@ -855,6 +924,11 @@ function validateEndpoint(entry, ctx) {
855
924
  }
856
925
  }
857
926
 
927
+ // --- SQL table existence (if schema.sql available) ---
928
+ // Defer actual error emission to runValidation: it batch-checks all missing
929
+ // tables against the remote before deciding whether local schema.sql is stale.
930
+ collectMissingTableCandidates(ctx, referencedTables, name, file)
931
+
858
932
  // --- Credential references ---
859
933
  for (const node of doc.workflow?.nodes || []) {
860
934
  if (!node || typeof node !== "object") continue
@@ -982,14 +1056,15 @@ function validateEndpoint(entry, ctx) {
982
1056
  for (const node of allNodes) {
983
1057
  const nodeType = node?.type ?? node?.node_type
984
1058
  if (nodeType !== "dypai_database") continue
985
- const op = node.operation
1059
+ const params = nodeParams(node)
1060
+ const op = nodeField(node, params, "operation")
986
1061
  let writeKind = null
987
1062
  if (op === "mutation") {
988
- if (node.insert !== undefined && node.insert !== null) writeKind = "INSERT"
989
- else if (node.update !== undefined && node.update !== null) writeKind = "UPDATE"
990
- else if (node.delete === true) writeKind = "DELETE"
1063
+ if (nodeField(node, params, "insert") !== undefined && nodeField(node, params, "insert") !== null) writeKind = "INSERT"
1064
+ else if (nodeField(node, params, "update") !== undefined && nodeField(node, params, "update") !== null) writeKind = "UPDATE"
1065
+ else if (nodeField(node, params, "delete") === true) writeKind = "DELETE"
991
1066
  } else if (op === "query") {
992
- const sql = String(node.query || "")
1067
+ const sql = String(nodeField(node, params, "query") || "")
993
1068
  if (/\b(INSERT|UPDATE|DELETE|TRUNCATE|DROP|ALTER|CREATE)\b/i.test(sql)) {
994
1069
  writeKind = "write SQL"
995
1070
  }
@@ -1069,6 +1144,68 @@ async function verifyTablesInRemote(missingTables, projectId) {
1069
1144
  }
1070
1145
  }
1071
1146
 
1147
+ function bucketNamesFromStorageList(payload) {
1148
+ const names = new Set()
1149
+ const add = (value) => {
1150
+ if (typeof value === "string" && value.trim()) names.add(value.trim())
1151
+ }
1152
+ const candidates = []
1153
+ if (Array.isArray(payload)) {
1154
+ candidates.push(payload)
1155
+ } else if (payload && typeof payload === "object") {
1156
+ for (const key of ["buckets", "data", "items", "results"]) {
1157
+ if (Array.isArray(payload[key])) candidates.push(payload[key])
1158
+ }
1159
+ if (!candidates.length) candidates.push([payload])
1160
+ }
1161
+ for (const list of candidates) {
1162
+ for (const item of list) {
1163
+ if (typeof item === "string") {
1164
+ add(item)
1165
+ } else if (item && typeof item === "object") {
1166
+ add(item.name)
1167
+ add(item.bucket)
1168
+ add(item.bucket_name)
1169
+ }
1170
+ }
1171
+ }
1172
+ return names
1173
+ }
1174
+
1175
+ async function verifyStorageBucketsInRemote(bucketRefs, projectId) {
1176
+ if (!bucketRefs.length) return []
1177
+ const uniqueRefs = new Map(bucketRefs.map(ref => [`${ref.bucket}|${ref.endpoint}|${ref.file}|${ref.loc}`, ref]))
1178
+ const refs = [...uniqueRefs.values()]
1179
+ const bucketNames = [...new Set(refs.map(ref => ref.bucket))].sort()
1180
+ try {
1181
+ const args = { operation: "list" }
1182
+ if (projectId) args.project_id = projectId
1183
+ const result = await proxyToolCall("manage_storage", args)
1184
+ const existing = bucketNamesFromStorageList(result)
1185
+ return refs
1186
+ .filter(ref => !existing.has(ref.bucket))
1187
+ .map(ref => ({
1188
+ severity: "error",
1189
+ rule: "storage_bucket_missing",
1190
+ endpoint: ref.endpoint,
1191
+ file: ref.file,
1192
+ loc: ref.loc,
1193
+ message: `Storage bucket '${ref.bucket}' is referenced by endpoint '${ref.endpoint}', but it does not exist in this project.`,
1194
+ fix_hint:
1195
+ `Create it before testing/pushing this endpoint: ` +
1196
+ `manage_storage({ operation: "create", name: "${ref.bucket}", public: false }). ` +
1197
+ `Use public: true only for public assets; user documents/attachments should stay private.`,
1198
+ }))
1199
+ } catch (e) {
1200
+ return [{
1201
+ severity: "warn",
1202
+ rule: "storage_bucket_check_failed",
1203
+ message: `Could not verify storage buckets: ${bucketNames.join(", ")}.`,
1204
+ fix_hint: `Retry manage_storage({ operation: "list" }) or create the expected bucket before testing upload/storage endpoints. Detail: ${e.message}`,
1205
+ }]
1206
+ }
1207
+ }
1208
+
1072
1209
  /**
1073
1210
  * Refresh dypai/schema.sql from the remote. Best-effort — if it fails the
1074
1211
  * caller continues with the existing local schema (just logs the warning).
@@ -1116,6 +1253,7 @@ export async function runValidation(rootDir, projectId) {
1116
1253
  // Collected during per-endpoint pass; resolved against the remote AFTER
1117
1254
  // all endpoints are checked (one batch query instead of N).
1118
1255
  suspectedMissingTables: [],
1256
+ suspectedStorageBuckets: [],
1119
1257
  }
1120
1258
 
1121
1259
  const diagnostics = []
@@ -1185,6 +1323,7 @@ export async function runValidation(rootDir, projectId) {
1185
1323
 
1186
1324
  // Realtime YAML rules
1187
1325
  diagnostics.push(...await validateRealtime(rootDir, ctx))
1326
+ diagnostics.push(...await verifyStorageBucketsInRemote(ctx.suspectedStorageBuckets, targetProjectId))
1188
1327
 
1189
1328
  // Surface any file-read errors too
1190
1329
  for (const err of local.errors || []) {
@@ -1219,7 +1358,7 @@ export const dypaiValidateTool = {
1219
1358
  name: "dypai_validate",
1220
1359
  description:
1221
1360
  "Lint the local dypai/ folder BEFORE pushing. Catches ${input.x} / ${nodes.x.y} / ${current_user_*} refs that don't resolve, " +
1222
- "SQL tables not in schema.sql, credentials that don't exist remotely, and agent tool refs to non-tool endpoints. " +
1361
+ "SQL tables not in schema.sql, missing storage buckets, credentials that don't exist remotely, and agent tool refs to non-tool endpoints. " +
1223
1362
  "Run this after editing YAMLs to catch typos pre-flight. Push already calls it by default — pass skip_validation:true to override.",
1224
1363
  inputSchema: {
1225
1364
  type: "object",