@kirrosh/zond 0.7.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.
Files changed (102) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/LICENSE +21 -0
  3. package/README.md +130 -0
  4. package/package.json +53 -0
  5. package/src/bun-types.d.ts +5 -0
  6. package/src/cli/commands/add-api.ts +51 -0
  7. package/src/cli/commands/ai-generate.ts +106 -0
  8. package/src/cli/commands/chat.ts +43 -0
  9. package/src/cli/commands/ci-init.ts +163 -0
  10. package/src/cli/commands/collections.ts +41 -0
  11. package/src/cli/commands/compare.ts +129 -0
  12. package/src/cli/commands/coverage.ts +156 -0
  13. package/src/cli/commands/doctor.ts +127 -0
  14. package/src/cli/commands/init.ts +84 -0
  15. package/src/cli/commands/mcp.ts +16 -0
  16. package/src/cli/commands/run.ts +156 -0
  17. package/src/cli/commands/runs.ts +108 -0
  18. package/src/cli/commands/serve.ts +22 -0
  19. package/src/cli/commands/update.ts +142 -0
  20. package/src/cli/commands/validate.ts +18 -0
  21. package/src/cli/index.ts +529 -0
  22. package/src/cli/output.ts +24 -0
  23. package/src/cli/runtime.ts +7 -0
  24. package/src/core/agent/agent-loop.ts +116 -0
  25. package/src/core/agent/context-manager.ts +41 -0
  26. package/src/core/agent/system-prompt.ts +28 -0
  27. package/src/core/agent/tools/diagnose-failure.ts +51 -0
  28. package/src/core/agent/tools/explore-api.ts +40 -0
  29. package/src/core/agent/tools/index.ts +46 -0
  30. package/src/core/agent/tools/query-results.ts +40 -0
  31. package/src/core/agent/tools/run-tests.ts +38 -0
  32. package/src/core/agent/tools/send-request.ts +44 -0
  33. package/src/core/agent/tools/validate-tests.ts +23 -0
  34. package/src/core/agent/types.ts +22 -0
  35. package/src/core/diagnostics/failure-hints.ts +63 -0
  36. package/src/core/generator/ai/ai-generator.ts +61 -0
  37. package/src/core/generator/ai/llm-client.ts +159 -0
  38. package/src/core/generator/ai/output-parser.ts +307 -0
  39. package/src/core/generator/ai/prompt-builder.ts +153 -0
  40. package/src/core/generator/ai/types.ts +56 -0
  41. package/src/core/generator/chunker.ts +47 -0
  42. package/src/core/generator/coverage-scanner.ts +87 -0
  43. package/src/core/generator/data-factory.ts +115 -0
  44. package/src/core/generator/endpoint-warnings.ts +43 -0
  45. package/src/core/generator/index.ts +12 -0
  46. package/src/core/generator/openapi-reader.ts +143 -0
  47. package/src/core/generator/schema-utils.ts +52 -0
  48. package/src/core/generator/serializer.ts +189 -0
  49. package/src/core/generator/types.ts +48 -0
  50. package/src/core/parser/filter.ts +14 -0
  51. package/src/core/parser/index.ts +21 -0
  52. package/src/core/parser/schema.ts +175 -0
  53. package/src/core/parser/types.ts +52 -0
  54. package/src/core/parser/variables.ts +154 -0
  55. package/src/core/parser/yaml-parser.ts +85 -0
  56. package/src/core/reporter/console.ts +175 -0
  57. package/src/core/reporter/index.ts +23 -0
  58. package/src/core/reporter/json.ts +9 -0
  59. package/src/core/reporter/junit.ts +78 -0
  60. package/src/core/reporter/types.ts +12 -0
  61. package/src/core/runner/assertions.ts +173 -0
  62. package/src/core/runner/execute-run.ts +97 -0
  63. package/src/core/runner/executor.ts +183 -0
  64. package/src/core/runner/http-client.ts +69 -0
  65. package/src/core/runner/index.ts +12 -0
  66. package/src/core/runner/types.ts +48 -0
  67. package/src/core/setup-api.ts +113 -0
  68. package/src/core/utils.ts +9 -0
  69. package/src/db/queries.ts +774 -0
  70. package/src/db/schema.ts +159 -0
  71. package/src/mcp/descriptions.ts +88 -0
  72. package/src/mcp/server.ts +52 -0
  73. package/src/mcp/tools/ci-init.ts +54 -0
  74. package/src/mcp/tools/coverage-analysis.ts +141 -0
  75. package/src/mcp/tools/describe-endpoint.ts +241 -0
  76. package/src/mcp/tools/explore-api.ts +84 -0
  77. package/src/mcp/tools/generate-and-save.ts +129 -0
  78. package/src/mcp/tools/generate-missing-tests.ts +91 -0
  79. package/src/mcp/tools/generate-tests-guide.ts +391 -0
  80. package/src/mcp/tools/manage-server.ts +86 -0
  81. package/src/mcp/tools/query-db.ts +255 -0
  82. package/src/mcp/tools/run-tests.ts +71 -0
  83. package/src/mcp/tools/save-test-suite.ts +218 -0
  84. package/src/mcp/tools/send-request.ts +63 -0
  85. package/src/mcp/tools/set-work-dir.ts +35 -0
  86. package/src/mcp/tools/setup-api.ts +84 -0
  87. package/src/mcp/tools/validate-tests.ts +43 -0
  88. package/src/tui/chat-ui.ts +150 -0
  89. package/src/web/data/collection-state.ts +360 -0
  90. package/src/web/routes/api.ts +234 -0
  91. package/src/web/routes/dashboard.ts +313 -0
  92. package/src/web/routes/runs.ts +64 -0
  93. package/src/web/schemas.ts +121 -0
  94. package/src/web/server.ts +134 -0
  95. package/src/web/static/htmx.min.js +1 -0
  96. package/src/web/static/style.css +827 -0
  97. package/src/web/views/endpoints-tab.ts +170 -0
  98. package/src/web/views/health-strip.ts +92 -0
  99. package/src/web/views/layout.ts +48 -0
  100. package/src/web/views/results.ts +209 -0
  101. package/src/web/views/runs-tab.ts +126 -0
  102. package/src/web/views/suites-tab.ts +153 -0
