truss-api-mcp 1.0.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 (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -0
  3. package/dist/index.d.ts +3 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +89 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/lib/code-generator.d.ts +6 -0
  8. package/dist/lib/code-generator.d.ts.map +1 -0
  9. package/dist/lib/code-generator.js +890 -0
  10. package/dist/lib/code-generator.js.map +1 -0
  11. package/dist/lib/http-client.d.ts +6 -0
  12. package/dist/lib/http-client.d.ts.map +1 -0
  13. package/dist/lib/http-client.js +76 -0
  14. package/dist/lib/http-client.js.map +1 -0
  15. package/dist/lib/license.d.ts +4 -0
  16. package/dist/lib/license.d.ts.map +1 -0
  17. package/dist/lib/license.js +97 -0
  18. package/dist/lib/license.js.map +1 -0
  19. package/dist/lib/openapi-parser.d.ts +11 -0
  20. package/dist/lib/openapi-parser.d.ts.map +1 -0
  21. package/dist/lib/openapi-parser.js +390 -0
  22. package/dist/lib/openapi-parser.js.map +1 -0
  23. package/dist/lib/schema-validator.d.ts +15 -0
  24. package/dist/lib/schema-validator.d.ts.map +1 -0
  25. package/dist/lib/schema-validator.js +206 -0
  26. package/dist/lib/schema-validator.js.map +1 -0
  27. package/dist/tools/compare-specs.d.ts +3 -0
  28. package/dist/tools/compare-specs.d.ts.map +1 -0
  29. package/dist/tools/compare-specs.js +59 -0
  30. package/dist/tools/compare-specs.js.map +1 -0
  31. package/dist/tools/generate-client.d.ts +3 -0
  32. package/dist/tools/generate-client.d.ts.map +1 -0
  33. package/dist/tools/generate-client.js +65 -0
  34. package/dist/tools/generate-client.js.map +1 -0
  35. package/dist/tools/generate-openapi.d.ts +3 -0
  36. package/dist/tools/generate-openapi.d.ts.map +1 -0
  37. package/dist/tools/generate-openapi.js +57 -0
  38. package/dist/tools/generate-openapi.js.map +1 -0
  39. package/dist/tools/generate-tests.d.ts +3 -0
  40. package/dist/tools/generate-tests.d.ts.map +1 -0
  41. package/dist/tools/generate-tests.js +59 -0
  42. package/dist/tools/generate-tests.js.map +1 -0
  43. package/dist/tools/mock-server.d.ts +3 -0
  44. package/dist/tools/mock-server.d.ts.map +1 -0
  45. package/dist/tools/mock-server.js +60 -0
  46. package/dist/tools/mock-server.js.map +1 -0
  47. package/dist/tools/parse-openapi.d.ts +3 -0
  48. package/dist/tools/parse-openapi.d.ts.map +1 -0
  49. package/dist/tools/parse-openapi.js +48 -0
  50. package/dist/tools/parse-openapi.js.map +1 -0
  51. package/dist/tools/test-endpoint.d.ts +3 -0
  52. package/dist/tools/test-endpoint.d.ts.map +1 -0
  53. package/dist/tools/test-endpoint.js +66 -0
  54. package/dist/tools/test-endpoint.js.map +1 -0
  55. package/dist/tools/validate-response.d.ts +3 -0
  56. package/dist/tools/validate-response.d.ts.map +1 -0
  57. package/dist/tools/validate-response.js +44 -0
  58. package/dist/tools/validate-response.js.map +1 -0
  59. package/dist/types.d.ts +121 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +3 -0
  62. package/dist/types.js.map +1 -0
  63. package/evals/eval-http.ts +163 -0
  64. package/evals/eval-openapi.ts +506 -0
  65. package/evals/run-evals.ts +29 -0
  66. package/glama.json +4 -0
  67. package/package.json +37 -0
  68. package/smithery.yaml +9 -0
  69. package/src/index.ts +110 -0
  70. package/src/lib/code-generator.ts +1045 -0
  71. package/src/lib/http-client.ts +87 -0
  72. package/src/lib/license.ts +121 -0
  73. package/src/lib/openapi-parser.ts +456 -0
  74. package/src/lib/schema-validator.ts +234 -0
  75. package/src/tools/compare-specs.ts +67 -0
  76. package/src/tools/generate-client.ts +75 -0
  77. package/src/tools/generate-openapi.ts +67 -0
  78. package/src/tools/generate-tests.ts +69 -0
  79. package/src/tools/mock-server.ts +68 -0
  80. package/src/tools/parse-openapi.ts +54 -0
  81. package/src/tools/test-endpoint.ts +71 -0
  82. package/src/tools/validate-response.ts +54 -0
  83. package/src/types.ts +156 -0
  84. package/tsconfig.json +19 -0
@@ -0,0 +1,1045 @@
1
+ import type {
2
+ ParsedOpenApiSpec,
3
+ OpenApiEndpoint,
4
+ JsonSchema,
5
+ CodeLanguage,
6
+ HttpStyle,
7
+ TestFramework,
8
+ SpecComparison,
9
+ SpecChange,
10
+ MockServerConfig,
11
+ MockRoute,
12
+ } from '../types.js';
13
+
14
+ // ── Client Code Generation ──────────────────────────────────────────
15
+
16
+ export function generateClient(
17
+ spec: ParsedOpenApiSpec,
18
+ language: CodeLanguage,
19
+ style: HttpStyle = 'fetch'
20
+ ): string {
21
+ switch (language) {
22
+ case 'typescript':
23
+ return generateTypeScriptClient(spec, style);
24
+ case 'python':
25
+ return generatePythonClient(spec);
26
+ case 'go':
27
+ return generateGoClient(spec);
28
+ }
29
+ }
30
+
31
+ // ── TypeScript Client ───────────────────────────────────────────────
32
+
33
+ function generateTypeScriptClient(spec: ParsedOpenApiSpec, style: HttpStyle): string {
34
+ const baseUrl = spec.servers?.[0]?.url ?? 'http://localhost:3000';
35
+ const lines: string[] = [];
36
+
37
+ lines.push(`// Auto-generated API client for ${spec.info.title} v${spec.info.version}`);
38
+ lines.push(`// Generated by TRUSS API MCP`);
39
+ lines.push('');
40
+
41
+ if (style === 'axios') {
42
+ lines.push(`import axios, { type AxiosInstance, type AxiosResponse } from 'axios';`);
43
+ lines.push('');
44
+ }
45
+
46
+ // Generate interfaces from endpoints
47
+ const interfaces = generateTypeScriptInterfaces(spec);
48
+ if (interfaces) {
49
+ lines.push(interfaces);
50
+ lines.push('');
51
+ }
52
+
53
+ // Client class
54
+ lines.push(`export class ApiClient {`);
55
+ lines.push(` private baseUrl: string;`);
56
+
57
+ if (style === 'axios') {
58
+ lines.push(` private client: AxiosInstance;`);
59
+ } else {
60
+ lines.push(` private headers: Record<string, string>;`);
61
+ }
62
+
63
+ lines.push('');
64
+ lines.push(` constructor(baseUrl = '${baseUrl}', headers: Record<string, string> = {}) {`);
65
+ lines.push(` this.baseUrl = baseUrl.replace(/\\/$/, '');`);
66
+
67
+ if (style === 'axios') {
68
+ lines.push(` this.client = axios.create({`);
69
+ lines.push(` baseURL: this.baseUrl,`);
70
+ lines.push(` headers: { 'Content-Type': 'application/json', ...headers },`);
71
+ lines.push(` });`);
72
+ } else {
73
+ lines.push(` this.headers = { 'Content-Type': 'application/json', ...headers };`);
74
+ }
75
+
76
+ lines.push(` }`);
77
+ lines.push('');
78
+
79
+ // Generate methods
80
+ for (const endpoint of spec.endpoints) {
81
+ const methodName = endpointToMethodName(endpoint);
82
+ const hasBody = ['POST', 'PUT', 'PATCH'].includes(endpoint.method);
83
+ const pathParams = endpoint.parameters.filter((p) => p.in === 'path');
84
+ const queryParams = endpoint.parameters.filter((p) => p.in === 'query');
85
+
86
+ // Build parameter list
87
+ const params: string[] = [];
88
+ for (const pp of pathParams) {
89
+ params.push(`${pp.name}: ${tsType(pp.schema)}`);
90
+ }
91
+ if (hasBody) {
92
+ params.push(`body: ${tsBodyType(endpoint.requestBody)}`);
93
+ }
94
+ if (queryParams.length > 0) {
95
+ const queryType = queryParams
96
+ .map((q) => `${q.name}${q.required ? '' : '?'}: ${tsType(q.schema)}`)
97
+ .join('; ');
98
+ params.push(`query?: { ${queryType} }`);
99
+ }
100
+
101
+ // JSDoc
102
+ if (endpoint.summary) {
103
+ lines.push(` /** ${endpoint.summary} */`);
104
+ }
105
+
106
+ const returnType = tsResponseType(endpoint);
107
+
108
+ if (style === 'axios') {
109
+ lines.push(` async ${methodName}(${params.join(', ')}): Promise<AxiosResponse<${returnType}>> {`);
110
+ const urlExpr = buildUrlExpr(endpoint, 'this.baseUrl');
111
+ const queryExpr = queryParams.length > 0 ? ', params: query' : '';
112
+ if (hasBody) {
113
+ lines.push(` return this.client.${endpoint.method.toLowerCase()}(${urlExpr}, body${queryExpr ? `, { ${queryExpr.slice(2)} }` : ''});`);
114
+ } else {
115
+ lines.push(` return this.client.${endpoint.method.toLowerCase()}(${urlExpr}${queryExpr ? `, { ${queryExpr.slice(2)} }` : ''});`);
116
+ }
117
+ } else {
118
+ lines.push(` async ${methodName}(${params.join(', ')}): Promise<${returnType}> {`);
119
+ const urlExpr = buildUrlExpr(endpoint, 'this.baseUrl');
120
+
121
+ if (queryParams.length > 0) {
122
+ lines.push(` const url = new URL(${urlExpr});`);
123
+ lines.push(` if (query) {`);
124
+ lines.push(` for (const [k, v] of Object.entries(query)) {`);
125
+ lines.push(` if (v !== undefined) url.searchParams.set(k, String(v));`);
126
+ lines.push(` }`);
127
+ lines.push(` }`);
128
+ lines.push(` const response = await fetch(url.toString(), {`);
129
+ } else {
130
+ lines.push(` const response = await fetch(${urlExpr}, {`);
131
+ }
132
+
133
+ lines.push(` method: '${endpoint.method}',`);
134
+ lines.push(` headers: this.headers,`);
135
+ if (hasBody) {
136
+ lines.push(` body: JSON.stringify(body),`);
137
+ }
138
+ lines.push(` });`);
139
+ lines.push(` if (!response.ok) {`);
140
+ lines.push(` throw new Error(\`HTTP \${response.status}: \${response.statusText}\`);`);
141
+ lines.push(` }`);
142
+ lines.push(` return response.json() as Promise<${returnType}>;`);
143
+ }
144
+
145
+ lines.push(` }`);
146
+ lines.push('');
147
+ }
148
+
149
+ lines.push(`}`);
150
+
151
+ return lines.join('\n');
152
+ }
153
+
154
+ function generateTypeScriptInterfaces(spec: ParsedOpenApiSpec): string {
155
+ const lines: string[] = [];
156
+ const seen = new Set<string>();
157
+
158
+ for (const endpoint of spec.endpoints) {
159
+ // Generate request body interface
160
+ if (endpoint.requestBody?.properties) {
161
+ const name = pascalCase(endpointToMethodName(endpoint)) + 'Request';
162
+ if (!seen.has(name)) {
163
+ seen.add(name);
164
+ lines.push(schemaToTsInterface(name, endpoint.requestBody));
165
+ lines.push('');
166
+ }
167
+ }
168
+
169
+ // Generate response interface
170
+ const okResponse = endpoint.responses.find(
171
+ (r) => r.status === '200' || r.status === '201'
172
+ );
173
+ if (okResponse?.schema?.properties) {
174
+ const name = pascalCase(endpointToMethodName(endpoint)) + 'Response';
175
+ if (!seen.has(name)) {
176
+ seen.add(name);
177
+ lines.push(schemaToTsInterface(name, okResponse.schema));
178
+ lines.push('');
179
+ }
180
+ }
181
+ }
182
+
183
+ return lines.join('\n');
184
+ }
185
+
186
+ function schemaToTsInterface(name: string, schema: JsonSchema): string {
187
+ const lines = [`export interface ${name} {`];
188
+ if (schema.properties) {
189
+ const required = new Set(schema.required ?? []);
190
+ for (const [key, prop] of Object.entries(schema.properties)) {
191
+ const optional = required.has(key) ? '' : '?';
192
+ lines.push(` ${key}${optional}: ${tsType(prop)};`);
193
+ }
194
+ }
195
+ lines.push('}');
196
+ return lines.join('\n');
197
+ }
198
+
199
+ function tsType(schema?: JsonSchema): string {
200
+ if (!schema) return 'unknown';
201
+ if (schema.enum) {
202
+ return schema.enum.map((v) => JSON.stringify(v)).join(' | ');
203
+ }
204
+ const type = Array.isArray(schema.type) ? schema.type[0] : schema.type;
205
+ switch (type) {
206
+ case 'string':
207
+ return 'string';
208
+ case 'integer':
209
+ case 'number':
210
+ return 'number';
211
+ case 'boolean':
212
+ return 'boolean';
213
+ case 'array':
214
+ return `${tsType(schema.items)}[]`;
215
+ case 'object':
216
+ if (schema.properties) {
217
+ const props = Object.entries(schema.properties)
218
+ .map(([k, v]) => `${k}: ${tsType(v)}`)
219
+ .join('; ');
220
+ return `{ ${props} }`;
221
+ }
222
+ return 'Record<string, unknown>';
223
+ default:
224
+ return 'unknown';
225
+ }
226
+ }
227
+
228
+ function tsBodyType(schema?: JsonSchema): string {
229
+ if (!schema) return 'Record<string, unknown>';
230
+ return tsType(schema);
231
+ }
232
+
233
+ function tsResponseType(endpoint: OpenApiEndpoint): string {
234
+ const okResp = endpoint.responses.find((r) => r.status === '200' || r.status === '201');
235
+ if (okResp?.schema) return tsType(okResp.schema);
236
+ return 'unknown';
237
+ }
238
+
239
+ // ── Python Client ───────────────────────────────────────────────────
240
+
241
+ function generatePythonClient(spec: ParsedOpenApiSpec): string {
242
+ const baseUrl = spec.servers?.[0]?.url ?? 'http://localhost:3000';
243
+ const lines: string[] = [];
244
+
245
+ lines.push(`"""Auto-generated API client for ${spec.info.title} v${spec.info.version}"""`);
246
+ lines.push(`# Generated by TRUSS API MCP`);
247
+ lines.push('');
248
+ lines.push('from __future__ import annotations');
249
+ lines.push('');
250
+ lines.push('from dataclasses import dataclass');
251
+ lines.push('from typing import Any, Optional');
252
+ lines.push('');
253
+ lines.push('import requests');
254
+ lines.push('');
255
+ lines.push('');
256
+
257
+ // Dataclasses for request/response types
258
+ for (const endpoint of spec.endpoints) {
259
+ if (endpoint.requestBody?.properties) {
260
+ const className = snakeToPascal(endpointToMethodName(endpoint)) + 'Request';
261
+ lines.push(`@dataclass`);
262
+ lines.push(`class ${className}:`);
263
+ for (const [key, prop] of Object.entries(endpoint.requestBody.properties)) {
264
+ lines.push(` ${key}: ${pyType(prop)}`);
265
+ }
266
+ lines.push('');
267
+ lines.push('');
268
+ }
269
+ }
270
+
271
+ lines.push(`class ApiClient:`);
272
+ lines.push(` """API client for ${spec.info.title}"""`);
273
+ lines.push('');
274
+ lines.push(` def __init__(self, base_url: str = "${baseUrl}", headers: Optional[dict[str, str]] = None):`);
275
+ lines.push(` self.base_url = base_url.rstrip("/")`);
276
+ lines.push(` self.session = requests.Session()`);
277
+ lines.push(` self.session.headers.update({"Content-Type": "application/json"})`);
278
+ lines.push(` if headers:`);
279
+ lines.push(` self.session.headers.update(headers)`);
280
+ lines.push('');
281
+
282
+ for (const endpoint of spec.endpoints) {
283
+ const methodName = camelToSnake(endpointToMethodName(endpoint));
284
+ const hasBody = ['POST', 'PUT', 'PATCH'].includes(endpoint.method);
285
+ const pathParams = endpoint.parameters.filter((p) => p.in === 'path');
286
+ const queryParams = endpoint.parameters.filter((p) => p.in === 'query');
287
+
288
+ const params: string[] = ['self'];
289
+ for (const pp of pathParams) {
290
+ params.push(`${pp.name}: ${pyType(pp.schema)}`);
291
+ }
292
+ if (hasBody) {
293
+ params.push(`body: dict[str, Any]`);
294
+ }
295
+ if (queryParams.length > 0) {
296
+ params.push(`params: Optional[dict[str, Any]] = None`);
297
+ }
298
+
299
+ const docstring = endpoint.summary ? ` """${endpoint.summary}"""` : '';
300
+
301
+ lines.push(` def ${methodName}(${params.join(', ')}) -> requests.Response:`);
302
+ if (docstring) lines.push(docstring);
303
+
304
+ // Build URL
305
+ let urlPath = endpoint.path;
306
+ for (const pp of pathParams) {
307
+ urlPath = urlPath.replace(`{${pp.name}}`, `{${pp.name}}`);
308
+ }
309
+ lines.push(` url = f"{self.base_url}${urlPath}"`);
310
+
311
+ const kwargs: string[] = [];
312
+ if (hasBody) kwargs.push('json=body');
313
+ if (queryParams.length > 0) kwargs.push('params=params');
314
+
315
+ lines.push(
316
+ ` response = self.session.${endpoint.method.toLowerCase()}(url${kwargs.length ? ', ' + kwargs.join(', ') : ''})`
317
+ );
318
+ lines.push(` response.raise_for_status()`);
319
+ lines.push(` return response`);
320
+ lines.push('');
321
+ }
322
+
323
+ return lines.join('\n');
324
+ }
325
+
326
+ function pyType(schema?: JsonSchema): string {
327
+ if (!schema) return 'Any';
328
+ const type = Array.isArray(schema.type) ? schema.type[0] : schema.type;
329
+ switch (type) {
330
+ case 'string':
331
+ return 'str';
332
+ case 'integer':
333
+ return 'int';
334
+ case 'number':
335
+ return 'float';
336
+ case 'boolean':
337
+ return 'bool';
338
+ case 'array':
339
+ return `list[${pyType(schema.items)}]`;
340
+ case 'object':
341
+ return 'dict[str, Any]';
342
+ default:
343
+ return 'Any';
344
+ }
345
+ }
346
+
347
+ // ── Go Client ───────────────────────────────────────────────────────
348
+
349
+ function generateGoClient(spec: ParsedOpenApiSpec): string {
350
+ const baseUrl = spec.servers?.[0]?.url ?? 'http://localhost:3000';
351
+ const lines: string[] = [];
352
+
353
+ lines.push(`// Auto-generated API client for ${spec.info.title} v${spec.info.version}`);
354
+ lines.push(`// Generated by TRUSS API MCP`);
355
+ lines.push('');
356
+ lines.push('package apiclient');
357
+ lines.push('');
358
+ lines.push('import (');
359
+ lines.push('\t"bytes"');
360
+ lines.push('\t"encoding/json"');
361
+ lines.push('\t"fmt"');
362
+ lines.push('\t"io"');
363
+ lines.push('\t"net/http"');
364
+ lines.push('\t"net/url"');
365
+ lines.push(')');
366
+ lines.push('');
367
+
368
+ // Generate structs for request/response types
369
+ for (const endpoint of spec.endpoints) {
370
+ if (endpoint.requestBody?.properties) {
371
+ const structName = pascalCase(endpointToMethodName(endpoint)) + 'Request';
372
+ lines.push(schemaToGoStruct(structName, endpoint.requestBody));
373
+ lines.push('');
374
+ }
375
+
376
+ const okResp = endpoint.responses.find((r) => r.status === '200' || r.status === '201');
377
+ if (okResp?.schema?.properties) {
378
+ const structName = pascalCase(endpointToMethodName(endpoint)) + 'Response';
379
+ lines.push(schemaToGoStruct(structName, okResp.schema));
380
+ lines.push('');
381
+ }
382
+ }
383
+
384
+ // Client struct
385
+ lines.push('// Client is the API client.');
386
+ lines.push('type Client struct {');
387
+ lines.push('\tBaseURL string');
388
+ lines.push('\tHTTPClient *http.Client');
389
+ lines.push('\tHeaders map[string]string');
390
+ lines.push('}');
391
+ lines.push('');
392
+
393
+ // Constructor
394
+ lines.push('// NewClient creates a new API client.');
395
+ lines.push(`func NewClient(baseURL string) *Client {`);
396
+ lines.push('\tif baseURL == "" {');
397
+ lines.push(`\t\tbaseURL = "${baseUrl}"`);
398
+ lines.push('\t}');
399
+ lines.push('\treturn &Client{');
400
+ lines.push('\t\tBaseURL: baseURL,');
401
+ lines.push('\t\tHTTPClient: &http.Client{},');
402
+ lines.push('\t\tHeaders: map[string]string{');
403
+ lines.push('\t\t\t"Content-Type": "application/json",');
404
+ lines.push('\t\t},');
405
+ lines.push('\t}');
406
+ lines.push('}');
407
+ lines.push('');
408
+
409
+ // Helper method
410
+ lines.push('func (c *Client) do(method, path string, body interface{}, result interface{}) error {');
411
+ lines.push('\tvar bodyReader io.Reader');
412
+ lines.push('\tif body != nil {');
413
+ lines.push('\t\tdata, err := json.Marshal(body)');
414
+ lines.push('\t\tif err != nil {');
415
+ lines.push('\t\t\treturn fmt.Errorf("marshal request: %w", err)');
416
+ lines.push('\t\t}');
417
+ lines.push('\t\tbodyReader = bytes.NewReader(data)');
418
+ lines.push('\t}');
419
+ lines.push('');
420
+ lines.push('\treq, err := http.NewRequest(method, c.BaseURL+path, bodyReader)');
421
+ lines.push('\tif err != nil {');
422
+ lines.push('\t\treturn fmt.Errorf("create request: %w", err)');
423
+ lines.push('\t}');
424
+ lines.push('\tfor k, v := range c.Headers {');
425
+ lines.push('\t\treq.Header.Set(k, v)');
426
+ lines.push('\t}');
427
+ lines.push('');
428
+ lines.push('\tresp, err := c.HTTPClient.Do(req)');
429
+ lines.push('\tif err != nil {');
430
+ lines.push('\t\treturn fmt.Errorf("execute request: %w", err)');
431
+ lines.push('\t}');
432
+ lines.push('\tdefer resp.Body.Close()');
433
+ lines.push('');
434
+ lines.push('\tif resp.StatusCode >= 400 {');
435
+ lines.push('\t\treturn fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)');
436
+ lines.push('\t}');
437
+ lines.push('');
438
+ lines.push('\tif result != nil {');
439
+ lines.push('\t\tif err := json.NewDecoder(resp.Body).Decode(result); err != nil {');
440
+ lines.push('\t\t\treturn fmt.Errorf("decode response: %w", err)');
441
+ lines.push('\t\t}');
442
+ lines.push('\t}');
443
+ lines.push('\treturn nil');
444
+ lines.push('}');
445
+ lines.push('');
446
+
447
+ // Generate methods
448
+ for (const endpoint of spec.endpoints) {
449
+ const methodName = pascalCase(endpointToMethodName(endpoint));
450
+ const hasBody = ['POST', 'PUT', 'PATCH'].includes(endpoint.method);
451
+ const pathParams = endpoint.parameters.filter((p) => p.in === 'path');
452
+ const queryParams = endpoint.parameters.filter((p) => p.in === 'query');
453
+
454
+ // Build parameters
455
+ const goParams: string[] = [];
456
+ for (const pp of pathParams) {
457
+ goParams.push(`${pp.name} ${goType(pp.schema)}`);
458
+ }
459
+ if (hasBody) {
460
+ const bodyStruct = pascalCase(endpointToMethodName(endpoint)) + 'Request';
461
+ const hasStruct = endpoint.requestBody?.properties;
462
+ goParams.push(`body ${hasStruct ? '*' + bodyStruct : 'interface{}'}`);
463
+ }
464
+ if (queryParams.length > 0) {
465
+ goParams.push(`params map[string]string`);
466
+ }
467
+
468
+ const okResp = endpoint.responses.find((r) => r.status === '200' || r.status === '201');
469
+ const hasResponseStruct = okResp?.schema?.properties;
470
+ const respType = hasResponseStruct
471
+ ? '*' + pascalCase(endpointToMethodName(endpoint)) + 'Response'
472
+ : 'error';
473
+ const returnType = hasResponseStruct ? `(${respType}, error)` : 'error';
474
+
475
+ if (endpoint.summary) {
476
+ lines.push(`// ${methodName} ${endpoint.summary}`);
477
+ }
478
+ lines.push(`func (c *Client) ${methodName}(${goParams.join(', ')}) ${returnType} {`);
479
+
480
+ // Build URL path with substitutions
481
+ let urlPath = endpoint.path;
482
+ for (const pp of pathParams) {
483
+ urlPath = urlPath.replace(
484
+ `{${pp.name}}`,
485
+ `" + fmt.Sprintf("%v", ${pp.name}) + "`
486
+ );
487
+ }
488
+ // Clean up empty concatenations
489
+ urlPath = urlPath.replace(/\+ ""$/, '').replace(/"" \+ /, '');
490
+
491
+ if (queryParams.length > 0) {
492
+ lines.push(`\tpath := "${urlPath}"`);
493
+ lines.push(`\tif len(params) > 0 {`);
494
+ lines.push(`\t\tv := url.Values{}`);
495
+ lines.push(`\t\tfor k, val := range params {`);
496
+ lines.push(`\t\t\tv.Set(k, val)`);
497
+ lines.push(`\t\t}`);
498
+ lines.push(`\t\tpath += "?" + v.Encode()`);
499
+ lines.push(`\t}`);
500
+
501
+ if (hasResponseStruct) {
502
+ lines.push(`\tvar result ${pascalCase(endpointToMethodName(endpoint))}Response`);
503
+ lines.push(`\terr := c.do("${endpoint.method}", path, ${hasBody ? 'body' : 'nil'}, &result)`);
504
+ lines.push(`\treturn &result, err`);
505
+ } else {
506
+ lines.push(`\treturn c.do("${endpoint.method}", path, ${hasBody ? 'body' : 'nil'}, nil)`);
507
+ }
508
+ } else {
509
+ if (hasResponseStruct) {
510
+ lines.push(`\tvar result ${pascalCase(endpointToMethodName(endpoint))}Response`);
511
+ lines.push(`\terr := c.do("${endpoint.method}", "${urlPath}", ${hasBody ? 'body' : 'nil'}, &result)`);
512
+ lines.push(`\treturn &result, err`);
513
+ } else {
514
+ lines.push(`\treturn c.do("${endpoint.method}", "${urlPath}", ${hasBody ? 'body' : 'nil'}, nil)`);
515
+ }
516
+ }
517
+
518
+ lines.push('}');
519
+ lines.push('');
520
+ }
521
+
522
+ return lines.join('\n');
523
+ }
524
+
525
+ function schemaToGoStruct(name: string, schema: JsonSchema): string {
526
+ const lines = [`type ${name} struct {`];
527
+ if (schema.properties) {
528
+ for (const [key, prop] of Object.entries(schema.properties)) {
529
+ const fieldName = pascalCase(key);
530
+ const tag = `\`json:"${key}"\``;
531
+ lines.push(`\t${fieldName} ${goType(prop)} ${tag}`);
532
+ }
533
+ }
534
+ lines.push('}');
535
+ return lines.join('\n');
536
+ }
537
+
538
+ function goType(schema?: JsonSchema): string {
539
+ if (!schema) return 'interface{}';
540
+ const type = Array.isArray(schema.type) ? schema.type[0] : schema.type;
541
+ switch (type) {
542
+ case 'string':
543
+ return 'string';
544
+ case 'integer':
545
+ return 'int64';
546
+ case 'number':
547
+ return 'float64';
548
+ case 'boolean':
549
+ return 'bool';
550
+ case 'array':
551
+ return `[]${goType(schema.items)}`;
552
+ case 'object':
553
+ return 'map[string]interface{}';
554
+ default:
555
+ return 'interface{}';
556
+ }
557
+ }
558
+
559
+ // ── Test Generation ─────────────────────────────────────────────────
560
+
561
+ export function generateTests(
562
+ spec: ParsedOpenApiSpec,
563
+ baseUrl: string,
564
+ framework: TestFramework = 'vitest'
565
+ ): string {
566
+ switch (framework) {
567
+ case 'jest':
568
+ case 'vitest':
569
+ return generateJsTests(spec, baseUrl, framework);
570
+ case 'pytest':
571
+ return generatePytestTests(spec, baseUrl);
572
+ }
573
+ }
574
+
575
+ function generateJsTests(
576
+ spec: ParsedOpenApiSpec,
577
+ baseUrl: string,
578
+ framework: TestFramework
579
+ ): string {
580
+ const lines: string[] = [];
581
+
582
+ lines.push(`// Auto-generated API tests for ${spec.info.title}`);
583
+ lines.push(`// Generated by TRUSS API MCP`);
584
+ lines.push('');
585
+
586
+ if (framework === 'vitest') {
587
+ lines.push(`import { describe, it, expect } from 'vitest';`);
588
+ }
589
+
590
+ lines.push('');
591
+ lines.push(`const BASE_URL = '${baseUrl}';`);
592
+ lines.push('');
593
+
594
+ // Group by tags or paths
595
+ const groups = groupEndpoints(spec.endpoints);
596
+
597
+ for (const [groupName, endpoints] of groups) {
598
+ lines.push(`describe('${groupName}', () => {`);
599
+
600
+ for (const endpoint of endpoints) {
601
+ const testName = `${endpoint.method} ${endpoint.path}`;
602
+ const hasBody = ['POST', 'PUT', 'PATCH'].includes(endpoint.method);
603
+
604
+ // Happy path test
605
+ lines.push(` it('${testName} — returns success', async () => {`);
606
+
607
+ // Build URL with sample path params
608
+ let testUrl = endpoint.path;
609
+ for (const param of endpoint.parameters.filter((p) => p.in === 'path')) {
610
+ testUrl = testUrl.replace(`{${param.name}}`, sampleValue(param.schema, param.name));
611
+ }
612
+
613
+ // Add query params
614
+ const queryParams = endpoint.parameters.filter((p) => p.in === 'query' && p.required);
615
+ if (queryParams.length > 0) {
616
+ const qs = queryParams
617
+ .map((q) => `${q.name}=${encodeURIComponent(sampleValue(q.schema, q.name))}`)
618
+ .join('&');
619
+ testUrl += `?${qs}`;
620
+ }
621
+
622
+ lines.push(` const response = await fetch(\`\${BASE_URL}${testUrl}\`, {`);
623
+ lines.push(` method: '${endpoint.method}',`);
624
+ lines.push(` headers: { 'Content-Type': 'application/json' },`);
625
+
626
+ if (hasBody && endpoint.requestBody) {
627
+ const sampleBody = generateSampleBody(endpoint.requestBody);
628
+ lines.push(` body: JSON.stringify(${JSON.stringify(sampleBody, null, 6).replace(/\n/g, '\n ')}),`);
629
+ }
630
+
631
+ lines.push(` });`);
632
+ lines.push('');
633
+
634
+ // Assertions
635
+ const okStatus = endpoint.responses.find(
636
+ (r) => r.status === '200' || r.status === '201' || r.status === '204'
637
+ );
638
+ const expectedStatus = okStatus ? parseInt(okStatus.status, 10) : 200;
639
+
640
+ lines.push(` expect(response.status).toBe(${expectedStatus});`);
641
+
642
+ if (expectedStatus !== 204) {
643
+ lines.push(` const data = await response.json();`);
644
+ lines.push(` expect(data).toBeDefined();`);
645
+
646
+ // Add property checks if schema is known
647
+ if (okStatus?.schema?.properties) {
648
+ for (const key of Object.keys(okStatus.schema.properties)) {
649
+ lines.push(` expect(data).toHaveProperty('${key}');`);
650
+ }
651
+ }
652
+ }
653
+
654
+ lines.push(` });`);
655
+ lines.push('');
656
+
657
+ // 404 test for endpoints with path params
658
+ if (endpoint.parameters.some((p) => p.in === 'path')) {
659
+ lines.push(` it('${testName} — returns 404 for invalid ID', async () => {`);
660
+ let badUrl = endpoint.path;
661
+ for (const param of endpoint.parameters.filter((p) => p.in === 'path')) {
662
+ badUrl = badUrl.replace(`{${param.name}}`, '99999999');
663
+ }
664
+ lines.push(` const response = await fetch(\`\${BASE_URL}${badUrl}\`, {`);
665
+ lines.push(` method: '${endpoint.method}',`);
666
+ lines.push(` headers: { 'Content-Type': 'application/json' },`);
667
+ lines.push(` });`);
668
+ lines.push(` expect(response.status).toBe(404);`);
669
+ lines.push(` });`);
670
+ lines.push('');
671
+ }
672
+ }
673
+
674
+ lines.push('});');
675
+ lines.push('');
676
+ }
677
+
678
+ return lines.join('\n');
679
+ }
680
+
681
+ function generatePytestTests(spec: ParsedOpenApiSpec, baseUrl: string): string {
682
+ const lines: string[] = [];
683
+
684
+ lines.push(`"""Auto-generated API tests for ${spec.info.title}"""`);
685
+ lines.push(`# Generated by TRUSS API MCP`);
686
+ lines.push('');
687
+ lines.push('import pytest');
688
+ lines.push('import requests');
689
+ lines.push('');
690
+ lines.push(`BASE_URL = "${baseUrl}"`);
691
+ lines.push('');
692
+ lines.push('');
693
+
694
+ for (const endpoint of spec.endpoints) {
695
+ const funcName = `test_${endpoint.method.toLowerCase()}_${endpoint.path.replace(/[/{}\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')}`;
696
+ const hasBody = ['POST', 'PUT', 'PATCH'].includes(endpoint.method);
697
+
698
+ let testUrl = endpoint.path;
699
+ for (const param of endpoint.parameters.filter((p) => p.in === 'path')) {
700
+ testUrl = testUrl.replace(`{${param.name}}`, sampleValue(param.schema, param.name));
701
+ }
702
+
703
+ lines.push(`def ${funcName}():`);
704
+ if (endpoint.summary) {
705
+ lines.push(` """${endpoint.summary}"""`);
706
+ }
707
+ lines.push(` url = f"{BASE_URL}${testUrl}"`);
708
+
709
+ if (hasBody && endpoint.requestBody) {
710
+ const sampleBody = generateSampleBody(endpoint.requestBody);
711
+ lines.push(` payload = ${JSON.stringify(sampleBody)}`);
712
+ lines.push(
713
+ ` response = requests.${endpoint.method.toLowerCase()}(url, json=payload, headers={"Content-Type": "application/json"})`
714
+ );
715
+ } else {
716
+ lines.push(
717
+ ` response = requests.${endpoint.method.toLowerCase()}(url, headers={"Content-Type": "application/json"})`
718
+ );
719
+ }
720
+
721
+ const okStatus = endpoint.responses.find(
722
+ (r) => r.status === '200' || r.status === '201' || r.status === '204'
723
+ );
724
+ const expectedStatus = okStatus ? parseInt(okStatus.status, 10) : 200;
725
+
726
+ lines.push(` assert response.status_code == ${expectedStatus}`);
727
+
728
+ if (expectedStatus !== 204) {
729
+ lines.push(` data = response.json()`);
730
+ lines.push(` assert data is not None`);
731
+ }
732
+
733
+ lines.push('');
734
+ lines.push('');
735
+ }
736
+
737
+ return lines.join('\n');
738
+ }
739
+
740
+ // ── Mock Server Generation ──────────────────────────────────────────
741
+
742
+ export function generateMockServer(spec: ParsedOpenApiSpec): MockServerConfig {
743
+ const routes: MockRoute[] = [];
744
+ const mockData: Record<string, unknown> = {};
745
+
746
+ for (const endpoint of spec.endpoints) {
747
+ const okResp = endpoint.responses.find(
748
+ (r) => r.status === '200' || r.status === '201'
749
+ );
750
+ const status = okResp ? parseInt(okResp.status, 10) : 200;
751
+
752
+ let mockResponse: unknown;
753
+ if (okResp?.schema) {
754
+ mockResponse = generateMockData(okResp.schema, endpoint.path);
755
+ } else {
756
+ mockResponse = { message: 'OK' };
757
+ }
758
+
759
+ // Convert OpenAPI path params to Express-style for json-server
760
+ const routePath = endpoint.path.replace(/\{(\w+)\}/g, ':$1');
761
+
762
+ routes.push({
763
+ method: endpoint.method,
764
+ path: routePath,
765
+ status,
766
+ response: mockResponse,
767
+ headers: { 'Content-Type': 'application/json' },
768
+ });
769
+
770
+ // Store in mock_data for json-server db.json format
771
+ const resourceName = endpoint.path
772
+ .split('/')[1]
773
+ ?.replace(/[^a-zA-Z0-9]/g, '') ?? 'data';
774
+
775
+ if (!mockData[resourceName] && Array.isArray(mockResponse)) {
776
+ mockData[resourceName] = mockResponse;
777
+ } else if (!mockData[resourceName] && typeof mockResponse === 'object') {
778
+ // For list endpoints, wrap in array
779
+ if (endpoint.method === 'GET' && !endpoint.parameters.some((p) => p.in === 'path')) {
780
+ mockData[resourceName] = [mockResponse];
781
+ }
782
+ }
783
+ }
784
+
785
+ return { routes, mock_data: mockData };
786
+ }
787
+
788
+ function generateMockData(schema: JsonSchema, context: string): unknown {
789
+ const type = Array.isArray(schema.type) ? schema.type[0] : schema.type;
790
+
791
+ switch (type) {
792
+ case 'string': {
793
+ if (schema.enum) return schema.enum[0];
794
+ if (schema.format === 'date-time') return '2026-01-15T10:00:00Z';
795
+ if (schema.format === 'email') return 'user@example.com';
796
+ if (schema.format === 'uri') return 'https://example.com';
797
+ return `sample_${context.replace(/[^a-z]/gi, '_')}`;
798
+ }
799
+ case 'integer':
800
+ return 1;
801
+ case 'number':
802
+ return 1.0;
803
+ case 'boolean':
804
+ return true;
805
+ case 'array':
806
+ return schema.items ? [generateMockData(schema.items, context)] : [];
807
+ case 'object': {
808
+ if (!schema.properties) return {};
809
+ const obj: Record<string, unknown> = {};
810
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
811
+ obj[key] = generateMockData(propSchema, key);
812
+ }
813
+ return obj;
814
+ }
815
+ default:
816
+ return null;
817
+ }
818
+ }
819
+
820
+ // ── Spec Comparison ─────────────────────────────────────────────────
821
+
822
+ export function compareSpecs(
823
+ oldSpec: ParsedOpenApiSpec,
824
+ newSpec: ParsedOpenApiSpec
825
+ ): SpecComparison {
826
+ const breaking: SpecChange[] = [];
827
+ const nonBreaking: SpecChange[] = [];
828
+ const newEndpoints: SpecChange[] = [];
829
+ const removedEndpoints: SpecChange[] = [];
830
+
831
+ // Build maps
832
+ const oldMap = new Map<string, OpenApiEndpoint>();
833
+ const newMap = new Map<string, OpenApiEndpoint>();
834
+
835
+ for (const ep of oldSpec.endpoints) {
836
+ oldMap.set(`${ep.method} ${ep.path}`, ep);
837
+ }
838
+ for (const ep of newSpec.endpoints) {
839
+ newMap.set(`${ep.method} ${ep.path}`, ep);
840
+ }
841
+
842
+ // Find removed endpoints (breaking)
843
+ for (const [key, ep] of oldMap) {
844
+ if (!newMap.has(key)) {
845
+ const change: SpecChange = {
846
+ path: ep.path,
847
+ method: ep.method,
848
+ description: `Endpoint removed: ${ep.method} ${ep.path}`,
849
+ };
850
+ removedEndpoints.push(change);
851
+ breaking.push(change);
852
+ }
853
+ }
854
+
855
+ // Find new endpoints
856
+ for (const [key, ep] of newMap) {
857
+ if (!oldMap.has(key)) {
858
+ newEndpoints.push({
859
+ path: ep.path,
860
+ method: ep.method,
861
+ description: `New endpoint: ${ep.method} ${ep.path}`,
862
+ });
863
+ }
864
+ }
865
+
866
+ // Compare matching endpoints
867
+ for (const [key, oldEp] of oldMap) {
868
+ const newEp = newMap.get(key);
869
+ if (!newEp) continue;
870
+
871
+ // Check required parameters
872
+ const oldRequired = new Set(
873
+ oldEp.parameters.filter((p) => p.required).map((p) => p.name)
874
+ );
875
+ const newRequired = new Set(
876
+ newEp.parameters.filter((p) => p.required).map((p) => p.name)
877
+ );
878
+
879
+ // New required parameters = breaking
880
+ for (const name of newRequired) {
881
+ if (!oldRequired.has(name)) {
882
+ breaking.push({
883
+ path: oldEp.path,
884
+ method: oldEp.method,
885
+ description: `New required parameter: ${name}`,
886
+ });
887
+ }
888
+ }
889
+
890
+ // Removed required parameters = non-breaking (parameter relaxed)
891
+ for (const name of oldRequired) {
892
+ if (!newRequired.has(name)) {
893
+ nonBreaking.push({
894
+ path: oldEp.path,
895
+ method: oldEp.method,
896
+ description: `Required parameter removed (relaxed): ${name}`,
897
+ });
898
+ }
899
+ }
900
+
901
+ // New optional parameters = non-breaking
902
+ const oldParamNames = new Set(oldEp.parameters.map((p) => p.name));
903
+ for (const p of newEp.parameters) {
904
+ if (!oldParamNames.has(p.name) && !p.required) {
905
+ nonBreaking.push({
906
+ path: oldEp.path,
907
+ method: oldEp.method,
908
+ description: `New optional parameter: ${p.name}`,
909
+ });
910
+ }
911
+ }
912
+
913
+ // Check response schema changes
914
+ for (const oldResp of oldEp.responses) {
915
+ const newResp = newEp.responses.find((r) => r.status === oldResp.status);
916
+ if (!newResp) {
917
+ breaking.push({
918
+ path: oldEp.path,
919
+ method: oldEp.method,
920
+ description: `Response ${oldResp.status} removed`,
921
+ });
922
+ continue;
923
+ }
924
+
925
+ // Check if response properties were removed (breaking)
926
+ if (oldResp.schema?.properties && newResp.schema?.properties) {
927
+ for (const key of Object.keys(oldResp.schema.properties)) {
928
+ if (!(key in newResp.schema.properties)) {
929
+ breaking.push({
930
+ path: oldEp.path,
931
+ method: oldEp.method,
932
+ description: `Response property '${key}' removed from ${oldResp.status}`,
933
+ });
934
+ }
935
+ }
936
+ // New response properties = non-breaking
937
+ for (const key of Object.keys(newResp.schema.properties)) {
938
+ if (!(key in oldResp.schema.properties)) {
939
+ nonBreaking.push({
940
+ path: oldEp.path,
941
+ method: oldEp.method,
942
+ description: `New response property '${key}' added to ${newResp.status}`,
943
+ });
944
+ }
945
+ }
946
+ }
947
+ }
948
+
949
+ // Summary change = non-breaking
950
+ if (oldEp.summary !== newEp.summary) {
951
+ nonBreaking.push({
952
+ path: oldEp.path,
953
+ method: oldEp.method,
954
+ description: `Summary changed: "${oldEp.summary}" -> "${newEp.summary}"`,
955
+ });
956
+ }
957
+ }
958
+
959
+ return {
960
+ breaking_changes: breaking,
961
+ non_breaking: nonBreaking,
962
+ new_endpoints: newEndpoints,
963
+ removed_endpoints: removedEndpoints,
964
+ };
965
+ }
966
+
967
+ // ── Helpers ─────────────────────────────────────────────────────────
968
+
969
+ function endpointToMethodName(endpoint: OpenApiEndpoint): string {
970
+ if (endpoint.operationId) {
971
+ return camelCase(endpoint.operationId);
972
+ }
973
+ const parts = endpoint.path
974
+ .split('/')
975
+ .filter((p) => p && !p.startsWith('{'))
976
+ .map((p) => p.replace(/[^a-zA-Z0-9]/g, ''));
977
+ const verb = endpoint.method.toLowerCase();
978
+ return camelCase(`${verb}_${parts.join('_')}`);
979
+ }
980
+
981
+ function camelCase(str: string): string {
982
+ return str
983
+ .replace(/[-_]+(.)/g, (_, c: string) => c.toUpperCase())
984
+ .replace(/^(.)/, (_, c: string) => c.toLowerCase());
985
+ }
986
+
987
+ function pascalCase(str: string): string {
988
+ const cc = camelCase(str);
989
+ return cc.charAt(0).toUpperCase() + cc.slice(1);
990
+ }
991
+
992
+ function snakeToPascal(str: string): string {
993
+ return str
994
+ .split(/[-_]/)
995
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
996
+ .join('');
997
+ }
998
+
999
+ function camelToSnake(str: string): string {
1000
+ return str.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
1001
+ }
1002
+
1003
+ function buildUrlExpr(endpoint: OpenApiEndpoint, baseVarName: string): string {
1004
+ const pathParams = endpoint.parameters.filter((p) => p.in === 'path');
1005
+ if (pathParams.length === 0) {
1006
+ return `\`\${${baseVarName}}${endpoint.path}\``;
1007
+ }
1008
+
1009
+ let path = endpoint.path;
1010
+ for (const pp of pathParams) {
1011
+ path = path.replace(`{${pp.name}}`, `\${${pp.name}}`);
1012
+ }
1013
+ return `\`\${${baseVarName}}${path}\``;
1014
+ }
1015
+
1016
+ function sampleValue(schema?: JsonSchema, name?: string): string {
1017
+ if (!schema) return '1';
1018
+ const type = Array.isArray(schema.type) ? schema.type[0] : schema.type;
1019
+ if (type === 'integer' || type === 'number') return '1';
1020
+ if (name?.toLowerCase().includes('id')) return '1';
1021
+ return 'test';
1022
+ }
1023
+
1024
+ function generateSampleBody(schema: JsonSchema): Record<string, unknown> {
1025
+ if (!schema.properties) return {};
1026
+ const body: Record<string, unknown> = {};
1027
+ for (const [key, prop] of Object.entries(schema.properties)) {
1028
+ body[key] = generateMockData(prop, key);
1029
+ }
1030
+ return body;
1031
+ }
1032
+
1033
+ function groupEndpoints(
1034
+ endpoints: OpenApiEndpoint[]
1035
+ ): Map<string, OpenApiEndpoint[]> {
1036
+ const groups = new Map<string, OpenApiEndpoint[]>();
1037
+
1038
+ for (const ep of endpoints) {
1039
+ const group = ep.tags?.[0] ?? ep.path.split('/')[1] ?? 'API';
1040
+ if (!groups.has(group)) groups.set(group, []);
1041
+ groups.get(group)!.push(ep);
1042
+ }
1043
+
1044
+ return groups;
1045
+ }