@dypai-ai/mcp 1.0.10 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,343 @@
1
+ /**
2
+ * dypai_test — YAML-defined tests against endpoints. Zero engine changes:
3
+ * orchestrates `execute_sql` + `test_workflow` (impersonation) + `execute_sql`
4
+ * using what already exists.
5
+ *
6
+ * File layout (committable under dypai/tests/):
7
+ * endpoint: create-order # or endpoint_id: <uuid>
8
+ * tests:
9
+ * - name: "creates order with valid product"
10
+ * setup_sql: INSERT INTO products ...
11
+ * as_user: 11111111-1111-1111-1111-111111111111
12
+ * input: { product_id: "p1", qty: 3 }
13
+ * expect:
14
+ * success: true
15
+ * response:
16
+ * ok: true
17
+ * order_id: { matches: uuid }
18
+ * db_queries:
19
+ * - sql: "SELECT COUNT(*)::int FROM orders WHERE product_id='p1'"
20
+ * equals: 1
21
+ * teardown_sql: DELETE FROM orders WHERE product_id='p1'; DELETE FROM products WHERE id='p1';
22
+ */
23
+
24
+ import { readFile, readdir } from "fs/promises"
25
+ import { join, resolve as resolvePath } from "path"
26
+ import YAML from "yaml"
27
+ import { proxyToolCall } from "../proxy.js"
28
+ import { readLocalConfig } from "./planner.js"
29
+
30
+ // ─── Test file discovery ────────────────────────────────────────────────────
31
+
32
+ async function findTestFiles(testsDir) {
33
+ const out = []
34
+ async function walk(dir) {
35
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
36
+ for (const e of entries) {
37
+ const full = join(dir, e.name)
38
+ if (e.isDirectory()) await walk(full)
39
+ else if (e.isFile() && (e.name.endsWith(".test.yaml") || e.name.endsWith(".test.yml"))) out.push(full)
40
+ }
41
+ }
42
+ await walk(testsDir)
43
+ return out
44
+ }
45
+
46
+ // ─── SQL helpers ────────────────────────────────────────────────────────────
47
+
48
+ async function execSql(projectId, sql) {
49
+ const res = await proxyToolCall("execute_sql", { project_id: projectId, sql })
50
+ if (res?.error) throw new Error(res.error)
51
+ return res
52
+ }
53
+
54
+ function firstCellOf(res) {
55
+ const row = res?.rows?.[0]
56
+ if (!row) return undefined
57
+ const k = Object.keys(row)[0]
58
+ return k ? row[k] : undefined
59
+ }
60
+
61
+ /** Resolve an endpoint name to its UUID via system.endpoints (the remote
62
+ * test_workflow tool only accepts endpoint_id, not endpoint_name). */
63
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
64
+ async function resolveEndpointId(projectId, ref, cache) {
65
+ if (UUID_RE.test(ref)) return ref
66
+ if (cache.has(ref)) return cache.get(ref)
67
+ const safeName = String(ref).replace(/'/g, "''")
68
+ const res = await execSql(projectId,
69
+ `SELECT id FROM system.endpoints WHERE name = '${safeName}' LIMIT 1`)
70
+ const id = firstCellOf(res)
71
+ if (!id) throw new Error(`Endpoint '${ref}' not found (looked up in system.endpoints by name).`)
72
+ cache.set(ref, id)
73
+ return id
74
+ }
75
+
76
+ // ─── Assertion matchers ─────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Deep match `actual` against `expected`. Expected can contain meta-matchers
80
+ * via special objects with keys `matches`, `equals`, `contains`, `type`,
81
+ * `exists`, `gte`, `lte`. Returns { ok, errors: [] }.
82
+ */
83
+ function matchValue(actual, expected, path = "") {
84
+ if (expected && typeof expected === "object" && !Array.isArray(expected)) {
85
+ const metaKeys = new Set(["matches", "equals", "contains", "type", "exists", "gte", "lte"])
86
+ const keys = Object.keys(expected)
87
+ if (keys.length && keys.every(k => metaKeys.has(k))) return matchMeta(actual, expected, path)
88
+
89
+ if (actual == null || typeof actual !== "object") {
90
+ return { ok: false, errors: [`${path || "(root)"}: expected object, got ${JSON.stringify(actual)}`] }
91
+ }
92
+ const errors = []
93
+ for (const k of keys) {
94
+ const r = matchValue(actual[k], expected[k], path ? `${path}.${k}` : k)
95
+ if (!r.ok) errors.push(...r.errors)
96
+ }
97
+ return errors.length ? { ok: false, errors } : { ok: true }
98
+ }
99
+
100
+ if (Array.isArray(expected)) {
101
+ if (!Array.isArray(actual)) return { ok: false, errors: [`${path}: expected array, got ${typeof actual}`] }
102
+ if (actual.length !== expected.length) return { ok: false, errors: [`${path}: length ${expected.length} expected, got ${actual.length}`] }
103
+ const errors = []
104
+ expected.forEach((item, i) => {
105
+ const r = matchValue(actual[i], item, `${path}[${i}]`)
106
+ if (!r.ok) errors.push(...r.errors)
107
+ })
108
+ return errors.length ? { ok: false, errors } : { ok: true }
109
+ }
110
+
111
+ if (actual !== expected) {
112
+ return { ok: false, errors: [`${path || "(root)"}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`] }
113
+ }
114
+ return { ok: true }
115
+ }
116
+
117
+ function matchMeta(actual, meta, path) {
118
+ const errs = []
119
+ if ("equals" in meta && actual !== meta.equals) {
120
+ errs.push(`${path}: expected ${JSON.stringify(meta.equals)}, got ${JSON.stringify(actual)}`)
121
+ }
122
+ if ("matches" in meta) {
123
+ const m = meta.matches
124
+ const str = String(actual ?? "")
125
+ if (m === "uuid") {
126
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str)) {
127
+ errs.push(`${path}: expected UUID, got ${JSON.stringify(actual)}`)
128
+ }
129
+ } else {
130
+ try {
131
+ if (!new RegExp(m).test(str)) errs.push(`${path}: did not match /${m}/, got ${JSON.stringify(actual)}`)
132
+ } catch {
133
+ errs.push(`${path}: invalid regex ${JSON.stringify(m)}`)
134
+ }
135
+ }
136
+ }
137
+ if ("contains" in meta) {
138
+ if (typeof actual !== "string" || !actual.includes(String(meta.contains))) {
139
+ errs.push(`${path}: expected to contain ${JSON.stringify(meta.contains)}, got ${JSON.stringify(actual)}`)
140
+ }
141
+ }
142
+ if ("type" in meta && typeof actual !== meta.type) {
143
+ errs.push(`${path}: expected type ${meta.type}, got ${typeof actual}`)
144
+ }
145
+ if ("exists" in meta) {
146
+ const exists = actual !== undefined && actual !== null
147
+ if (meta.exists !== exists) errs.push(`${path}: expected exists=${meta.exists}, got ${exists}`)
148
+ }
149
+ if ("gte" in meta && !(Number(actual) >= Number(meta.gte))) {
150
+ errs.push(`${path}: expected >= ${meta.gte}, got ${actual}`)
151
+ }
152
+ if ("lte" in meta && !(Number(actual) <= Number(meta.lte))) {
153
+ errs.push(`${path}: expected <= ${meta.lte}, got ${actual}`)
154
+ }
155
+ return errs.length ? { ok: false, errors: errs } : { ok: true }
156
+ }
157
+
158
+ // ─── Single test runner ─────────────────────────────────────────────────────
159
+
160
+ async function runSingleTest(test, fileCtx) {
161
+ const result = { name: test.name || "(unnamed)", status: "pass", errors: [] }
162
+
163
+ // Setup
164
+ if (test.setup_sql) {
165
+ try { await execSql(fileCtx.projectId, test.setup_sql) }
166
+ catch (e) { return { ...result, status: "error", phase: "setup", errors: [`setup_sql failed: ${e.message}`] } }
167
+ }
168
+
169
+ try {
170
+ // Resolve endpoint → endpoint_id (remote only accepts UUIDs)
171
+ const endpointRef = test.endpoint || test.endpoint_id || fileCtx.endpoint
172
+ if (!endpointRef) {
173
+ return { ...result, status: "error", errors: ["No endpoint specified. Set `endpoint` at file root or test level."] }
174
+ }
175
+ const endpointId = await resolveEndpointId(fileCtx.projectId, endpointRef, fileCtx.endpointCache)
176
+
177
+ const execArgs = {
178
+ project_id: fileCtx.projectId,
179
+ endpoint_id: endpointId,
180
+ data: test.input || {},
181
+ trace_mode: "minimal", // keep tests fast; detail on failure via dypai_trace
182
+ }
183
+ if (test.as_user) execArgs.impersonated_user_id = test.as_user
184
+
185
+ const runResponse = await proxyToolCall("test_workflow", execArgs)
186
+
187
+ // Normalize what counts as the workflow "result body" (vary by engine version)
188
+ const body = runResponse?.result ?? runResponse?.data ?? runResponse?.output ?? runResponse
189
+ const trace = runResponse?.trace
190
+
191
+ // Assertions
192
+ const expect = test.expect || {}
193
+
194
+ // expect.success — did the workflow complete?
195
+ if ("success" in expect) {
196
+ const status = trace?.status ?? trace?.workflow?.status
197
+ const actualSuccess = status
198
+ ? status === "completed"
199
+ : !runResponse?.error // fallback: presence of error field
200
+ if (actualSuccess !== expect.success) {
201
+ result.errors.push(`expected success=${expect.success}, got ${actualSuccess}`)
202
+ }
203
+ }
204
+
205
+ // expect.response — match body
206
+ if (expect.response !== undefined) {
207
+ const r = matchValue(body, expect.response, "response")
208
+ if (!r.ok) result.errors.push(...r.errors)
209
+ }
210
+
211
+ // expect.failed_at — name of the node that should have failed
212
+ if (expect.failed_at) {
213
+ const failedAt = trace?.failed_at ?? trace?.workflow?.failed_at
214
+ if (failedAt !== expect.failed_at) {
215
+ result.errors.push(`expected failed_at='${expect.failed_at}', got ${JSON.stringify(failedAt)}`)
216
+ }
217
+ }
218
+
219
+ // expect.db_queries — each runs a SELECT and compares first cell or full rows
220
+ if (Array.isArray(expect.db_queries)) {
221
+ for (const q of expect.db_queries) {
222
+ try {
223
+ const res = await execSql(fileCtx.projectId, q.sql)
224
+ const first = firstCellOf(res)
225
+ if ("equals" in q && first !== q.equals) {
226
+ result.errors.push(`db_query "${q.sql.slice(0, 50)}": expected ${q.equals}, got ${JSON.stringify(first)}`)
227
+ }
228
+ if ("gte" in q && !(Number(first) >= Number(q.gte))) {
229
+ result.errors.push(`db_query: expected >= ${q.gte}, got ${first}`)
230
+ }
231
+ if ("lte" in q && !(Number(first) <= Number(q.lte))) {
232
+ result.errors.push(`db_query: expected <= ${q.lte}, got ${first}`)
233
+ }
234
+ if (q.match) {
235
+ const r = matchValue(res?.rows, q.match, "db_rows")
236
+ if (!r.ok) result.errors.push(...r.errors)
237
+ }
238
+ } catch (e) {
239
+ result.errors.push(`db_query execution failed: ${e.message}`)
240
+ }
241
+ }
242
+ }
243
+ } catch (e) {
244
+ result.errors.push(`execution error: ${e.message}`)
245
+ }
246
+
247
+ // Teardown — always try, even if the test failed
248
+ if (test.teardown_sql) {
249
+ try { await execSql(fileCtx.projectId, test.teardown_sql) }
250
+ catch (e) { result.errors.push(`teardown_sql failed: ${e.message}`) }
251
+ }
252
+
253
+ if (result.errors.length) result.status = "fail"
254
+ else delete result.errors
255
+ return result
256
+ }
257
+
258
+ // ─── Tool ───────────────────────────────────────────────────────────────────
259
+
260
+ export const dypaiTestTool = {
261
+ name: "dypai_test",
262
+ description:
263
+ "Run YAML-defined tests from dypai/tests/*.test.yaml. Each test does: setup_sql → test_workflow (with impersonation) → response assertions → db_queries assertions → teardown_sql. " +
264
+ "Uses existing remote tools (execute_sql, test_workflow) — no engine changes needed. " +
265
+ "Pass `only` to run a subset (substring match on test name).",
266
+ inputSchema: {
267
+ type: "object",
268
+ properties: {
269
+ project_id: { type: "string", description: "Project UUID. Auto-resolved from dypai.config.yaml." },
270
+ root_dir: { type: "string", default: "./dypai" },
271
+ only: { type: "string", description: "Only run tests whose name includes this substring." },
272
+ file: { type: "string", description: "Relative path to a single test file under dypai/tests/." },
273
+ },
274
+ },
275
+ async execute({ project_id, root_dir = "./dypai", only, file } = {}) {
276
+ const rootDir = resolvePath(process.cwd(), root_dir)
277
+ const config = await readLocalConfig(rootDir)
278
+ const projectId = project_id || config?.project_id
279
+ if (!projectId) {
280
+ return {
281
+ success: false,
282
+ error: "project_id is required. Set it in dypai.config.yaml or pass it explicitly.",
283
+ }
284
+ }
285
+
286
+ const testsDir = join(rootDir, "tests")
287
+ let files = []
288
+ if (file) {
289
+ files = [file.startsWith("/") ? file : join(testsDir, file)]
290
+ } else {
291
+ files = await findTestFiles(testsDir)
292
+ }
293
+ if (!files.length) {
294
+ return {
295
+ success: true,
296
+ total: 0, pass: 0, fail: 0, error: 0,
297
+ results: [],
298
+ hint: "No test files found. Create dypai/tests/<endpoint>.test.yaml — see README for format.",
299
+ }
300
+ }
301
+
302
+ const allResults = []
303
+ const parseErrors = []
304
+ for (const f of files) {
305
+ let doc
306
+ try {
307
+ const raw = await readFile(f, "utf8")
308
+ doc = YAML.parse(raw) || {}
309
+ } catch (e) {
310
+ parseErrors.push({ file: f, error: e.message })
311
+ continue
312
+ }
313
+ const tests = Array.isArray(doc) ? doc : (doc.tests || [])
314
+ const fileCtx = {
315
+ projectId,
316
+ endpoint: doc.endpoint || doc.endpoint_id,
317
+ rootDir,
318
+ endpointCache: new Map(),
319
+ }
320
+ for (const t of tests) {
321
+ if (only && !(t.name || "").toLowerCase().includes(only.toLowerCase())) continue
322
+ const r = await runSingleTest(t, fileCtx)
323
+ r.file = f.replace(rootDir + "/", "")
324
+ allResults.push(r)
325
+ }
326
+ }
327
+
328
+ const pass = allResults.filter(r => r.status === "pass").length
329
+ const fail = allResults.filter(r => r.status === "fail").length
330
+ const error = allResults.filter(r => r.status === "error").length
331
+
332
+ return {
333
+ success: fail === 0 && error === 0 && parseErrors.length === 0,
334
+ total: allResults.length,
335
+ pass, fail, error,
336
+ results: allResults,
337
+ parse_errors: parseErrors.length ? parseErrors : undefined,
338
+ hint: (fail + error) > 0
339
+ ? `${fail} test(s) failed, ${error} errored. For failing ones, use dypai_trace on the reported execution_id for deeper inspection.`
340
+ : undefined,
341
+ }
342
+ },
343
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Bidirectional transformations between the DB's executable JSON workflow
3
+ * format and the declarative YAML/files format.
4
+ *
5
+ * CRITICAL: every transform declares its pull (DB → file) and push (file → DB)
6
+ * direction together. This guarantees round-trip symmetry by construction —
7
+ * there's no separate "push" file that can drift out of sync with "pull".
8
+ *
9
+ * Adding a new transform = edit one object here, both directions in one commit.
10
+ *
11
+ * Context shape for pull:
12
+ * {
13
+ * endpointName, nodeId,
14
+ * credIdToName, groupIdToName, endpointIdToName,
15
+ * emitFile(path, content), // collects files to write to disk
16
+ * }
17
+ *
18
+ * Context shape for push:
19
+ * {
20
+ * endpointName, nodeId,
21
+ * credNameToId, groupNameToId, endpointNameToId,
22
+ * readFile(path), // returns file contents
23
+ * }
24
+ */
25
+
26
+ // Only extract truly large content. Below these thresholds, SQL and prompts
27
+ // stay inline in the YAML so the endpoint is one self-contained file.
28
+ const SQL_INLINE_MAX_CHARS = 500
29
+ const PROMPT_INLINE_MAX_CHARS = 800
30
+
31
+ const shouldInlineSql = (q) => !q || q.length <= SQL_INLINE_MAX_CHARS
32
+
33
+ // ─── Node-level field transforms ────────────────────────────────────────────
34
+
35
+ export const NODE_FIELD_TRANSFORMS = [
36
+ {
37
+ name: "credential",
38
+ pull(params, ctx) {
39
+ if (params.credential_id && ctx.credIdToName[params.credential_id]) {
40
+ return { credential: ctx.credIdToName[params.credential_id] }
41
+ }
42
+ return {}
43
+ },
44
+ push(params, ctx) {
45
+ if (params.credential && ctx.credNameToId[params.credential]) {
46
+ return { credential_id: ctx.credNameToId[params.credential] }
47
+ }
48
+ return {}
49
+ },
50
+ pullConsumes: ["credential_id"],
51
+ pushConsumes: ["credential"],
52
+ },
53
+
54
+ {
55
+ name: "agent_tools",
56
+ appliesWhen: (nodeType) => nodeType === "agent",
57
+ pull(params, ctx) {
58
+ if (Array.isArray(params.tool_ids)) {
59
+ return { tools: params.tool_ids.map(id => ctx.endpointIdToName[id] || id) }
60
+ }
61
+ return {}
62
+ },
63
+ push(params, ctx) {
64
+ if (Array.isArray(params.tools)) {
65
+ return { tool_ids: params.tools.map(n => ctx.endpointNameToId[n] || n) }
66
+ }
67
+ return {}
68
+ },
69
+ pullConsumes: ["tool_ids"],
70
+ pushConsumes: ["tools"],
71
+ },
72
+
73
+ {
74
+ name: "sql_extraction",
75
+ appliesWhen: (nodeType) => nodeType === "dypai_database",
76
+ pull(params, ctx) {
77
+ if (params.query && !shouldInlineSql(params.query)) {
78
+ // Flat layout: sql/<endpoint>.sql if only one SQL node, else sql/<endpoint>.<node>.sql
79
+ const suffix = ctx.sqlNodeCount > 1 ? `.${ctx.nodeId}` : ""
80
+ const path = `sql/${ctx.endpointName}${suffix}.sql`
81
+ ctx.emitFile(path, params.query.trim() + "\n")
82
+ return { query_file: path }
83
+ }
84
+ return {}
85
+ },
86
+ push(params, ctx) {
87
+ if (params.query_file) {
88
+ return { query: ctx.readFile(params.query_file).trimEnd() }
89
+ }
90
+ return {}
91
+ },
92
+ pullConsumes: ["query"],
93
+ pushConsumes: ["query_file"],
94
+ },
95
+
96
+ {
97
+ name: "prompt_extraction",
98
+ appliesWhen: (nodeType) => nodeType === "agent",
99
+ pull(params, ctx) {
100
+ if (params.system_prompt && params.system_prompt.length > PROMPT_INLINE_MAX_CHARS) {
101
+ const suffix = ctx.promptNodeCount > 1 ? `.${ctx.nodeId}` : ""
102
+ const path = `prompts/${ctx.endpointName}${suffix}.md`
103
+ ctx.emitFile(path, params.system_prompt.trim() + "\n")
104
+ return { system_prompt_file: path }
105
+ }
106
+ return {}
107
+ },
108
+ push(params, ctx) {
109
+ if (params.system_prompt_file) {
110
+ return { system_prompt: ctx.readFile(params.system_prompt_file).trimEnd() }
111
+ }
112
+ return {}
113
+ },
114
+ pullConsumes: ["system_prompt"],
115
+ pushConsumes: ["system_prompt_file"],
116
+ },
117
+
118
+ {
119
+ name: "code_extraction",
120
+ appliesWhen: (nodeType) => nodeType === "javascript_code" || nodeType === "python_code",
121
+ pull(params, ctx) {
122
+ if (params.code && params.code.length > SQL_INLINE_MAX_CHARS) {
123
+ const ext = ctx.nodeType === "python_code" ? "py" : "js"
124
+ const suffix = ctx.codeNodeCount > 1 ? `.${ctx.nodeId}` : ""
125
+ const path = `code/${ctx.endpointName}${suffix}.${ext}`
126
+ ctx.emitFile(path, params.code.trim() + "\n")
127
+ return { code_file: path }
128
+ }
129
+ return {}
130
+ },
131
+ push(params, ctx) {
132
+ if (params.code_file) {
133
+ return { code: ctx.readFile(params.code_file).trimEnd() }
134
+ }
135
+ return {}
136
+ },
137
+ pullConsumes: ["code"],
138
+ pushConsumes: ["code_file"],
139
+ },
140
+ ]
141
+
142
+ // ─── Engine ────────────────────────────────────────────────────────────────
143
+
144
+ function applyTransforms(input, direction, ctx, nodeType) {
145
+ let result = { ...input }
146
+ for (const t of NODE_FIELD_TRANSFORMS) {
147
+ if (t.appliesWhen && !t.appliesWhen(nodeType)) continue
148
+ const added = t[direction](result, ctx) || {}
149
+ const consumes = direction === "pull" ? t.pullConsumes : t.pushConsumes
150
+ for (const f of consumes || []) delete result[f]
151
+ Object.assign(result, added)
152
+ }
153
+ return result
154
+ }
155
+
156
+ export const pullNodeParams = (params, ctx, nodeType) => applyTransforms(params, "pull", ctx, nodeType)
157
+ export const pushNodeParams = (params, ctx, nodeType) => applyTransforms(params, "push", ctx, nodeType)