chrometools-mcp 3.3.8 → 3.3.9

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.
@@ -0,0 +1,25 @@
1
+ /**
2
+ * utils/openapi/helpers.js
3
+ *
4
+ * Shared utilities for OpenAPI processing.
5
+ */
6
+
7
+ /**
8
+ * Compare two arrays for shallow equality
9
+ */
10
+ export function arraysEqual(a, b) {
11
+ if (!a || !b || a.length !== b.length) return false;
12
+ return a.every((v, i) => v === b[i]);
13
+ }
14
+
15
+ /**
16
+ * Build a signature from object properties (key + type) for schema matching.
17
+ * Used by _findSchemaName to avoid false positives from key-only comparison.
18
+ */
19
+ export function schemaSignature(properties) {
20
+ if (!properties) return '';
21
+ return Object.entries(properties)
22
+ .map(([k, v]) => `${k}:${v.type || v.$circularRef || '?'}`)
23
+ .sort()
24
+ .join(',');
25
+ }
@@ -0,0 +1,448 @@
1
+ /**
2
+ * utils/openapi/parser.js
3
+ *
4
+ * Unified parser for OpenAPI 2.0 (Swagger) and 3.x specs.
5
+ * Normalizes 2.0 to 3.x internally.
6
+ */
7
+
8
+ import yaml from 'js-yaml';
9
+ import { readFileSync } from 'fs';
10
+ import { resolveAllRefs } from './ref-resolver.js';
11
+ import { schemaSignature } from './helpers.js';
12
+
13
+ export class OpenAPIParser {
14
+ constructor(rawSpec) {
15
+ this.raw = rawSpec;
16
+ this.version = this.detectVersion();
17
+ this.spec = this.normalize();
18
+ this.spec = resolveAllRefs(this.spec);
19
+ }
20
+
21
+ /**
22
+ * Load spec from URL or file path
23
+ * @param {string} source - URL or file path
24
+ * @param {string} [format='auto'] - 'auto' | 'json' | 'yaml'
25
+ * @returns {OpenAPIParser}
26
+ */
27
+ static async load(source, format = 'auto') {
28
+ let content;
29
+
30
+ if (source.startsWith('http://') || source.startsWith('https://')) {
31
+ const controller = new AbortController();
32
+ const timeoutId = setTimeout(() => controller.abort(), 30000);
33
+ try {
34
+ const response = await fetch(source, { signal: controller.signal });
35
+ if (!response.ok) {
36
+ throw new Error(`Failed to fetch ${source}: ${response.status} ${response.statusText}`);
37
+ }
38
+ content = await response.text();
39
+ } finally {
40
+ clearTimeout(timeoutId);
41
+ }
42
+ } else {
43
+ content = readFileSync(source, 'utf-8');
44
+ }
45
+
46
+ const spec = parseContent(content, format, source);
47
+ return new OpenAPIParser(spec);
48
+ }
49
+
50
+ detectVersion() {
51
+ if (this.raw.swagger === '2.0') return '2.0';
52
+ if (this.raw.openapi && this.raw.openapi.startsWith('3.')) return this.raw.openapi;
53
+ throw new Error('Unsupported OpenAPI version. Expected swagger: "2.0" or openapi: "3.x.x"');
54
+ }
55
+
56
+ normalize() {
57
+ if (this.version === '2.0') return this.normalize2to3(this.raw);
58
+ return this.raw;
59
+ }
60
+
61
+ /**
62
+ * Convert OpenAPI 2.0 to 3.x structure
63
+ */
64
+ normalize2to3(spec) {
65
+ const result = {
66
+ openapi: '3.0.0',
67
+ info: spec.info || {},
68
+ servers: [{
69
+ url: `${spec.schemes?.[0] || 'https'}://${spec.host || 'localhost'}${spec.basePath || ''}`
70
+ }],
71
+ paths: this.normalizePaths2to3(spec.paths || {}, spec),
72
+ components: {
73
+ schemas: spec.definitions || {},
74
+ securitySchemes: this.normalizeSecurityDefs(spec.securityDefinitions || {})
75
+ },
76
+ security: spec.security,
77
+ tags: spec.tags
78
+ };
79
+ // Rewrite $ref from #/definitions/X to #/components/schemas/X
80
+ this._rewriteRefs(result);
81
+ return result;
82
+ }
83
+
84
+ /**
85
+ * Rewrite all $ref strings: #/definitions/X → #/components/schemas/X
86
+ */
87
+ _rewriteRefs(obj) {
88
+ if (!obj || typeof obj !== 'object') return;
89
+ if (Array.isArray(obj)) { obj.forEach(item => this._rewriteRefs(item)); return; }
90
+ for (const [key, val] of Object.entries(obj)) {
91
+ if (key === '$ref' && typeof val === 'string' && val.startsWith('#/definitions/')) {
92
+ obj[key] = val.replace('#/definitions/', '#/components/schemas/');
93
+ } else if (val && typeof val === 'object') {
94
+ this._rewriteRefs(val);
95
+ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Normalize paths: convert body params to requestBody
101
+ */
102
+ normalizePaths2to3(paths, spec) {
103
+ const normalized = {};
104
+ for (const [pathStr, methods] of Object.entries(paths)) {
105
+ normalized[pathStr] = {};
106
+ const pathParams = methods.parameters || [];
107
+
108
+ for (const [method, operation] of Object.entries(methods)) {
109
+ if (method === 'parameters') continue;
110
+ if (!['get', 'post', 'put', 'patch', 'delete', 'head', 'options'].includes(method)) continue;
111
+
112
+ // Operation params override path params with same name+in (per OpenAPI spec)
113
+ const opParams = operation.parameters || [];
114
+ const opKeys = new Set(opParams.map(p => `${p.name}:${p.in}`));
115
+ const mergedParams = [...opParams, ...pathParams.filter(p => !opKeys.has(`${p.name}:${p.in}`))];
116
+ const bodyParam = mergedParams.find(p => p.in === 'body');
117
+ const nonBodyParams = mergedParams.filter(p => p.in !== 'body' && p.in !== 'formData');
118
+
119
+ const op = { ...operation, parameters: nonBodyParams };
120
+
121
+ // Convert body param to requestBody
122
+ if (bodyParam) {
123
+ op.requestBody = {
124
+ required: bodyParam.required || false,
125
+ content: {
126
+ 'application/json': {
127
+ schema: bodyParam.schema || {}
128
+ }
129
+ }
130
+ };
131
+ }
132
+
133
+ // Convert formData params to requestBody
134
+ const formParams = mergedParams.filter(p => p.in === 'formData');
135
+ if (formParams.length > 0 && !op.requestBody) {
136
+ const properties = {};
137
+ const required = [];
138
+ for (const fp of formParams) {
139
+ properties[fp.name] = { type: fp.type, description: fp.description };
140
+ if (fp.required) required.push(fp.name);
141
+ }
142
+ const contentType = operation.consumes?.includes('multipart/form-data')
143
+ ? 'multipart/form-data'
144
+ : 'application/x-www-form-urlencoded';
145
+ op.requestBody = {
146
+ required: required.length > 0,
147
+ content: {
148
+ [contentType]: {
149
+ schema: { type: 'object', properties, required: required.length > 0 ? required : undefined }
150
+ }
151
+ }
152
+ };
153
+ }
154
+
155
+ // Normalize responses
156
+ if (op.responses) {
157
+ for (const [code, resp] of Object.entries(op.responses)) {
158
+ if (resp.schema) {
159
+ op.responses[code] = {
160
+ description: resp.description || '',
161
+ content: {
162
+ 'application/json': { schema: resp.schema }
163
+ }
164
+ };
165
+ }
166
+ }
167
+ }
168
+
169
+ normalized[pathStr][method] = op;
170
+ }
171
+ }
172
+ return normalized;
173
+ }
174
+
175
+ /**
176
+ * Normalize securityDefinitions (2.0) to securitySchemes (3.x)
177
+ */
178
+ normalizeSecurityDefs(defs) {
179
+ const schemes = {};
180
+ for (const [name, def] of Object.entries(defs)) {
181
+ switch (def.type) {
182
+ case 'basic':
183
+ schemes[name] = { type: 'http', scheme: 'basic' };
184
+ break;
185
+ case 'apiKey':
186
+ schemes[name] = { type: 'apiKey', in: def.in, name: def.name };
187
+ break;
188
+ case 'oauth2': {
189
+ const flows = {};
190
+ const flowMap = {
191
+ implicit: 'implicit',
192
+ password: 'password',
193
+ application: 'clientCredentials',
194
+ accessCode: 'authorizationCode'
195
+ };
196
+ const flowName = flowMap[def.flow] || def.flow;
197
+ flows[flowName] = {
198
+ ...(def.authorizationUrl ? { authorizationUrl: def.authorizationUrl } : {}),
199
+ ...(def.tokenUrl ? { tokenUrl: def.tokenUrl } : {}),
200
+ scopes: def.scopes || {}
201
+ };
202
+ schemes[name] = { type: 'oauth2', flows };
203
+ break;
204
+ }
205
+ default:
206
+ schemes[name] = def;
207
+ }
208
+ }
209
+ return schemes;
210
+ }
211
+
212
+ getSchemas() {
213
+ return this.spec.components?.schemas || {};
214
+ }
215
+
216
+ getSecuritySchemes() {
217
+ return this.spec.components?.securitySchemes || {};
218
+ }
219
+
220
+ getBaseUrl() {
221
+ return this.spec.servers?.[0]?.url || '';
222
+ }
223
+
224
+ /**
225
+ * Get all endpoints as flat list
226
+ * @returns {Array<Object>}
227
+ */
228
+ getEndpoints() {
229
+ const endpoints = [];
230
+ for (const [pathStr, methods] of Object.entries(this.spec.paths || {})) {
231
+ for (const [method, operation] of Object.entries(methods)) {
232
+ if (!['get', 'post', 'put', 'patch', 'delete', 'head', 'options'].includes(method)) continue;
233
+
234
+ endpoints.push({
235
+ method: method.toUpperCase(),
236
+ path: pathStr,
237
+ operationId: operation.operationId || this.generateOperationId(method, pathStr),
238
+ summary: operation.summary || '',
239
+ description: operation.description || '',
240
+ tags: operation.tags || [],
241
+ parameters: this.extractParameters(operation),
242
+ requestBody: this.extractRequestBody(operation),
243
+ responses: this.extractResponses(operation),
244
+ security: operation.security || this.spec.security || [],
245
+ deprecated: operation.deprecated || false
246
+ });
247
+ }
248
+ }
249
+ return endpoints;
250
+ }
251
+
252
+ /**
253
+ * Generate operationId from method + path
254
+ * GET /pets/{petId} → getPetsByPetId
255
+ */
256
+ generateOperationId(method, pathStr) {
257
+ const prefixMap = { get: 'get', post: 'create', put: 'update', patch: 'patch', delete: 'delete', head: 'head', options: 'options' };
258
+ const prefix = prefixMap[method] || method;
259
+
260
+ const segments = pathStr.split('/').filter(Boolean);
261
+ const parts = segments.map(seg => {
262
+ if (seg.startsWith('{') && seg.endsWith('}')) {
263
+ const param = seg.slice(1, -1);
264
+ return 'By' + param.charAt(0).toUpperCase() + param.slice(1);
265
+ }
266
+ return seg.charAt(0).toUpperCase() + seg.slice(1);
267
+ });
268
+
269
+ return prefix + parts.join('');
270
+ }
271
+
272
+ /**
273
+ * Extract parameters (path, query, header)
274
+ */
275
+ extractParameters(operation) {
276
+ return (operation.parameters || []).map(p => ({
277
+ name: p.name,
278
+ in: p.in,
279
+ type: p.schema?.type || p.type || 'string',
280
+ format: p.schema?.format || p.format,
281
+ required: p.required || p.in === 'path',
282
+ description: p.description || '',
283
+ enum: p.schema?.enum || p.enum,
284
+ schema: p.schema || { type: p.type || 'string' }
285
+ }));
286
+ }
287
+
288
+ /**
289
+ * Extract request body info
290
+ */
291
+ extractRequestBody(operation) {
292
+ const rb = operation.requestBody;
293
+ if (!rb) return null;
294
+
295
+ const content = rb.content || {};
296
+ const jsonContent = content['application/json'] || content[Object.keys(content)[0]];
297
+ const schema = jsonContent?.schema;
298
+
299
+ // Get schema name from the schema itself
300
+ let schemaName = null;
301
+ if (schema) {
302
+ // If it was a $ref that got resolved, try to get the name
303
+ schemaName = this._getSchemaName(schema);
304
+ }
305
+
306
+ return {
307
+ required: rb.required || false,
308
+ schema: schemaName,
309
+ schemaObject: schema || null
310
+ };
311
+ }
312
+
313
+ /**
314
+ * Extract response info
315
+ */
316
+ extractResponses(operation) {
317
+ const responses = {};
318
+ for (const [code, resp] of Object.entries(operation.responses || {})) {
319
+ const content = resp.content || {};
320
+ const jsonContent = content['application/json'] || content[Object.keys(content)[0]];
321
+ const schema = jsonContent?.schema;
322
+ let schemaName = schema ? this._getSchemaName(schema) : null;
323
+
324
+ responses[code] = {
325
+ description: resp.description || '',
326
+ schema: schemaName
327
+ };
328
+ }
329
+ return responses;
330
+ }
331
+
332
+ /**
333
+ * Try to determine schema name from a resolved schema object
334
+ */
335
+ _getSchemaName(schema) {
336
+ if (!schema) return null;
337
+
338
+ // Fast path: resolved $ref carries its origin name
339
+ if (schema._refName && this.spec.components?.schemas?.[schema._refName]) {
340
+ return schema._refName;
341
+ }
342
+
343
+ // Check if this matches a known schema
344
+ const schemas = this.spec.components?.schemas || {};
345
+ for (const [name, s] of Object.entries(schemas)) {
346
+ if (s === schema) return name;
347
+ // Signature-based match (keys + types to avoid false positives)
348
+ if (s.properties && schema.properties) {
349
+ const sSig = schemaSignature(s.properties);
350
+ const schemaSig = schemaSignature(schema.properties);
351
+ if (sSig === schemaSig && sSig.length > 0) return name;
352
+ }
353
+ }
354
+
355
+ // Array type
356
+ if (schema.type === 'array' && schema.items) {
357
+ const itemName = this._getSchemaName(schema.items);
358
+ if (itemName) return `${itemName}[]`;
359
+ }
360
+
361
+ return null;
362
+ }
363
+
364
+ /**
365
+ * Get auth summary for loadSwagger response
366
+ */
367
+ getAuthSummary() {
368
+ const schemes = this.getSecuritySchemes();
369
+ return Object.entries(schemes).map(([name, scheme]) => {
370
+ const summary = { name, type: scheme.type };
371
+ if (scheme.scheme) summary.scheme = scheme.scheme;
372
+ if (scheme.bearerFormat) summary.bearerFormat = scheme.bearerFormat;
373
+ if (scheme.in) summary.in = scheme.in;
374
+ if (scheme.name) summary.paramName = scheme.name;
375
+ if (scheme.flows) summary.flows = scheme.flows;
376
+ return summary;
377
+ });
378
+ }
379
+
380
+ /**
381
+ * Get schemas summary (names + top-level property names)
382
+ */
383
+ getSchemasSummary() {
384
+ const schemas = this.getSchemas();
385
+ const summary = {};
386
+ for (const [name, schema] of Object.entries(schemas)) {
387
+ summary[name] = {
388
+ type: schema.type || 'object',
389
+ required: schema.required || [],
390
+ properties: schema.properties
391
+ ? Object.entries(schema.properties).reduce((acc, [k, v]) => {
392
+ acc[k] = {
393
+ type: v.type || (v.enum ? 'enum' : 'object'),
394
+ ...(v.format ? { format: v.format } : {}),
395
+ ...(v.enum ? { enum: v.enum } : {}),
396
+ ...(v.items ? { items: v.items.type || 'object' } : {}),
397
+ ...(v.$circularRef ? { $circularRef: v.$circularRef } : {})
398
+ };
399
+ return acc;
400
+ }, {})
401
+ : undefined,
402
+ ...(schema.enum ? { enum: schema.enum } : {}),
403
+ ...(schema.description ? { description: schema.description } : {}),
404
+ ...(schema.allOf ? { allOf: true } : {}),
405
+ ...(schema.oneOf ? { oneOf: true } : {}),
406
+ ...(schema.anyOf ? { anyOf: true } : {})
407
+ };
408
+ }
409
+ return summary;
410
+ }
411
+
412
+ /**
413
+ * Full summary for loadSwagger response
414
+ */
415
+ getSummary() {
416
+ const endpoints = this.getEndpoints();
417
+ return {
418
+ version: this.version,
419
+ title: this.spec.info?.title || '',
420
+ description: this.spec.info?.description || '',
421
+ baseUrl: this.getBaseUrl(),
422
+ auth: this.getAuthSummary(),
423
+ endpoints,
424
+ schemas: this.getSchemasSummary(),
425
+ endpointCount: endpoints.length,
426
+ schemaCount: Object.keys(this.getSchemas()).length
427
+ };
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Parse content string to object
433
+ */
434
+ function parseContent(content, format, source) {
435
+ if (format === 'json') return JSON.parse(content);
436
+ if (format === 'yaml') return yaml.load(content);
437
+
438
+ // Auto-detect
439
+ try {
440
+ return JSON.parse(content);
441
+ } catch {
442
+ try {
443
+ return yaml.load(content);
444
+ } catch (yamlErr) {
445
+ throw new Error(`Failed to parse spec from ${source}. Not valid JSON or YAML: ${yamlErr.message}`);
446
+ }
447
+ }
448
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * utils/openapi/ref-resolver.js
3
+ *
4
+ * Recursive $ref resolution for OpenAPI specs.
5
+ * Handles internal refs, circular refs, allOf/oneOf/anyOf.
6
+ */
7
+
8
+ /**
9
+ * Resolve all $ref in a spec object (in-place on a deep clone)
10
+ * @param {Object} spec - full OpenAPI spec
11
+ * @returns {Object} - spec with resolved $ref
12
+ */
13
+ export function resolveAllRefs(spec) {
14
+ const clone = JSON.parse(JSON.stringify(spec));
15
+ const cache = new Map();
16
+ resolveObject(clone, clone, new Set(), cache);
17
+ return clone;
18
+ }
19
+
20
+ /**
21
+ * Recursively walk an object and resolve $ref
22
+ */
23
+ function resolveObject(obj, root, visited, cache) {
24
+ if (!obj || typeof obj !== 'object') return obj;
25
+
26
+ if (Array.isArray(obj)) {
27
+ for (let i = 0; i < obj.length; i++) {
28
+ if (obj[i] && typeof obj[i] === 'object' && obj[i].$ref) {
29
+ obj[i] = resolveRef(obj[i].$ref, root, visited, cache);
30
+ } else {
31
+ resolveObject(obj[i], root, visited, cache);
32
+ }
33
+ }
34
+ return obj;
35
+ }
36
+
37
+ for (const key of Object.keys(obj)) {
38
+ const val = obj[key];
39
+ if (!val || typeof val !== 'object') continue;
40
+
41
+ if (val.$ref) {
42
+ obj[key] = resolveRef(val.$ref, root, visited, cache);
43
+ } else {
44
+ resolveObject(val, root, visited, cache);
45
+ }
46
+ }
47
+
48
+ return obj;
49
+ }
50
+
51
+ /**
52
+ * Resolve a single $ref string
53
+ * @param {string} ref - e.g. "#/components/schemas/Pet"
54
+ * @param {Object} root - root spec object
55
+ * @param {Set} visited - cycle detection
56
+ * @param {Map} cache - resolved ref cache
57
+ * @returns {Object} - resolved object
58
+ */
59
+ export function resolveRef(ref, root, visited = new Set(), cache = new Map()) {
60
+ if (!ref || typeof ref !== 'string' || !ref.startsWith('#/')) {
61
+ // External $ref - not supported
62
+ return { $externalRef: ref, _warning: 'External $ref not supported' };
63
+ }
64
+
65
+ // Circular check MUST come before cache check:
66
+ // During resolution A→B→A, ref A is both in visited and cache.
67
+ // Cache would return the partially-resolved object, creating circular JS refs.
68
+ // Visited correctly returns a $circularRef marker instead.
69
+ if (visited.has(ref)) {
70
+ const name = ref.split('/').pop();
71
+ return { $circularRef: name };
72
+ }
73
+
74
+ if (cache.has(ref)) {
75
+ return cache.get(ref);
76
+ }
77
+
78
+ visited.add(ref);
79
+
80
+ const parts = ref.replace('#/', '').split('/');
81
+ let resolved = root;
82
+ for (const part of parts) {
83
+ const decoded = decodeURIComponent(part.replace(/~1/g, '/').replace(/~0/g, '~'));
84
+ if (resolved == null || typeof resolved !== 'object') {
85
+ visited.delete(ref);
86
+ return { $brokenRef: ref, _warning: `Could not resolve path segment: ${decoded}` };
87
+ }
88
+ resolved = resolved[decoded];
89
+ }
90
+
91
+ if (resolved == null) {
92
+ visited.delete(ref);
93
+ return { $brokenRef: ref };
94
+ }
95
+
96
+ // Deep clone to avoid mutation between different $ref usages
97
+ const result = JSON.parse(JSON.stringify(resolved));
98
+
99
+ // Tag with original ref name for fast identification by generators
100
+ const refName = ref.split('/').pop();
101
+ if (refName) {
102
+ Object.defineProperty(result, '_refName', {
103
+ value: refName, enumerable: false, writable: false
104
+ });
105
+ }
106
+
107
+ // Cache before recursing to handle self-references
108
+ cache.set(ref, result);
109
+
110
+ // Recursively resolve nested $ref
111
+ resolveObject(result, root, visited, cache);
112
+
113
+ visited.delete(ref);
114
+ return result;
115
+ }
116
+
117
+ /**
118
+ * Merge allOf schemas into a single schema
119
+ * @param {Array} allOfSchemas - array of schemas
120
+ * @returns {Object} - merged schema
121
+ */
122
+ export function mergeAllOf(allOfSchemas) {
123
+ const merged = {
124
+ type: 'object',
125
+ properties: {},
126
+ required: [],
127
+ };
128
+ let description = '';
129
+
130
+ for (const schema of allOfSchemas) {
131
+ if (schema.properties) {
132
+ Object.assign(merged.properties, schema.properties);
133
+ }
134
+ if (schema.required) {
135
+ merged.required.push(...schema.required);
136
+ }
137
+ if (schema.description && !description) {
138
+ description = schema.description;
139
+ }
140
+ // Carry over other top-level fields
141
+ if (schema.type) merged.type = schema.type;
142
+ }
143
+
144
+ if (description) merged.description = description;
145
+ merged.required = [...new Set(merged.required)];
146
+ if (merged.required.length === 0) delete merged.required;
147
+
148
+ return merged;
149
+ }