@dypai-ai/mcp 1.0.9 → 1.1.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,210 @@
1
+ /**
2
+ * dypai_test_endpoint — test an endpoint by name using its LOCAL YAML.
3
+ *
4
+ * Pre-git-first world: the agent had to pass workflow_code or endpoint_id to
5
+ * the remote test_workflow. Now the YAML lives on disk — so we just take a
6
+ * name + input, read the file, deserialize via the codec, and execute.
7
+ *
8
+ * The big win: you're testing the EDITED version before pushing. Tight
9
+ * iteration loop without round-tripping through dypai_push + remote lookup.
10
+ */
11
+
12
+ import { readFile, readdir } from "fs/promises"
13
+ import { join, resolve as resolvePath } from "path"
14
+ import YAML from "yaml"
15
+ import { proxyToolCall } from "../proxy.js"
16
+ import { deserializeEndpoint } from "./codec.js"
17
+ import { readLocalConfig, fetchRemoteState } from "./planner.js"
18
+ import { summarizeTestWorkflowResponse } from "../trace-summarize.js"
19
+
20
+ // ─── Local endpoint file discovery ──────────────────────────────────────────
21
+
22
+ function collectFileRefs(doc) {
23
+ const refs = new Set()
24
+ const walk = (node) => {
25
+ if (!node || typeof node !== "object") return
26
+ if (Array.isArray(node)) return node.forEach(walk)
27
+ for (const [k, v] of Object.entries(node)) {
28
+ if (typeof v === "string" && k.endsWith("_file")) refs.add(v)
29
+ else walk(v)
30
+ }
31
+ }
32
+ walk(doc)
33
+ return Array.from(refs)
34
+ }
35
+
36
+ async function findEndpointByName(rootDir, name) {
37
+ const endpointsDir = join(rootDir, "endpoints")
38
+ const candidates = []
39
+
40
+ async function walk(dir) {
41
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
42
+ for (const e of entries) {
43
+ const full = join(dir, e.name)
44
+ if (e.isDirectory()) await walk(full)
45
+ else if (e.isFile() && (e.name.endsWith(".yaml") || e.name.endsWith(".yml"))) {
46
+ candidates.push(full)
47
+ }
48
+ }
49
+ }
50
+ await walk(endpointsDir)
51
+
52
+ // 1. Exact doc.name match (authoritative)
53
+ for (const full of candidates) {
54
+ try {
55
+ const raw = await readFile(full, "utf8")
56
+ const doc = YAML.parse(raw)
57
+ if (doc?.name === name) return { full, doc }
58
+ } catch { /* skip malformed */ }
59
+ }
60
+
61
+ // 2. Fallback: filename match (handles case where the agent guessed the file)
62
+ const filenameMatch = candidates.find(p => p.endsWith(`/${name}.yaml`) || p.endsWith(`/${name}.yml`))
63
+ if (filenameMatch) {
64
+ const raw = await readFile(filenameMatch, "utf8")
65
+ const doc = YAML.parse(raw)
66
+ return { full: filenameMatch, doc }
67
+ }
68
+
69
+ return null
70
+ }
71
+
72
+ // ─── Tool ───────────────────────────────────────────────────────────────────
73
+
74
+ export const dypaiTestEndpointTool = {
75
+ name: "dypai_test_endpoint",
76
+ description:
77
+ "Test an endpoint using its LOCAL YAML (dypai/endpoints/<name>.yaml, any group). " +
78
+ "You only pass the endpoint name + input — the tool reads the YAML, inlines referenced SQL/code/prompt files, " +
79
+ "and runs a debug execution against the engine. Use this to iterate BEFORE dypai_push. " +
80
+ "For jwt endpoints, pass as_user with the UUID to impersonate. " +
81
+ "Returns a summarized per-node trace by default; pass trace_mode: 'full' for the unabbreviated view.",
82
+ inputSchema: {
83
+ type: "object",
84
+ properties: {
85
+ endpoint: {
86
+ type: "string",
87
+ description: "Endpoint name as declared in its YAML (e.g. 'create-order'). Looked up in dypai/endpoints/** by matching doc.name.",
88
+ },
89
+ input: {
90
+ type: "object",
91
+ description: "Input body for the execution. Maps to ${input.*} placeholders inside the workflow.",
92
+ },
93
+ as_user: {
94
+ type: "string",
95
+ description: "User UUID to impersonate. Required for jwt endpoints that read ${current_user_id}.",
96
+ },
97
+ trace_mode: {
98
+ type: "string",
99
+ enum: ["smart", "full", "minimal"],
100
+ description: "How to summarize the returned trace. 'smart' (default) surfaces the failing node in detail; 'full' returns everything; 'minimal' returns only status + duration.",
101
+ default: "smart",
102
+ },
103
+ root_dir: {
104
+ type: "string",
105
+ description: "Path to the dypai/ folder (default: ./dypai).",
106
+ default: "./dypai",
107
+ },
108
+ project_id: {
109
+ type: "string",
110
+ description: "Project UUID. Auto-resolved from dypai.config.yaml.",
111
+ },
112
+ },
113
+ required: ["endpoint"],
114
+ },
115
+
116
+ async execute({ endpoint, input = {}, as_user, trace_mode = "smart", root_dir = "./dypai", project_id } = {}) {
117
+ const rootDir = resolvePath(process.cwd(), root_dir)
118
+
119
+ if (!endpoint) {
120
+ return { success: false, error: "endpoint name is required." }
121
+ }
122
+
123
+ const found = await findEndpointByName(rootDir, endpoint)
124
+ if (!found) {
125
+ return {
126
+ success: false,
127
+ error: `Endpoint '${endpoint}' not found under dypai/endpoints/.`,
128
+ hint: "Run dypai_pull to refresh, or check that the YAML's top-level `name` matches.",
129
+ }
130
+ }
131
+
132
+ // Pre-read any *_file references so the codec can inline them
133
+ const refs = collectFileRefs(found.doc)
134
+ const fileMap = {}
135
+ for (const ref of refs) {
136
+ try {
137
+ fileMap[ref] = await readFile(join(rootDir, ref), "utf8")
138
+ } catch (e) {
139
+ return {
140
+ success: false,
141
+ error: `Referenced file not readable: ${ref} (${e.message})`,
142
+ hint: "The YAML points to a *_file that doesn't exist on disk. Run dypai_pull or create the missing file.",
143
+ }
144
+ }
145
+ }
146
+
147
+ // Resolve project + credential/tool UUID maps for the codec
148
+ const config = await readLocalConfig(rootDir)
149
+ const targetProjectId = project_id || config?.project_id
150
+ if (!targetProjectId) {
151
+ return {
152
+ success: false,
153
+ error: "project_id required (set it in dypai.config.yaml or pass it explicitly).",
154
+ }
155
+ }
156
+
157
+ let mapsCtx
158
+ try {
159
+ const remote = await fetchRemoteState(targetProjectId)
160
+ mapsCtx = remote.mapsCtx
161
+ } catch (e) {
162
+ return {
163
+ success: false,
164
+ error: `Could not fetch remote state to resolve credential/tool references: ${e.message}`,
165
+ }
166
+ }
167
+
168
+ // Deserialize the local YAML to the engine-shaped workflow_code
169
+ const deserCtx = { ...mapsCtx, readFile: (p) => fileMap[p] ?? "" }
170
+ let row
171
+ try {
172
+ row = deserializeEndpoint(found.doc, deserCtx)
173
+ } catch (e) {
174
+ return {
175
+ success: false,
176
+ error: `Failed to deserialize '${endpoint}' from its YAML: ${e.message}`,
177
+ hint: "Run dypai_validate to surface the specific problem.",
178
+ }
179
+ }
180
+
181
+ // Build the call to the remote test_workflow (pass workflow_code inline so
182
+ // we test the LOCAL version, not whatever is currently deployed).
183
+ const execArgs = {
184
+ project_id: targetProjectId,
185
+ workflow_code: row.workflow_code,
186
+ data: input,
187
+ trace_mode, // used by the local MCP enrichment layer
188
+ }
189
+ if (as_user) execArgs.impersonated_user_id = as_user
190
+
191
+ try {
192
+ const result = await proxyToolCall("test_workflow", execArgs)
193
+ // Summarize the trace just like the direct test_workflow path.
194
+ const summarized = summarizeTestWorkflowResponse(result, trace_mode)
195
+ return {
196
+ endpoint,
197
+ file: found.full.replace(rootDir + "/", ""),
198
+ as_user: as_user || null,
199
+ ...summarized,
200
+ }
201
+ } catch (e) {
202
+ return {
203
+ success: false,
204
+ error: `Execution failed: ${e.message}`,
205
+ endpoint,
206
+ hint: "If the error is cryptic, try trace_mode: 'full' or use dypai_trace with the execution_id from the response.",
207
+ }
208
+ }
209
+ },
210
+ }
@@ -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
+ }