@dypai-ai/mcp 1.6.20 → 1.7.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.
@@ -8,7 +8,7 @@
8
8
  * trigger blocks, and other noise don't show up as false-positive diffs.
9
9
  */
10
10
 
11
- import { readFile, readdir } from "fs/promises"
11
+ import { readFile, readdir, mkdir, writeFile } from "fs/promises"
12
12
  import { join } from "path"
13
13
  import YAML from "yaml"
14
14
  import { proxyToolCall } from "../proxy.js"
@@ -71,6 +71,33 @@ export async function readLocalStateSnapshot(rootDir) {
71
71
  }
72
72
  }
73
73
 
74
+ /**
75
+ * Refresh .dypai/state.json from live remote endpoint timestamps.
76
+ * Call after publish/deploy so the next push does not see phantom conflicts.
77
+ */
78
+ export async function refreshLocalStateSnapshot({ projectId, rootDir }) {
79
+ if (!projectId || !rootDir) return null
80
+ const rows = await execSql(projectId, `
81
+ SELECT id, name, updated_at
82
+ FROM system.endpoints
83
+ ORDER BY name
84
+ `)
85
+ const successfullyPulledIds = new Set(rows.map((row) => row.id))
86
+ const state = {
87
+ pulled_at: new Date().toISOString(),
88
+ project_id: projectId,
89
+ endpoints: Object.fromEntries(
90
+ rows
91
+ .filter((row) => successfullyPulledIds.has(row.id))
92
+ .map((row) => [row.name, { id: row.id, updated_at: row.updated_at }]),
93
+ ),
94
+ }
95
+ const statePath = join(rootDir, ".dypai", "state.json")
96
+ await mkdir(join(rootDir, ".dypai"), { recursive: true })
97
+ await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8")
98
+ return state
99
+ }
100
+
74
101
  /**
75
102
  * Load dypai.config.yaml if it exists. This is the committed identity of the
76
103
  * project (project_id, project_name). Used to auto-resolve project_id and to
@@ -138,7 +165,8 @@ export async function fetchRemoteState(projectId) {
138
165
  // same session concurrently, especially right after a local restart.
139
166
  const endpoints = await execSql(projectId, `
140
167
  SELECT id, name, method, description, workflow_code, input, output,
141
- allowed_roles, is_tool, tool_description, group_id, is_active, updated_at
168
+ allowed_roles, is_tool, tool_description, group_id, is_active,
169
+ response_cardinality, updated_at
142
170
  FROM system.endpoints
143
171
  ORDER BY name
144
172
  `)
@@ -571,11 +599,20 @@ function inlineFileRefs(node, fileMap) {
571
599
  * are brought to this shape so file contents are part of the comparison
572
600
  * (otherwise identical file paths would mask content changes).
573
601
  */
602
+ function normalizeComparableDoc(doc) {
603
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) return doc
604
+ const copy = { ...doc }
605
+ if (copy.response_cardinality == null || copy.response_cardinality === "") {
606
+ delete copy.response_cardinality
607
+ }
608
+ return copy
609
+ }
610
+
574
611
  function remoteToComparable(row, mapsCtx) {
575
612
  const { doc, extractedFiles } = serializeEndpoint(row, mapsCtx)
576
613
  const fileMap = Object.fromEntries(extractedFiles.map(f => [f.path, f.content]))
577
614
  inlineFileRefs(doc, fileMap)
578
- return doc
615
+ return normalizeComparableDoc(doc)
579
616
  }
580
617
 
