@kirrosh/zond 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/LICENSE +21 -0
  3. package/README.md +130 -0
  4. package/package.json +53 -0
  5. package/src/bun-types.d.ts +5 -0
  6. package/src/cli/commands/add-api.ts +51 -0
  7. package/src/cli/commands/ai-generate.ts +106 -0
  8. package/src/cli/commands/chat.ts +43 -0
  9. package/src/cli/commands/ci-init.ts +163 -0
  10. package/src/cli/commands/collections.ts +41 -0
  11. package/src/cli/commands/compare.ts +129 -0
  12. package/src/cli/commands/coverage.ts +156 -0
  13. package/src/cli/commands/doctor.ts +127 -0
  14. package/src/cli/commands/init.ts +84 -0
  15. package/src/cli/commands/mcp.ts +16 -0
  16. package/src/cli/commands/run.ts +156 -0
  17. package/src/cli/commands/runs.ts +108 -0
  18. package/src/cli/commands/serve.ts +22 -0
  19. package/src/cli/commands/update.ts +142 -0
  20. package/src/cli/commands/validate.ts +18 -0
  21. package/src/cli/index.ts +529 -0
  22. package/src/cli/output.ts +24 -0
  23. package/src/cli/runtime.ts +7 -0
  24. package/src/core/agent/agent-loop.ts +116 -0
  25. package/src/core/agent/context-manager.ts +41 -0
  26. package/src/core/agent/system-prompt.ts +28 -0
  27. package/src/core/agent/tools/diagnose-failure.ts +51 -0
  28. package/src/core/agent/tools/explore-api.ts +40 -0
  29. package/src/core/agent/tools/index.ts +46 -0
  30. package/src/core/agent/tools/query-results.ts +40 -0
  31. package/src/core/agent/tools/run-tests.ts +38 -0
  32. package/src/core/agent/tools/send-request.ts +44 -0
  33. package/src/core/agent/tools/validate-tests.ts +23 -0
  34. package/src/core/agent/types.ts +22 -0
  35. package/src/core/diagnostics/failure-hints.ts +63 -0
  36. package/src/core/generator/ai/ai-generator.ts +61 -0
  37. package/src/core/generator/ai/llm-client.ts +159 -0
  38. package/src/core/generator/ai/output-parser.ts +307 -0
  39. package/src/core/generator/ai/prompt-builder.ts +153 -0
  40. package/src/core/generator/ai/types.ts +56 -0
  41. package/src/core/generator/chunker.ts +47 -0
  42. package/src/core/generator/coverage-scanner.ts +87 -0
  43. package/src/core/generator/data-factory.ts +115 -0
  44. package/src/core/generator/endpoint-warnings.ts +43 -0
  45. package/src/core/generator/index.ts +12 -0
  46. package/src/core/generator/openapi-reader.ts +143 -0
  47. package/src/core/generator/schema-utils.ts +52 -0
  48. package/src/core/generator/serializer.ts +189 -0
  49. package/src/core/generator/types.ts +48 -0
  50. package/src/core/parser/filter.ts +14 -0
  51. package/src/core/parser/index.ts +21 -0
  52. package/src/core/parser/schema.ts +175 -0
  53. package/src/core/parser/types.ts +52 -0
  54. package/src/core/parser/variables.ts +154 -0
  55. package/src/core/parser/yaml-parser.ts +85 -0
  56. package/src/core/reporter/console.ts +175 -0
  57. package/src/core/reporter/index.ts +23 -0
  58. package/src/core/reporter/json.ts +9 -0
  59. package/src/core/reporter/junit.ts +78 -0
  60. package/src/core/reporter/types.ts +12 -0
  61. package/src/core/runner/assertions.ts +173 -0
  62. package/src/core/runner/execute-run.ts +97 -0
  63. package/src/core/runner/executor.ts +183 -0
  64. package/src/core/runner/http-client.ts +69 -0
  65. package/src/core/runner/index.ts +12 -0
  66. package/src/core/runner/types.ts +48 -0
  67. package/src/core/setup-api.ts +113 -0
  68. package/src/core/utils.ts +9 -0
  69. package/src/db/queries.ts +774 -0
  70. package/src/db/schema.ts +159 -0
  71. package/src/mcp/descriptions.ts +88 -0
  72. package/src/mcp/server.ts +52 -0
  73. package/src/mcp/tools/ci-init.ts +54 -0
  74. package/src/mcp/tools/coverage-analysis.ts +141 -0
  75. package/src/mcp/tools/describe-endpoint.ts +241 -0
  76. package/src/mcp/tools/explore-api.ts +84 -0
  77. package/src/mcp/tools/generate-and-save.ts +129 -0
  78. package/src/mcp/tools/generate-missing-tests.ts +91 -0
  79. package/src/mcp/tools/generate-tests-guide.ts +391 -0
  80. package/src/mcp/tools/manage-server.ts +86 -0
  81. package/src/mcp/tools/query-db.ts +255 -0
  82. package/src/mcp/tools/run-tests.ts +71 -0
  83. package/src/mcp/tools/save-test-suite.ts +218 -0
  84. package/src/mcp/tools/send-request.ts +63 -0
  85. package/src/mcp/tools/set-work-dir.ts +35 -0
  86. package/src/mcp/tools/setup-api.ts +84 -0
  87. package/src/mcp/tools/validate-tests.ts +43 -0
  88. package/src/tui/chat-ui.ts +150 -0
  89. package/src/web/data/collection-state.ts +360 -0
  90. package/src/web/routes/api.ts +234 -0
  91. package/src/web/routes/dashboard.ts +313 -0
  92. package/src/web/routes/runs.ts +64 -0
  93. package/src/web/schemas.ts +121 -0
  94. package/src/web/server.ts +134 -0
  95. package/src/web/static/htmx.min.js +1 -0
  96. package/src/web/static/style.css +827 -0
  97. package/src/web/views/endpoints-tab.ts +170 -0
  98. package/src/web/views/health-strip.ts +92 -0
  99. package/src/web/views/layout.ts +48 -0
  100. package/src/web/views/results.ts +209 -0
  101. package/src/web/views/runs-tab.ts +126 -0
  102. package/src/web/views/suites-tab.ts +153 -0
