@kirrosh/zond 0.14.0 → 0.16.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/README.md +1 -1
- package/package.json +4 -3
- package/src/cli/commands/ci-init.ts +12 -1
- package/src/cli/commands/coverage.ts +21 -1
- package/src/cli/commands/db.ts +121 -0
- package/src/cli/commands/describe.ts +60 -0
- package/src/cli/commands/generate.ts +127 -0
- package/src/cli/commands/guide.ts +127 -0
- package/src/cli/commands/init.ts +57 -0
- package/src/cli/commands/request.ts +57 -0
- package/src/cli/commands/run.ts +53 -10
- package/src/cli/commands/serve.ts +62 -3
- package/src/cli/commands/validate.ts +18 -2
- package/src/cli/index.ts +204 -7
- package/src/cli/json-envelope.ts +19 -0
- package/src/core/diagnostics/db-analysis.ts +351 -0
- package/src/core/diagnostics/failure-hints.ts +1 -0
- package/src/core/generator/data-factory.ts +19 -8
- package/src/core/generator/describe.ts +250 -0
- package/src/core/generator/guide-builder.ts +20 -0
- package/src/core/generator/suite-generator.ts +133 -20
- package/src/core/runner/executor.ts +1 -0
- package/src/core/runner/send-request.ts +94 -0
- package/src/core/runner/types.ts +1 -0
- package/src/db/queries.ts +4 -2
- package/src/db/schema.ts +11 -3
- package/src/mcp/descriptions.ts +0 -24
- package/src/mcp/server.ts +1 -8
- package/src/mcp/tools/describe-endpoint.ts +3 -218
- package/src/mcp/tools/query-db.ts +6 -222
- package/src/mcp/tools/run-tests.ts +1 -0
- package/src/mcp/tools/send-request.ts +15 -61
- package/src/web/views/suites-tab.ts +1 -1
- package/src/mcp/tools/generate-and-save.ts +0 -202
- package/src/mcp/tools/save-test-suite.ts +0 -218
- package/src/mcp/tools/set-work-dir.ts +0 -35
|
@@ -233,6 +233,13 @@ export function generateCrudSuite(
|
|
|
233
233
|
const allEps = [group.create, group.list, group.read, group.update, group.delete].filter(Boolean) as EndpointInfo[];
|
|
234
234
|
const suiteHeaders = getSuiteHeaders(allEps, securitySchemes);
|
|
235
235
|
|
|
236
|
+
// 0. List all (before create, to verify collection exists)
|
|
237
|
+
if (group.list) {
|
|
238
|
+
const step = generateStep(group.list, securitySchemes);
|
|
239
|
+
if (suiteHeaders) delete (step as any).headers;
|
|
240
|
+
tests.push(step);
|
|
241
|
+
}
|
|
242
|
+
|
|
236
243
|
// 1. Create
|
|
237
244
|
if (group.create) {
|
|
238
245
|
const step = generateStep(group.create, securitySchemes);
|
|
@@ -335,6 +342,131 @@ export function findUnresolvedVars(suite: RawSuite, envKeys?: Set<string>): stri
|
|
|
335
342
|
return [...vars];
|
|
336
343
|
}
|
|
337
344
|
|
|
345
|
+
/** Check if a schema has a specific field name (case-insensitive) */
|
|
346
|
+
function schemaHasField(schema: OpenAPIV3.SchemaObject | undefined, ...names: string[]): boolean {
|
|
347
|
+
if (!schema?.properties) return false;
|
|
348
|
+
const keys = Object.keys(schema.properties).map(k => k.toLowerCase());
|
|
349
|
+
return names.some(n => keys.includes(n.toLowerCase()));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Generate auth suite with register+login consistency */
|
|
353
|
+
export function generateAuthSuite(
|
|
354
|
+
authEndpoints: EndpointInfo[],
|
|
355
|
+
securitySchemes: SecuritySchemeInfo[],
|
|
356
|
+
): RawSuite {
|
|
357
|
+
// Detect register → login pair
|
|
358
|
+
const registerEp = authEndpoints.find(ep =>
|
|
359
|
+
/\/(register|signup)\b/i.test(ep.path) && ep.method.toUpperCase() === "POST"
|
|
360
|
+
);
|
|
361
|
+
const loginEp = authEndpoints.find(ep =>
|
|
362
|
+
ep !== registerEp &&
|
|
363
|
+
/\/(login|signin|auth)\b/i.test(ep.path) &&
|
|
364
|
+
ep.method.toUpperCase() === "POST"
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
const hasCredentialPair = registerEp && loginEp &&
|
|
368
|
+
schemaHasField(registerEp.requestBodySchema, "email", "username") &&
|
|
369
|
+
schemaHasField(registerEp.requestBodySchema, "password") &&
|
|
370
|
+
schemaHasField(loginEp.requestBodySchema, "email", "username") &&
|
|
371
|
+
schemaHasField(loginEp.requestBodySchema, "password");
|
|
372
|
+
|
|
373
|
+
if (hasCredentialPair) {
|
|
374
|
+
return generateConsistentAuthSuite(registerEp, loginEp, authEndpoints, securitySchemes);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Fallback: plain auth suite
|
|
378
|
+
const tests = authEndpoints.map(ep => generateStep(ep, securitySchemes));
|
|
379
|
+
const headers = getSuiteHeaders(authEndpoints, securitySchemes);
|
|
380
|
+
|
|
381
|
+
const suite: RawSuite = {
|
|
382
|
+
name: "auth",
|
|
383
|
+
tags: ["auth"],
|
|
384
|
+
fileStem: "auth",
|
|
385
|
+
base_url: "{{base_url}}",
|
|
386
|
+
tests,
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
if (headers) {
|
|
390
|
+
suite.headers = headers;
|
|
391
|
+
for (const t of tests) {
|
|
392
|
+
if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
|
|
393
|
+
delete (t as any).headers;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return suite;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Generate auth suite with consistent register → login credentials */
|
|
402
|
+
function generateConsistentAuthSuite(
|
|
403
|
+
registerEp: EndpointInfo,
|
|
404
|
+
loginEp: EndpointInfo,
|
|
405
|
+
allAuthEndpoints: EndpointInfo[],
|
|
406
|
+
securitySchemes: SecuritySchemeInfo[],
|
|
407
|
+
): RawSuite {
|
|
408
|
+
const tests: RawStep[] = [];
|
|
409
|
+
|
|
410
|
+
// Determine credential field: "email" or "username"
|
|
411
|
+
const useEmail = schemaHasField(registerEp.requestBodySchema, "email");
|
|
412
|
+
const credField = useEmail ? "email" : "username";
|
|
413
|
+
const credValue = useEmail ? "test_{{$timestamp}}@test.com" : "testuser_{{$timestamp}}";
|
|
414
|
+
|
|
415
|
+
// 0. Set shared credentials
|
|
416
|
+
const setStep: RawStep = {
|
|
417
|
+
name: "Set test credentials",
|
|
418
|
+
set: {
|
|
419
|
+
[`test_${credField}`]: credValue,
|
|
420
|
+
test_password: "TestPass123!",
|
|
421
|
+
},
|
|
422
|
+
expect: {},
|
|
423
|
+
} as RawStep;
|
|
424
|
+
tests.push(setStep);
|
|
425
|
+
|
|
426
|
+
// 1. Register step — replace credential fields with shared vars
|
|
427
|
+
const registerStep = generateStep(registerEp, securitySchemes);
|
|
428
|
+
if (registerStep.json && typeof registerStep.json === "object") {
|
|
429
|
+
const json = registerStep.json as Record<string, unknown>;
|
|
430
|
+
if (credField in json) json[credField] = `{{test_${credField}}}`;
|
|
431
|
+
if ("password" in json) json.password = "{{test_password}}";
|
|
432
|
+
}
|
|
433
|
+
tests.push(registerStep);
|
|
434
|
+
|
|
435
|
+
// 2. Login step — reuse same credentials + capture token
|
|
436
|
+
const loginStep = generateStep(loginEp, securitySchemes);
|
|
437
|
+
if (loginStep.json && typeof loginStep.json === "object") {
|
|
438
|
+
const json = loginStep.json as Record<string, unknown>;
|
|
439
|
+
if (credField in json) json[credField] = `{{test_${credField}}}`;
|
|
440
|
+
if ("password" in json) json.password = "{{test_password}}";
|
|
441
|
+
}
|
|
442
|
+
// Try to capture auth token from login response
|
|
443
|
+
const loginSchema = getSuccessSchema(loginEp);
|
|
444
|
+
if (loginSchema?.properties) {
|
|
445
|
+
const tokenField = Object.keys(loginSchema.properties).find(k =>
|
|
446
|
+
/token|access_token|accessToken|jwt/i.test(k)
|
|
447
|
+
);
|
|
448
|
+
if (tokenField) {
|
|
449
|
+
if (!loginStep.expect.body) loginStep.expect.body = {};
|
|
450
|
+
loginStep.expect.body[tokenField] = { capture: "auth_token" };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
tests.push(loginStep);
|
|
454
|
+
|
|
455
|
+
// 3. Any remaining auth endpoints (not register/login)
|
|
456
|
+
const others = allAuthEndpoints.filter(ep => ep !== registerEp && ep !== loginEp);
|
|
457
|
+
for (const ep of others) {
|
|
458
|
+
tests.push(generateStep(ep, securitySchemes));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
name: "auth",
|
|
463
|
+
tags: ["auth"],
|
|
464
|
+
fileStem: "auth",
|
|
465
|
+
base_url: "{{base_url}}",
|
|
466
|
+
tests,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
338
470
|
/** Main entry point: generate all suites from endpoints */
|
|
339
471
|
export function generateSuites(opts: {
|
|
340
472
|
endpoints: EndpointInfo[];
|
|
@@ -433,26 +565,7 @@ export function generateSuites(opts: {
|
|
|
433
565
|
|
|
434
566
|
// 4. Auth suite (separate — requires real credentials)
|
|
435
567
|
if (authEndpoints.length > 0) {
|
|
436
|
-
const
|
|
437
|
-
const headers = getSuiteHeaders(authEndpoints, securitySchemes);
|
|
438
|
-
|
|
439
|
-
const suite: RawSuite = {
|
|
440
|
-
name: "auth",
|
|
441
|
-
tags: ["auth"],
|
|
442
|
-
fileStem: "auth",
|
|
443
|
-
base_url: "{{base_url}}",
|
|
444
|
-
tests,
|
|
445
|
-
};
|
|
446
|
-
|
|
447
|
-
if (headers) {
|
|
448
|
-
suite.headers = headers;
|
|
449
|
-
for (const t of tests) {
|
|
450
|
-
if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
|
|
451
|
-
delete (t as any).headers;
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
|
|
568
|
+
const suite = generateAuthSuite(authEndpoints, securitySchemes);
|
|
456
569
|
suites.push(suite);
|
|
457
570
|
}
|
|
458
571
|
|
|
@@ -286,6 +286,7 @@ export async function runSuite(suite: TestSuite, env: Environment = {}, dryRun =
|
|
|
286
286
|
suite_name: suite.name,
|
|
287
287
|
suite_tags: suite.tags,
|
|
288
288
|
suite_description: suite.description,
|
|
289
|
+
suite_file: suite.filePath,
|
|
289
290
|
started_at: startedAt,
|
|
290
291
|
finished_at: finishedAt,
|
|
291
292
|
total: steps.length,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { executeRequest } from "./http-client.ts";
|
|
2
|
+
import { loadEnvironment, substituteString, substituteDeep } from "../parser/variables.ts";
|
|
3
|
+
import { getDb } from "../../db/schema.ts";
|
|
4
|
+
import { findCollectionByNameOrId } from "../../db/queries.ts";
|
|
5
|
+
|
|
6
|
+
function extractByPath(obj: unknown, path: string): unknown {
|
|
7
|
+
const segments = path.replace(/\[(\d+)\]/g, '.$1').split('.').filter(Boolean);
|
|
8
|
+
let current: unknown = obj;
|
|
9
|
+
for (const seg of segments) {
|
|
10
|
+
if (current === null || current === undefined) return undefined;
|
|
11
|
+
if (Array.isArray(current)) {
|
|
12
|
+
const idx = parseInt(seg, 10);
|
|
13
|
+
if (isNaN(idx)) return undefined;
|
|
14
|
+
current = current[idx];
|
|
15
|
+
} else if (typeof current === 'object') {
|
|
16
|
+
current = (current as Record<string, unknown>)[seg];
|
|
17
|
+
} else {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return current;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SendAdHocRequestOptions {
|
|
25
|
+
method: string;
|
|
26
|
+
url: string;
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
body?: string;
|
|
29
|
+
timeout?: number;
|
|
30
|
+
envName?: string;
|
|
31
|
+
collectionName?: string;
|
|
32
|
+
jsonPath?: string;
|
|
33
|
+
maxResponseChars?: number;
|
|
34
|
+
dbPath?: string;
|
|
35
|
+
searchDir?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SendAdHocRequestResult {
|
|
39
|
+
status: number;
|
|
40
|
+
headers: Record<string, string>;
|
|
41
|
+
body: unknown;
|
|
42
|
+
duration_ms: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function sendAdHocRequest(options: SendAdHocRequestOptions): Promise<SendAdHocRequestResult> {
|
|
46
|
+
let searchDir = options.searchDir ?? process.cwd();
|
|
47
|
+
if (options.collectionName) {
|
|
48
|
+
getDb(options.dbPath);
|
|
49
|
+
const col = findCollectionByNameOrId(options.collectionName);
|
|
50
|
+
if (col?.base_dir) searchDir = col.base_dir;
|
|
51
|
+
}
|
|
52
|
+
const vars = await loadEnvironment(options.envName, searchDir);
|
|
53
|
+
|
|
54
|
+
const resolvedUrl = substituteString(options.url, vars) as string;
|
|
55
|
+
const parsedHeaders = options.headers ?? {};
|
|
56
|
+
const resolvedHeaders = Object.keys(parsedHeaders).length > 0 ? substituteDeep(parsedHeaders, vars) : {};
|
|
57
|
+
const resolvedBody = options.body ? substituteString(options.body, vars) as string : undefined;
|
|
58
|
+
|
|
59
|
+
// Auto-detect Content-Type for body if not explicitly set
|
|
60
|
+
const finalHeaders: Record<string, string> = { ...resolvedHeaders };
|
|
61
|
+
if (resolvedBody && !finalHeaders["Content-Type"] && !finalHeaders["content-type"]) {
|
|
62
|
+
try {
|
|
63
|
+
JSON.parse(resolvedBody);
|
|
64
|
+
finalHeaders["Content-Type"] = "application/json";
|
|
65
|
+
} catch {
|
|
66
|
+
// Not JSON — don't set content-type, let server decide
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const response = await executeRequest(
|
|
71
|
+
{
|
|
72
|
+
method: options.method,
|
|
73
|
+
url: resolvedUrl,
|
|
74
|
+
headers: finalHeaders,
|
|
75
|
+
body: resolvedBody,
|
|
76
|
+
},
|
|
77
|
+
options.timeout ? { timeout: options.timeout } : undefined,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
let responseBody: unknown = response.body_parsed ?? response.body;
|
|
81
|
+
|
|
82
|
+
if (options.jsonPath && responseBody !== undefined) {
|
|
83
|
+
responseBody = extractByPath(responseBody, options.jsonPath);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const result: SendAdHocRequestResult = {
|
|
87
|
+
status: response.status,
|
|
88
|
+
headers: response.headers,
|
|
89
|
+
body: responseBody,
|
|
90
|
+
duration_ms: response.duration_ms,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return result;
|
|
94
|
+
}
|
package/src/core/runner/types.ts
CHANGED
package/src/db/queries.ts
CHANGED
|
@@ -103,6 +103,7 @@ export interface StoredStepResult {
|
|
|
103
103
|
error_message: string | null;
|
|
104
104
|
assertions: import("../core/runner/types.ts").AssertionResult[];
|
|
105
105
|
captures: Record<string, unknown>;
|
|
106
|
+
suite_file: string | null;
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
// ──────────────────────────────────────────────
|
|
@@ -244,11 +245,11 @@ export function saveResults(runId: number, suiteResults: TestRunResult[]): void
|
|
|
244
245
|
INSERT INTO results
|
|
245
246
|
(run_id, suite_name, test_name, status, duration_ms,
|
|
246
247
|
request_method, request_url, request_body,
|
|
247
|
-
response_status, response_body, response_headers, error_message, assertions, captures)
|
|
248
|
+
response_status, response_body, response_headers, error_message, assertions, captures, suite_file)
|
|
248
249
|
VALUES
|
|
249
250
|
($run_id, $suite_name, $test_name, $status, $duration_ms,
|
|
250
251
|
$request_method, $request_url, $request_body,
|
|
251
|
-
$response_status, $response_body, $response_headers, $error_message, $assertions, $captures)
|
|
252
|
+
$response_status, $response_body, $response_headers, $error_message, $assertions, $captures, $suite_file)
|
|
252
253
|
`);
|
|
253
254
|
|
|
254
255
|
db.transaction(() => {
|
|
@@ -272,6 +273,7 @@ export function saveResults(runId: number, suiteResults: TestRunResult[]): void
|
|
|
272
273
|
$error_message: step.error ?? null,
|
|
273
274
|
$assertions: step.assertions.length > 0 ? JSON.stringify(step.assertions) : null,
|
|
274
275
|
$captures: Object.keys(step.captures).length > 0 ? JSON.stringify(step.captures) : null,
|
|
276
|
+
$suite_file: suite.suite_file ?? null,
|
|
275
277
|
});
|
|
276
278
|
}
|
|
277
279
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -48,7 +48,7 @@ export function resetDb(): void {
|
|
|
48
48
|
// Schema
|
|
49
49
|
// ──────────────────────────────────────────────
|
|
50
50
|
|
|
51
|
-
const SCHEMA_VERSION =
|
|
51
|
+
const SCHEMA_VERSION = 2;
|
|
52
52
|
|
|
53
53
|
const SCHEMA = `
|
|
54
54
|
CREATE TABLE IF NOT EXISTS runs (
|
|
@@ -82,7 +82,8 @@ const SCHEMA = `
|
|
|
82
82
|
error_message TEXT,
|
|
83
83
|
assertions TEXT,
|
|
84
84
|
captures TEXT,
|
|
85
|
-
response_headers TEXT
|
|
85
|
+
response_headers TEXT,
|
|
86
|
+
suite_file TEXT
|
|
86
87
|
);
|
|
87
88
|
|
|
88
89
|
CREATE TABLE IF NOT EXISTS collections (
|
|
@@ -153,7 +154,14 @@ function runMigrations(db: Database): void {
|
|
|
153
154
|
if (ver >= SCHEMA_VERSION) return;
|
|
154
155
|
|
|
155
156
|
db.transaction(() => {
|
|
156
|
-
|
|
157
|
+
if (ver === 0) {
|
|
158
|
+
// Fresh database — create all tables
|
|
159
|
+
db.exec(SCHEMA);
|
|
160
|
+
}
|
|
161
|
+
if (ver >= 1 && ver < 2) {
|
|
162
|
+
// Migration v1→v2: add suite_file column to results
|
|
163
|
+
db.exec("ALTER TABLE results ADD COLUMN suite_file TEXT");
|
|
164
|
+
}
|
|
157
165
|
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
158
166
|
})();
|
|
159
167
|
}
|
package/src/mcp/descriptions.ts
CHANGED
|
@@ -3,12 +3,6 @@
|
|
|
3
3
|
* Update descriptions here — they are imported by each tool file.
|
|
4
4
|
*/
|
|
5
5
|
export const TOOL_DESCRIPTIONS = {
|
|
6
|
-
set_work_dir:
|
|
7
|
-
"Set the working directory for this MCP session. " +
|
|
8
|
-
"Call this FIRST before any other tool when using a shared MCP server (npx). " +
|
|
9
|
-
"Determines where zond.db and relative test paths resolve to. " +
|
|
10
|
-
"Pass the absolute path to your project root (same as workspace root in your editor).",
|
|
11
|
-
|
|
12
6
|
setup_api:
|
|
13
7
|
"Register a new API for testing. Creates directory structure, reads OpenAPI spec, " +
|
|
14
8
|
"sets up environment variables, and creates a collection in the database. " +
|
|
@@ -20,15 +14,6 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
20
14
|
"all response schemas + response headers, security, deprecated flag. " +
|
|
21
15
|
"Use when a test fails and you need complete endpoint spec without reading the whole file.",
|
|
22
16
|
|
|
23
|
-
save_test_suite:
|
|
24
|
-
"Save a YAML test suite file with validation. Parses and validates the YAML content " +
|
|
25
|
-
"before writing. Returns structured errors if validation fails so you can fix and retry. " +
|
|
26
|
-
"Use after generating test content with generate_and_save.",
|
|
27
|
-
|
|
28
|
-
save_test_suites:
|
|
29
|
-
"Save multiple YAML test suite files in a single call. Each file is validated before writing. " +
|
|
30
|
-
"Returns per-file results. Use when you have generated multiple suites at once.",
|
|
31
|
-
|
|
32
17
|
run_tests:
|
|
33
18
|
"Execute API tests from a YAML file or directory and return results summary with failures. " +
|
|
34
19
|
"Use after saving test suites with save_test_suite. Check query_db(action: 'diagnose_failure') for detailed failure analysis.",
|
|
@@ -54,15 +39,6 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
54
39
|
"Start, stop, restart, or check status of the zond WebUI server. " +
|
|
55
40
|
"Useful for viewing test results in a browser without leaving the MCP session.",
|
|
56
41
|
|
|
57
|
-
generate_and_save:
|
|
58
|
-
"Read an OpenAPI spec, auto-chunk by tags if large (>30 endpoints), " +
|
|
59
|
-
"and return a focused test generation guide. For large APIs returns a chunking plan — " +
|
|
60
|
-
"call again with tag parameter for each chunk. Use testsDir param to only generate for uncovered endpoints. " +
|
|
61
|
-
"After generating YAML, use save_test_suites to save files, then run_tests to verify. " +
|
|
62
|
-
"Includes YAML format cheatsheet by default; pass includeFormat: false for subsequent tag chunks to save tokens. " +
|
|
63
|
-
"Use mode: 'generate' to auto-generate and save deterministic YAML test files (smoke + CRUD) without LLM. " +
|
|
64
|
-
"Default mode is 'generate'; use mode: 'guide' for the text-based generation guide.",
|
|
65
|
-
|
|
66
42
|
ci_init:
|
|
67
43
|
"Generate a CI/CD workflow file for running API tests automatically on push, PR, and schedule. " +
|
|
68
44
|
"Supports GitHub Actions and GitLab CI. Auto-detects platform from project structure " +
|
package/src/mcp/server.ts
CHANGED
|
@@ -4,13 +4,10 @@ import { registerRunTestsTool } from "./tools/run-tests.ts";
|
|
|
4
4
|
import { registerQueryDbTool } from "./tools/query-db.ts";
|
|
5
5
|
import { registerSendRequestTool } from "./tools/send-request.ts";
|
|
6
6
|
import { registerCoverageAnalysisTool } from "./tools/coverage-analysis.ts";
|
|
7
|
-
import { registerSaveTestSuiteTool, registerSaveTestSuitesTool } from "./tools/save-test-suite.ts";
|
|
8
7
|
import { registerSetupApiTool } from "./tools/setup-api.ts";
|
|
9
8
|
import { registerManageServerTool } from "./tools/manage-server.ts";
|
|
10
9
|
import { registerCiInitTool } from "./tools/ci-init.ts";
|
|
11
|
-
import { registerSetWorkDirTool } from "./tools/set-work-dir.ts";
|
|
12
10
|
import { registerDescribeEndpointTool } from "./tools/describe-endpoint.ts";
|
|
13
|
-
import { registerGenerateAndSaveTool } from "./tools/generate-and-save.ts";
|
|
14
11
|
import { version } from "../../package.json";
|
|
15
12
|
|
|
16
13
|
export interface McpServerOptions {
|
|
@@ -25,19 +22,15 @@ export async function startMcpServer(options: McpServerOptions = {}): Promise<vo
|
|
|
25
22
|
version,
|
|
26
23
|
});
|
|
27
24
|
|
|
28
|
-
// Register
|
|
25
|
+
// Register tools (slim set — removed set_work_dir, save_test_suite, save_test_suites)
|
|
29
26
|
registerRunTestsTool(server, dbPath);
|
|
30
27
|
registerQueryDbTool(server, dbPath);
|
|
31
28
|
registerSendRequestTool(server, dbPath);
|
|
32
29
|
registerCoverageAnalysisTool(server, dbPath);
|
|
33
|
-
registerSaveTestSuiteTool(server, dbPath);
|
|
34
|
-
registerSaveTestSuitesTool(server, dbPath);
|
|
35
30
|
registerSetupApiTool(server, dbPath);
|
|
36
31
|
registerManageServerTool(server, dbPath);
|
|
37
32
|
registerCiInitTool(server);
|
|
38
|
-
registerSetWorkDirTool(server);
|
|
39
33
|
registerDescribeEndpointTool(server);
|
|
40
|
-
registerGenerateAndSaveTool(server);
|
|
41
34
|
|
|
42
35
|
// Connect via stdio transport
|
|
43
36
|
const transport = new StdioServerTransport();
|
|
@@ -1,67 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
-
import
|
|
4
|
-
import { readOpenApiSpec } from "../../core/generator/index.ts";
|
|
5
|
-
import { decycleSchema } from "../../core/generator/schema-utils.ts";
|
|
3
|
+
import { describeEndpoint } from "../../core/generator/describe.ts";
|
|
6
4
|
import { TOOL_DESCRIPTIONS } from "../descriptions.js";
|
|
7
5
|
|
|
8
|
-
function generateTestSnippet(params: {
|
|
9
|
-
method: string;
|
|
10
|
-
path: string;
|
|
11
|
-
operationId?: string;
|
|
12
|
-
pathParams: string[];
|
|
13
|
-
queryParams: Array<{ name: string; required?: boolean }>;
|
|
14
|
-
requestBody?: { required?: boolean; schema?: OpenAPIV3.SchemaObject };
|
|
15
|
-
hasSecurity: boolean;
|
|
16
|
-
successStatus: string;
|
|
17
|
-
}): string {
|
|
18
|
-
const { method, path, operationId, pathParams, queryParams, requestBody, hasSecurity, successStatus } = params;
|
|
19
|
-
|
|
20
|
-
// Build URL with path params as {{paramName}}
|
|
21
|
-
const urlPath = path.replace(/\{([^}]+)\}/g, (_, name) => `{{${name}}}`);
|
|
22
|
-
const url = `{{base_url}}${urlPath}`;
|
|
23
|
-
|
|
24
|
-
const lines: string[] = [];
|
|
25
|
-
const testName = operationId ?? `${method} ${path}`;
|
|
26
|
-
lines.push(`- name: "${testName}"`);
|
|
27
|
-
lines.push(` ${method}: "${url}"`);
|
|
28
|
-
|
|
29
|
-
if (hasSecurity) {
|
|
30
|
-
lines.push(` headers:`);
|
|
31
|
-
lines.push(` Authorization: "Bearer {{auth_token}}"`);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Required query params
|
|
35
|
-
const requiredQuery = queryParams.filter(p => p.required);
|
|
36
|
-
if (requiredQuery.length > 0) {
|
|
37
|
-
lines.push(` query:`);
|
|
38
|
-
for (const p of requiredQuery) {
|
|
39
|
-
lines.push(` ${p.name}: "{{${p.name}}}"`);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Request body for POST/PUT/PATCH
|
|
44
|
-
if (requestBody && ["POST", "PUT", "PATCH"].includes(method)) {
|
|
45
|
-
const schema = requestBody.schema as OpenAPIV3.SchemaObject | undefined;
|
|
46
|
-
const required = Array.isArray(schema?.required) ? schema.required : [];
|
|
47
|
-
const properties = schema?.properties as Record<string, OpenAPIV3.SchemaObject> | undefined;
|
|
48
|
-
if (properties && Object.keys(properties).length > 0) {
|
|
49
|
-
lines.push(` json:`);
|
|
50
|
-
for (const [propName, propSchema] of Object.entries(properties)) {
|
|
51
|
-
if (!required.includes(propName)) continue;
|
|
52
|
-
const type = (propSchema as OpenAPIV3.SchemaObject).type ?? "string";
|
|
53
|
-
const placeholder = type === "integer" || type === "number" ? 0 : type === "boolean" ? false : `"{{${propName}}}"`;
|
|
54
|
-
lines.push(` ${propName}: ${placeholder}`);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
lines.push(` expect:`);
|
|
60
|
-
lines.push(` status: ${successStatus}`);
|
|
61
|
-
|
|
62
|
-
return lines.join("\n");
|
|
63
|
-
}
|
|
64
|
-
|
|
65
6
|
export function registerDescribeEndpointTool(server: McpServer) {
|
|
66
7
|
server.registerTool("describe_endpoint", {
|
|
67
8
|
description: TOOL_DESCRIPTIONS.describe_endpoint,
|
|
@@ -72,165 +13,9 @@ export function registerDescribeEndpointTool(server: McpServer) {
|
|
|
72
13
|
},
|
|
73
14
|
}, async ({ specPath, method, path: endpointPath }) => {
|
|
74
15
|
try {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
// Normalize inputs
|
|
78
|
-
const methodLower = method.toLowerCase() as OpenAPIV3.HttpMethods;
|
|
79
|
-
const normalizedPath = endpointPath.replace(/\/+$/, "") || "/";
|
|
80
|
-
|
|
81
|
-
// Find operation — try exact match first, then case-insensitive path match
|
|
82
|
-
let operation: OpenAPIV3.OperationObject | undefined;
|
|
83
|
-
let resolvedPath = normalizedPath;
|
|
84
|
-
|
|
85
|
-
const paths = doc.paths ?? {};
|
|
86
|
-
|
|
87
|
-
if (paths[normalizedPath]?.[methodLower]) {
|
|
88
|
-
operation = paths[normalizedPath][methodLower] as OpenAPIV3.OperationObject;
|
|
89
|
-
} else {
|
|
90
|
-
// Case-insensitive fallback
|
|
91
|
-
const lowerTarget = normalizedPath.toLowerCase();
|
|
92
|
-
for (const [p, pathItem] of Object.entries(paths)) {
|
|
93
|
-
if (p.toLowerCase() === lowerTarget && pathItem?.[methodLower]) {
|
|
94
|
-
operation = pathItem[methodLower] as OpenAPIV3.OperationObject;
|
|
95
|
-
resolvedPath = p;
|
|
96
|
-
break;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
if (!operation) {
|
|
102
|
-
const available = Object.entries(paths).flatMap(([p, pathItem]) =>
|
|
103
|
-
Object.keys(pathItem ?? {})
|
|
104
|
-
.filter(k => ["get","post","put","patch","delete","head","options","trace"].includes(k))
|
|
105
|
-
.map(k => `${k.toUpperCase()} ${p}`)
|
|
106
|
-
).sort();
|
|
107
|
-
return {
|
|
108
|
-
content: [{
|
|
109
|
-
type: "text" as const,
|
|
110
|
-
text: JSON.stringify({
|
|
111
|
-
error: `Endpoint ${method.toUpperCase()} ${endpointPath} not found in spec`,
|
|
112
|
-
availableEndpoints: available,
|
|
113
|
-
}, null, 2),
|
|
114
|
-
}],
|
|
115
|
-
isError: true,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const pathItem = paths[resolvedPath] ?? {};
|
|
120
|
-
|
|
121
|
-
// Merge path-level and operation-level parameters (operation overrides by name+in)
|
|
122
|
-
const pathLevelParams = (pathItem.parameters ?? []) as OpenAPIV3.ParameterObject[];
|
|
123
|
-
const opLevelParams = (operation.parameters ?? []) as OpenAPIV3.ParameterObject[];
|
|
124
|
-
|
|
125
|
-
const paramMap = new Map<string, OpenAPIV3.ParameterObject>();
|
|
126
|
-
for (const p of pathLevelParams) paramMap.set(`${p.in}:${p.name}`, p);
|
|
127
|
-
for (const p of opLevelParams) paramMap.set(`${p.in}:${p.name}`, p); // operation overrides
|
|
128
|
-
|
|
129
|
-
// Group by "in"
|
|
130
|
-
const grouped: Record<string, object[]> = { path: [], query: [], header: [], cookie: [] };
|
|
131
|
-
for (const p of paramMap.values()) {
|
|
132
|
-
const loc = p.in in grouped ? p.in : "query";
|
|
133
|
-
const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
|
|
134
|
-
grouped[loc]!.push({
|
|
135
|
-
name: p.name,
|
|
136
|
-
required: p.required ?? false,
|
|
137
|
-
...(schema?.type ? { type: schema.type } : {}),
|
|
138
|
-
...(schema?.format ? { format: schema.format } : {}),
|
|
139
|
-
...(schema?.enum ? { enum: schema.enum } : {}),
|
|
140
|
-
...(schema?.default !== undefined ? { default: schema.default } : {}),
|
|
141
|
-
...(p.description ? { description: p.description } : {}),
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Request body
|
|
146
|
-
let requestBody: object | undefined;
|
|
147
|
-
if (operation.requestBody) {
|
|
148
|
-
const rb = operation.requestBody as OpenAPIV3.RequestBodyObject;
|
|
149
|
-
const contentTypes = Object.keys(rb.content ?? {});
|
|
150
|
-
const preferredCt = contentTypes.find(ct => ct.includes("application/json")) ?? contentTypes[0];
|
|
151
|
-
const mediaObj = preferredCt ? rb.content[preferredCt] : undefined;
|
|
152
|
-
requestBody = {
|
|
153
|
-
required: rb.required ?? false,
|
|
154
|
-
...(preferredCt ? { contentType: preferredCt } : {}),
|
|
155
|
-
...(mediaObj?.schema ? { schema: mediaObj.schema } : {}),
|
|
156
|
-
...(rb.description ? { description: rb.description } : {}),
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Responses
|
|
161
|
-
const responses: Record<string, object> = {};
|
|
162
|
-
for (const [statusCode, respObj] of Object.entries(operation.responses ?? {})) {
|
|
163
|
-
const resp = respObj as OpenAPIV3.ResponseObject;
|
|
164
|
-
const contentTypes = Object.keys(resp.content ?? {});
|
|
165
|
-
const preferredCt = contentTypes.find(ct => ct.includes("application/json")) ?? contentTypes[0];
|
|
166
|
-
const mediaObj = preferredCt ? resp.content?.[preferredCt] : undefined;
|
|
167
|
-
|
|
168
|
-
// Response headers
|
|
169
|
-
const headers: Record<string, object> = {};
|
|
170
|
-
for (const [hName, hObj] of Object.entries(resp.headers ?? {})) {
|
|
171
|
-
const h = hObj as OpenAPIV3.HeaderObject;
|
|
172
|
-
headers[hName] = {
|
|
173
|
-
...(h.description ? { description: h.description } : {}),
|
|
174
|
-
...(h.schema ? { schema: h.schema } : {}),
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
responses[statusCode] = {
|
|
179
|
-
description: resp.description,
|
|
180
|
-
headers,
|
|
181
|
-
...(preferredCt ? { contentType: preferredCt } : {}),
|
|
182
|
-
...(mediaObj?.schema ? { schema: mediaObj.schema } : {}),
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Security — merge doc-level and operation-level
|
|
187
|
-
const docSecurity = (doc.security ?? []) as OpenAPIV3.SecurityRequirementObject[];
|
|
188
|
-
const opSecurity = (operation.security ?? docSecurity) as OpenAPIV3.SecurityRequirementObject[];
|
|
189
|
-
const securityNames = [...new Set(opSecurity.flatMap(req => Object.keys(req)))];
|
|
190
|
-
|
|
191
|
-
// Derive success status (first 2xx, or first response code)
|
|
192
|
-
const responseCodes = Object.keys(operation.responses ?? {});
|
|
193
|
-
const successStatus = responseCodes.find(c => c.startsWith("2")) ?? responseCodes[0] ?? "200";
|
|
194
|
-
|
|
195
|
-
// Build testSnippet
|
|
196
|
-
const pathParamNames = [...paramMap.values()]
|
|
197
|
-
.filter(p => p.in === "path")
|
|
198
|
-
.map(p => p.name);
|
|
199
|
-
const queryParamsList = [...paramMap.values()]
|
|
200
|
-
.filter(p => p.in === "query")
|
|
201
|
-
.map(p => ({ name: p.name, required: p.required }));
|
|
202
|
-
const reqBodyForSnippet = requestBody
|
|
203
|
-
? { required: (operation.requestBody as OpenAPIV3.RequestBodyObject)?.required, schema: (requestBody as any).schema }
|
|
204
|
-
: undefined;
|
|
205
|
-
|
|
206
|
-
const testSnippet = generateTestSnippet({
|
|
207
|
-
method: method.toUpperCase(),
|
|
208
|
-
path: resolvedPath,
|
|
209
|
-
operationId: operation.operationId,
|
|
210
|
-
pathParams: pathParamNames,
|
|
211
|
-
queryParams: queryParamsList,
|
|
212
|
-
requestBody: reqBodyForSnippet,
|
|
213
|
-
hasSecurity: securityNames.length > 0,
|
|
214
|
-
successStatus,
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
const result = {
|
|
218
|
-
method: method.toUpperCase(),
|
|
219
|
-
path: resolvedPath,
|
|
220
|
-
...(operation.operationId ? { operationId: operation.operationId } : {}),
|
|
221
|
-
...(operation.summary ? { summary: operation.summary } : {}),
|
|
222
|
-
...(operation.description ? { description: operation.description } : {}),
|
|
223
|
-
...(operation.tags?.length ? { tags: operation.tags } : {}),
|
|
224
|
-
deprecated: operation.deprecated ?? false,
|
|
225
|
-
security: securityNames,
|
|
226
|
-
parameters: grouped,
|
|
227
|
-
...(requestBody ? { requestBody } : {}),
|
|
228
|
-
responses,
|
|
229
|
-
testSnippet,
|
|
230
|
-
};
|
|
231
|
-
|
|
16
|
+
const result = await describeEndpoint(specPath, method, endpointPath);
|
|
232
17
|
return {
|
|
233
|
-
content: [{ type: "text" as const, text: JSON.stringify(
|
|
18
|
+
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
|
234
19
|
};
|
|
235
20
|
} catch (err) {
|
|
236
21
|
return {
|