@kirrosh/zond 0.16.0 → 0.17.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 (40) hide show
  1. package/CHANGELOG.md +132 -112
  2. package/README.md +3 -10
  3. package/package.json +2 -3
  4. package/src/cli/commands/export.ts +144 -0
  5. package/src/cli/commands/generate.ts +31 -0
  6. package/src/cli/commands/run.ts +22 -5
  7. package/src/cli/commands/sync.ts +240 -0
  8. package/src/cli/index.ts +54 -10
  9. package/src/core/diagnostics/db-analysis.ts +79 -7
  10. package/src/core/diagnostics/failure-hints.ts +39 -0
  11. package/src/core/exporter/postman.ts +963 -0
  12. package/src/core/generator/data-factory.ts +38 -3
  13. package/src/core/generator/index.ts +1 -1
  14. package/src/core/generator/openapi-reader.ts +6 -0
  15. package/src/core/generator/serializer.ts +17 -2
  16. package/src/core/generator/suite-generator.ts +163 -14
  17. package/src/core/generator/types.ts +1 -0
  18. package/src/core/meta/meta-store.ts +78 -0
  19. package/src/core/meta/types.ts +21 -0
  20. package/src/core/parser/schema.ts +12 -2
  21. package/src/core/parser/types.ts +12 -1
  22. package/src/core/parser/variables.ts +3 -0
  23. package/src/core/parser/yaml-parser.ts +2 -1
  24. package/src/core/runner/assertions.ts +44 -20
  25. package/src/core/runner/execute-run.ts +31 -8
  26. package/src/core/runner/executor.ts +34 -8
  27. package/src/core/runner/http-client.ts +1 -1
  28. package/src/core/runner/types.ts +1 -0
  29. package/src/core/sync/spec-differ.ts +38 -0
  30. package/src/cli/commands/mcp.ts +0 -16
  31. package/src/mcp/descriptions.ts +0 -47
  32. package/src/mcp/server.ts +0 -38
  33. package/src/mcp/tools/ci-init.ts +0 -54
  34. package/src/mcp/tools/coverage-analysis.ts +0 -141
  35. package/src/mcp/tools/describe-endpoint.ts +0 -27
  36. package/src/mcp/tools/manage-server.ts +0 -86
  37. package/src/mcp/tools/query-db.ts +0 -84
  38. package/src/mcp/tools/run-tests.ts +0 -116
  39. package/src/mcp/tools/send-request.ts +0 -51
  40. package/src/mcp/tools/setup-api.ts +0 -88
@@ -244,15 +244,24 @@ export function checkAssertions(expect: TestStepExpect, response: HttpResponse):
244
244
  }
245
245
 
