@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.
Files changed (36) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -3
  3. package/src/cli/commands/ci-init.ts +12 -1
  4. package/src/cli/commands/coverage.ts +21 -1
  5. package/src/cli/commands/db.ts +121 -0
  6. package/src/cli/commands/describe.ts +60 -0
  7. package/src/cli/commands/generate.ts +127 -0
  8. package/src/cli/commands/guide.ts +127 -0
  9. package/src/cli/commands/init.ts +57 -0
  10. package/src/cli/commands/request.ts +57 -0
  11. package/src/cli/commands/run.ts +53 -10
  12. package/src/cli/commands/serve.ts +62 -3
  13. package/src/cli/commands/validate.ts +18 -2
  14. package/src/cli/index.ts +204 -7
  15. package/src/cli/json-envelope.ts +19 -0
  16. package/src/core/diagnostics/db-analysis.ts +351 -0
  17. package/src/core/diagnostics/failure-hints.ts +1 -0
  18. package/src/core/generator/data-factory.ts +19 -8
  19. package/src/core/generator/describe.ts +250 -0
  20. package/src/core/generator/guide-builder.ts +20 -0
  21. package/src/core/generator/suite-generator.ts +133 -20
  22. package/src/core/runner/executor.ts +1 -0
  23. package/src/core/runner/send-request.ts +94 -0
  24. package/src/core/runner/types.ts +1 -0
  25. package/src/db/queries.ts +4 -2
  26. package/src/db/schema.ts +11 -3
  27. package/src/mcp/descriptions.ts +0 -24
  28. package/src/mcp/server.ts +1 -8
  29. package/src/mcp/tools/describe-endpoint.ts +3 -218
  30. package/src/mcp/tools/query-db.ts +6 -222
  31. package/src/mcp/tools/run-tests.ts +1 -0
  32. package/src/mcp/tools/send-request.ts +15 -61
  33. package/src/web/views/suites-tab.ts +1 -1
  34. package/src/mcp/tools/generate-and-save.ts +0 -202
  35. package/src/mcp/tools/save-test-suite.ts +0 -218
  36. 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 tests = authEndpoints.map(ep => generateStep(ep, securitySchemes));
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
+ }
@@ -38,6 +38,7 @@ export interface TestRunResult {
38
38
  suite_name: string;
39
39
  suite_tags?: string[];
40
40
  suite_description?: string;
41
+ suite_file?: string;
41
42
  started_at: string;
42
43
  finished_at: string;
43
44
  total: number;
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 = 1;
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
- db.exec(SCHEMA);
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
  }
@@ -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 all tools
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 type { OpenAPIV3 } from "openapi-types";
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 doc = await readOpenApiSpec(specPath) as OpenAPIV3.Document;
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(decycleSchema(result), null, 2) }],
18
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
234
19
  };
235
20
  } catch (err) {
236
21
  return {