@dypai-ai/mcp 1.2.4 → 1.3.1

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.
@@ -105,10 +105,351 @@ Declarative snapshot of your DYPAI project's backend.
105
105
 
106
106
  Paths inside YAML (e.g. \`query_file: sql/create_invoice.sql\`) are always relative
107
107
  to this folder's root, regardless of where the YAML lives.
108
+
109
+ ## Reference examples
110
+
111
+ When the project has no endpoints yet, \`dypai_pull\` writes three reference
112
+ files (all with the \`.disabled\` suffix so \`dypai_push\` ignores them):
113
+
114
+ - \`endpoints/_example.yaml.disabled\` full workflow tour
115
+ - \`endpoints/_example-tool.yaml.disabled\` endpoint marked \`tool: true\`
116
+ - \`endpoints/_example-agent.yaml.disabled\` endpoint with an agent node using the tool
117
+
118
+ Read them to learn the canonical YAML format (placeholders, branching, error
119
+ handling, agent tools). Copy + adapt:
120
+
121
+ cp endpoints/_example.yaml.disabled endpoints/my-feature.yaml
122
+ cp endpoints/_example-tool.yaml.disabled endpoints/search-products.yaml
123
+ cp endpoints/_example-agent.yaml.disabled endpoints/chat.yaml
124
+ `
125
+
126
+ // Reference endpoint that ships with empty projects so the agent can learn
127
+ // the YAML format by reading a real, complete example. Covers: trigger, input
128
+ // schema, output schema, multi-node workflow, ${input.x} / ${nodes.x.y} /
129
+ // ${current_user_id} placeholders, expressions, branching with `handle`,
130
+ // per-node error handling, multiple return paths, *_file references.
131
+ const EXAMPLE_ENDPOINT_YAML = `# ============================================================================
132
+ # DYPAI endpoint reference — complete tour of the YAML format.
133
+ # This file is IGNORED by dypai_push, dypai_validate, dypai_diff and
134
+ # dypai_test_endpoint (the .disabled extension keeps it out of every flow).
135
+ #
136
+ # Copy + adapt when authoring a new endpoint:
137
+ # cp endpoints/_example.yaml.disabled endpoints/my-feature.yaml
138
+ # Then edit and remove the .disabled suffix.
139
+ # ============================================================================
140
+
141
+ name: example-process-order
142
+ description: |
143
+ Reference endpoint demonstrating the canonical YAML format:
144
+ trigger, input/output schemas, multi-node workflow, placeholders,
145
+ branching, per-node error handling, and multiple return paths.
146
+ method: POST
147
+
148
+ # ─── Trigger ────────────────────────────────────────────────────────────────
149
+ # Pick exactly one. Options:
150
+ # trigger: { http_api: { auth_mode: jwt | api_key | public } }
151
+ # trigger: { webhook: {} } # path is auto-generated
152
+ # trigger: { schedule: { cron: "0 9 * * 1-5", timezone: Europe/Madrid } }
153
+ # trigger: { telegram: {} } # incoming Telegram messages
154
+ #
155
+ # auth_mode notes:
156
+ # jwt — user-authenticated. \${current_user_id} / \${current_user_role} available.
157
+ # api_key — server-to-server. No user context.
158
+ # public — anonymous. Use only for read-only, non-sensitive data.
159
+ trigger:
160
+ http_api:
161
+ auth_mode: jwt
162
+
163
+ # ─── Input schema (JSON Schema) ─────────────────────────────────────────────
164
+ # Validates the request body. Fields here are referenced via \${input.<field>}.
165
+ # The validator (dypai_validate) catches typos in placeholders against this.
166
+ input:
167
+ type: object
168
+ required: [product_id, quantity]
169
+ properties:
170
+ product_id:
171
+ type: string
172
+ format: uuid
173
+ quantity:
174
+ type: integer
175
+ minimum: 1
176
+ note:
177
+ type: string
178
+
179
+ # ─── Output schema (optional but recommended) ───────────────────────────────
180
+ # Describes the response shape. Helps the validator + frontend type-checkers.
181
+ output:
182
+ type: object
183
+ properties:
184
+ order_id: { type: string }
185
+ total: { type: number }
186
+ status: { type: string }
187
+
188
+ # ─── Workflow ───────────────────────────────────────────────────────────────
189
+ # Nodes are the steps. Placeholders wire data flow between them:
190
+ #
191
+ # \${input.<field>} — the request body / query params
192
+ # \${nodes.<id>.<field>} — output of a previous node
193
+ # \${current_user_id} — UUID of the JWT-authenticated user
194
+ # \${current_user_role} — role name from the JWT
195
+ #
196
+ # Expressions inside placeholders work: arithmetic, comparisons, JS-ish.
197
+ # \${input.qty * 2}, \${nodes.x.stock >= input.qty}
198
+ #
199
+ # In SQL contexts (custom_query, dypai_database queries) the engine
200
+ # automatically casts placeholders by value shape:
201
+ # UUID-shaped string → binds as ::uuid
202
+ # plain object/array → binds as ::jsonb
203
+ # Date instance → binds as ::timestamptz
204
+ # text / int / bool → binds without an explicit cast (Postgres infers)
205
+ # So you write SQL naturally — DON'T add '\${current_user_id}'::uuid manually.
206
+ # Just write: WHERE user_id = \${current_user_id}
207
+ workflow:
208
+ nodes:
209
+
210
+ # 1. Read the product. \`operation: query\` for ANY read — pure SQL.
211
+ - id: get_product
212
+ type: dypai_database
213
+ operation: query
214
+ query: SELECT * FROM products WHERE id = \${input.product_id} LIMIT 1
215
+ # Output is accessible as \${nodes.get_product.<column>} (first row's columns)
216
+
217
+ # 2. Branch on stock availability.
218
+ # \`logic\` nodes with \`operation: if\` send flow down two edges
219
+ # differentiated by \`handle: "true"\` and \`handle: "false"\`.
220
+ - id: check_stock
221
+ type: logic
222
+ operation: if
223
+ condition: \${nodes.get_product.stock >= input.quantity}
224
+
225
+ # 3. Insert the order. \`operation: mutation\` for trivial single-table writes
226
+ # — declarative, no SQL needed. The shape (insert/update/delete) decides.
227
+ - id: insert_order
228
+ type: dypai_database
229
+ operation: mutation
230
+ table: orders
231
+ insert:
232
+ user_id: \${current_user_id} # JWT identity
233
+ product_id: \${input.product_id} # request input
234
+ quantity: \${input.quantity}
235
+ unit_price: \${nodes.get_product.price} # cross-node ref
236
+ total: \${nodes.get_product.price * input.quantity} # expression
237
+ note: \${input.note}
238
+ returning: "*"
239
+ # For UPSERT: add on_conflict: { columns: [id], action: update }
240
+ # For UPDATE: replace \`insert:\` with \`update: {...}\` + \`where: {...}\`
241
+ # For DELETE: replace \`insert:\` with \`delete: true\` + \`where: {...}\`
242
+ #
243
+ # Long SQL (>500 chars) inside \`operation: query\` is auto-extracted on
244
+ # \`dypai_pull\` to sql/<endpoint>.sql and referenced like:
245
+ # query_file: sql/insert_order.sql
246
+ # Same for prompts (system_prompt_file) and JS/Python code (code_file).
247
+
248
+ # 4. Notify a downstream system. \`on_error_step_id\` routes failures
249
+ # to \`build_response\` so a notification outage doesn't 500 the order.
250
+ - id: notify
251
+ type: http_request
252
+ method: POST
253
+ url: https://hooks.example.com/orders
254
+ body:
255
+ order_id: \${nodes.insert_order.id}
256
+ total: \${nodes.insert_order.total}
257
+ on_error_step_id: build_response
258
+
259
+ # 5. Build the success response. Exactly one node per code path needs
260
+ # \`return: true\` — it marks that node's output as the HTTP body.
261
+ - id: build_response
262
+ type: set_fields
263
+ return: true
264
+ fields:
265
+ order_id: \${nodes.insert_order.id}
266
+ total: \${nodes.insert_order.total}
267
+ status: created
268
+
269
+ # 6. Error path: out of stock. Different shape, also \`return: true\`.
270
+ # Multiple return nodes are fine as long as only one runs per execution.
271
+ - id: out_of_stock
272
+ type: set_fields
273
+ return: true
274
+ fields:
275
+ error: out_of_stock
276
+ message: Not enough stock for the requested quantity
277
+ available: \${nodes.get_product.stock}
278
+ requested: \${input.quantity}
279
+
280
+ # ─── Edges ────────────────────────────────────────────────────────────────
281
+ # Connect nodes. Form: { from: <id>, to: <id> }.
282
+ # For \`logic\` if-nodes use \`handle: "true"\` / \`handle: "false"\` to pick the branch.
283
+ edges:
284
+ - { from: get_product, to: check_stock }
285
+ - { from: check_stock, to: insert_order, handle: "true" }
286
+ - { from: check_stock, to: out_of_stock, handle: "false" }
287
+ - { from: insert_order, to: notify }
288
+ - { from: notify, to: build_response }
289
+
290
+ # \`on_error\` at the workflow level: stop (default) | continue
291
+ # on_error: stop
292
+ `
293
+
294
+ // Reference TOOL endpoint — an endpoint marked \`tool: true\` so an \`agent\` node
295
+ // elsewhere can invoke it. Pair with EXAMPLE_AGENT_ENDPOINT_YAML below.
296
+ const EXAMPLE_TOOL_ENDPOINT_YAML = `# ============================================================================
297
+ # DYPAI reference — endpoint exposed as a TOOL for \`agent\` nodes.
298
+ # Ignored by dypai_push/validate/diff/test_endpoint (.disabled suffix).
299
+ #
300
+ # Pair with _example-agent.yaml.disabled — that one has an agent node that
301
+ # references THIS endpoint by name.
302
+ #
303
+ # Copy + adapt:
304
+ # cp endpoints/_example-tool.yaml.disabled endpoints/search-products.yaml
305
+ # ============================================================================
306
+
307
+ name: example-search-products
308
+ description: Search the product catalog. Exposed as a tool for agent nodes.
309
+ method: GET
310
+
311
+ # ─── Tool flag + description ────────────────────────────────────────────────
312
+ # \`tool: true\` exposes this endpoint to agent nodes (writes is_tool=true in DB).
313
+ # \`tool_description\` is the prompt the LLM reads when deciding to call it.
314
+ # Be SPECIFIC about when to use it and what it returns — vague descriptions
315
+ # cause the LLM to skip useful tools or misuse them.
316
+ tool: true
317
+ tool_description: |
318
+ Search the product catalog by free-text query.
319
+ Use when the user asks about availability, prices, or categories.
320
+ Returns up to \`limit\` products with id, name, price, stock.
321
+
322
+ # ─── Input schema (tight JSON Schema with descriptions) ─────────────────────
323
+ # The LLM uses THIS schema to fabricate tool arguments. Every field benefits
324
+ # from a \`description\`; typed and required fields keep hallucinated args out.
325
+ input:
326
+ type: object
327
+ required: [query]
328
+ properties:
329
+ query:
330
+ type: string
331
+ description: Free-text search term (matches product name, case-insensitive).
332
+ limit:
333
+ type: integer
334
+ default: 10
335
+ minimum: 1
336
+ maximum: 50
337
+ description: Max results to return.
338
+
339
+ # ─── Output schema (optional but recommended for tools) ─────────────────────
340
+ output:
341
+ type: object
342
+ properties:
343
+ results:
344
+ type: array
345
+ items:
346
+ type: object
347
+ properties:
348
+ id: { type: string }
349
+ name: { type: string }
350
+ price: { type: number }
351
+ stock: { type: integer }
352
+
353
+ # ─── Trigger ────────────────────────────────────────────────────────────────
354
+ # Agent calls are server-to-server → \`api_key\`. Use \`jwt\` only if the tool
355
+ # needs \${current_user_id}. NEVER \`public\` for a tool that writes data.
356
+ trigger:
357
+ http_api:
358
+ auth_mode: api_key
359
+
360
+ workflow:
361
+ nodes:
362
+ - id: run
363
+ type: dypai_database
364
+ operation: query
365
+ return: true
366
+ query: |
367
+ SELECT id, name, price, stock
368
+ FROM products
369
+ WHERE name ILIKE '%' || \${input.query} || '%'
370
+ ORDER BY name
371
+ LIMIT \${input.limit}
372
+ `
373
+
374
+ // Reference endpoint with an \`agent\` node that USES a tool endpoint by name.
375
+ // Pair with EXAMPLE_TOOL_ENDPOINT_YAML above.
376
+ const EXAMPLE_AGENT_ENDPOINT_YAML = `# ============================================================================
377
+ # DYPAI reference — endpoint with an \`agent\` node that invokes tool endpoints.
378
+ # Ignored by dypai_push/validate/diff/test_endpoint (.disabled suffix).
379
+ #
380
+ # Pair with _example-tool.yaml.disabled — this file references that one by name.
381
+ #
382
+ # Copy + adapt:
383
+ # cp endpoints/_example-agent.yaml.disabled endpoints/chat.yaml
384
+ # ============================================================================
385
+
386
+ name: example-chat-assistant
387
+ description: Chat assistant that uses tool endpoints to answer questions about products.
388
+ method: POST
389
+
390
+ trigger:
391
+ http_api:
392
+ auth_mode: jwt # user-authenticated chat
393
+
394
+ input:
395
+ type: object
396
+ required: [message]
397
+ properties:
398
+ message:
399
+ type: string
400
+ description: The user's message to the assistant.
401
+
402
+ output:
403
+ type: object
404
+ properties:
405
+ reply: { type: string }
406
+ tool_calls: { type: array }
407
+
408
+ workflow:
409
+ nodes:
410
+ - id: chat
411
+ type: agent
412
+ provider: openai # openai | anthropic | google
413
+ model: gpt-4o # model id — see list via UI or search_docs
414
+ credential: openai-prod # credential NAME — must exist (get_app_credentials)
415
+ return: true
416
+
417
+ system_prompt: |
418
+ You are a helpful shop assistant. You have access to tools for searching
419
+ the product catalog and sending emails. When the user asks about
420
+ availability, prices, or categories, call example-search-products.
421
+ Always be concise and friendly.
422
+
423
+ input: \${input.message} # what the user said → the agent's turn
424
+
425
+ # ─── Tools (IMPORTANT — BY NAME, not UUID) ─────────────────────────────
426
+ # In YAML the parameter is \`tools: [<endpoint_name>, ...]\`. The codec
427
+ # resolves names ↔ UUIDs automatically on push/pull. Writing
428
+ # \`tool_ids: [...]\` in YAML BYPASSES the codec and fails in production.
429
+ #
430
+ # Every name here MUST reference an endpoint with \`tool: true\`.
431
+ # dypai_validate catches typos (rule: agent_tool_not_found) and lists
432
+ # the available tool endpoints in its fix_hint.
433
+ tools:
434
+ - example-search-products # ← references the other example file
435
+ # - example-send-email # add more tools as you define them
436
+
437
+ # ─── Agent behavior tuning ─────────────────────────────────────────────
438
+ max_iterations: 5 # max rounds of tool-calling (anti-runaway)
439
+ temperature: 0.7
440
+ tool_timeout: 30 # seconds per tool call
108
441
  `
109
442
 
110
443
  async function execSql(projectId, sql) {
111
- const args = projectId ? { project_id: projectId, sql } : { sql }
444
+ // The remote `execute_sql` silently injects `LIMIT 20` whenever a query has no
445
+ // LIMIT of its own — intended as a guard against ad-hoc queries returning
446
+ // gigantic result sets. For pull we want the COMPLETE lists of endpoints,
447
+ // credentials, groups, realtime policies, etc. Without an explicit LIMIT
448
+ // here, a project with >20 of any resource would silently lose rows.
449
+ // We append a generous cap (100k) only when the caller didn't specify one.
450
+ const hasLimit = /\bLIMIT\b/i.test(sql)
451
+ const finalSql = hasLimit ? sql : `${sql.replace(/;?\s*$/, "")} LIMIT 100000`
452
+ const args = projectId ? { project_id: projectId, sql: finalSql } : { sql: finalSql }
112
453
  const result = await proxyToolCall("execute_sql", args)
113
454
  if (result?.error) throw new Error(`SQL error: ${result.error}`)
114
455
  if (!result?.rows) {
@@ -150,10 +491,13 @@ function renderYaml(doc) {
150
491
  export const dypaiPullTool = {
151
492
  name: "dypai_pull",
152
493
  description:
153
- "Serializes the remote project state to local YAML files under ./dypai/. " +
154
- "Writes endpoints/<name>.yaml + sql/ and prompts/ for extracted content. " +
494
+ "Downloads BACKEND state (endpoints, SQL, prompts, node catalog, realtime policies, schema) to local YAML files under ./dypai/. " +
495
+ "Writes endpoints/<name>.yaml + sql/ + prompts/ + code/ for extracted content. " +
155
496
  "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. " +
497
+ "Use this to start editing a project locally with your editor + AI agent.\n\n" +
498
+ "SCOPE: backend only. This does NOT download frontend React/Vite source code — for that call " +
499
+ "`manage_frontend(operation: \"sync\", targetDirectory: <abs>)`. The two are independent; run both when " +
500
+ "starting fresh on a full-stack project.\n\n" +
157
501
  "IMPORTANT: when called by an IDE-hosted MCP, the process cwd is often the user's home dir — " +
158
502
  "pass an ABSOLUTE path in out_dir (e.g. /Users/me/projects/my-app/dypai) to avoid writing to the wrong place. " +
159
503
  "If out_dir is relative, the tool tries to auto-detect the workspace via env vars and git markers, " +
@@ -217,9 +561,8 @@ export const dypaiPullTool = {
217
561
  const [endpoints, credentials, groups, schemaSql, nodeCatalogResult, realtimePolicies] = await Promise.all([
218
562
  execSql(project_id, `
219
563
  SELECT id, name, method, description, workflow_code, input, output,
220
- allowed_roles, is_tool, tool_description, group_id, updated_at
564
+ allowed_roles, is_tool, tool_description, group_id, is_active, updated_at
221
565
  FROM system.endpoints
222
- WHERE is_active = true
223
566
  ORDER BY name
224
567
  `),
225
568
  execSql(project_id, "SELECT id, name, type FROM system.credentials"),
@@ -314,13 +657,52 @@ export const dypaiPullTool = {
314
657
  const relPath = groupName
315
658
  ? `endpoints/${groupName}/${row.name}.yaml`
316
659
  : `endpoints/${row.name}.yaml`
317
- await writeFileEnsured(join(outDir, relPath), renderYaml(doc))
660
+
661
+ // Inactive endpoints ARE pulled (user needs to see them), but flagged
662
+ // with a visible header so the agent/user knows the endpoint won't
663
+ // execute until re-enabled in the dashboard. `is_active` isn't
664
+ // serialized into the YAML itself — push only updates content, never
665
+ // the active flag, so editing + pushing an inactive endpoint keeps it
666
+ // inactive (safe default).
667
+ const yamlBody = renderYaml(doc)
668
+ const content = row.is_active === false
669
+ ? "# ⚠️ INACTIVE on the engine — this endpoint will NOT execute until re-enabled in the dashboard.\n" +
670
+ "# Edits applied via `dypai_push` are still saved, but the endpoint stays paused.\n" +
671
+ "# To reactivate: go to the project dashboard → Endpoints → toggle this one back on.\n\n" +
672
+ yamlBody
673
+ : yamlBody
674
+
675
+ await writeFileEnsured(join(outDir, relPath), content)
318
676
  filesWritten.push(relPath)
319
677
  } catch (e) {
320
678
  errors.push({ endpoint: row.name, error: e.message })
321
679
  }
322
680
  }
323
681
 
682
+ // Reference examples: only ship them on empty projects so the agent has
683
+ // canonical YAML tours to learn from. Once the project has real endpoints,
684
+ // those serve as the live examples — no need for the disabled stubs.
685
+ // .disabled suffix keeps them out of validate / diff / push / test_endpoint.
686
+ //
687
+ // Three separate files, each copy-ready for a different pattern:
688
+ // _example.yaml.disabled — full workflow tour (trigger, nodes, edges, branching)
689
+ // _example-tool.yaml.disabled — endpoint exposed as a tool for agent nodes
690
+ // _example-agent.yaml.disabled — endpoint with an agent node that uses the tool above
691
+ if (endpoints.length === 0) {
692
+ const refs = [
693
+ ["endpoints/_example.yaml.disabled", EXAMPLE_ENDPOINT_YAML],
694
+ ["endpoints/_example-tool.yaml.disabled", EXAMPLE_TOOL_ENDPOINT_YAML],
695
+ ["endpoints/_example-agent.yaml.disabled", EXAMPLE_AGENT_ENDPOINT_YAML],
696
+ ]
697
+ for (const [relPath, content] of refs) {
698
+ const fullPath = join(outDir, relPath)
699
+ try { await access(fullPath) } catch {
700
+ await writeFileEnsured(fullPath, content)
701
+ filesWritten.push(relPath)
702
+ }
703
+ }
704
+ }
705
+
324
706
  // Fetch project metadata to persist identity in the committed config
325
707
  let projectInfo = null
326
708
  if (project_id) {
@@ -370,6 +752,9 @@ export const dypaiPullTool = {
370
752
  const toolEndpoints = (endpoints || [])
371
753
  .filter(e => e.is_tool)
372
754
  .map(e => ({ name: e.name, description: e.tool_description || null }))
755
+ const inactiveEndpoints = (endpoints || [])
756
+ .filter(e => e.is_active === false)
757
+ .map(e => e.name)
373
758
 
374
759
  const overview = {
375
760
  project: projectInfo ? {
@@ -379,9 +764,12 @@ export const dypaiPullTool = {
379
764
  } : { id: resolvedProjectId || "(from token)" },
380
765
  endpoints: {
381
766
  total: (endpoints || []).length,
767
+ active: (endpoints || []).filter(e => e.is_active !== false).length,
768
+ inactive: inactiveEndpoints.length,
382
769
  groups: Object.keys(byGroup).filter(g => g !== "(no group)").sort(),
383
770
  by_group: byGroup,
384
771
  tool_endpoints: toolEndpoints,
772
+ inactive_endpoints: inactiveEndpoints.length > 0 ? inactiveEndpoints : undefined,
385
773
  },
386
774
  credentials: (credentials || []).map(c => ({ name: c.name, type: c.type })),
387
775
  realtime_policies: (realtimePolicies || []).length,
@@ -7,7 +7,13 @@
7
7
  import { proxyToolCall } from "../proxy.js"
8
8
 
9
9
  async function execSql(projectId, sql) {
10
- const args = projectId ? { project_id: projectId, sql } : { sql }
10
+ // Bypass remote execute_sql auto-LIMIT 20 so the schema dump is complete.
11
+ // Without this, a project with >20 columns across public.* (very normal)
12
+ // would produce a truncated schema.sql silently — breaks the validator and
13
+ // any agent that reads schema.sql before writing SQL.
14
+ const hasLimit = /\bLIMIT\b/i.test(sql)
15
+ const finalSql = hasLimit ? sql : `${sql.replace(/;?\s*$/, "")} LIMIT 100000`
16
+ const args = projectId ? { project_id: projectId, sql: finalSql } : { sql: finalSql }
11
17
  const result = await proxyToolCall("execute_sql", args)
12
18
  if (result?.error) throw new Error(`SQL error: ${result.error}`)
13
19
  if (!result?.rows) throw new Error(`Unexpected SQL response: ${JSON.stringify(result).slice(0, 300)}`)
@@ -15,6 +15,7 @@ import YAML from "yaml"
15
15
  import { proxyToolCall } from "../proxy.js"
16
16
  import { deserializeEndpoint } from "./codec.js"
17
17
  import { readLocalConfig, fetchRemoteState } from "./planner.js"
18
+ import { runValidation } from "./validate.js"
18
19
  import { summarizeTestWorkflowResponse } from "../trace-summarize.js"
19
20
 
20
21
  // ─── Local endpoint file discovery ──────────────────────────────────────────
@@ -43,6 +44,9 @@ async function findEndpointByName(rootDir, name) {
43
44
  const full = join(dir, e.name)
44
45
  if (e.isDirectory()) await walk(full)
45
46
  else if (e.isFile() && (e.name.endsWith(".yaml") || e.name.endsWith(".yml"))) {
47
+ // Skip disabled reference files (e.g. _example.yaml.disabled — already
48
+ // filtered by extension above, but also skip *.disabled.yaml form).
49
+ if (e.name.endsWith(".disabled.yaml") || e.name.endsWith(".disabled.yml")) continue
46
50
  candidates.push(full)
47
51
  }
48
52
  }
@@ -109,11 +113,16 @@ export const dypaiTestEndpointTool = {
109
113
  type: "string",
110
114
  description: "Project UUID. Auto-resolved from dypai.config.yaml.",
111
115
  },
116
+ skip_validation: {
117
+ type: "boolean",
118
+ description: "Skip the pre-flight validate pass. Default false — useful only when you intentionally want to send a malformed YAML to the engine to inspect its raw error.",
119
+ default: false,
120
+ },
112
121
  },
113
122
  required: ["endpoint"],
114
123
  },
115
124
 
116
- async execute({ endpoint, input = {}, as_user, trace_mode = "smart", root_dir = "./dypai", project_id } = {}) {
125
+ async execute({ endpoint, input = {}, as_user, trace_mode = "smart", root_dir = "./dypai", project_id, skip_validation = false } = {}) {
117
126
  const rootDir = resolvePath(process.cwd(), root_dir)
118
127
 
119
128
  if (!endpoint) {
@@ -172,6 +181,35 @@ export const dypaiTestEndpointTool = {
172
181
  }
173
182
  }
174
183
 
184
+ // ── Pre-flight validation (skippable) ────────────────────────────────────
185
+ // Run the same linter dypai_push uses, but filter to errors that affect THIS
186
+ // endpoint only — warnings and other-endpoint diagnostics shouldn't block
187
+ // a focused test. Catches things like mutation+query_file, missing where,
188
+ // unknown node types, placeholder typos, etc. before the engine sees them.
189
+ if (!skip_validation) {
190
+ try {
191
+ const valResult = await runValidation(rootDir, targetProjectId)
192
+ const relevantErrors = (valResult.diagnostics || []).filter(d =>
193
+ d.severity === "error" && d.endpoint === found.doc.name
194
+ )
195
+ if (relevantErrors.length > 0) {
196
+ return {
197
+ success: false,
198
+ phase: "pre_flight_validation",
199
+ endpoint,
200
+ file: found.full.replace(rootDir + "/", ""),
201
+ error: `${relevantErrors.length} validation error(s) in this endpoint — fix them before testing.`,
202
+ errors: relevantErrors,
203
+ hint: "Each error includes a fix_hint. Address them and re-run dypai_test_endpoint. Pass skip_validation: true if you want to bypass this and see the raw engine response.",
204
+ }
205
+ }
206
+ } catch (e) {
207
+ // Non-fatal: if validation itself crashes, continue to the test so
208
+ // the agent at least gets the engine's response. Logged as a warning.
209
+ process.stderr.write(`[dypai_test_endpoint] pre-flight validation failed: ${e.message}\n`)
210
+ }
211
+ }
212
+
175
213
  // Deserialize the local YAML to the engine-shaped workflow_code
176
214
  const deserCtx = { ...mapsCtx, readFile: (p) => fileMap[p] ?? "" }
177
215
  let row
@@ -197,13 +235,41 @@ export const dypaiTestEndpointTool = {
197
235
 
198
236
  try {
199
237
  const result = await proxyToolCall("test_workflow", execArgs)
238
+
239
+ // Defensive: the remote sometimes returns a plain error string (not a
240
+ // structured object) when something fails outside the workflow trace —
241
+ // e.g. SQL operator errors before the trace is built. Spreading a string
242
+ // with `...` would explode it character-by-character into the response,
243
+ // which is what produced the {"0":"o","1":"p",...} bug. Detect and wrap.
244
+ if (typeof result === "string") {
245
+ return {
246
+ success: false,
247
+ endpoint,
248
+ file: found.full.replace(rootDir + "/", ""),
249
+ as_user: as_user || null,
250
+ error: result.length > 2000 ? result.slice(0, 2000) + "...[truncated]" : result,
251
+ hint: "The remote returned a raw error string (no per-node trace available). Read the error above for the root cause.",
252
+ }
253
+ }
254
+ if (!result || typeof result !== "object") {
255
+ return {
256
+ success: false,
257
+ endpoint,
258
+ file: found.full.replace(rootDir + "/", ""),
259
+ as_user: as_user || null,
260
+ error: `Unexpected response type from remote test_workflow: ${typeof result}`,
261
+ raw_response: result,
262
+ }
263
+ }
264
+
200
265
  // Summarize the trace just like the direct test_workflow path.
201
266
  const summarized = summarizeTestWorkflowResponse(result, trace_mode)
267
+ const safeSummary = (summarized && typeof summarized === "object" && !Array.isArray(summarized)) ? summarized : { result: summarized }
202
268
  return {
203
269
  endpoint,
204
270
  file: found.full.replace(rootDir + "/", ""),
205
271
  as_user: as_user || null,
206
- ...summarized,
272
+ ...safeSummary,
207
273
  }
208
274
  } catch (e) {
209
275
  return {