246
246
  if (expect.headers) {
247
- for (const [key, expectedValue] of Object.entries(expect.headers)) {
247
+ for (const [key, rule] of Object.entries(expect.headers)) {
248
248
  const actual = response.headers[key.toLowerCase()];
249
- results.push({
250
- field: `headers.${key}`,
251
- rule: `equals "${expectedValue}"`,
252
- passed: actual === expectedValue,
253
- actual,
254
- expected: expectedValue,
255
- });
249
+ if (typeof rule === "string") {
250
+ results.push({
251
+ field: `headers.${key}`,
252
+ rule: `equals "${rule}"`,
253
+ passed: actual === rule,
254
+ actual,
255
+ expected: rule,
256
+ });
257
+ } else {
258
+ // AssertionRule in header — supports capture and other checks
259
+ const ruleResults = checkRule(key, rule, actual).map(r => ({
260
+ ...r,
261
+ field: r.field.replace(/^body\./, "headers."),
262
+ }));
263
+ results.push(...ruleResults);
264
+ }
256
265
  }
257
266
  }
258
267
 
@@ -276,24 +285,39 @@ export function checkAssertions(expect: TestStepExpect, response: HttpResponse):
276
285
  export function extractCaptures(
277
286
  bodyRules: Record<string, AssertionRule> | undefined,
278
287
  responseBody: unknown,
288
+ headerRules?: Record<string, string | AssertionRule>,
289
+ responseHeaders?: Record<string, string>,
279
290
  ): Record<string, unknown> {
280
291
  const captures: Record<string, unknown> = {};
281
- if (!bodyRules || responseBody === undefined) return captures;
282
292
 
283
- for (const [path, rule] of Object.entries(bodyRules)) {
284
- if (rule.capture) {
285
- let value: unknown;
286
- if (path === "_body") {
287
- value = responseBody;
288
- } else if (path.startsWith("_body.")) {
289
- value = getByPath(responseBody, path.slice(6));
290
- } else {
291
- value = getByPath(responseBody, path);
293
+ if (bodyRules && responseBody !== undefined) {
294
+ for (const [path, rule] of Object.entries(bodyRules)) {
295
+ if (rule.capture) {
296
+ let value: unknown;
297
+ if (path === "_body") {
298
+ value = responseBody;
299
+ } else if (path.startsWith("_body.")) {
300
+ value = getByPath(responseBody, path.slice(6));
301
+ } else {
302
+ value = getByPath(responseBody, path);
303
+ }
304
+ if (value !== undefined) {
305
+ captures[rule.capture] = value;
306
+ }
292
307
  }
293
- if (value !== undefined) {
294
- captures[rule.capture] = value;
308
+ }
309
+ }
310
+
311
+ if (headerRules && responseHeaders) {
312
+ for (const [key, rule] of Object.entries(headerRules)) {
313
+ if (typeof rule !== "string" && rule.capture) {
314
+ const value = responseHeaders[key.toLowerCase()];
315
+ if (value !== undefined) {
316
+ captures[rule.capture] = value;
317
+ }
295
318
  }
296
319
  }
297
320
  }
321
+
298
322
  return captures;
299
323
  }
@@ -8,6 +8,8 @@ import { dirname, resolve } from "path";
8
8
  import { stat } from "node:fs/promises";
9
9
  import type { TestRunResult } from "./types.ts";
10
10
 
11
+ export const AUTH_PATH_RE = /\/(auth|login|signin|token|oauth)\b/i;
12
+
11
13
  export interface ExecuteRunOptions {
12
14
  testPath: string;
13
15
  envName?: string;
@@ -52,14 +54,14 @@ export async function executeRun(options: ExecuteRunOptions): Promise<ExecuteRun
52
54
  }
53
55
  }
54
56
 
55
- // Safe mode: filter to GET-only tests
57
+ // Safe mode: filter to GET + auth endpoints (same logic as run.ts)
56
58
  if (safe) {
57
59
  for (const suite of suites) {
58
- suite.tests = suite.tests.filter(t => t.method === "GET");
60
+ suite.tests = suite.tests.filter(t => t.method === "GET" || !t.method || AUTH_PATH_RE.test(t.path));
59
61
  }
60
62
  suites = suites.filter(s => s.tests.length > 0);
61
63
  if (suites.length === 0) {
62
- throw new Error("No GET tests found. Nothing to run in safe mode.");
64
+ throw new Error("No safe tests found. Nothing to run in safe mode.");
63
65
  }
64
66
  }
65
67
 
@@ -83,19 +85,40 @@ export async function executeRun(options: ExecuteRunOptions): Promise<ExecuteRun
83
85
  return env;
84
86
  }
85
87
 
86
- let results: Awaited<ReturnType<typeof runSuite>>[];
88
+ // Phase 1: run setup suites first (sequentially), collect their captures
89
+ const setupSuites = suites.filter(s => s.setup);
90
+ const regularSuites = suites.filter(s => !s.setup);
91
+ const setupResults: Awaited<ReturnType<typeof runSuite>>[] = [];
92
+ const setupCaptures: Record<string, string> = {};
93
+
94
+ for (const suite of setupSuites) {
95
+ const suiteDir = suite.filePath ? dirname(suite.filePath) : envDir;
96
+ const env = await loadEnvWithOverrides(suiteDir);
97
+ const result = await runSuite(suite, env, options.dryRun);
98
+ setupResults.push(result);
99
+ for (const step of result.steps) {
100
+ for (const [k, v] of Object.entries(step.captures)) {
101
+ setupCaptures[k] = String(v);
102
+ }
103
+ }
104
+ }
105
+
106
+ // Phase 2: run regular suites with env enriched by setup captures
107
+ let regularResults: Awaited<ReturnType<typeof runSuite>>[];
87
108
  if (isDirectory) {
88
- // Per-suite env: load env from each suite's own directory
89
- results = await Promise.all(suites.map(async (s) => {
109
+ regularResults = await Promise.all(regularSuites.map(async (s) => {
90
110
  const suiteDir = s.filePath ? dirname(s.filePath) : envDir;
91
111
  const env = await loadEnvWithOverrides(suiteDir);
92
- return runSuite(s, env, options.dryRun);
112
+ return runSuite(s, { ...env, ...setupCaptures }, options.dryRun);
93
113
  }));
94
114
  } else {
95
115
  const env = await loadEnvWithOverrides(envDir);
96
- results = await Promise.all(suites.map((s) => runSuite(s, env, options.dryRun)));
116
+ const enrichedEnv = { ...env, ...setupCaptures };
117
+ regularResults = await Promise.all(regularSuites.map(s => runSuite(s, enrichedEnv, options.dryRun)));
97
118
  }
98
119
 
120
+ const results = [...setupResults, ...regularResults];
121
+
99
122
  const runId = createRun({
100
123
  started_at: results[0]?.started_at ?? new Date().toISOString(),
101
124
  environment: effectiveEnvName,
@@ -1,3 +1,4 @@
1
+ import { resolve, dirname, basename } from "node:path";
1
2
  import type { TestSuite, TestStep, Environment } from "../parser/types.ts";
2
3
  import { substituteString, substituteStep, substituteDeep, extractVariableReferences } from "../parser/variables.ts";
3
4
  import type { TestRunResult, StepResult, HttpRequest } from "./types.ts";
@@ -66,16 +67,16 @@ export async function runSuite(suite: TestSuite, env: Environment = {}, dryRun =
66
67
  const expandedStep = rawSteps[stepIndex + i]!;
67
68
  // Temporarily inject into variables when we reach this step
68
69
  // We need a way to pass the variable — use a hidden _for_each_vars
69
- (expandedStep as Record<string, unknown>).__for_each_var = { key: step.for_each.var, value: items[i] };
70
+ (expandedStep as unknown as Record<string, unknown>).__for_each_var = { key: step.for_each.var, value: items[i] };
70
71
  }
71
72
  continue;
72
73
  }
73
74
 
74
75
  // Inject for_each variable if present
75
- const forEachData = (step as Record<string, unknown>).__for_each_var as { key: string; value: unknown } | undefined;
76
+ const forEachData = (step as unknown as Record<string, unknown>).__for_each_var as { key: string; value: unknown } | undefined;
76
77
  if (forEachData) {
77
78
  variables[forEachData.key] = forEachData.value;
78
- delete (step as Record<string, unknown>).__for_each_var;
79
+ delete (step as unknown as Record<string, unknown>).__for_each_var;
79
80
  }
80
81
 
81
82
  // Handle set-only steps (no HTTP request)
@@ -112,6 +113,14 @@ export async function runSuite(suite: TestSuite, env: Environment = {}, dryRun =
112
113
  }
113
114
  }
114
115
 
116
+ // Process set: on HTTP steps — evaluate generators once before building request
117
+ if (step.set) {
118
+ for (const [key, rawDirective] of Object.entries(step.set)) {
119
+ const substituted = substituteDeep(rawDirective, variables);
120
+ variables[key] = applyTransform(substituted);
121
+ }
122
+ }
123
+
115
124
  // Substitute variables
116
125
  const resolved = substituteStep(step, variables);
117
126
 
@@ -121,6 +130,7 @@ export async function runSuite(suite: TestSuite, env: Environment = {}, dryRun =
121
130
  const url = buildUrl(resolvedBaseUrl, resolved.path, resolved.query);
122
131
  const headers: Record<string, string> = { ...resolvedSuiteHeaders, ...resolved.headers };
123
132
  let body: string | undefined;
133
+ let formData: FormData | undefined;
124
134
 
125
135
  if (resolved.json !== undefined) {
126
136
  body = JSON.stringify(resolved.json);
@@ -132,9 +142,23 @@ export async function runSuite(suite: TestSuite, env: Environment = {}, dryRun =
132
142
  if (!headers["Content-Type"] && !headers["content-type"]) {
133
143
  headers["Content-Type"] = "application/x-www-form-urlencoded";
134
144
  }
145
+ } else if (resolved.multipart) {
146
+ const basedir = suite.filePath ? dirname(suite.filePath) : process.cwd();
147
+ formData = new FormData();
148
+ for (const [key, field] of Object.entries(resolved.multipart)) {
149
+ if (typeof field === "string") {
150
+ formData.append(key, field);
151
+ } else {
152
+ const absPath = resolve(basedir, field.file);
153
+ const buf = await Bun.file(absPath).arrayBuffer();
154
+ const mime = field.content_type ?? "application/octet-stream";
155
+ const filename = field.filename ?? basename(absPath);
156
+ formData.append(key, new Blob([buf], { type: mime }), filename);
157
+ }
158
+ }
135
159
  }
136
160
 
137
- const request: HttpRequest = { method: resolved.method, url, headers, body };
161
+ const request: HttpRequest = { method: resolved.method, url, headers, body, formData };
138
162
 
139
163
  // Validate absolute URL before attempting fetch
140
164
  if (!url.startsWith("http://") && !url.startsWith("https://")) {
@@ -156,7 +180,9 @@ export async function runSuite(suite: TestSuite, env: Environment = {}, dryRun =
156
180
  }
157
181
 
158
182
  if (dryRun) {
159
- const bodyPreview = body ? ` ${body.slice(0, 200)}` : "";
183
+ const bodyPreview = formData
184
+ ? ` [multipart: ${[...formData.keys()].length} field(s)]`
185
+ : body ? ` ${body.slice(0, 200)}` : "";
160
186
  steps.push({
161
187
  name: step.name,
162
188
  status: "pass",
@@ -176,7 +202,7 @@ export async function runSuite(suite: TestSuite, env: Environment = {}, dryRun =
176
202
  for (let attempt = 0; attempt < rt.max_attempts; attempt++) {
177
203
  try {
178
204
  const response = await executeRequest(request, fetchOptions);
179
- const captures = extractCaptures(resolved.expect.body, response.body_parsed);
205
+ const captures = extractCaptures(resolved.expect.body, response.body_parsed, resolved.expect.headers, response.headers);
180
206
  const assertions = checkAssertions(resolved.expect, response);
181
207
  const allPassed = assertions.every((a) => a.passed);
182
208
 
@@ -225,8 +251,8 @@ export async function runSuite(suite: TestSuite, env: Environment = {}, dryRun =
225
251
  try {
226
252
  const response = await executeRequest(request, fetchOptions);
227
253
 
228
- // Extract captures
229
- const captures = extractCaptures(resolved.expect.body, response.body_parsed);
254
+ // Extract captures (body + header)
255
+ const captures = extractCaptures(resolved.expect.body, response.body_parsed, resolved.expect.headers, response.headers);
230
256
  Object.assign(variables, captures);
231
257
 
232
258
  // Track expected captures that weren't obtained
@@ -34,7 +34,7 @@ export async function executeRequest(
34
34
  const response = await fetch(request.url, {
35
35
  method: request.method,
36
36
  headers: request.headers,
37
- body: request.body ?? undefined,
37
+ body: request.formData ?? request.body ?? undefined,
38
38
  signal: controller.signal,
39
39
  redirect: opts.follow_redirects ? "follow" : "manual",
40
40
  tls: { rejectUnauthorized: false },
@@ -5,6 +5,7 @@ export interface HttpRequest {
5
5
  url: string;
6
6
  headers: Record<string, string>;
7
7
  body?: string;
8
+ formData?: FormData;
8
9
  }
9
10
 
10
11
  export interface HttpResponse {
@@ -0,0 +1,38 @@
1
+ import type { EndpointInfo } from "../generator/types.ts";
2
+ import { normalizePath } from "../generator/coverage-scanner.ts";
3
+
4
+ export interface SpecDiff {
5
+ /** Endpoints in current spec not present in previous snapshot */
6
+ newEndpoints: EndpointInfo[];
7
+ /** Endpoint keys from previous snapshot not present in current spec */
8
+ removedKeys: string[];
9
+ /** True if spec content hash changed (could be just description changes) */
10
+ specChanged: boolean;
11
+ }
12
+
13
+ /** Produce a normalized key for an endpoint: "GET /users/{*}" */
14
+ export function endpointKey(method: string, path: string): string {
15
+ return `${method.toUpperCase()} ${normalizePath(path)}`;
16
+ }
17
+
18
+ /**
19
+ * Compare current endpoints against previously-known endpoint keys
20
+ * (stored as strings in .zond-meta.json).
21
+ */
22
+ export function diffEndpoints(
23
+ prevKeys: string[],
24
+ currentEndpoints: EndpointInfo[],
25
+ ): Omit<SpecDiff, "specChanged"> {
26
+ const prevSet = new Set(prevKeys);
27
+ const currentSet = new Set(
28
+ currentEndpoints.map((ep) => endpointKey(ep.method, ep.path)),
29
+ );
30
+
31
+ const newEndpoints = currentEndpoints.filter(
32
+ (ep) => !prevSet.has(endpointKey(ep.method, ep.path)),
33
+ );
34
+
35
+ const removedKeys = [...prevSet].filter((key) => !currentSet.has(key));
36
+
37
+ return { newEndpoints, removedKeys };
38
+ }
@@ -1,16 +0,0 @@
1
- import { startMcpServer } from "../../mcp/server.ts";
2
- import { resolve } from "node:path";
3
-
4
- export interface McpCommandOptions {
5
- dbPath?: string;
6
- dir?: string;
7
- }
8
-
9
- export async function mcpCommand(options: McpCommandOptions): Promise<number> {
10
- if (options.dir) {
11
- process.chdir(resolve(options.dir));
12
- }
13
- await startMcpServer({ dbPath: options.dbPath });
14
- // Server runs until stdin closes — this promise never resolves during normal operation
15
- return 0;
16
- }
@@ -1,47 +0,0 @@
1
- /**
2
- * Single source of truth for all MCP tool descriptions.
3
- * Update descriptions here — they are imported by each tool file.
4
- */
5
- export const TOOL_DESCRIPTIONS = {
6
- setup_api:
7
- "Register a new API for testing. Creates directory structure, reads OpenAPI spec, " +
8
- "sets up environment variables, and creates a collection in the database. " +
9
- "Use this before generating tests for a new API. " +
10
- "Warns if spec has relative server URL. Use insecure: true for self-signed HTTPS certs.",
11
-
12
- describe_endpoint:
13
- "Full details for one endpoint: params grouped by type, request body schema, " +
14
- "all response schemas + response headers, security, deprecated flag. " +
15
- "Use when a test fails and you need complete endpoint spec without reading the whole file.",
16
-
17
- run_tests:
18
- "Execute API tests from a YAML file or directory and return results summary with failures. " +
19
- "Use after saving test suites with save_test_suite. Check query_db(action: 'diagnose_failure') for detailed failure analysis.",
20
-
21
- query_db:
22
- "Query the zond database. Actions: list_collections (all APIs with run stats), " +
23
- "list_runs (recent test runs), get_run_results (full detail for a run), " +
24
- "diagnose_failure (only failed/errored steps for a run — each failure includes failure_type: api_error/assertion_failed/network_error, " +
25
- "and summary includes api_errors/assertion_failures/network_errors counts; stack traces are truncated by default, use verbose: true for full traces), " +
26
- "compare_runs (regressions and fixes between two runs).",
27
-
28
- coverage_analysis:
29
- "Compare an OpenAPI spec against existing test files to find untested endpoints. " +
30
- "Use to identify gaps and prioritize which endpoints to generate tests for next. " +
31
- "Pass runId to get enriched pass/fail/5xx breakdown per endpoint. " +
32
- "Always includes static spec warnings (deprecated, missing response schemas, required params without examples).",
33
-
34
- send_request:
35
- "Send an ad-hoc HTTP request. Supports variable interpolation from environments (e.g. {{base_url}}). " +
36
- "Use jsonPath to extract a subset of the response (e.g. '[0].code'), maxResponseChars to truncate large responses.",
37
-
38
- manage_server:
39
- "Start, stop, restart, or check status of the zond WebUI server. " +
40
- "Useful for viewing test results in a browser without leaving the MCP session.",
41
-
42
- ci_init:
43
- "Generate a CI/CD workflow file for running API tests automatically on push, PR, and schedule. " +
44
- "Supports GitHub Actions and GitLab CI. Auto-detects platform from project structure " +
45
- "(.github/ → GitHub, .gitlab-ci.yml → GitLab). " +
46
- "Use after tests are generated and passing. After generating the workflow, help the user commit and push to activate CI.",
47
- } as const;
package/src/mcp/server.ts DELETED
@@ -1,38 +0,0 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
- import { registerRunTestsTool } from "./tools/run-tests.ts";
4
- import { registerQueryDbTool } from "./tools/query-db.ts";
5
- import { registerSendRequestTool } from "./tools/send-request.ts";
6
- import { registerCoverageAnalysisTool } from "./tools/coverage-analysis.ts";
7
- import { registerSetupApiTool } from "./tools/setup-api.ts";
8
- import { registerManageServerTool } from "./tools/manage-server.ts";
9
- import { registerCiInitTool } from "./tools/ci-init.ts";
10
- import { registerDescribeEndpointTool } from "./tools/describe-endpoint.ts";
11
- import { version } from "../../package.json";
12
-
13
- export interface McpServerOptions {
14
- dbPath?: string;
15
- }
16
-
17
- export async function startMcpServer(options: McpServerOptions = {}): Promise<void> {
18
- const { dbPath } = options;
19
-
20
- const server = new McpServer({
21
- name: "zond",
22
- version,
23
- });
24
-
25
- // Register tools (slim set — removed set_work_dir, save_test_suite, save_test_suites)
26
- registerRunTestsTool(server, dbPath);
27
- registerQueryDbTool(server, dbPath);
28
- registerSendRequestTool(server, dbPath);
29
- registerCoverageAnalysisTool(server, dbPath);
30
- registerSetupApiTool(server, dbPath);
31
- registerManageServerTool(server, dbPath);
32
- registerCiInitTool(server);
33
- registerDescribeEndpointTool(server);
34
-
35
- // Connect via stdio transport
36
- const transport = new StdioServerTransport();
37
- await server.connect(transport);
38
- }
@@ -1,54 +0,0 @@
1
- import { z } from "zod";
2
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import { ciInitCommand } from "../../cli/commands/ci-init.ts";
4
- import { TOOL_DESCRIPTIONS } from "../descriptions.js";
5
-
6
- export function registerCiInitTool(server: McpServer) {
7
- server.registerTool("ci_init", {
8
- description: TOOL_DESCRIPTIONS.ci_init,
9
- inputSchema: {
10
- platform: z.optional(z.enum(["github", "gitlab"]))
11
- .describe("CI platform. If omitted, auto-detects from project structure (defaults to GitHub)"),
12
- force: z.optional(z.boolean())
13
- .describe("Overwrite existing CI config (default: false)"),
14
- dir: z.optional(z.string())
15
- .describe("Project root directory where CI config will be created (default: current working directory)"),
16
- },
17
- }, async ({ platform, force, dir }) => {
18
- // Capture stdout to return as result
19
- const logs: string[] = [];
20
- const origWrite = process.stdout.write;
21
- process.stdout.write = ((chunk: string | Uint8Array) => {
22
- logs.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk));
23
- return true;
24
- }) as typeof process.stdout.write;
25
-
26
- try {
27
- const code = await ciInitCommand({
28
- platform,
29
- force: force ?? false,
30
- dir,
31
- });
32
-
33
- process.stdout.write = origWrite;
34
-
35
- const output = logs.join("").trim();
36
- if (code !== 0) {
37
- return {
38
- content: [{ type: "text" as const, text: JSON.stringify({ error: output || "ci init failed", exitCode: code }, null, 2) }],
39
- isError: true,
40
- };
41
- }
42
-
43
- return {
44
- content: [{ type: "text" as const, text: JSON.stringify({ message: output, exitCode: 0 }, null, 2) }],
45
- };
46
- } catch (err) {
47
- process.stdout.write = origWrite;
48
- return {
49
- content: [{ type: "text" as const, text: JSON.stringify({ error: (err as Error).message }, null, 2) }],
50
- isError: true,
51
- };
52
- }
53
- });
54
- }
@@ -1,141 +0,0 @@
1
- import { z } from "zod";
2
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import { readOpenApiSpec, extractEndpoints, scanCoveredEndpoints, filterUncoveredEndpoints, normalizePath, specPathToRegex, analyzeEndpoints } from "../../core/generator/index.ts";
4
- import { getDb } from "../../db/schema.ts";
5
- import { getResultsByRunId, getRunById } from "../../db/queries.ts";
6
- import { TOOL_DESCRIPTIONS } from "../descriptions.js";
7
-
8
- function extractPathFromUrl(url: string): string | null {
9
- try {
10
- return new URL(url).pathname;
11
- } catch {
12
- // If not a full URL, treat as path directly
13
- return url.startsWith("/") ? url : null;
14
- }
15
- }
16
-
17
- export function registerCoverageAnalysisTool(server: McpServer, dbPath?: string) {
18
- server.registerTool("coverage_analysis", {
19
- description: TOOL_DESCRIPTIONS.coverage_analysis,
20
- inputSchema: {
21
- specPath: z.string().describe("Path to OpenAPI spec file (JSON or YAML)"),
22
- testsDir: z.string().describe("Path to directory with test YAML files"),
23
- failThreshold: z.optional(z.number().min(0).max(100)).describe("Return isError when coverage % is below this threshold (0–100)"),
24
- runId: z.optional(z.number().int()).describe("Run ID to cross-reference test results for pass/fail/5xx breakdown"),
25
- },
26
- }, async ({ specPath, testsDir, failThreshold, runId }) => {
27
- try {
28
- const doc = await readOpenApiSpec(specPath);
29
- const allEndpoints = extractEndpoints(doc);
30
-
31
- if (allEndpoints.length === 0) {
32
- return {
33
- content: [{ type: "text" as const, text: JSON.stringify({ error: "No endpoints found in the spec" }, null, 2) }],
34
- isError: true,
35
- };
36
- }
37
-
38
- const covered = await scanCoveredEndpoints(testsDir);
39
- const uncovered = filterUncoveredEndpoints(allEndpoints, covered);
40
- const coveredCount = allEndpoints.length - uncovered.length;
41
- const percentage = Math.round((coveredCount / allEndpoints.length) * 100);
42
-
43
- // Static warnings
44
- const warnings = analyzeEndpoints(allEndpoints);
45
-
46
- const result: Record<string, unknown> = {
47
- totalEndpoints: allEndpoints.length,
48
- covered: coveredCount,
49
- uncovered: uncovered.length,
50
- percentage,
51
- uncoveredEndpoints: uncovered.map(ep => ({
52
- method: ep.method,
53
- path: ep.path,
54
- summary: ep.summary,
55
- tags: ep.tags,
56
- })),
57
- coveredEndpoints: covered.map(ep => ({
58
- method: ep.method,
59
- path: ep.path,
60
- file: ep.file,
61
- })),
62
- };
63
-
64
- if (warnings.length > 0) {
65
- result.warnings = warnings;
66
- }
67
-
68
- // Enriched breakdown when runId is provided
69
- if (runId != null) {
70
- getDb(dbPath);
71
- const run = getRunById(runId);
72
- if (!run) {
73
- return {
74
- content: [{ type: "text" as const, text: JSON.stringify({ error: `Run ${runId} not found` }, null, 2) }],
75
- isError: true,
76
- };
77
- }
78
-
79
- const results = getResultsByRunId(runId);
80
-
81
- // Build a map: spec endpoint → status classification
82
- const endpointStatus = new Map<string, "passing" | "api_error" | "test_failed">();
83
-
84
- for (const r of results) {
85
- if (!r.request_url || !r.request_method) continue;
86
- const urlPath = extractPathFromUrl(r.request_url);
87
- if (!urlPath) continue;
88
- const normalizedUrl = normalizePath(urlPath);
89
-
90
- // Find matching spec endpoint
91
- for (const ep of allEndpoints) {
92
- const regex = specPathToRegex(ep.path);
93
- if (r.request_method === ep.method && regex.test(normalizedUrl)) {
94
- const key = `${ep.method} ${ep.path}`;
95
- const existing = endpointStatus.get(key);
96
-
97
- // Worst status wins: api_error > test_failed > passing
98
- if (r.response_status !== null && r.response_status >= 500) {
99
- endpointStatus.set(key, "api_error");
100
- } else if (r.status === "fail" || r.status === "error") {
101
- if (existing !== "api_error") {
102
- endpointStatus.set(key, "test_failed");
103
- }
104
- } else if (!existing) {
105
- endpointStatus.set(key, "passing");
106
- }
107
- break;
108
- }
109
- }
110
- }
111
-
112
- let passing = 0;
113
- let apiError = 0;
114
- let testFailed = 0;
115
- for (const status of endpointStatus.values()) {
116
- if (status === "passing") passing++;
117
- else if (status === "api_error") apiError++;
118
- else if (status === "test_failed") testFailed++;
119
- }
120
-
121
- result.enriched = {
122
- passing,
123
- api_error: apiError,
124
- test_failed: testFailed,
125
- not_covered: uncovered.length,
126
- };
127
- }
128
-
129
- const belowThreshold = failThreshold !== undefined && percentage < failThreshold;
130
- return {
131
- content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
132
- ...(belowThreshold ? { isError: true } : {}),
133
- };
134
- } catch (err) {
135
- return {
136
- content: [{ type: "text" as const, text: JSON.stringify({ error: (err as Error).message }, null, 2) }],
137
- isError: true,
138
- };
139
- }
140
- });
141
- }
@@ -1,27 +0,0 @@
1
- import { z } from "zod";
2
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import { describeEndpoint } from "../../core/generator/describe.ts";
4
- import { TOOL_DESCRIPTIONS } from "../descriptions.js";
5
-
6
- export function registerDescribeEndpointTool(server: McpServer) {
7
- server.registerTool("describe_endpoint", {
8
- description: TOOL_DESCRIPTIONS.describe_endpoint,
9
- inputSchema: {
10
- specPath: z.string().describe("Path to OpenAPI spec file (JSON or YAML) or HTTP URL"),
11
- method: z.string().describe('HTTP method, e.g. "GET", "POST", "PUT"'),
12
- path: z.string().describe('Endpoint path, e.g. "/pets/{petId}"'),
13
- },
14
- }, async ({ specPath, method, path: endpointPath }) => {
15
- try {
16
- const result = await describeEndpoint(specPath, method, endpointPath);
17
- return {
18
- content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
19
- };
20
- } catch (err) {
21
- return {
22
- content: [{ type: "text" as const, text: JSON.stringify({ error: (err as Error).message }, null, 2) }],
23
- isError: true,
24
- };
25
- }
26
- });
27
- }