@@ -0,0 +1,255 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { getDb } from "../../db/schema.ts";
4
+ import { listCollections, listRuns, getRunById, getResultsByRunId, getCollectionById } from "../../db/queries.ts";
5
+ import { join } from "node:path";
6
+ import { TOOL_DESCRIPTIONS } from "../descriptions.js";
7
+ import { statusHint, classifyFailure, envHint, envCategory } from "../../core/diagnostics/failure-hints.ts";
8
+
9
+ function parseBodySafe(raw: string | null | undefined): unknown {
10
+ if (!raw) return undefined;
11
+ const truncated = raw.length > 2000 ? raw.slice(0, 2000) + "…[truncated]" : raw;
12
+ try {
13
+ return JSON.parse(raw);
14
+ } catch {
15
+ return truncated;
16
+ }
17
+ }
18
+
19
+ export function registerQueryDbTool(server: McpServer, dbPath?: string) {
20
+ server.registerTool("query_db", {
21
+ description: TOOL_DESCRIPTIONS.query_db,
22
+ inputSchema: {
23
+ action: z.enum(["list_collections", "list_runs", "get_run_results", "diagnose_failure", "compare_runs"])
24
+ .describe("Query action to perform"),
25
+ runId: z.optional(z.number().int())
26
+ .describe("Run ID (required for get_run_results and diagnose_failure)"),
27
+ runIdB: z.optional(z.number().int())
28
+ .describe("Second run ID (required for compare_runs — this is the newer run)"),
29
+ limit: z.optional(z.number().int().min(1).max(100))
30
+ .describe("Max number of runs to return (default: 20, only for list_runs)"),
31
+ },
32
+ }, async ({ action, runId, runIdB, limit }) => {
33
+ try {
34
+ getDb(dbPath);
35
+
36
+ switch (action) {
37
+ case "list_collections": {
38
+ const collections = listCollections();
39
+ return {
40
+ content: [{ type: "text" as const, text: JSON.stringify(collections, null, 2) }],
41
+ };
42
+ }
43
+
44
+ case "list_runs": {
45
+ const runs = listRuns(limit ?? 20);
46
+ return {
47
+ content: [{ type: "text" as const, text: JSON.stringify(runs, null, 2) }],
48
+ };
49
+ }
50
+
51
+ case "get_run_results": {
52
+ if (runId == null) {
53
+ return {
54
+ content: [{ type: "text" as const, text: JSON.stringify({ error: "runId is required for get_run_results" }, null, 2) }],
55
+ isError: true,
56
+ };
57
+ }
58
+ const run = getRunById(runId);
59
+ if (!run) {
60
+ return {
61
+ content: [{ type: "text" as const, text: JSON.stringify({ error: `Run ${runId} not found` }, null, 2) }],
62
+ isError: true,
63
+ };
64
+ }
65
+ const results = getResultsByRunId(runId);
66
+ const detail = {
67
+ run: {
68
+ id: run.id,
69
+ started_at: run.started_at,
70
+ finished_at: run.finished_at,
71
+ total: run.total,
72
+ passed: run.passed,
73
+ failed: run.failed,
74
+ skipped: run.skipped,
75
+ trigger: run.trigger,
76
+ environment: run.environment,
77
+ duration_ms: run.duration_ms,
78
+ },
79
+ results: results.map(r => ({
80
+ suite_name: r.suite_name,
81
+ test_name: r.test_name,
82
+ status: r.status,
83
+ duration_ms: r.duration_ms,
84
+ request_method: r.request_method,
85
+ request_url: r.request_url,
86
+ response_status: r.response_status,
87
+ error_message: r.error_message,
88
+ assertions: r.assertions,
89
+ })),
90
+ };
91
+ return {
92
+ content: [{ type: "text" as const, text: JSON.stringify(detail, null, 2) }],
93
+ };
94
+ }
95
+
96
+ case "diagnose_failure": {
97
+ if (runId == null) {
98
+ return {
99
+ content: [{ type: "text" as const, text: JSON.stringify({ error: "runId is required for diagnose_failure" }, null, 2) }],
100
+ isError: true,
101
+ };
102
+ }
103
+ const diagRun = getRunById(runId);
104
+ if (!diagRun) {
105
+ return {
106
+ content: [{ type: "text" as const, text: JSON.stringify({ error: `Run ${runId} not found` }, null, 2) }],
107
+ isError: true,
108
+ };
109
+ }
110
+
111
+ // Resolve env file path from collection for actionable hints
112
+ let envFilePath: string | undefined;
113
+ if (diagRun.collection_id) {
114
+ const collection = getCollectionById(diagRun.collection_id);
115
+ if (collection?.base_dir) {
116
+ envFilePath = join(collection.base_dir, ".env.yaml").replace(/\\/g, "/");
117
+ }
118
+ }
119
+
120
+ const allResults = getResultsByRunId(runId);
121
+ const failures = allResults
122
+ .filter(r => r.status === "fail" || r.status === "error")
123
+ .map(r => {
124
+ // env issues take priority over generic status hints
125
+ const hint = envHint(r.request_url, r.error_message, envFilePath) ?? statusHint(r.response_status);
126
+ const failure_type = classifyFailure(r.status, r.response_status);
127
+ return {
128
+ suite_name: r.suite_name,
129
+ test_name: r.test_name,
130
+ status: r.status,
131
+ failure_type,
132
+ error_message: r.error_message,
133
+ request_method: r.request_method,
134
+ request_url: r.request_url,
135
+ response_status: r.response_status,
136
+ ...(hint ? { hint } : {}),
137
+ response_body: parseBodySafe(r.response_body),
138
+ response_headers: r.response_headers
139
+ ? JSON.parse(r.response_headers)
140
+ : undefined,
141
+ assertions: r.assertions,
142
+ duration_ms: r.duration_ms,
143
+ };
144
+ });
145
+
146
+ // Top-level env_issue when all failures have the same env problem category
147
+ const categories = new Set(failures.map(f => envCategory(f.hint)).filter(Boolean));
148
+ const sharedEnvHint = categories.size === 1
149
+ ? categories.has("base_url_missing")
150
+ ? `All failures: base_url is not set — add base_url to ${envFilePath ?? ".env.yaml"}`
151
+ : categories.has("unresolved_variable")
152
+ ? `All failures: some variables are not substituted — check variable names in ${envFilePath ?? ".env.yaml"}`
153
+ : [...failures.map(f => f.hint).filter(Boolean)][0]
154
+ : undefined;
155
+
156
+ const apiErrors = failures.filter(f => f.failure_type === "api_error").length;
157
+ const assertionFailures = failures.filter(f => f.failure_type === "assertion_failed").length;
158
+ const networkErrors = failures.filter(f => f.failure_type === "network_error").length;
159
+
160
+ const result = {
161
+ run: {
162
+ id: diagRun.id,
163
+ started_at: diagRun.started_at,
164
+ environment: diagRun.environment,
165
+ duration_ms: diagRun.duration_ms,
166
+ },
167
+ summary: {
168
+ total: diagRun.total,
169
+ passed: diagRun.passed,
170
+ failed: diagRun.failed,
171
+ api_errors: apiErrors,
172
+ assertion_failures: assertionFailures,
173
+ network_errors: networkErrors,
174
+ },
175
+ ...(sharedEnvHint ? { env_issue: sharedEnvHint } : {}),
176
+ failures,
177
+ };
178
+ return {
179
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
180
+ };
181
+ }
182
+
183
+ case "compare_runs": {
184
+ if (runId == null || runIdB == null) {
185
+ return {
186
+ content: [{ type: "text" as const, text: JSON.stringify({ error: "Both runId (run A) and runIdB (run B) are required for compare_runs" }, null, 2) }],
187
+ isError: true,
188
+ };
189
+ }
190
+ const runARecord = getRunById(runId);
191
+ const runBRecord = getRunById(runIdB);
192
+ if (!runARecord) {
193
+ return {
194
+ content: [{ type: "text" as const, text: JSON.stringify({ error: `Run #${runId} not found` }, null, 2) }],
195
+ isError: true,
196
+ };
197
+ }
198
+ if (!runBRecord) {
199
+ return {
200
+ content: [{ type: "text" as const, text: JSON.stringify({ error: `Run #${runIdB} not found` }, null, 2) }],
201
+ isError: true,
202
+ };
203
+ }
204
+
205
+ const resultsA = getResultsByRunId(runId);
206
+ const resultsB = getResultsByRunId(runIdB);
207
+
208
+ const mapA = new Map<string, string>();
209
+ const mapB = new Map<string, string>();
210
+ for (const r of resultsA) mapA.set(`${r.suite_name}::${r.test_name}`, r.status);
211
+ for (const r of resultsB) mapB.set(`${r.suite_name}::${r.test_name}`, r.status);
212
+
213
+ const regressions: Array<{ suite: string; test: string; before: string; after: string }> = [];
214
+ const fixes: Array<{ suite: string; test: string; before: string; after: string }> = [];
215
+ let unchanged = 0;
216
+ let newTests = 0;
217
+ let removedTests = 0;
218
+
219
+ for (const [key, statusB] of mapB) {
220
+ const statusA = mapA.get(key);
221
+ if (statusA === undefined) { newTests++; continue; }
222
+ const [suite, test] = key.split("::") as [string, string];
223
+ const wasPass = statusA === "pass";
224
+ const isPass = statusB === "pass";
225
+ const wasFail = statusA === "fail" || statusA === "error";
226
+ const isFail = statusB === "fail" || statusB === "error";
227
+ if (wasPass && isFail) regressions.push({ suite, test, before: statusA, after: statusB });
228
+ else if (wasFail && isPass) fixes.push({ suite, test, before: statusA, after: statusB });
229
+ else unchanged++;
230
+ }
231
+ for (const key of mapA.keys()) {
232
+ if (!mapB.has(key)) removedTests++;
233
+ }
234
+
235
+ const compareResult = {
236
+ runA: { id: runId, started_at: runARecord.started_at },
237
+ runB: { id: runIdB, started_at: runBRecord.started_at },
238
+ summary: { regressions: regressions.length, fixes: fixes.length, unchanged, newTests, removedTests },
239
+ regressions,
240
+ fixes,
241
+ hasRegressions: regressions.length > 0,
242
+ };
243
+ return {
244
+ content: [{ type: "text" as const, text: JSON.stringify(compareResult, null, 2) }],
245
+ };
246
+ }
247
+ }
248
+ } catch (err) {
249
+ return {
250
+ content: [{ type: "text" as const, text: JSON.stringify({ error: (err as Error).message }, null, 2) }],
251
+ isError: true,
252
+ };
253
+ }
254
+ });
255
+ }
@@ -0,0 +1,71 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { executeRun } from "../../core/runner/execute-run.ts";
4
+ import { TOOL_DESCRIPTIONS } from "../descriptions.js";
5
+
6
+ export function registerRunTestsTool(server: McpServer, dbPath?: string) {
7
+ server.registerTool("run_tests", {
8
+ description: TOOL_DESCRIPTIONS.run_tests,
9
+ inputSchema: {
10
+ testPath: z.string().describe("Path to test YAML file or directory"),
11
+ envName: z.optional(z.string()).describe("Environment name (loads .env.<name>.yaml)"),
12
+ safe: z.optional(z.boolean()).describe("Run only GET tests (read-only, safe mode)"),
13
+ tag: z.optional(z.array(z.string())).describe("Filter suites by tag (OR logic)"),
14
+ envVars: z.optional(z.record(z.string(), z.string())).describe("Environment variables to inject (override env file, e.g. {\"TOKEN\": \"xxx\"})"),
15
+ dryRun: z.optional(z.boolean()).describe("Show requests without sending them (always exits 0)"),
16
+ },
17
+ }, async ({ testPath, envName, safe, tag, envVars, dryRun }) => {
18
+ const { runId, results } = await executeRun({
19
+ testPath,
20
+ envName,
21
+ trigger: "mcp",
22
+ dbPath,
23
+ safe,
24
+ tag,
25
+ envVars,
26
+ dryRun,
27
+ });
28
+
29
+ const total = results.reduce((s, r) => s + r.total, 0);
30
+ const passed = results.reduce((s, r) => s + r.passed, 0);
31
+ const failed = results.reduce((s, r) => s + r.failed, 0);
32
+ const skipped = results.reduce((s, r) => s + r.skipped, 0);
33
+
34
+ const failedSteps = results.flatMap(r =>
35
+ r.steps.filter(s => s.status === "fail" || s.status === "error").map(s => ({
36
+ suite: r.suite_name,
37
+ test: s.name,
38
+ status: s.status,
39
+ error: s.error,
40
+ assertions: s.assertions.filter(a => !a.passed).map(a => ({
41
+ field: a.field,
42
+ expected: a.expected,
43
+ actual: a.actual,
44
+ })),
45
+ }))
46
+ );
47
+
48
+ const hints: string[] = [];
49
+ if (failedSteps.length > 0) {
50
+ hints.push("Use query_db(action: 'diagnose_failure', runId: " + runId + ") for detailed failure analysis");
51
+ }
52
+ hints.push("Use manage_server(action: 'start') to launch the Web UI and view results visually in a browser at http://localhost:8080");
53
+ hints.push("Ask the user if they want to set up CI/CD to run these tests automatically on push. If yes, use ci_init to generate a workflow and help them push to GitHub/GitLab.");
54
+
55
+ const summary = {
56
+ runId,
57
+ total,
58
+ passed,
59
+ failed,
60
+ skipped,
61
+ suites: results.length,
62
+ status: failed === 0 ? "all_passed" : "has_failures",
63
+ ...(failedSteps.length > 0 ? { failures: failedSteps } : {}),
64
+ hints,
65
+ };
66
+
67
+ return {
68
+ content: [{ type: "text" as const, text: JSON.stringify(summary, null, 2) }],
69
+ };
70
+ });
71
+ }
@@ -0,0 +1,218 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { validateSuite } from "../../core/parser/schema.ts";
4
+ import { join, dirname } from "node:path";
5
+ import { existsSync, mkdirSync } from "node:fs";
6
+ import YAML from "yaml";
7
+ import { TOOL_DESCRIPTIONS } from "../descriptions.js";
8
+
9
+ export interface SaveResult {
10
+ saved: boolean;
11
+ filePath?: string;
12
+ suite?: { name: unknown; tests: number; base_url: unknown };
13
+ hint?: string;
14
+ coverage?: Record<string, unknown>;
15
+ error?: string;
16
+ detected?: string[];
17
+ }
18
+
19
+ export async function validateAndSave(
20
+ filePath: string,
21
+ content: string,
22
+ overwrite: boolean | undefined,
23
+ dbPath?: string,
24
+ ): Promise<{ result: SaveResult; isError: boolean }> {
25
+ // Parse YAML
26
+ let parsed: unknown;
27
+ try {
28
+ parsed = YAML.parse(content);
29
+ } catch (err) {
30
+ return {
31
+ result: {
32
+ saved: false,
33
+ error: `YAML parse error: ${(err as Error).message}`,
34
+ hint: "Check YAML syntax — indentation, colons, and quoting",
35
+ },
36
+ isError: true,
37
+ };
38
+ }
39
+
40
+ // Validate against test suite schema
41
+ try {
42
+ validateSuite(parsed);
43
+ } catch (err) {
44
+ const message = (err as Error).message;
45
+ let hint = "Check the test suite structure matches the expected format";
46
+ if (message.includes("status")) {
47
+ hint = "Status codes must be numbers, not strings (e.g. status: 200, not status: \"200\")";
48
+ } else if (message.includes("exists")) {
49
+ hint = "exists must be boolean true/false, not string \"true\"/\"false\"";
50
+ } else if (message.includes("tests")) {
51
+ hint = "Suite must have a 'tests' array with at least one test step";
52
+ }
53
+ return {
54
+ result: { saved: false, error: `Validation: ${message}`, hint },
55
+ isError: true,
56
+ };
57
+ }
58
+
59
+ // Detect hardcoded credentials — long opaque strings in auth headers
60
+ const credentialPattern = /Authorization\s*:\s*["']?(Basic|Bearer)\s+([A-Za-z0-9+/=_\-]{20,})["']?/g;
61
+ const credMatches = [...content.matchAll(credentialPattern)];
62
+ const suspiciousCredentials = credMatches.filter(m => {
63
+ const value = m[2]!;
64
+ return !value.startsWith("{{") && !value.endsWith("}}");
65
+ });
66
+ if (suspiciousCredentials.length > 0) {
67
+ return {
68
+ result: {
69
+ saved: false,
70
+ error: "Hardcoded credentials detected in Authorization header(s)",
71
+ hint: "Never put literal API keys or tokens in YAML files. Store them in the .env.yaml file in the API directory and reference as {{api_key}} in headers.",
72
+ detected: suspiciousCredentials.map(m => `${m[1]} <redacted>`),
73
+ },
74
+ isError: true,
75
+ };
76
+ }
77
+
78
+ // Resolve path
79
+ const resolvedPath = filePath.startsWith("/") || /^[a-zA-Z]:/.test(filePath)
80
+ ? filePath
81
+ : join(process.cwd(), filePath);
82
+
83
+ // Check existing file
84
+ if (!overwrite && existsSync(resolvedPath)) {
85
+ return {
86
+ result: {
87
+ saved: false,
88
+ error: `File already exists: ${resolvedPath}`,
89
+ hint: "Use overwrite: true to replace the existing file",
90
+ },
91
+ isError: true,
92
+ };
93
+ }
94
+
95
+ // Create directories
96
+ const dir = dirname(resolvedPath);
97
+ if (!existsSync(dir)) {
98
+ mkdirSync(dir, { recursive: true });
99
+ }
100
+
101
+ // Write original YAML content (preserve formatting/comments)
102
+ await Bun.write(resolvedPath, content);
103
+
104
+ // Extract summary info
105
+ const suite = parsed as Record<string, unknown>;
106
+ const tests = (suite.tests as unknown[]) ?? [];
107
+
108
+ const result: SaveResult = {
109
+ saved: true,
110
+ filePath: resolvedPath,
111
+ suite: {
112
+ name: suite.name,
113
+ tests: tests.length,
114
+ base_url: suite.base_url ?? null,
115
+ },
116
+ hint: "After tests are ready, ask the user if they want to set up CI/CD with ci_init to run tests automatically on push.",
117
+ };
118
+
119
+ // Attempt to compute coverage hint
120
+ try {
121
+ const testDir = dirname(resolvedPath);
122
+ const { findCollectionByTestPath } = await import("../../db/queries.ts");
123
+ const { getDb } = await import("../../db/schema.ts");
124
+ getDb(dbPath);
125
+ const collection = findCollectionByTestPath(testDir);
126
+ if (collection?.openapi_spec) {
127
+ const { readOpenApiSpec, extractEndpoints } = await import("../../core/generator/openapi-reader.ts");
128
+ const { scanCoveredEndpoints, filterUncoveredEndpoints } = await import("../../core/generator/coverage-scanner.ts");
129
+
130
+ const doc = await readOpenApiSpec(collection.openapi_spec);
131
+ const allEndpoints = extractEndpoints(doc);
132
+ const covered = await scanCoveredEndpoints(testDir);
133
+ const uncovered = filterUncoveredEndpoints(allEndpoints, covered);
134
+
135
+ const total = allEndpoints.length;
136
+ const coveredCount = total - uncovered.length;
137
+ const percentage = total > 0 ? Math.round((coveredCount / total) * 100) : 0;
138
+
139
+ const coverage: Record<string, unknown> = { percentage, covered: coveredCount, total, uncoveredCount: uncovered.length };
140
+ if (percentage < 80 && uncovered.length > 0) {
141
+ coverage.suggestion = `Use generate_missing_tests to cover ${uncovered.length} remaining endpoint${uncovered.length > 1 ? "s" : ""}`;
142
+ }
143
+ result.coverage = coverage;
144
+ }
145
+ } catch { /* silently skip coverage if unavailable */ }
146
+
147
+ return { result, isError: false };
148
+ }
149
+
150
+ export function registerSaveTestSuiteTool(server: McpServer, dbPath?: string) {
151
+ server.registerTool("save_test_suite", {
152
+ description: TOOL_DESCRIPTIONS.save_test_suite,
153
+ inputSchema: {
154
+ filePath: z.string().describe("Path for saving the YAML test file (e.g. apis/petstore/tests/pets-crud.yaml)"),
155
+ content: z.string().describe("YAML content of the test suite"),
156
+ overwrite: z.optional(z.boolean()).describe("Overwrite existing file (default: false)"),
157
+ },
158
+ }, async ({ filePath, content, overwrite }) => {
159
+ try {
160
+ const { result, isError } = await validateAndSave(filePath, content, overwrite, dbPath);
161
+ return {
162
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
163
+ ...(isError ? { isError: true } : {}),
164
+ };
165
+ } catch (err) {
166
+ return {
167
+ content: [{ type: "text" as const, text: JSON.stringify({
168
+ saved: false,
169
+ error: (err as Error).message,
170
+ }, null, 2) }],
171
+ isError: true,
172
+ };
173
+ }
174
+ });
175
+ }
176
+
177
+ export function registerSaveTestSuitesTool(server: McpServer, dbPath?: string) {
178
+ server.registerTool("save_test_suites", {
179
+ description: TOOL_DESCRIPTIONS.save_test_suites,
180
+ inputSchema: {
181
+ files: z.array(z.object({
182
+ filePath: z.string().describe("Path for saving the YAML test file"),
183
+ content: z.string().describe("YAML content of the test suite"),
184
+ })).describe("Array of files to save"),
185
+ overwrite: z.optional(z.boolean()).describe("Overwrite existing files (default: false)"),
186
+ },
187
+ }, async ({ files, overwrite }) => {
188
+ try {
189
+ const results: Array<SaveResult & { filePath: string; inputPath: string }> = [];
190
+ let hasErrors = false;
191
+
192
+ for (const file of files) {
193
+ const { result, isError } = await validateAndSave(file.filePath, file.content, overwrite, dbPath);
194
+ results.push({ ...result, inputPath: file.filePath, filePath: result.filePath ?? file.filePath });
195
+ if (isError) hasErrors = true;
196
+ }
197
+
198
+ const summary = {
199
+ total: files.length,
200
+ saved: results.filter(r => r.saved).length,
201
+ failed: results.filter(r => !r.saved).length,
202
+ files: results,
203
+ };
204
+
205
+ return {
206
+ content: [{ type: "text" as const, text: JSON.stringify(summary, null, 2) }],
207
+ ...(hasErrors ? { isError: true } : {}),
208
+ };
209
+ } catch (err) {
210
+ return {
211
+ content: [{ type: "text" as const, text: JSON.stringify({
212
+ error: (err as Error).message,
213
+ }, null, 2) }],
214
+ isError: true,
215
+ };
216
+ }
217
+ });
218
+ }
@@ -0,0 +1,63 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { executeRequest } from "../../core/runner/http-client.ts";
4
+ import { loadEnvironment, substituteString, substituteDeep } from "../../core/parser/variables.ts";
5
+ import { getDb } from "../../db/schema.ts";
6
+ import { findCollectionByNameOrId } from "../../db/queries.ts";
7
+ import { TOOL_DESCRIPTIONS } from "../descriptions.js";
8
+
9
+ export function registerSendRequestTool(server: McpServer, dbPath?: string) {
10
+ server.registerTool("send_request", {
11
+ description: TOOL_DESCRIPTIONS.send_request,
12
+ inputSchema: {
13
+ method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]).describe("HTTP method"),
14
+ url: z.string().describe("Request URL (supports {{variable}} interpolation)"),
15
+ headers: z.optional(z.string()).describe("Request headers as JSON string (e.g. '{\"Content-Type\": \"application/json\"}')"),
16
+ body: z.optional(z.string()).describe("Request body (JSON string)"),
17
+ timeout: z.optional(z.number().int().positive()).describe("Request timeout in ms"),
18
+ envName: z.optional(z.string()).describe("Environment name for variable interpolation"),
19
+ collectionName: z.optional(z.string()).describe("Collection name to load env from its base_dir (e.g. 'petstore'). Required for {{variable}} interpolation."),
20
+ },
21
+ }, async ({ method, url, headers, body, timeout, envName, collectionName }) => {
22
+ try {
23
+ let searchDir = process.cwd();
24
+ if (collectionName) {
25
+ getDb(dbPath);
26
+ const col = findCollectionByNameOrId(collectionName);
27
+ if (col?.base_dir) searchDir = col.base_dir;
28
+ }
29
+ const vars = await loadEnvironment(envName, searchDir);
30
+
31
+ const resolvedUrl = substituteString(url, vars) as string;
32
+ const parsedHeaders = headers ? JSON.parse(headers) as Record<string, string> : {};
33
+ const resolvedHeaders = Object.keys(parsedHeaders).length > 0 ? substituteDeep(parsedHeaders, vars) : {};
34
+ const resolvedBody = body ? substituteString(body, vars) as string : undefined;
35
+
36
+ const response = await executeRequest(
37
+ {
38
+ method,
39
+ url: resolvedUrl,
40
+ headers: resolvedHeaders,
41
+ body: resolvedBody,
42
+ },
43
+ timeout ? { timeout } : undefined,
44
+ );
45
+
46
+ const result = {
47
+ status: response.status,
48
+ headers: response.headers,
49
+ body: response.body_parsed ?? response.body,
50
+ duration_ms: response.duration_ms,
51
+ };
52
+
53
+ return {
54
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
55
+ };
56
+ } catch (err) {
57
+ return {
58
+ content: [{ type: "text" as const, text: JSON.stringify({ error: (err as Error).message }, null, 2) }],
59
+ isError: true,
60
+ };
61
+ }
62
+ });
63
+ }
@@ -0,0 +1,35 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { resolve, join } from "node:path";
4
+ import { existsSync } from "node:fs";
5
+ import { resetDb } from "../../db/schema.ts";
6
+ import { TOOL_DESCRIPTIONS } from "../descriptions.js";
7
+
8
+ export function registerSetWorkDirTool(server: McpServer) {
9
+ server.registerTool("set_work_dir", {
10
+ description: TOOL_DESCRIPTIONS.set_work_dir,
11
+ inputSchema: {
12
+ workDir: z.string().describe(
13
+ "Absolute path to project root (e.g. /home/user/myproject or C:/Users/user/myproject)"
14
+ ),
15
+ },
16
+ }, async ({ workDir }) => {
17
+ const resolved = resolve(workDir);
18
+ if (!existsSync(resolved)) {
19
+ return {
20
+ content: [{ type: "text" as const, text: JSON.stringify({ error: `Directory not found: ${resolved}` }, null, 2) }],
21
+ isError: true,
22
+ };
23
+ }
24
+ process.chdir(resolved);
25
+ resetDb();
26
+ const dbPath = join(resolved, "zond.db");
27
+ return {
28
+ content: [{ type: "text" as const, text: JSON.stringify({
29
+ workDir: resolved,
30
+ zond_db: dbPath,
31
+ hint: "Working directory set. All relative paths and zond.db will now resolve from this directory.",
32
+ }, null, 2) }],
33
+ };
34
+ });
35
+ }