581
618
  function flowPayloadToRow(payload) {
@@ -264,6 +264,20 @@ function endpointPayload(row) {
264
264
  return p
265
265
  }
266
266
 
267
+ function splitBlockingConflicts(plan, conflicts) {
268
+ const touched = new Set([
269
+ ...(plan.update || []).map((item) => item.name),
270
+ ...(plan.delete || []).map((item) => item.name),
271
+ ])
272
+ const blocking = []
273
+ const non_blocking = []
274
+ for (const conflict of conflicts || []) {
275
+ if (touched.has(conflict.endpoint)) blocking.push(conflict)
276
+ else non_blocking.push(conflict)
277
+ }
278
+ return { blocking, non_blocking }
279
+ }
280
+
267
281
  /**
268
282
  * Treat a remote tool response as a definite success only when it has at
269
283
  * least one of the markers we expect from a real mutation. Anything else
@@ -631,13 +645,15 @@ export const dypaiPushTool = {
631
645
 
632
646
  // Block push on conflicts unless forced
633
647
  const conflicts = (plan.warnings || []).filter(w => w.type === "remote_changed_since_pull")
634
- if (conflicts.length && !force) {
648
+ const conflictSplit = splitBlockingConflicts(plan, conflicts)
649
+ if (conflictSplit.blocking.length && !force) {
635
650
  return {
636
651
  success: false,
637
652
  applied: false,
638
653
  reason: "conflicts_detected",
639
- conflicts,
640
- hint: "Run dypai_pull to refresh local state, resolve any overlap, then push again. To override, pass force=true.",
654
+ conflicts: conflictSplit.blocking,
655
+ non_blocking_conflicts: conflictSplit.non_blocking.length ? conflictSplit.non_blocking : undefined,
656
+ hint: "Remote changes overlap endpoints this push would update/delete. Run dypai_pull to refresh local state, resolve the overlap, then push again. To override, pass force=true.",
641
657
  }
642
658
  }
643
659
 
@@ -663,6 +679,7 @@ export const dypaiPushTool = {
663
679
  : totalChanges === 0 ? "no_changes" : "dry_run",
664
680
  summary: summaryFromPlan(plan),
665
681
  plan,
682
+ non_blocking_conflicts: conflictSplit.non_blocking.length ? conflictSplit.non_blocking : undefined,
666
683
  types: typesGenerated,
667
684
  automation_sync: automationSync,
668
685
  source_checkpoint: sourceCheckpoint || undefined,
@@ -861,6 +878,7 @@ export const dypaiPushTool = {
861
878
  },
862
879
  details: applied,
863
880
  endpoint_results,
881
+ non_blocking_conflicts: conflictSplit.non_blocking.length ? conflictSplit.non_blocking : undefined,
864
882
  errors: errors.length ? errors : undefined,
865
883
  types: typesGenerated,
866
884
  automation_sync: automationSync,
@@ -907,6 +925,7 @@ function summaryFromPlan(plan) {
907
925
  export const __testing = {
908
926
  normalizeResponseCardinality,
909
927
  endpointPayload,
928
+ splitBlockingConflicts,
910
929
  collectBackendSourceCheckpointFiles,
911
930
  deletedBackendSourcePathsFromPlan,
912
931
  syncAutomationMetadata,
@@ -424,8 +424,8 @@ export const dypaiTestEndpointTool = {
424
424
  },
425
425
  trace_mode: {
426
426
  type: "string",
427
- enum: ["smart", "full", "minimal"],
428
- description: "How to summarize the returned trace. 'smart' (default) surfaces the failing node in detail; with stop_at_step, includes outputs for all completed steps. 'full' returns everything; 'minimal' returns only status + duration.",
427
+ enum: ["smart", "output_only", "full", "minimal"],
428
+ description: "How to summarize the returned trace. 'smart' (default) surfaces the failing node in detail; 'output_only' returns final endpoint output + compact timings without prompts/inputs; with stop_at_step, includes outputs for completed steps. 'full' returns everything; 'minimal' returns only status + duration.",
429
429
  default: "smart",
430
430
  },
431
431
  root_dir: {
@@ -611,17 +611,18 @@ export const dypaiTestEndpointTool = {
611
611
  : undefined
612
612
  const summarized = summarizeTestWorkflowResponse(result, trace_mode, traceOptions)
613
613
  const safeSummary = (summarized && typeof summarized === "object" && !Array.isArray(summarized)) ? summarized : { result: summarized }
614
+ const compactTrace = trace_mode === "minimal"
614
615
 
615
616
  return {
616
617
  operation: "run",
617
618
  endpoint,
618
619
  source: sourceMetaRun,
619
620
  as_user: as_user || null,
620
- sdk_format_note: SDK_RESPONSE_FORMAT_NOTE,
621
- sdk_response: sdkResponseFromEndpointTest(result),
622
- frontend_usage: frontendUsageForEndpoint({ endpoint, method: sourceMetaRun.method, input }),
621
+ ...(compactTrace ? {} : { sdk_format_note: SDK_RESPONSE_FORMAT_NOTE }),
622
+ ...(compactTrace ? {} : { sdk_response: sdkResponseFromEndpointTest(result) }),
623
+ ...(compactTrace ? {} : { frontend_usage: frontendUsageForEndpoint({ endpoint, method: sourceMetaRun.method, input }) }),
623
624
  ...(stopAtStep ? { stop_at_step: stopAtStep } : {}),
624
- ...(step_outputs && Object.keys(step_outputs).length ? { step_outputs } : {}),
625
+ ...(compactTrace || !step_outputs || !Object.keys(step_outputs).length ? {} : { step_outputs }),
625
626
  ...safeSummary,
626
627
  ...(stopAtStep && !step_outputs ? {
627
628
  hint: "No per-step outputs in trace. Try trace_mode:'full' or inspect execution_id with search_logs(include_trace:true).",
@@ -932,6 +932,15 @@ function buildNodeOutputContracts(nodes, fileMap, ctx) {
932
932
  continue
933
933
  }
934
934
 
935
+ if (op === "semantic_search") {
936
+ contracts.set(node.id, {
937
+ type: "object",
938
+ properties: new Set(["ok", "operation", "table", "query", "results_count", "matches", "semantic_index"]),
939
+ strict: true,
940
+ })
941
+ continue
942
+ }
943
+
935
944
  if (op === "insert") {
936
945
  const bulk = nodeField(node, params, "mode") === "bulk" || Array.isArray(nodeField(node, params, "data"))
937
946
  contracts.set(node.id, {
@@ -1429,6 +1438,9 @@ function validateEndpoint(entry, ctx) {
1429
1438
  if (op === "mutation" && typeof table === "string") {
1430
1439
  referencedTables.add(table)
1431
1440
  }
1441
+ if (op === "semantic_search" && typeof table === "string") {
1442
+ referencedTables.add(table)
1443
+ }
1432
1444
  // Legacy ops like `select` / `insert` / `update` / `delete` use `table:`
1433
1445
  // as the target table directly.
1434
1446
  if (op && LEGACY_OPS_THAT_USE_TABLE_FIELD.has(op) && typeof table === "string") {
@@ -1549,6 +1561,41 @@ function validateEndpoint(entry, ctx) {
1549
1561
  })
1550
1562
  }
1551
1563
  }
1564
+
1565
+ // ── operation: semantic_search coherence ─────────────────────────────
1566
+ // Runtime semantic search is read-only over tables enabled through
1567
+ // manage_table_semantics. It uses `search_query` rather than `query`
1568
+ // because SQL query fields are rendered raw by the engine.
1569
+ if (op === "semantic_search") {
1570
+ const searchQuery = nodeField(node, params, "search_query")
1571
+ if (!table) {
1572
+ diagnostics.push({
1573
+ severity: "error",
1574
+ rule: "semantic_search_missing_table",
1575
+ endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
1576
+ message: `Node '${node.id}' uses 'operation: semantic_search' but is missing 'table_name:'.`,
1577
+ fix_hint: `Add 'table_name: <public_table>' and enable the table with manage_table_semantics(operation:"enable").`,
1578
+ })
1579
+ }
1580
+ if (!searchQuery && !query) {
1581
+ diagnostics.push({
1582
+ severity: "error",
1583
+ rule: "semantic_search_missing_query",
1584
+ endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
1585
+ message: `Node '${node.id}' uses 'operation: semantic_search' but is missing 'search_query:'.`,
1586
+ fix_hint: `Add 'search_query: \${input.query}'. Do not use hand-written pgvector SQL.`,
1587
+ })
1588
+ }
1589
+ if (!searchQuery && query) {
1590
+ diagnostics.push({
1591
+ severity: "warn",
1592
+ rule: "semantic_search_legacy_query_field",
1593
+ endpoint: name, file, loc: `workflow.nodes[${node.id}].query`,
1594
+ message: `Node '${node.id}' uses 'query:' for semantic_search. Prefer 'search_query:' so placeholders are template-rendered.`,
1595
+ fix_hint: `Rename 'query:' to 'search_query:'.`,
1596
+ })
1597
+ }
1598
+ }
1552
1599
  }
1553
1600
  if (node.tool_ids !== undefined && node.tools === undefined) {
1554
1601
  const toolIds = Array.isArray(node.tool_ids) ? node.tool_ids : []
@@ -1898,7 +1945,13 @@ function validateEndpoint(entry, ctx) {
1898
1945
 
1899
1946
  // Agent structured output + tools — engine uses generateObject OR generateText+tools, not both.
1900
1947
  if (nodeType === "agent") {
1901
- const tools = Array.isArray(node.tools) ? node.tools : []
1948
+ const tools = Array.isArray(node.tools)
1949
+ ? node.tools
1950
+ : Array.isArray(node.parameters?.tool_ids)
1951
+ ? node.parameters.tool_ids
1952
+ : Array.isArray(node.tool_ids)
1953
+ ? node.tool_ids
1954
+ : []
1902
1955
  const hasTools = tools.length > 0
1903
1956
  const hasOutputSchema =
1904
1957
  node.output_schema != null &&
@@ -1917,6 +1970,26 @@ function validateEndpoint(entry, ctx) {
1917
1970
  fix_hint: "search_docs('document extraction ocr')",
1918
1971
  })
1919
1972
  }
1973
+
1974
+ const activeWorkspace = node.active_workspace_id ?? node.parameters?.active_workspace_id
1975
+ const usesWorkspaceTools = tools.some((toolName) => String(toolName).startsWith("workspace-"))
1976
+ const hasActiveWorkspace =
1977
+ typeof activeWorkspace === "string"
1978
+ ? activeWorkspace.trim().length > 0
1979
+ : activeWorkspace != null && typeof activeWorkspace === "object"
1980
+ if (usesWorkspaceTools && !hasActiveWorkspace) {
1981
+ diagnostics.push({
1982
+ severity: "error",
1983
+ rule: "agent_workspace_binding_missing",
1984
+ endpoint: name,
1985
+ file,
1986
+ loc: `workflow.nodes[${node.id}]`,
1987
+ message:
1988
+ "agent uses workspace-* tools but active_workspace_id / activeWorkspace is not configured.",
1989
+ fix_hint:
1990
+ "Create a workspace step, then bind it with .agent({ activeWorkspace: ref.step('workspace', 'workspace_id'), tools: [...] }).",
1991
+ })
1992
+ }
1920
1993
  }
1921
1994
  }
1922
1995
 
@@ -11,6 +11,7 @@
11
11
  * - "smart" (default) — success → minimal; failure → focused on failing node
12
12
  * - "full" — raw trace (opt-in, for deep dives)
13
13
  * - "minimal" — just success/failure + duration + execution_order
14
+ * - "output_only" — final endpoint output + compact node timings, no inputs/snapshots
14
15
  *
15
16
  * The raw `events[]` array (node-scoped event log) is always dropped from
16
17
  * "smart" and "minimal" — it's redundant with the parsed fields.
@@ -18,11 +19,14 @@
18
19
 
19
20
  const MAX_FIELD_CHARS = 2000 // per-string truncation threshold
20
21
  const MAX_SNAPSHOT_KEYS = 6 // summarize large snapshots into a key outline
22
+ const MAX_OUTPUT_CHARS = 8000
23
+ const MAX_OUTPUT_KEYS = 40
24
+ const MAX_OUTPUT_ITEMS = 30
21
25
 
22
26
  function truncateString(s, max = MAX_FIELD_CHARS) {
23
27
  if (typeof s !== "string") return s
24
28
  if (s.length <= max) return s
25
- return s.slice(0, max) + `... [truncated ${s.length - max} chars, call dypai_trace with full=true]`
29
+ return s.slice(0, max) + `... [truncated ${s.length - max} chars, use trace_mode:'full' or search_logs(include_trace:true)]`
26
30
  }
27
31
 
28
32
  function summarizeValue(v) {
@@ -49,6 +53,41 @@ function summarizeValue(v) {
49
53
  return v
50
54
  }
51
55
 
56
+ function summarizeOutputValue(v) {
57
+ if (v == null) return v
58
+ if (typeof v === "string") return truncateString(v, MAX_OUTPUT_CHARS)
59
+ if (Array.isArray(v)) {
60
+ const items = v.slice(0, MAX_OUTPUT_ITEMS).map(summarizeOutputValue)
61
+ if (v.length > MAX_OUTPUT_ITEMS) {
62
+ items.push(`... ${v.length - MAX_OUTPUT_ITEMS} more items`)
63
+ }
64
+ return items
65
+ }
66
+ if (typeof v === "object") {
67
+ const keys = Object.keys(v)
68
+ const out = {}
69
+ for (const k of keys.slice(0, MAX_OUTPUT_KEYS)) {
70
+ out[k] = summarizeOutputValue(v[k])
71
+ }
72
+ if (keys.length > MAX_OUTPUT_KEYS) out._truncated_keys = keys.length - MAX_OUTPUT_KEYS
73
+ return out
74
+ }
75
+ return v
76
+ }
77
+
78
+ function hasOwn(obj, key) {
79
+ return Object.prototype.hasOwnProperty.call(obj, key)
80
+ }
81
+
82
+ function extractEndpointOutput(response) {
83
+ if (!response || typeof response !== "object" || Array.isArray(response)) return undefined
84
+ if (hasOwn(response, "result")) return response.result
85
+ if (hasOwn(response, "output")) return response.output
86
+ if (hasOwn(response, "data")) return response.data
87
+ if (hasOwn(response, "response")) return response.response
88
+ return undefined
89
+ }
90
+
52
91
  /** Build a hint string from the error message based on common patterns. */
53
92
  function hintFromError(err) {
54
93
  const m = String(err?.message || "")
@@ -81,6 +120,45 @@ export function summarizeTrace(trace, mode = "smart", options = {}) {
81
120
  }
82
121
  }
83
122
 
123
+ if (mode === "output_only") {
124
+ let failedAt = null
125
+ const nodeSummaries = []
126
+
127
+ for (const id of execOrder) {
128
+ const n = nodes[id]
129
+ if (!n) continue
130
+ const node = {
131
+ id: n.node_id,
132
+ type: n.node_type,
133
+ status: n.status,
134
+ duration_ms: n.duration_ms,
135
+ }
136
+ if (n.status === "failed") {
137
+ failedAt = failedAt || n.node_id
138
+ node.error = {
139
+ message: truncateString(n.error?.message),
140
+ type: n.error?.type,
141
+ }
142
+ if (n.fix_hint || n.error?.fix_hint) {
143
+ node.fix_hint = truncateString(n.fix_hint || n.error?.fix_hint)
144
+ }
145
+ }
146
+ nodeSummaries.push(node)
147
+ if (stopAtStep && id === stopAtStep) break
148
+ }
149
+
150
+ return {
151
+ execution_id: trace.execution_id,
152
+ success: wf.status === "completed",
153
+ status: wf.status,
154
+ duration_ms: wf.duration_ms,
155
+ failed_at: failedAt || undefined,
156
+ execution_order: execOrder,
157
+ nodes: nodeSummaries,
158
+ hint: "Compact trace only. Use trace_mode:'smart' for failing-node context or trace_mode:'full' for raw inputs/snapshots.",
159
+ }
160
+ }
161
+
84
162
  // "smart" mode
85
163
  const failed = wf.status === "failed"
86
164
  const failedNodeId = failed
@@ -170,6 +248,36 @@ export function summarizeTrace(trace, mode = "smart", options = {}) {
170
248
  */
171
249
  export function summarizeTestWorkflowResponse(response, mode = "smart", options = {}) {
172
250
  if (!response || typeof response !== "object") return response
251
+ if (mode === "minimal") {
252
+ const trace = response.trace ? summarizeTrace(response.trace, mode, options) : undefined
253
+ const output = extractEndpointOutput(response)
254
+ const out = {
255
+ success: trace?.status ? trace.status === "completed" : (response.error ? false : undefined),
256
+ execution_id: response.execution_id || trace?.execution_id,
257
+ status: trace?.status || response.status,
258
+ duration_ms: trace?.duration_ms || response.duration_ms,
259
+ ...(output !== undefined ? { result: summarizeOutputValue(output) } : {}),
260
+ ...(response.error ? { error: summarizeOutputValue(response.error) } : {}),
261
+ ...(trace ? { trace } : {}),
262
+ hint: "Returned endpoint result only. Use trace_mode:'smart' for failure context or trace_mode:'full' for raw execution snapshots.",
263
+ }
264
+ return Object.fromEntries(Object.entries(out).filter(([, v]) => v !== undefined))
265
+ }
266
+ if (mode === "output_only") {
267
+ const trace = response.trace ? summarizeTrace(response.trace, mode, options) : undefined
268
+ const output = extractEndpointOutput(response)
269
+ const out = {
270
+ success: trace?.success ?? (response.error ? false : undefined),
271
+ execution_id: response.execution_id || trace?.execution_id,
272
+ status: trace?.status || response.status,
273
+ duration_ms: trace?.duration_ms || response.duration_ms,
274
+ ...(output !== undefined ? { result: summarizeOutputValue(output) } : {}),
275
+ ...(response.error ? { error: summarizeOutputValue(response.error) } : {}),
276
+ ...(trace ? { trace } : {}),
277
+ hint: "Returned final endpoint output only. Use trace_mode:'smart' for failure context, stop_at_step for per-step outputs, or trace_mode:'full' for raw trace.",
278
+ }
279
+ return Object.fromEntries(Object.entries(out).filter(([, v]) => v !== undefined))
280
+ }
173
281
  if (!response.trace) return response
174
282
  return {
175
283
  ...response,