@@ -0,0 +1,241 @@
1
+ import { z } from "zod";
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 { TOOL_DESCRIPTIONS } from "../descriptions.js";
6
+
7
+ function generateTestSnippet(params: {
8
+ method: string;
9
+ path: string;
10
+ operationId?: string;
11
+ pathParams: string[];
12
+ queryParams: Array<{ name: string; required?: boolean }>;
13
+ requestBody?: { required?: boolean; schema?: OpenAPIV3.SchemaObject };
14
+ hasSecurity: boolean;
15
+ successStatus: string;
16
+ }): string {
17
+ const { method, path, operationId, pathParams, queryParams, requestBody, hasSecurity, successStatus } = params;
18
+
19
+ // Build URL with path params as {{paramName}}
20
+ const urlPath = path.replace(/\{([^}]+)\}/g, (_, name) => `{{${name}}}`);
21
+ const url = `{{base_url}}${urlPath}`;
22
+
23
+ const lines: string[] = [];
24
+ const testName = operationId ?? `${method} ${path}`;
25
+ lines.push(`- name: "${testName}"`);
26
+ lines.push(` ${method}: "${url}"`);
27
+
28
+ if (hasSecurity) {
29
+ lines.push(` headers:`);
30
+ lines.push(` Authorization: "Bearer {{auth_token}}"`);
31
+ }
32
+
33
+ // Required query params
34
+ const requiredQuery = queryParams.filter(p => p.required);
35
+ if (requiredQuery.length > 0) {
36
+ lines.push(` query:`);
37
+ for (const p of requiredQuery) {
38
+ lines.push(` ${p.name}: "{{${p.name}}}"`);
39
+ }
40
+ }
41
+
42
+ // Request body for POST/PUT/PATCH
43
+ if (requestBody && ["POST", "PUT", "PATCH"].includes(method)) {
44
+ const schema = requestBody.schema as OpenAPIV3.SchemaObject | undefined;
45
+ const required = Array.isArray(schema?.required) ? schema.required : [];
46
+ const properties = schema?.properties as Record<string, OpenAPIV3.SchemaObject> | undefined;
47
+ if (properties && Object.keys(properties).length > 0) {
48
+ lines.push(` json:`);
49
+ for (const [propName, propSchema] of Object.entries(properties)) {
50
+ if (!required.includes(propName)) continue;
51
+ const type = (propSchema as OpenAPIV3.SchemaObject).type ?? "string";
52
+ const placeholder = type === "integer" || type === "number" ? 0 : type === "boolean" ? false : `"{{${propName}}}"`;
53
+ lines.push(` ${propName}: ${placeholder}`);
54
+ }
55
+ }
56
+ }
57
+
58
+ lines.push(` expect:`);
59
+ lines.push(` status: ${successStatus}`);
60
+
61
+ return lines.join("\n");
62
+ }
63
+
64
+ export function registerDescribeEndpointTool(server: McpServer) {
65
+ server.registerTool("describe_endpoint", {
66
+ description: TOOL_DESCRIPTIONS.describe_endpoint,
67
+ inputSchema: {
68
+ specPath: z.string().describe("Path to OpenAPI spec file (JSON or YAML) or HTTP URL"),
69
+ method: z.string().describe('HTTP method, e.g. "GET", "POST", "PUT"'),
70
+ path: z.string().describe('Endpoint path, e.g. "/pets/{petId}"'),
71
+ },
72
+ }, async ({ specPath, method, path: endpointPath }) => {
73
+ try {
74
+ const doc = await readOpenApiSpec(specPath) as OpenAPIV3.Document;
75
+
76
+ // Normalize inputs
77
+ const methodLower = method.toLowerCase() as OpenAPIV3.HttpMethods;
78
+ const normalizedPath = endpointPath.replace(/\/+$/, "") || "/";
79
+
80
+ // Find operation — try exact match first, then case-insensitive path match
81
+ let operation: OpenAPIV3.OperationObject | undefined;
82
+ let resolvedPath = normalizedPath;
83
+
84
+ const paths = doc.paths ?? {};
85
+
86
+ if (paths[normalizedPath]?.[methodLower]) {
87
+ operation = paths[normalizedPath][methodLower] as OpenAPIV3.OperationObject;
88
+ } else {
89
+ // Case-insensitive fallback
90
+ const lowerTarget = normalizedPath.toLowerCase();
91
+ for (const [p, pathItem] of Object.entries(paths)) {
92
+ if (p.toLowerCase() === lowerTarget && pathItem?.[methodLower]) {
93
+ operation = pathItem[methodLower] as OpenAPIV3.OperationObject;
94
+ resolvedPath = p;
95
+ break;
96
+ }
97
+ }
98
+ }
99
+
100
+ if (!operation) {
101
+ const available = Object.entries(paths).flatMap(([p, pathItem]) =>
102
+ Object.keys(pathItem ?? {})
103
+ .filter(k => ["get","post","put","patch","delete","head","options","trace"].includes(k))
104
+ .map(k => `${k.toUpperCase()} ${p}`)
105
+ ).sort();
106
+ return {
107
+ content: [{
108
+ type: "text" as const,
109
+ text: JSON.stringify({
110
+ error: `Endpoint ${method.toUpperCase()} ${endpointPath} not found in spec`,
111
+ availableEndpoints: available,
112
+ }, null, 2),
113
+ }],
114
+ isError: true,
115
+ };
116
+ }
117
+
118
+ const pathItem = paths[resolvedPath] ?? {};
119
+
120
+ // Merge path-level and operation-level parameters (operation overrides by name+in)
121
+ const pathLevelParams = (pathItem.parameters ?? []) as OpenAPIV3.ParameterObject[];
122
+ const opLevelParams = (operation.parameters ?? []) as OpenAPIV3.ParameterObject[];
123
+
124
+ const paramMap = new Map<string, OpenAPIV3.ParameterObject>();
125
+ for (const p of pathLevelParams) paramMap.set(`${p.in}:${p.name}`, p);
126
+ for (const p of opLevelParams) paramMap.set(`${p.in}:${p.name}`, p); // operation overrides
127
+
128
+ // Group by "in"
129
+ const grouped: Record<string, object[]> = { path: [], query: [], header: [], cookie: [] };
130
+ for (const p of paramMap.values()) {
131
+ const loc = p.in in grouped ? p.in : "query";
132
+ const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
133
+ grouped[loc]!.push({
134
+ name: p.name,
135
+ required: p.required ?? false,
136
+ ...(schema?.type ? { type: schema.type } : {}),
137
+ ...(schema?.format ? { format: schema.format } : {}),
138
+ ...(schema?.enum ? { enum: schema.enum } : {}),
139
+ ...(schema?.default !== undefined ? { default: schema.default } : {}),
140
+ ...(p.description ? { description: p.description } : {}),
141
+ });
142
+ }
143
+
144
+ // Request body
145
+ let requestBody: object | undefined;
146
+ if (operation.requestBody) {
147
+ const rb = operation.requestBody as OpenAPIV3.RequestBodyObject;
148
+ const contentTypes = Object.keys(rb.content ?? {});
149
+ const preferredCt = contentTypes.find(ct => ct.includes("application/json")) ?? contentTypes[0];
150
+ const mediaObj = preferredCt ? rb.content[preferredCt] : undefined;
151
+ requestBody = {
152
+ required: rb.required ?? false,
153
+ ...(preferredCt ? { contentType: preferredCt } : {}),
154
+ ...(mediaObj?.schema ? { schema: mediaObj.schema } : {}),
155
+ ...(rb.description ? { description: rb.description } : {}),
156
+ };
157
+ }
158
+
159
+ // Responses
160
+ const responses: Record<string, object> = {};
161
+ for (const [statusCode, respObj] of Object.entries(operation.responses ?? {})) {
162
+ const resp = respObj as OpenAPIV3.ResponseObject;
163
+ const contentTypes = Object.keys(resp.content ?? {});
164
+ const preferredCt = contentTypes.find(ct => ct.includes("application/json")) ?? contentTypes[0];
165
+ const mediaObj = preferredCt ? resp.content?.[preferredCt] : undefined;
166
+
167
+ // Response headers
168
+ const headers: Record<string, object> = {};
169
+ for (const [hName, hObj] of Object.entries(resp.headers ?? {})) {
170
+ const h = hObj as OpenAPIV3.HeaderObject;
171
+ headers[hName] = {
172
+ ...(h.description ? { description: h.description } : {}),
173
+ ...(h.schema ? { schema: h.schema } : {}),
174
+ };
175
+ }
176
+
177
+ responses[statusCode] = {
178
+ description: resp.description,
179
+ headers,
180
+ ...(preferredCt ? { contentType: preferredCt } : {}),
181
+ ...(mediaObj?.schema ? { schema: mediaObj.schema } : {}),
182
+ };
183
+ }
184
+
185
+ // Security — merge doc-level and operation-level
186
+ const docSecurity = (doc.security ?? []) as OpenAPIV3.SecurityRequirementObject[];
187
+ const opSecurity = (operation.security ?? docSecurity) as OpenAPIV3.SecurityRequirementObject[];
188
+ const securityNames = [...new Set(opSecurity.flatMap(req => Object.keys(req)))];
189
+
190
+ // Derive success status (first 2xx, or first response code)
191
+ const responseCodes = Object.keys(operation.responses ?? {});
192
+ const successStatus = responseCodes.find(c => c.startsWith("2")) ?? responseCodes[0] ?? "200";
193
+
194
+ // Build testSnippet
195
+ const pathParamNames = [...paramMap.values()]
196
+ .filter(p => p.in === "path")
197
+ .map(p => p.name);
198
+ const queryParamsList = [...paramMap.values()]
199
+ .filter(p => p.in === "query")
200
+ .map(p => ({ name: p.name, required: p.required }));
201
+ const reqBodyForSnippet = requestBody
202
+ ? { required: (operation.requestBody as OpenAPIV3.RequestBodyObject)?.required, schema: (requestBody as any).schema }
203
+ : undefined;
204
+
205
+ const testSnippet = generateTestSnippet({
206
+ method: method.toUpperCase(),
207
+ path: resolvedPath,
208
+ operationId: operation.operationId,
209
+ pathParams: pathParamNames,
210
+ queryParams: queryParamsList,
211
+ requestBody: reqBodyForSnippet,
212
+ hasSecurity: securityNames.length > 0,
213
+ successStatus,
214
+ });
215
+
216
+ const result = {
217
+ method: method.toUpperCase(),
218
+ path: resolvedPath,
219
+ ...(operation.operationId ? { operationId: operation.operationId } : {}),
220
+ ...(operation.summary ? { summary: operation.summary } : {}),
221
+ ...(operation.description ? { description: operation.description } : {}),
222
+ ...(operation.tags?.length ? { tags: operation.tags } : {}),
223
+ deprecated: operation.deprecated ?? false,
224
+ security: securityNames,
225
+ parameters: grouped,
226
+ ...(requestBody ? { requestBody } : {}),
227
+ responses,
228
+ testSnippet,
229
+ };
230
+
231
+ return {
232
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
233
+ };
234
+ } catch (err) {
235
+ return {
236
+ content: [{ type: "text" as const, text: JSON.stringify({ error: (err as Error).message }, null, 2) }],
237
+ isError: true,
238
+ };
239
+ }
240
+ });
241
+ }
@@ -0,0 +1,84 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { readOpenApiSpec, extractEndpoints, extractSecuritySchemes } from "../../core/generator/index.ts";
4
+ import { compressSchema, formatParam } from "../../core/generator/schema-utils.ts";
5
+ import { TOOL_DESCRIPTIONS } from "../descriptions.js";
6
+
7
+ export function registerExploreApiTool(server: McpServer) {
8
+ server.registerTool("explore_api", {
9
+ description: TOOL_DESCRIPTIONS.explore_api,
10
+ inputSchema: {
11
+ specPath: z.string().describe("Path to OpenAPI spec file (JSON or YAML)"),
12
+ tag: z.optional(z.string()).describe("Filter endpoints by tag"),
13
+ includeSchemas: z.optional(z.boolean()).describe("Include request/response body schemas and parameter types (default: false)"),
14
+ },
15
+ }, async ({ specPath, tag, includeSchemas }) => {
16
+ try {
17
+ const doc = await readOpenApiSpec(specPath);
18
+ const allEndpoints = extractEndpoints(doc);
19
+ const securitySchemes = extractSecuritySchemes(doc);
20
+ const servers = ((doc as any).servers ?? []) as Array<{ url: string; description?: string }>;
21
+
22
+ const endpoints = tag
23
+ ? allEndpoints.filter(ep => ep.tags.includes(tag))
24
+ : allEndpoints;
25
+
26
+ const result = {
27
+ title: (doc as any).info?.title,
28
+ version: (doc as any).info?.version,
29
+ servers: servers.map(s => ({ url: s.url, description: s.description })),
30
+ securitySchemes: securitySchemes.map(s => ({
31
+ name: s.name,
32
+ type: s.type,
33
+ ...(s.scheme ? { scheme: s.scheme } : {}),
34
+ ...(s.in ? { in: s.in, keyName: s.apiKeyName } : {}),
35
+ })),
36
+ totalEndpoints: allEndpoints.length,
37
+ ...(tag ? { filteredByTag: tag, matchingEndpoints: endpoints.length } : {}),
38
+ endpoints: endpoints.map(ep => {
39
+ const base: Record<string, unknown> = {
40
+ method: ep.method,
41
+ path: ep.path,
42
+ summary: ep.summary,
43
+ tags: ep.tags,
44
+ parameters: ep.parameters.map(p => ({
45
+ name: p.name,
46
+ in: p.in,
47
+ required: p.required ?? false,
48
+ ...(includeSchemas ? { type: formatParam(p).split(": ")[1] } : {}),
49
+ })),
50
+ hasRequestBody: !!ep.requestBodySchema,
51
+ responses: ep.responses.map(r => ({
52
+ statusCode: r.statusCode,
53
+ description: r.description,
54
+ ...(includeSchemas && r.schema ? { schema: compressSchema(r.schema) } : {}),
55
+ })),
56
+ };
57
+
58
+ if (includeSchemas) {
59
+ if (ep.requestBodySchema) {
60
+ base.requestBodySchema = compressSchema(ep.requestBodySchema);
61
+ }
62
+ if (ep.requestBodyContentType) {
63
+ base.requestBodyContentType = ep.requestBodyContentType;
64
+ }
65
+ if (ep.security.length > 0) {
66
+ base.security = ep.security;
67
+ }
68
+ }
69
+
70
+ return base;
71
+ }),
72
+ };
73
+
74
+ return {
75
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
76
+ };
77
+ } catch (err) {
78
+ return {
79
+ content: [{ type: "text" as const, text: JSON.stringify({ error: (err as Error).message }, null, 2) }],
80
+ isError: true,
81
+ };
82
+ }
83
+ });
84
+ }
@@ -0,0 +1,129 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import {
4
+ readOpenApiSpec,
5
+ extractEndpoints,
6
+ extractSecuritySchemes,
7
+ scanCoveredEndpoints,
8
+ filterUncoveredEndpoints,
9
+ } from "../../core/generator/index.ts";
10
+ import { compressEndpointsWithSchemas, buildGenerationGuide } from "./generate-tests-guide.ts";
11
+ import { planChunks, filterByTag } from "../../core/generator/chunker.ts";
12
+ import { TOOL_DESCRIPTIONS } from "../descriptions.js";
13
+
14
+ export function registerGenerateAndSaveTool(server: McpServer) {
15
+ server.registerTool("generate_and_save", {
16
+ description: TOOL_DESCRIPTIONS.generate_and_save,
17
+ inputSchema: {
18
+ specPath: z.string().describe("Path or URL to OpenAPI spec file"),
19
+ outputDir: z.optional(z.string()).describe("Directory for saving test files (default: ./tests/)"),
20
+ tag: z.optional(z.string()).describe("Generate tests only for this tag's endpoints"),
21
+ methodFilter: z.optional(z.array(z.string())).describe("Only include endpoints with these HTTP methods (e.g. [\"GET\"] for smoke tests)"),
22
+ testsDir: z.optional(z.string()).describe("Path to existing tests directory — filters to uncovered endpoints only"),
23
+ overwrite: z.optional(z.boolean()).describe("Hint for save_test_suites overwrite behavior (default: false)"),
24
+ },
25
+ }, async ({ specPath, outputDir, tag, methodFilter, testsDir, overwrite }) => {
26
+ try {
27
+ const doc = await readOpenApiSpec(specPath);
28
+ let endpoints = extractEndpoints(doc);
29
+ const securitySchemes = extractSecuritySchemes(doc);
30
+ const baseUrl = ((doc as any).servers?.[0]?.url) as string | undefined;
31
+ const title = (doc as any).info?.title as string | undefined;
32
+ const effectiveOutputDir = outputDir ?? "./tests/";
33
+
34
+ // Apply method filter
35
+ if (methodFilter && methodFilter.length > 0) {
36
+ const methods = methodFilter.map(m => m.toUpperCase());
37
+ endpoints = endpoints.filter(ep => methods.includes(ep.method.toUpperCase()));
38
+ }
39
+
40
+ // Coverage filtering
41
+ let coverageInfo: { covered: number; total: number; percentage: number } | undefined;
42
+ if (testsDir) {
43
+ const totalBefore = endpoints.length;
44
+ const covered = await scanCoveredEndpoints(testsDir);
45
+ const uncovered = filterUncoveredEndpoints(endpoints, covered);
46
+ const coveredCount = totalBefore - uncovered.length;
47
+ const percentage = totalBefore > 0 ? Math.round((coveredCount / totalBefore) * 100) : 100;
48
+ coverageInfo = { covered: coveredCount, total: totalBefore, percentage };
49
+ endpoints = uncovered;
50
+ }
51
+
52
+ if (endpoints.length === 0) {
53
+ const msg = testsDir
54
+ ? { fullyCovered: true, ...coverageInfo }
55
+ : { error: "No endpoints found in the spec" };
56
+ return {
57
+ content: [{ type: "text" as const, text: JSON.stringify(msg, null, 2) }],
58
+ isError: !testsDir,
59
+ };
60
+ }
61
+
62
+ // Tag filtering
63
+ if (tag) {
64
+ endpoints = filterByTag(endpoints, tag);
65
+ if (endpoints.length === 0) {
66
+ return {
67
+ content: [{ type: "text" as const, text: JSON.stringify({ error: `No endpoints found for tag "${tag}"` }, null, 2) }],
68
+ isError: true,
69
+ };
70
+ }
71
+ }
72
+
73
+ const plan = planChunks(endpoints);
74
+
75
+ // Plan mode: large API without specific tag
76
+ if (plan.needsChunking && !tag) {
77
+ const result: Record<string, unknown> = {
78
+ mode: "plan",
79
+ title: title ?? "API",
80
+ totalEndpoints: plan.totalEndpoints,
81
+ chunks: plan.chunks,
82
+ instruction:
83
+ `This API has ${plan.totalEndpoints} endpoints across ${plan.chunks.length} tags. ` +
84
+ `Call generate_and_save with tag parameter for each chunk sequentially. ` +
85
+ `Example: generate_and_save(specPath: '${specPath}', tag: '${plan.chunks[0].tag}')`,
86
+ };
87
+ if (coverageInfo) {
88
+ result.coverage = coverageInfo;
89
+ }
90
+ return {
91
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
92
+ };
93
+ }
94
+
95
+ // Guide mode: small API or specific tag
96
+ const coverageHeader = coverageInfo
97
+ ? `## Coverage: ${coverageInfo.covered}/${coverageInfo.total} endpoints covered (${coverageInfo.percentage}%). Generating tests for ${endpoints.length} uncovered endpoints:`
98
+ : undefined;
99
+
100
+ const apiContext = compressEndpointsWithSchemas(endpoints, securitySchemes);
101
+ const guide = buildGenerationGuide({
102
+ title: tag ? `${title ?? "API"} — tag: ${tag}` : (title ?? "API"),
103
+ baseUrl,
104
+ apiContext,
105
+ outputDir: effectiveOutputDir,
106
+ securitySchemes,
107
+ endpointCount: endpoints.length,
108
+ coverageHeader,
109
+ });
110
+
111
+ const saveInstructions = `
112
+ ---
113
+ ## Save Instructions
114
+ - Output directory: ${effectiveOutputDir}
115
+ - Use \`save_test_suites\` to save all generated files in one call
116
+ - Overwrite: ${overwrite ? "true" : "false (set overwrite: true in save_test_suites to replace existing files)"}
117
+ - After saving, run \`run_tests\` to verify`;
118
+
119
+ return {
120
+ content: [{ type: "text" as const, text: guide + saveInstructions }],
121
+ };
122
+ } catch (err) {
123
+ return {
124
+ content: [{ type: "text" as const, text: JSON.stringify({ error: (err as Error).message }, null, 2) }],
125
+ isError: true,
126
+ };
127
+ }
128
+ });
129
+ }
@@ -0,0 +1,91 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import {
4
+ readOpenApiSpec,
5
+ extractEndpoints,
6
+ extractSecuritySchemes,
7
+ scanCoveredEndpoints,
8
+ filterUncoveredEndpoints,
9
+ } from "../../core/generator/index.ts";
10
+ import { compressEndpointsWithSchemas, buildGenerationGuide } from "./generate-tests-guide.ts";
11
+ import { TOOL_DESCRIPTIONS } from "../descriptions.js";
12
+
13
+ export function registerGenerateMissingTestsTool(server: McpServer) {
14
+ server.registerTool("generate_missing_tests", {
15
+ description: TOOL_DESCRIPTIONS.generate_missing_tests,
16
+ inputSchema: {
17
+ specPath: z.string().describe("Path or URL to OpenAPI spec file"),
18
+ testsDir: z.string().describe("Path to directory with existing test YAML files"),
19
+ outputDir: z.optional(z.string()).describe("Directory for saving new test files (default: same as testsDir)"),
20
+ methodFilter: z.optional(z.array(z.string())).describe("Only include endpoints with these HTTP methods (e.g. [\"GET\"] for smoke tests)"),
21
+ tag: z.optional(z.string()).describe("Filter endpoints by tag"),
22
+ },
23
+ }, async ({ specPath, testsDir, outputDir, methodFilter, tag }) => {
24
+ try {
25
+ const doc = await readOpenApiSpec(specPath);
26
+ let allEndpoints = extractEndpoints(doc);
27
+ const securitySchemes = extractSecuritySchemes(doc);
28
+ const baseUrl = ((doc as any).servers?.[0]?.url) as string | undefined;
29
+ const title = (doc as any).info?.title as string | undefined;
30
+
31
+ // Apply method filter before coverage check
32
+ if (methodFilter && methodFilter.length > 0) {
33
+ const methods = methodFilter.map(m => m.toUpperCase());
34
+ allEndpoints = allEndpoints.filter(ep => methods.includes(ep.method.toUpperCase()));
35
+ }
36
+ if (tag) {
37
+ const lower = tag.toLowerCase();
38
+ allEndpoints = allEndpoints.filter(ep => ep.tags.some(t => t.toLowerCase() === lower));
39
+ }
40
+
41
+ if (allEndpoints.length === 0) {
42
+ return {
43
+ content: [{ type: "text" as const, text: JSON.stringify({ error: "No endpoints found in the spec" }, null, 2) }],
44
+ isError: true,
45
+ };
46
+ }
47
+
48
+ const covered = await scanCoveredEndpoints(testsDir);
49
+ const uncovered = filterUncoveredEndpoints(allEndpoints, covered);
50
+ const coveredCount = allEndpoints.length - uncovered.length;
51
+ const percentage = Math.round((coveredCount / allEndpoints.length) * 100);
52
+
53
+ if (uncovered.length === 0) {
54
+ return {
55
+ content: [{
56
+ type: "text" as const,
57
+ text: JSON.stringify({
58
+ fullyCovered: true,
59
+ percentage: 100,
60
+ totalEndpoints: allEndpoints.length,
61
+ covered: coveredCount,
62
+ }, null, 2),
63
+ }],
64
+ };
65
+ }
66
+
67
+ // Build guide for uncovered endpoints only
68
+ const apiContext = compressEndpointsWithSchemas(uncovered, securitySchemes);
69
+ const coverageHeader = `## Coverage: ${coveredCount}/${allEndpoints.length} endpoints covered (${percentage}%). Generating tests for ${uncovered.length} uncovered endpoints:`;
70
+
71
+ const guide = buildGenerationGuide({
72
+ title: title ?? "API",
73
+ baseUrl,
74
+ apiContext,
75
+ outputDir: outputDir ?? testsDir,
76
+ securitySchemes,
77
+ endpointCount: uncovered.length,
78
+ coverageHeader,
79
+ });
80
+
81
+ return {
82
+ content: [{ type: "text" as const, text: guide }],
83
+ };
84
+ } catch (err) {
85
+ return {
86
+ content: [{ type: "text" as const, text: JSON.stringify({ error: (err as Error).message }, null, 2) }],
87
+ isError: true,
88
+ };
89
+ }
90
+ });
91
+ }