@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.
- package/CHANGELOG.md +132 -112
- package/README.md +3 -10
- package/package.json +2 -3
- package/src/cli/commands/export.ts +144 -0
- package/src/cli/commands/generate.ts +31 -0
- package/src/cli/commands/run.ts +22 -5
- package/src/cli/commands/sync.ts +240 -0
- package/src/cli/index.ts +54 -10
- package/src/core/diagnostics/db-analysis.ts +79 -7
- package/src/core/diagnostics/failure-hints.ts +39 -0
- package/src/core/exporter/postman.ts +963 -0
- package/src/core/generator/data-factory.ts +38 -3
- package/src/core/generator/index.ts +1 -1
- package/src/core/generator/openapi-reader.ts +6 -0
- package/src/core/generator/serializer.ts +17 -2
- package/src/core/generator/suite-generator.ts +163 -14
- package/src/core/generator/types.ts +1 -0
- package/src/core/meta/meta-store.ts +78 -0
- package/src/core/meta/types.ts +21 -0
- package/src/core/parser/schema.ts +12 -2
- package/src/core/parser/types.ts +12 -1
- package/src/core/parser/variables.ts +3 -0
- package/src/core/parser/yaml-parser.ts +2 -1
- package/src/core/runner/assertions.ts +44 -20
- package/src/core/runner/execute-run.ts +31 -8
- package/src/core/runner/executor.ts +34 -8
- package/src/core/runner/http-client.ts +1 -1
- package/src/core/runner/types.ts +1 -0
- package/src/core/sync/spec-differ.ts +38 -0
- package/src/cli/commands/mcp.ts +0 -16
- package/src/mcp/descriptions.ts +0 -47
- package/src/mcp/server.ts +0 -38
- package/src/mcp/tools/ci-init.ts +0 -54
- package/src/mcp/tools/coverage-analysis.ts +0 -141
- package/src/mcp/tools/describe-endpoint.ts +0 -27
- package/src/mcp/tools/manage-server.ts +0 -86
- package/src/mcp/tools/query-db.ts +0 -84
- package/src/mcp/tools/run-tests.ts +0 -116
- package/src/mcp/tools/send-request.ts +0 -51
- 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,
|
|
247
|
+
for (const [key, rule] of Object.entries(expect.headers)) {
|
|
248
248
|
const actual = response.headers[key.toLowerCase()];
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 },
|
package/src/core/runner/types.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/cli/commands/mcp.ts
DELETED
|
@@ -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
|
-
}
|
package/src/mcp/descriptions.ts
DELETED
|
@@ -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
|
-
}
|
package/src/mcp/tools/ci-init.ts
DELETED
|
@@ -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
|
-
}
|