# Directory Structure
```
├── .gitignore
├── examples
│ ├── claude_desktop_config.json
│ └── sample-openapi.json
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── src
│ └── index.ts
├── tests
│ ├── analyzer.test.ts
│ ├── fixtures
│ │ ├── another-api.json
│ │ ├── invalid-api.json
│ │ └── sample-api.json
│ ├── server.test.ts
│ ├── setup.ts
│ └── validation.test.ts
├── tsconfig.json
└── vitest.config.ts
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | # Runtime data
11 | pids
12 | *.pid
13 | *.seed
14 | *.pid.lock
15 |
16 | # Coverage directory used by tools like istanbul
17 | coverage
18 | *.lcov
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # node-wnyc
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (https://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | jspm_packages/
38 |
39 | # Snowpack dependency directory (https://snowpack.dev/)
40 | web_modules/
41 |
42 | # TypeScript cache
43 | *.tsbuildinfo
44 |
45 | # Optional npm cache directory
46 | .npm
47 |
48 | # Optional eslint cache
49 | .eslintcache
50 |
51 | # Optional stylelint cache
52 | .stylelintcache
53 |
54 | # Microbundle cache
55 | .rpt2_cache/
56 | .rts2_cache_cjs/
57 | .rts2_cache_es/
58 | .rts2_cache_umd/
59 |
60 | # Optional REPL history
61 | .node_repl_history
62 |
63 | # Output of 'npm pack'
64 | *.tgz
65 |
66 | # Yarn Integrity file
67 | .yarn-integrity
68 |
69 | # dotenv environment variable files
70 | .env
71 | .env.development.local
72 | .env.test.local
73 | .env.production.local
74 | .env.local
75 |
76 | # parcel-bundler cache (https://parceljs.org/)
77 | .cache
78 | .parcel-cache
79 |
80 | # Next.js build output
81 | .next
82 | out
83 |
84 | # Nuxt.js build / generate output
85 | .nuxt
86 | dist
87 |
88 | # Gatsby files
89 | .cache/
90 | # Comment in the public line in if your project uses Gatsby and not Next.js
91 | # https://nextjs.org/blog/next-9-1#public-directory-support
92 | # public
93 |
94 | # vuepress build output
95 | .vuepress/dist
96 |
97 | # vuepress v2.x temp and cache directory
98 | .temp
99 | .cache
100 |
101 | # Docusaurus cache and generated files
102 | .docusaurus
103 |
104 | # Serverless directories
105 | .serverless/
106 |
107 | # FuseBox cache
108 | .fusebox/
109 |
110 | # DynamoDB Local files
111 | .dynamodb/
112 |
113 | # TernJS port file
114 | .tern-port
115 |
116 | # Stores VSCode versions used for testing VSCode extensions
117 | .vscode-test
118 |
119 | # yarn v2
120 | .yarn/cache
121 | .yarn/unplugged
122 | .yarn/build-state.yml
123 | .yarn/install-state.gz
124 | .pnp.*
125 |
126 | # macOS
127 | .DS_Store
128 |
129 | # IDE
130 | .vscode/
131 | .idea/
132 | *.swp
133 | *.swo
134 |
135 | # TypeScript
136 | dist/
137 | *.tsbuildinfo
138 |
139 | # Test files
140 | test-results/
141 | *.test.js
142 | *.spec.js
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # OpenAPI Analyzer MCP Server
2 |
3 | [](https://badge.fury.io/js/openapi-analyzer-mcp)
4 | [](https://opensource.org/licenses/MIT)
5 |
6 | A powerful **Model Context Protocol (MCP) server** for analyzing OpenAPI specifications with Claude Desktop and other LLM clients. This server enables natural language queries about your API structures, endpoints, schemas, and helps identify inconsistencies across multiple OpenAPI specs.
7 |
8 | ## 📋 Table of Contents
9 |
10 | - [🚀 Features](#-features)
11 | - [🛠 Installation](#-installation)
12 | - [⚙️ Configuration](#️-configuration)
13 | - [🎯 Usage](#-usage)
14 | - [🔧 Available Tools](#-available-tools)
15 | - [🔍 Example Output](#-example-output)
16 | - [🏗️ Creating Your Own API Registry](#️-creating-your-own-api-registry)
17 | - [🚨 Troubleshooting](#-troubleshooting)
18 | - [🤝 Contributing](#-contributing)
19 | - [🆕 Changelog](#-changelog)
20 | - [📝 License](#-license)
21 |
22 | ## 🚀 Features
23 |
24 | ### 🎯 **Smart Discovery System**
25 | - **📡 API Registry Support**: Automatically discover APIs from `apis.json` registries (support for 30+ APIs)
26 | - **🔗 URL-Based Loading**: Load specs from individual URLs with automatic fallback
27 | - **📁 Local File Support**: Traditional folder-based spec loading with multi-format support
28 | - **🔄 Priority System**: Discovery URL → Individual URLs → Local folder (intelligent fallback)
29 |
30 | ### 🔍 **Advanced Analysis**
31 | - **📊 Bulk Analysis**: Load and analyze 90+ OpenAPI specification files simultaneously
32 | - **🔍 Smart Search**: Find endpoints across all APIs using natural language queries
33 | - **📈 Comprehensive Stats**: Generate detailed statistics about your API ecosystem
34 | - **🔧 Inconsistency Detection**: Identify authentication schemes and naming convention mismatches
35 | - **📋 Schema Comparison**: Compare schemas with the same name across different APIs
36 | - **⚡ Fast Queries**: In-memory indexing for lightning-fast responses
37 |
38 | ### 🌐 **Universal Compatibility**
39 | - **Multi-Format Support**: JSON, YAML, and YML specifications
40 | - **Version Support**: OpenAPI 2.0, 3.0, and 3.1 specifications
41 | - **Remote & Local**: Works with URLs, API registries, and local files
42 | - **Source Tracking**: Know exactly where each API spec was loaded from
43 |
44 | ## 🛠 Installation
45 |
46 | ### Option 1: Install from npm
47 |
48 | ```bash
49 | npm install openapi-analyzer-mcp
50 | ```
51 |
52 | ### Option 2: Build from source
53 |
54 | ```bash
55 | git clone https://github.com/sureshkumars/openapi-analyzer-mcp.git
56 | cd openapi-analyzer-mcp
57 | npm install
58 | npm run build
59 | ```
60 |
61 | ## ⚙️ Configuration
62 |
63 | ### 🎯 Smart Discovery Options
64 |
65 | The OpenAPI Analyzer supports **three discovery methods** with intelligent priority fallback:
66 |
67 | 1. **🏆 Priority 1: API Registry** (`OPENAPI_DISCOVERY_URL`)
68 | 2. **🥈 Priority 2: Individual URLs** (`OPENAPI_SPEC_URLS`)
69 | 3. **🥉 Priority 3: Local Folder** (`OPENAPI_SPECS_FOLDER`)
70 |
71 | ### Claude Desktop Setup
72 |
73 | **Find your config file:**
74 | - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
75 | - **Windows**: `%APPDATA%\\Claude\\claude_desktop_config.json`
76 |
77 | ### Configuration Examples
78 |
79 | #### 🌟 **Option 1: API Registry Discovery (Recommended)**
80 | Perfect for companies with centralized API registries:
81 |
82 | ```json
83 | {
84 | "mcpServers": {
85 | "openapi-analyzer": {
86 | "command": "npx",
87 | "args": ["-y", "openapi-analyzer-mcp"],
88 | "env": {
89 | "OPENAPI_DISCOVERY_URL": "https://docs.company.com/apis.json"
90 | }
91 | }
92 | }
93 | }
94 | ```
95 |
96 | #### 🔗 **Option 2: Individual API URLs**
97 | Load specific APIs from direct URLs:
98 |
99 | ```json
100 | {
101 | "mcpServers": {
102 | "openapi-analyzer": {
103 | "command": "npx",
104 | "args": ["-y", "openapi-analyzer-mcp"],
105 | "env": {
106 | "OPENAPI_SPEC_URLS": "https://api.example.com/v1/openapi.yaml,https://api.example.com/v2/openapi.yaml,https://petstore.swagger.io/v2/swagger.json"
107 | }
108 | }
109 | }
110 | }
111 | ```
112 |
113 | #### 📁 **Option 3: Local Folder**
114 | Traditional approach for local specification files:
115 |
116 | ```json
117 | {
118 | "mcpServers": {
119 | "openapi-analyzer": {
120 | "command": "npx",
121 | "args": ["-y", "openapi-analyzer-mcp"],
122 | "env": {
123 | "OPENAPI_SPECS_FOLDER": "/absolute/path/to/your/openapi-specs"
124 | }
125 | }
126 | }
127 | }
128 | ```
129 |
130 | #### 🔄 **Option 4: Multi-Source with Fallback**
131 | Ultimate flexibility - tries all methods with intelligent fallback:
132 |
133 | ```json
134 | {
135 | "mcpServers": {
136 | "openapi-analyzer": {
137 | "command": "npx",
138 | "args": ["-y", "openapi-analyzer-mcp"],
139 | "env": {
140 | "OPENAPI_DISCOVERY_URL": "https://docs.company.com/apis.json",
141 | "OPENAPI_SPEC_URLS": "https://legacy-api.com/spec.yaml,https://external-api.com/spec.json",
142 | "OPENAPI_SPECS_FOLDER": "/path/to/local/specs"
143 | }
144 | }
145 | }
146 | }
147 | ```
148 |
149 | #### 🏢 **Real-World Examples**
150 |
151 | **Company with API Registry:**
152 | ```json
153 | {
154 | "mcpServers": {
155 | "company-apis": {
156 | "command": "npx",
157 | "args": ["-y", "openapi-analyzer-mcp"],
158 | "env": {
159 | "OPENAPI_DISCOVERY_URL": "https://api.company.com/registry/apis.json"
160 | }
161 | }
162 | }
163 | }
164 | ```
165 |
166 | **Multiple API Sources:**
167 | ```json
168 | {
169 | "mcpServers": {
170 | "multi-apis": {
171 | "command": "npx",
172 | "args": ["-y", "openapi-analyzer-mcp"],
173 | "env": {
174 | "OPENAPI_SPEC_URLS": "https://petstore.swagger.io/v2/swagger.json,https://api.example.com/v1/openapi.yaml"
175 | }
176 | }
177 | }
178 | }
179 | ```
180 |
181 | ### 🔧 Environment Variables
182 |
183 | | Variable | Description | Example | Priority |
184 | |----------|-------------|---------|----------|
185 | | `OPENAPI_DISCOVERY_URL` | URL to API registry (apis.json format) | `https://docs.company.com/apis.json` | 1 (Highest) |
186 | | `OPENAPI_SPEC_URLS` | Comma-separated list of OpenAPI spec URLs | `https://api1.com/spec.yaml,https://api2.com/spec.json` | 2 (Medium) |
187 | | `OPENAPI_SPECS_FOLDER` | Absolute path to local OpenAPI files folder | `/absolute/path/to/specs` | 3 (Fallback) |
188 |
189 | **⚠️ Important Notes:**
190 | - At least one environment variable must be set
191 | - System tries sources in priority order and stops at first success
192 | - Always use absolute paths for `OPENAPI_SPECS_FOLDER`
193 | - Supports JSON, YAML, and YML formats for all sources
194 |
195 | ## 🎯 Usage
196 |
197 | Once configured, you can interact with your OpenAPI specs using natural language in Claude Desktop:
198 |
199 | ### 🚀 Smart Discovery Queries
200 |
201 | #### API Registry Discovery
202 | ```
203 | "Load all APIs from the company registry and show me an overview"
204 | "Discover APIs from the configured registry and analyze their authentication patterns"
205 | "What APIs are available in our API registry?"
206 | "Show me where my specs were loaded from"
207 | ```
208 |
209 | #### Cross-API Analysis
210 | ```
211 | "Load all my OpenAPI specs and give me a comprehensive summary"
212 | "How many APIs do I have and what's the total number of endpoints?"
213 | "Compare authentication schemes across all loaded APIs"
214 | "Which APIs are using different versions of the same schema?"
215 | ```
216 |
217 | #### Search and Discovery
218 | ```
219 | "Show me all POST endpoints for user creation across all APIs"
220 | "Find all endpoints related to authentication across all loaded APIs"
221 | "Which APIs have pagination parameters?"
222 | "Search for endpoints that handle file uploads"
223 | "Find all APIs that use the 'User' schema"
224 | ```
225 |
226 | #### Analysis and Comparison
227 | ```
228 | "What authentication schemes are used across my APIs?"
229 | "Which APIs have inconsistent naming conventions?"
230 | "Compare the User schema across different APIs"
231 | "Show me APIs that are still using version 1.0"
232 | ```
233 |
234 | #### Statistics and Insights
235 | ```
236 | "Generate comprehensive statistics about my API ecosystem"
237 | "Which HTTP methods are most commonly used?"
238 | "What are the most common path patterns?"
239 | "Show me version distribution across my APIs"
240 | ```
241 |
242 | ## 🔧 Available Tools
243 |
244 | The MCP server provides these tools for programmatic access:
245 |
246 | | Tool | Description | Parameters |
247 | |------|-------------|------------|
248 | | `load_specs` | **Smart Load**: Automatically load specs using priority system (registry → URLs → folder) | None |
249 | | `list_apis` | List all loaded APIs with basic info (title, version, endpoint count) | None |
250 | | `get_api_spec` | Get the full OpenAPI spec for a specific file | `filename` |
251 | | `search_endpoints` | Search endpoints by keyword across all APIs | `query` |
252 | | `get_api_stats` | Generate comprehensive statistics about all loaded APIs | None |
253 | | `find_inconsistencies` | Detect inconsistencies in authentication schemes | None |
254 | | `compare_schemas` | Compare schemas with the same name across different APIs | `schema1`, `schema2` (optional) |
255 | | `get_load_sources` | **New!** Show where specs were loaded from (registry, URLs, or folder) | None |
256 |
257 | ## 📁 Project Structure
258 |
259 | ```
260 | openapi-analyzer-mcp/
261 | ├── src/
262 | │ └── index.ts # Main server implementation
263 | ├── tests/ # Comprehensive test suite
264 | │ ├── analyzer.test.ts # Core functionality tests
265 | │ ├── server.test.ts # MCP server tests
266 | │ ├── validation.test.ts # Environment tests
267 | │ ├── setup.ts # Test configuration
268 | │ └── fixtures/ # Test data files
269 | ├── dist/ # Compiled JavaScript
270 | ├── coverage/ # Test coverage reports
271 | ├── examples/ # Example configurations
272 | │ ├── claude_desktop_config.json
273 | │ └── sample-openapi.json
274 | ├── vitest.config.ts # Test configuration
275 | ├── package.json
276 | ├── tsconfig.json
277 | └── README.md
278 | ```
279 |
280 | **Note**: You don't need an `openapi-specs` folder in this repository. Point `OPENAPI_SPECS_FOLDER` to wherever your actual OpenAPI files are located.
281 |
282 | ## 🔍 Example Output
283 |
284 | ### 🎯 Smart Discovery Results
285 |
286 | #### Load Sources Information
287 | ```json
288 | [
289 | {
290 | "type": "discovery",
291 | "url": "https://api.company.com/registry/apis.json",
292 | "count": 12,
293 | "metadata": {
294 | "name": "Company APIs",
295 | "description": "Collection of company API specifications",
296 | "total_apis": 12
297 | }
298 | }
299 | ]
300 | ```
301 |
302 | #### Registry Discovery Success
303 | ```json
304 | {
305 | "totalApis": 12,
306 | "totalEndpoints": 247,
307 | "loadedFrom": "API Registry",
308 | "discoveryUrl": "https://api.company.com/registry/apis.json",
309 | "apis": [
310 | {
311 | "filename": "User Management API",
312 | "title": "User Management API",
313 | "version": "2.1.0",
314 | "endpointCount": 18,
315 | "source": "https://docs.company.com/user-api.yaml"
316 | },
317 | {
318 | "filename": "Product Catalog API",
319 | "title": "Product Catalog API",
320 | "version": "1.5.0",
321 | "endpointCount": 32,
322 | "source": "https://docs.company.com/product-api.yaml"
323 | }
324 | ]
325 | }
326 | ```
327 |
328 | ### 📊 API Statistics
329 | ```json
330 | {
331 | "totalApis": 12,
332 | "totalEndpoints": 247,
333 | "methodCounts": {
334 | "GET": 98,
335 | "POST": 67,
336 | "PUT": 45,
337 | "DELETE": 37
338 | },
339 | "versions": {
340 | "1.0.0": 8,
341 | "2.0.0": 3,
342 | "3.1.0": 1
343 | },
344 | "commonPaths": {
345 | "/api/v1/users/{id}": 8,
346 | "/api/v1/orders": 6,
347 | "/health": 12
348 | }
349 | }
350 | ```
351 |
352 | ### Search Results
353 | ```json
354 | [
355 | {
356 | "filename": "user-api.json",
357 | "api_title": "User Management API",
358 | "path": "/api/v1/users",
359 | "method": "POST",
360 | "summary": "Create a new user",
361 | "operationId": "createUser"
362 | },
363 | {
364 | "filename": "admin-api.json",
365 | "api_title": "Admin API",
366 | "path": "/admin/users",
367 | "method": "POST",
368 | "summary": "Create user account",
369 | "operationId": "adminCreateUser"
370 | }
371 | ]
372 | ```
373 |
374 | ## 🏗️ Creating Your Own API Registry
375 |
376 | Want to set up your own `apis.json` registry? Here's how:
377 |
378 | ### Standard APIs.json Format
379 |
380 | Create a file at `https://your-domain.com/apis.json`:
381 |
382 | ```json
383 | {
384 | "name": "Your Company APIs",
385 | "description": "Collection of all our API specifications",
386 | "url": "https://your-domain.com",
387 | "apis": [
388 | {
389 | "name": "User API",
390 | "baseURL": "https://api.your-domain.com/users",
391 | "properties": [
392 | {
393 | "type": "Swagger",
394 | "url": "https://docs.your-domain.com/user-api.yaml"
395 | }
396 | ]
397 | },
398 | {
399 | "name": "Orders API",
400 | "baseURL": "https://api.your-domain.com/orders",
401 | "properties": [
402 | {
403 | "type": "OpenAPI",
404 | "url": "https://docs.your-domain.com/orders-api.json"
405 | }
406 | ]
407 | }
408 | ]
409 | }
410 | ```
411 |
412 | ### Custom Registry Format
413 |
414 | Or use the simpler custom format:
415 |
416 | ```json
417 | {
418 | "name": "Your Company APIs",
419 | "description": "Our API registry",
420 | "apis": [
421 | {
422 | "name": "User API",
423 | "version": "v2",
424 | "spec_url": "https://docs.your-domain.com/user-api.yaml",
425 | "docs_url": "https://docs.your-domain.com/user-api",
426 | "status": "stable",
427 | "tags": ["auth", "users"]
428 | }
429 | ]
430 | }
431 | ```
432 |
433 | ## 🚨 Troubleshooting
434 |
435 | ### Tools not appearing in Claude Desktop
436 |
437 | 1. **Verify environment variables are set** - At least one source must be configured
438 | 2. **Check that URLs are accessible** - Test discovery URLs and spec URLs manually
439 | 3. **Restart Claude Desktop** completely after configuration changes
440 | 4. **Check network connectivity** for remote API registries
441 | 5. **Verify file formats** - Supports JSON, YAML, and YML
442 |
443 | ### Common Error Messages
444 |
445 | #### Smart Discovery Errors
446 | - **"❌ Error: No OpenAPI source configured"**: Set at least one of `OPENAPI_DISCOVERY_URL`, `OPENAPI_SPEC_URLS`, or `OPENAPI_SPECS_FOLDER`
447 | - **"⚠️ Warning: Failed to load from discovery URL"**: Check if the registry URL is accessible and returns valid JSON
448 | - **"Invalid registry format: missing apis array"**: Your APIs.json file must have an `apis` array
449 | - **"No OpenAPI spec URL found"**: API entries must have either `spec_url` or `properties` with OpenAPI/Swagger type
450 |
451 | #### Traditional Errors
452 | - **"❌ Error: OPENAPI_SPECS_FOLDER does not exist"**: The specified directory doesn't exist
453 | - **"❌ Error: OPENAPI_SPECS_FOLDER is not a directory"**: The path points to a file, not a directory
454 | - **"❌ Error: No read permission for OPENAPI_SPECS_FOLDER"**: Check folder permissions
455 | - **"⚠️ Warning: No OpenAPI specification files found"**: Directory exists but contains no supported files
456 | - **"⚠️ Skipping [file]: Invalid format"**: File is not valid JSON/YAML or malformed OpenAPI spec
457 |
458 | ### Debug Mode
459 |
460 | Set `NODE_ENV=development` to see detailed logging:
461 |
462 | ```json
463 | {
464 | "mcpServers": {
465 | "openapi-analyzer": {
466 | "command": "node",
467 | "args": ["/path/to/dist/index.js"],
468 | "env": {
469 | "OPENAPI_SPECS_FOLDER": "/path/to/specs",
470 | "NODE_ENV": "development"
471 | }
472 | }
473 | }
474 | }
475 | ```
476 |
477 | ## 🤝 Contributing
478 |
479 | Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
480 |
481 | ### Development Setup
482 |
483 | ```bash
484 | git clone https://github.com/sureshkumars/openapi-analyzer-mcp.git
485 | cd openapi-analyzer-mcp
486 | npm install
487 | npm run build
488 | npm run dev # Start in development mode
489 | ```
490 |
491 | ### Running Tests
492 |
493 | This project includes a comprehensive test suite with 46+ tests covering all functionality:
494 |
495 | ```bash
496 | # Run all tests
497 | npm test
498 |
499 | # Run tests in watch mode (re-runs on file changes)
500 | npm run test:watch
501 |
502 | # Run tests with coverage report
503 | npm run test:coverage
504 | ```
505 |
506 | #### Test Coverage
507 |
508 | The test suite provides extensive coverage with **100% test success rate**:
509 | - **✅ 46 tests passing** with **66.79% statement coverage** and **100% function coverage**
510 | - **Unit tests** for the OpenAPIAnalyzer class (30 tests) - covers all loading methods and analysis features
511 | - **Integration tests** for MCP server configuration (8 tests) - validates all tools and exports
512 | - **Validation tests** for environment setup and error handling (8 tests) - tests all discovery methods
513 |
514 | **New in v1.2.0:**
515 | - ✅ **Smart discovery testing** - URL loading, API registry parsing, fallback mechanisms
516 | - ✅ **Constructor-based testing** - Flexible test configuration without environment variables
517 | - ✅ **Remote spec mocking** - Full coverage of HTTP-based spec loading
518 | - ✅ **Backward compatibility** - All existing functionality preserved
519 |
520 | #### Test Structure
521 |
522 | ```
523 | tests/
524 | ├── analyzer.test.ts # Core OpenAPIAnalyzer functionality tests
525 | ├── server.test.ts # MCP server configuration tests
526 | ├── validation.test.ts # Environment validation tests
527 | ├── setup.ts # Test configuration
528 | └── fixtures/ # Sample test data
529 | ├── sample-api.json
530 | ├── another-api.json
531 | └── invalid-api.json
532 | ```
533 |
534 | #### What's Tested
535 |
536 | - ✅ **OpenAPI spec loading** (valid/invalid files, JSON parsing)
537 | - ✅ **Search functionality** (by path, method, summary, operationId)
538 | - ✅ **Statistics generation** (method counts, versions, common paths)
539 | - ✅ **Schema comparison** (cross-API schema analysis)
540 | - ✅ **Inconsistency detection** (authentication schemes)
541 | - ✅ **Error handling** (missing env vars, file permissions)
542 | - ✅ **Edge cases** (empty directories, malformed JSON)
543 |
544 | #### Test Technology
545 |
546 | - **Vitest** - Fast test framework with TypeScript support
547 | - **Comprehensive mocking** - File system operations and console output
548 | - **Type safety** - Full TypeScript integration with proper interfaces
549 |
550 | ## 🆕 Changelog
551 |
552 | ### Version 1.2.0 - Smart Discovery System
553 | **Released: September 2025**
554 |
555 | #### 🎯 Major Features
556 | - **🚀 Smart Discovery System**: Revolutionary API discovery with priority-based fallback
557 | - **📡 API Registry Support**: Full support for `apis.json` format and custom registries
558 | - **🔗 URL-Based Loading**: Load specs directly from individual URLs
559 | - **🔄 Intelligent Fallback**: Discovery URL → Individual URLs → Local folder priority system
560 | - **🏷️ Source Tracking**: New `get_load_sources` tool shows where specs were loaded from
561 |
562 | #### ✨ Real-World Integration
563 | - **🏢 Production Ready**: Successfully tested with 30+ production APIs from various registries
564 | - **📊 Bulk Processing**: Load 90+ APIs from registries in seconds
565 | - **🌐 Universal Format Support**: JSON, YAML, YML from any source (remote or local)
566 |
567 | #### 🧪 Enhanced Testing
568 | - **✅ 46 tests passing** with 100% success rate
569 | - **📈 Improved coverage**: 66.79% statement coverage, 100% function coverage
570 | - **🔧 Constructor-based testing**: Flexible test configuration
571 | - **🔗 Remote spec mocking**: Full HTTP-based loading test coverage
572 |
573 | #### 🔧 Developer Experience
574 | - **⚡ Zero Breaking Changes**: Full backward compatibility maintained
575 | - **📚 Comprehensive Documentation**: Updated with real-world examples
576 | - **🏗️ Registry Setup Guide**: Instructions for creating your own APIs.json registry
577 | - **🚨 Enhanced Error Handling**: Better error messages and graceful fallbacks
578 |
579 | ### Version 1.1.0 - YAML Support & Enhanced Analysis
580 | - Added YAML/YML format support using @apidevtools/swagger-parser
581 | - Enhanced schema comparison and inconsistency detection
582 | - Improved error handling and validation
583 |
584 | ### Version 1.0.0 - Initial Release
585 | - Core OpenAPI analysis functionality
586 | - Local folder-based spec loading
587 | - MCP server implementation with 6 core tools
588 |
589 | ## 📝 License
590 |
591 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
592 |
593 | ## 🙏 Acknowledgments
594 |
595 | - Built with the [Model Context Protocol SDK](https://github.com/modelcontextprotocol/sdk)
596 | - Supports OpenAPI specifications as defined by the [OpenAPI Initiative](https://www.openapis.org/)
597 | - Compatible with [Claude Desktop](https://claude.ai/desktop) and other MCP clients
598 |
599 | ---
600 |
601 | **Made with ❤️ for API developers and documentation teams**
602 |
603 | If you find this tool helpful, please consider giving it a ⭐ on GitHub!
```
--------------------------------------------------------------------------------
/tests/fixtures/invalid-api.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "title": "Invalid API",
3 | "version": "1.0.0",
4 | "paths": {}
5 | }
```
--------------------------------------------------------------------------------
/examples/claude_desktop_config.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "mcpServers": {
3 | "openapi-analyzer": {
4 | "command": "npx",
5 | "args": ["openapi-analyzer-mcp"],
6 | "env": {
7 | "OPENAPI_SPECS_FOLDER": "/absolute/path/to/your/openapi-specs"
8 | }
9 | }
10 | }
11 | }
```
--------------------------------------------------------------------------------
/tests/setup.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Test setup file for vitest
2 | import { beforeEach, vi } from 'vitest';
3 |
4 | // Mock process.exit to prevent actual process termination during tests
5 | vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
6 |
7 | beforeEach(() => {
8 | // Clear environment variables
9 | delete process.env.OPENAPI_SPECS_FOLDER;
10 | });
```
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { defineConfig } from 'vitest/config';
2 |
3 | export default defineConfig({
4 | test: {
5 | globals: true,
6 | environment: 'node',
7 | include: ['tests/**/*.{test,spec}.{js,ts}'],
8 | coverage: {
9 | provider: 'v8',
10 | reporter: ['text', 'json', 'html'],
11 | exclude: [
12 | 'node_modules/',
13 | 'dist/',
14 | 'coverage/',
15 | '**/*.d.ts',
16 | '**/*.config.*',
17 | ],
18 | },
19 | setupFiles: ['./tests/setup.ts'],
20 | },
21 | });
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "ESNext",
5 | "moduleResolution": "node",
6 | "outDir": "./dist",
7 | "rootDir": "./src",
8 | "strict": false,
9 | "esModuleInterop": true,
10 | "skipLibCheck": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "allowSyntheticDefaultImports": true,
13 | "declaration": true,
14 | "declarationMap": true,
15 | "sourceMap": true,
16 | "types": ["node"]
17 | },
18 | "include": [
19 | "src/**/*"
20 | ],
21 | "exclude": [
22 | "node_modules",
23 | "dist"
24 | ]
25 | }
```
--------------------------------------------------------------------------------
/tests/fixtures/another-api.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "openapi": "3.0.0",
3 | "info": {
4 | "title": "Another API",
5 | "version": "2.0.0",
6 | "description": "Another sample API for testing"
7 | },
8 | "paths": {
9 | "/products": {
10 | "get": {
11 | "summary": "Get all products",
12 | "description": "Retrieve a list of all products",
13 | "operationId": "getProducts",
14 | "responses": {
15 | "200": {
16 | "description": "Successful response"
17 | }
18 | }
19 | }
20 | },
21 | "/products/{id}": {
22 | "delete": {
23 | "summary": "Delete product",
24 | "description": "Delete a specific product",
25 | "operationId": "deleteProduct",
26 | "responses": {
27 | "204": {
28 | "description": "Product deleted"
29 | }
30 | }
31 | }
32 | }
33 | },
34 | "components": {
35 | "schemas": {
36 | "Product": {
37 | "type": "object",
38 | "properties": {
39 | "id": {
40 | "type": "integer"
41 | },
42 | "name": {
43 | "type": "string"
44 | },
45 | "price": {
46 | "type": "number"
47 | }
48 | }
49 | },
50 | "User": {
51 | "type": "object",
52 | "properties": {
53 | "id": {
54 | "type": "integer"
55 | },
56 | "username": {
57 | "type": "string"
58 | }
59 | }
60 | }
61 | },
62 | "securitySchemes": {
63 | "apiKey": {
64 | "type": "apiKey",
65 | "in": "header",
66 | "name": "X-API-Key"
67 | }
68 | }
69 | }
70 | }
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "openapi-analyzer-mcp",
3 | "version": "1.2.1",
4 | "description": "A powerful Model Context Protocol (MCP) server for analyzing OpenAPI specifications with Claude Desktop and other LLM clients",
5 | "type": "module",
6 | "main": "dist/index.js",
7 | "bin": {
8 | "openapi-analyzer-mcp": "dist/index.js"
9 | },
10 | "files": [
11 | "dist/**/*",
12 | "README.md",
13 | "LICENSE"
14 | ],
15 | "scripts": {
16 | "build": "tsc && chmod +x dist/index.js",
17 | "start": "node dist/index.js",
18 | "dev": "tsc && node dist/index.js",
19 | "clean": "rm -rf dist",
20 | "prepublishOnly": "npm run clean && npm run build",
21 | "test": "vitest run",
22 | "test:watch": "vitest",
23 | "test:coverage": "vitest run --coverage"
24 | },
25 | "dependencies": {
26 | "@apidevtools/swagger-parser": "^12.0.0",
27 | "@modelcontextprotocol/sdk": "0.5.0"
28 | },
29 | "devDependencies": {
30 | "@types/node": "^20.19.13",
31 | "@vitest/coverage-v8": "^3.2.4",
32 | "typescript": "^5.0.0",
33 | "vitest": "^3.2.4"
34 | },
35 | "keywords": [
36 | "mcp",
37 | "openapi",
38 | "api",
39 | "analysis",
40 | "claude",
41 | "llm",
42 | "model-context-protocol",
43 | "swagger",
44 | "api-documentation",
45 | "schema-analysis",
46 | "endpoint-discovery"
47 | ],
48 | "author": "Suresh Sivasankaran <[email protected]>",
49 | "license": "MIT",
50 | "repository": {
51 | "type": "git",
52 | "url": "git+https://github.com/sureshkumars/openapi-analyzer-mcp.git"
53 | },
54 | "homepage": "https://github.com/sureshkumars/openapi-analyzer-mcp#readme",
55 | "bugs": {
56 | "url": "https://github.com/sureshkumars/openapi-analyzer-mcp/issues"
57 | },
58 | "engines": {
59 | "node": ">=18.0.0"
60 | }
61 | }
62 |
```
--------------------------------------------------------------------------------
/tests/fixtures/sample-api.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "openapi": "3.0.0",
3 | "info": {
4 | "title": "Sample API",
5 | "version": "1.0.0",
6 | "description": "A sample API for testing"
7 | },
8 | "paths": {
9 | "/users": {
10 | "get": {
11 | "summary": "Get all users",
12 | "description": "Retrieve a list of all users",
13 | "operationId": "getUsers",
14 | "responses": {
15 | "200": {
16 | "description": "Successful response"
17 | }
18 | }
19 | },
20 | "post": {
21 | "summary": "Create user",
22 | "description": "Create a new user",
23 | "operationId": "createUser",
24 | "responses": {
25 | "201": {
26 | "description": "User created"
27 | }
28 | }
29 | }
30 | },
31 | "/users/{id}": {
32 | "get": {
33 | "summary": "Get user by ID",
34 | "description": "Retrieve a specific user by ID",
35 | "operationId": "getUserById",
36 | "parameters": [
37 | {
38 | "name": "id",
39 | "in": "path",
40 | "required": true,
41 | "schema": {
42 | "type": "integer"
43 | }
44 | }
45 | ],
46 | "responses": {
47 | "200": {
48 | "description": "Successful response"
49 | },
50 | "404": {
51 | "description": "User not found"
52 | }
53 | }
54 | }
55 | }
56 | },
57 | "components": {
58 | "schemas": {
59 | "User": {
60 | "type": "object",
61 | "properties": {
62 | "id": {
63 | "type": "integer"
64 | },
65 | "name": {
66 | "type": "string"
67 | },
68 | "email": {
69 | "type": "string"
70 | }
71 | }
72 | }
73 | },
74 | "securitySchemes": {
75 | "bearerAuth": {
76 | "type": "http",
77 | "scheme": "bearer"
78 | }
79 | }
80 | }
81 | }
```
--------------------------------------------------------------------------------
/tests/server.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2 | import fs from 'fs/promises';
3 |
4 | // Mock fs module
5 | vi.mock('fs/promises');
6 | const mockedFs = vi.mocked(fs);
7 |
8 | // Mock console methods to avoid cluttering test output
9 | const mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
10 |
11 | // Mock the MCP SDK components
12 | const mockServer = {
13 | setRequestHandler: vi.fn(),
14 | connect: vi.fn()
15 | };
16 |
17 | const mockTransport = {};
18 |
19 | vi.mock('@modelcontextprotocol/sdk/server/index.js', () => ({
20 | Server: vi.fn().mockImplementation(() => mockServer)
21 | }));
22 |
23 | vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
24 | StdioServerTransport: vi.fn().mockImplementation(() => mockTransport)
25 | }));
26 |
27 | vi.mock('@modelcontextprotocol/sdk/types.js', () => ({
28 | ListToolsRequestSchema: 'ListToolsRequestSchema',
29 | CallToolRequestSchema: 'CallToolRequestSchema'
30 | }));
31 |
32 | describe('MCP Server Configuration', () => {
33 | beforeEach(() => {
34 | vi.clearAllMocks();
35 | mockConsoleError.mockClear();
36 | process.env.OPENAPI_SPECS_FOLDER = '/test/specs';
37 |
38 | // Reset mocks
39 | mockServer.setRequestHandler.mockClear();
40 | mockServer.connect.mockClear();
41 | });
42 |
43 | afterEach(() => {
44 | delete process.env.OPENAPI_SPECS_FOLDER;
45 | });
46 |
47 | it('should create server instance with correct configuration', async () => {
48 | const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
49 |
50 | // Import the module which creates the server instance
51 | await import('../src/index.js');
52 |
53 | // Server should be created with correct configuration
54 | expect(Server).toHaveBeenCalledWith(
55 | {
56 | name: 'openapi-analyzer',
57 | version: '1.0.0',
58 | },
59 | {
60 | capabilities: {
61 | tools: {},
62 | },
63 | }
64 | );
65 | });
66 |
67 | it('should define all required MCP tools', () => {
68 | // Test that we define all the required tools for MCP
69 | const requiredTools = [
70 | 'load_specs',
71 | 'list_apis',
72 | 'get_api_spec',
73 | 'search_endpoints',
74 | 'get_api_stats',
75 | 'find_inconsistencies',
76 | 'compare_schemas'
77 | ];
78 |
79 | // These tools should exist (this validates our design)
80 | expect(requiredTools).toHaveLength(7);
81 | requiredTools.forEach(toolName => {
82 | expect(typeof toolName).toBe('string');
83 | expect(toolName).toBeTruthy();
84 | });
85 | });
86 |
87 | it('should validate tool schemas have required properties', () => {
88 | // Test the structure of tool schemas we would define
89 | const toolSchemas = [
90 | {
91 | name: 'get_api_spec',
92 | requiredParams: ['filename']
93 | },
94 | {
95 | name: 'search_endpoints',
96 | requiredParams: ['query']
97 | },
98 | {
99 | name: 'compare_schemas',
100 | requiredParams: ['schema1']
101 | }
102 | ];
103 |
104 | toolSchemas.forEach(({ name, requiredParams }) => {
105 | expect(name).toBeTruthy();
106 | expect(requiredParams).toBeInstanceOf(Array);
107 | expect(requiredParams.length).toBeGreaterThan(0);
108 | });
109 | });
110 |
111 | it('should handle MCP response format correctly', () => {
112 | // Test that responses follow MCP format
113 | const successResponse = {
114 | content: [
115 | {
116 | type: 'text',
117 | text: JSON.stringify({ result: 'success' }, null, 2),
118 | },
119 | ],
120 | };
121 |
122 | expect(successResponse.content).toHaveLength(1);
123 | expect(successResponse.content[0].type).toBe('text');
124 | expect(successResponse.content[0].text).toContain('result');
125 |
126 | // Test error response format
127 | const errorResponse = {
128 | content: [
129 | {
130 | type: 'text',
131 | text: 'Error: Something went wrong',
132 | },
133 | ],
134 | };
135 |
136 | expect(errorResponse.content).toHaveLength(1);
137 | expect(errorResponse.content[0].type).toBe('text');
138 | expect(errorResponse.content[0].text).toContain('Error:');
139 | });
140 |
141 | it('should handle JSON serialization in responses', () => {
142 | const testData = {
143 | apis: [
144 | { filename: 'test.json', title: 'Test API', version: '1.0.0' }
145 | ]
146 | };
147 |
148 | const serialized = JSON.stringify(testData, null, 2);
149 | expect(serialized).toContain('Test API');
150 | expect(serialized).toContain('1.0.0');
151 |
152 | const parsed = JSON.parse(serialized);
153 | expect(parsed.apis).toHaveLength(1);
154 | expect(parsed.apis[0].title).toBe('Test API');
155 | });
156 |
157 | it('should validate environment requirements', async () => {
158 | // Test that validateConfiguration is exported from the module
159 | const { validateConfiguration } = await import('../src/index.js');
160 | expect(validateConfiguration).toBeDefined();
161 | expect(typeof validateConfiguration).toBe('function');
162 | });
163 |
164 | it('should export OpenAPIAnalyzer class', async () => {
165 | // Test that the main functionality is exported
166 | const { OpenAPIAnalyzer } = await import('../src/index.js');
167 | expect(OpenAPIAnalyzer).toBeDefined();
168 | expect(typeof OpenAPIAnalyzer).toBe('function'); // Constructor function
169 |
170 | // Should be able to instantiate
171 | const analyzer = new OpenAPIAnalyzer();
172 | expect(analyzer).toBeInstanceOf(OpenAPIAnalyzer);
173 | });
174 |
175 | it('should export TypeScript interfaces', async () => {
176 | // Test that types can be imported (this validates exports)
177 | const module = await import('../src/index.js');
178 |
179 | // Key exports should exist
180 | expect(module.OpenAPIAnalyzer).toBeDefined();
181 | expect(module.validateConfiguration).toBeDefined();
182 |
183 | // These would be type-only imports in real usage, but we validate the structure
184 | const testApiSummary: any = {
185 | filename: 'test.json',
186 | title: 'Test API',
187 | version: '1.0.0',
188 | description: 'Test description',
189 | endpointCount: 5
190 | };
191 |
192 | expect(testApiSummary.filename).toBeTruthy();
193 | expect(testApiSummary.endpointCount).toBeGreaterThan(0);
194 | });
195 | });
```
--------------------------------------------------------------------------------
/examples/sample-openapi.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "openapi": "3.0.3",
3 | "info": {
4 | "title": "Sample Pet Store API",
5 | "description": "A sample API that demonstrates OpenAPI features",
6 | "version": "1.0.0",
7 | "contact": {
8 | "name": "API Support",
9 | "email": "[email protected]"
10 | }
11 | },
12 | "servers": [
13 | {
14 | "url": "https://api.petstore.com/v1",
15 | "description": "Production server"
16 | }
17 | ],
18 | "paths": {
19 | "/pets": {
20 | "get": {
21 | "summary": "List all pets",
22 | "description": "Retrieve a paginated list of pets",
23 | "operationId": "listPets",
24 | "parameters": [
25 | {
26 | "name": "limit",
27 | "in": "query",
28 | "description": "Number of pets to return",
29 | "schema": {
30 | "type": "integer",
31 | "default": 10
32 | }
33 | }
34 | ],
35 | "responses": {
36 | "200": {
37 | "description": "A list of pets",
38 | "content": {
39 | "application/json": {
40 | "schema": {
41 | "type": "array",
42 | "items": {
43 | "$ref": "#/components/schemas/Pet"
44 | }
45 | }
46 | }
47 | }
48 | }
49 | }
50 | },
51 | "post": {
52 | "summary": "Create a pet",
53 | "description": "Add a new pet to the store",
54 | "operationId": "createPet",
55 | "requestBody": {
56 | "required": true,
57 | "content": {
58 | "application/json": {
59 | "schema": {
60 | "$ref": "#/components/schemas/NewPet"
61 | }
62 | }
63 | }
64 | },
65 | "responses": {
66 | "201": {
67 | "description": "Pet created successfully",
68 | "content": {
69 | "application/json": {
70 | "schema": {
71 | "$ref": "#/components/schemas/Pet"
72 | }
73 | }
74 | }
75 | }
76 | }
77 | }
78 | },
79 | "/pets/{petId}": {
80 | "get": {
81 | "summary": "Get a pet by ID",
82 | "description": "Retrieve a specific pet by its ID",
83 | "operationId": "getPetById",
84 | "parameters": [
85 | {
86 | "name": "petId",
87 | "in": "path",
88 | "required": true,
89 | "description": "ID of the pet to retrieve",
90 | "schema": {
91 | "type": "integer",
92 | "format": "int64"
93 | }
94 | }
95 | ],
96 | "responses": {
97 | "200": {
98 | "description": "Pet found",
99 | "content": {
100 | "application/json": {
101 | "schema": {
102 | "$ref": "#/components/schemas/Pet"
103 | }
104 | }
105 | }
106 | },
107 | "404": {
108 | "description": "Pet not found"
109 | }
110 | }
111 | },
112 | "put": {
113 | "summary": "Update a pet",
114 | "description": "Update an existing pet",
115 | "operationId": "updatePet",
116 | "parameters": [
117 | {
118 | "name": "petId",
119 | "in": "path",
120 | "required": true,
121 | "description": "ID of the pet to update",
122 | "schema": {
123 | "type": "integer",
124 | "format": "int64"
125 | }
126 | }
127 | ],
128 | "requestBody": {
129 | "required": true,
130 | "content": {
131 | "application/json": {
132 | "schema": {
133 | "$ref": "#/components/schemas/NewPet"
134 | }
135 | }
136 | }
137 | },
138 | "responses": {
139 | "200": {
140 | "description": "Pet updated successfully",
141 | "content": {
142 | "application/json": {
143 | "schema": {
144 | "$ref": "#/components/schemas/Pet"
145 | }
146 | }
147 | }
148 | },
149 | "404": {
150 | "description": "Pet not found"
151 | }
152 | }
153 | },
154 | "delete": {
155 | "summary": "Delete a pet",
156 | "description": "Remove a pet from the store",
157 | "operationId": "deletePet",
158 | "parameters": [
159 | {
160 | "name": "petId",
161 | "in": "path",
162 | "required": true,
163 | "description": "ID of the pet to delete",
164 | "schema": {
165 | "type": "integer",
166 | "format": "int64"
167 | }
168 | }
169 | ],
170 | "responses": {
171 | "204": {
172 | "description": "Pet deleted successfully"
173 | },
174 | "404": {
175 | "description": "Pet not found"
176 | }
177 | }
178 | }
179 | }
180 | },
181 | "components": {
182 | "schemas": {
183 | "Pet": {
184 | "type": "object",
185 | "required": ["id", "name"],
186 | "properties": {
187 | "id": {
188 | "type": "integer",
189 | "format": "int64",
190 | "description": "Unique identifier for the pet"
191 | },
192 | "name": {
193 | "type": "string",
194 | "description": "Name of the pet"
195 | },
196 | "tag": {
197 | "type": "string",
198 | "description": "Tag to categorize the pet"
199 | },
200 | "status": {
201 | "type": "string",
202 | "enum": ["available", "pending", "sold"],
203 | "description": "Pet status in the store"
204 | }
205 | }
206 | },
207 | "NewPet": {
208 | "type": "object",
209 | "required": ["name"],
210 | "properties": {
211 | "name": {
212 | "type": "string",
213 | "description": "Name of the pet"
214 | },
215 | "tag": {
216 | "type": "string",
217 | "description": "Tag to categorize the pet"
218 | },
219 | "status": {
220 | "type": "string",
221 | "enum": ["available", "pending", "sold"],
222 | "default": "available",
223 | "description": "Pet status in the store"
224 | }
225 | }
226 | }
227 | },
228 | "securitySchemes": {
229 | "ApiKeyAuth": {
230 | "type": "apiKey",
231 | "in": "header",
232 | "name": "X-API-Key"
233 | }
234 | }
235 | },
236 | "security": [
237 | {
238 | "ApiKeyAuth": []
239 | }
240 | ]
241 | }
```
--------------------------------------------------------------------------------
/tests/validation.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeEach, vi } from 'vitest';
2 | import fs from 'fs/promises';
3 |
4 | // Mock fs module
5 | vi.mock('fs/promises');
6 | const mockedFs = vi.mocked(fs);
7 |
8 | describe('Environment Validation', () => {
9 | beforeEach(() => {
10 | vi.clearAllMocks();
11 | // Clean environment
12 | delete process.env.OPENAPI_SPECS_FOLDER;
13 | });
14 |
15 | describe('validateSpecsFolder', () => {
16 | it('should validate existing readable directory', async () => {
17 | process.env.OPENAPI_SPECS_FOLDER = '/valid/path';
18 |
19 | mockedFs.stat.mockResolvedValue({
20 | isDirectory: () => true
21 | } as any);
22 | mockedFs.access.mockResolvedValue();
23 |
24 | // Test passes if no error is thrown
25 | expect(process.env.OPENAPI_SPECS_FOLDER).toBe('/valid/path');
26 | });
27 |
28 | it('should handle missing environment variable', async () => {
29 | const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
30 | const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
31 | throw new Error(`Process exit with code ${code}`);
32 | });
33 |
34 | expect(() => {
35 | // This would normally trigger validation in the real module
36 | if (!process.env.OPENAPI_SPECS_FOLDER) {
37 | console.error('❌ Error: OPENAPI_SPECS_FOLDER environment variable is required');
38 | process.exit(1);
39 | }
40 | }).toThrow('Process exit with code 1');
41 |
42 | expect(consoleSpy).toHaveBeenCalledWith(
43 | '❌ Error: OPENAPI_SPECS_FOLDER environment variable is required'
44 | );
45 |
46 | consoleSpy.mockRestore();
47 | exitSpy.mockRestore();
48 | });
49 |
50 | it('should handle non-existent directory', async () => {
51 | process.env.OPENAPI_SPECS_FOLDER = '/non/existent/path';
52 |
53 | mockedFs.stat.mockRejectedValue({ code: 'ENOENT' });
54 |
55 | const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
56 | const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
57 | throw new Error(`Process exit with code ${code}`);
58 | });
59 |
60 | try {
61 | await mockedFs.stat(process.env.OPENAPI_SPECS_FOLDER);
62 | } catch (error: any) {
63 | if (error.code === 'ENOENT') {
64 | console.error(`❌ Error: OPENAPI_SPECS_FOLDER does not exist: ${process.env.OPENAPI_SPECS_FOLDER}`);
65 | expect(() => process.exit(1)).toThrow('Process exit with code 1');
66 | }
67 | }
68 |
69 | expect(consoleSpy).toHaveBeenCalledWith(
70 | '❌ Error: OPENAPI_SPECS_FOLDER does not exist: /non/existent/path'
71 | );
72 |
73 | consoleSpy.mockRestore();
74 | exitSpy.mockRestore();
75 | });
76 |
77 | it('should handle path that is not a directory', async () => {
78 | process.env.OPENAPI_SPECS_FOLDER = '/path/to/file';
79 |
80 | mockedFs.stat.mockResolvedValue({
81 | isDirectory: () => false
82 | } as any);
83 |
84 | const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
85 | const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
86 | throw new Error(`Process exit with code ${code}`);
87 | });
88 |
89 | try {
90 | const stats = await mockedFs.stat(process.env.OPENAPI_SPECS_FOLDER);
91 | if (!stats.isDirectory()) {
92 | console.error(`❌ Error: OPENAPI_SPECS_FOLDER is not a directory: ${process.env.OPENAPI_SPECS_FOLDER}`);
93 | expect(() => process.exit(1)).toThrow('Process exit with code 1');
94 | }
95 | } catch (error) {
96 | // Expected behavior
97 | }
98 |
99 | expect(consoleSpy).toHaveBeenCalledWith(
100 | '❌ Error: OPENAPI_SPECS_FOLDER is not a directory: /path/to/file'
101 | );
102 |
103 | consoleSpy.mockRestore();
104 | exitSpy.mockRestore();
105 | });
106 |
107 | it('should handle permission denied', async () => {
108 | process.env.OPENAPI_SPECS_FOLDER = '/protected/path';
109 |
110 | mockedFs.stat.mockResolvedValue({
111 | isDirectory: () => true
112 | } as any);
113 | mockedFs.access.mockRejectedValue({ code: 'EACCES' });
114 |
115 | const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
116 | const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
117 | throw new Error(`Process exit with code ${code}`);
118 | });
119 |
120 | try {
121 | const stats = await mockedFs.stat(process.env.OPENAPI_SPECS_FOLDER);
122 | if (stats.isDirectory()) {
123 | await mockedFs.access(process.env.OPENAPI_SPECS_FOLDER, fs.constants.R_OK);
124 | }
125 | } catch (error: any) {
126 | if (error.code === 'EACCES') {
127 | console.error(`❌ Error: No read permission for OPENAPI_SPECS_FOLDER: ${process.env.OPENAPI_SPECS_FOLDER}`);
128 | expect(() => process.exit(1)).toThrow('Process exit with code 1');
129 | }
130 | }
131 |
132 | expect(consoleSpy).toHaveBeenCalledWith(
133 | '❌ Error: No read permission for OPENAPI_SPECS_FOLDER: /protected/path'
134 | );
135 |
136 | consoleSpy.mockRestore();
137 | exitSpy.mockRestore();
138 | });
139 | });
140 |
141 | describe('File Processing', () => {
142 | beforeEach(() => {
143 | process.env.OPENAPI_SPECS_FOLDER = '/test/specs';
144 | mockedFs.stat.mockResolvedValue({ isDirectory: () => true } as any);
145 | mockedFs.access.mockResolvedValue();
146 | });
147 |
148 | it('should filter JSON files correctly', async () => {
149 | mockedFs.readdir.mockResolvedValue([
150 | 'api1.json',
151 | 'api2.json',
152 | 'readme.txt',
153 | 'schema.yaml',
154 | 'config.json'
155 | ] as any);
156 |
157 | const files = await mockedFs.readdir('/test/specs');
158 | const jsonFiles = files.filter((file: string) => file.endsWith('.json'));
159 |
160 | expect(jsonFiles).toEqual(['api1.json', 'api2.json', 'config.json']);
161 | expect(jsonFiles).toHaveLength(3);
162 | });
163 |
164 | it('should handle empty directory', async () => {
165 | mockedFs.readdir.mockResolvedValue([] as any);
166 |
167 | const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
168 |
169 | const files = await mockedFs.readdir('/test/specs');
170 | const jsonFiles = files.filter((file: string) => file.endsWith('.json'));
171 |
172 | if (jsonFiles.length === 0) {
173 | console.error('⚠️ Warning: No .json files found in /test/specs');
174 | }
175 |
176 | expect(jsonFiles).toHaveLength(0);
177 | expect(consoleSpy).toHaveBeenCalledWith(
178 | '⚠️ Warning: No .json files found in /test/specs'
179 | );
180 |
181 | consoleSpy.mockRestore();
182 | });
183 |
184 | it('should handle directory with only non-JSON files', async () => {
185 | mockedFs.readdir.mockResolvedValue([
186 | 'readme.md',
187 | 'config.yaml',
188 | 'script.py'
189 | ] as any);
190 |
191 | const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
192 |
193 | const files = await mockedFs.readdir('/test/specs');
194 | const jsonFiles = files.filter((file: string) => file.endsWith('.json'));
195 |
196 | if (jsonFiles.length === 0) {
197 | console.error('⚠️ Warning: No .json files found in /test/specs');
198 | }
199 |
200 | expect(jsonFiles).toHaveLength(0);
201 | expect(consoleSpy).toHaveBeenCalledWith(
202 | '⚠️ Warning: No .json files found in /test/specs'
203 | );
204 |
205 | consoleSpy.mockRestore();
206 | });
207 | });
208 | });
```
--------------------------------------------------------------------------------
/tests/analyzer.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2 | import fs from 'fs/promises';
3 | import type {
4 | ApiSummary,
5 | SearchResult,
6 | ApiStats,
7 | Inconsistency,
8 | SchemaComparison
9 | } from '../src/index';
10 |
11 | // Mock fs module
12 | vi.mock('fs/promises');
13 | const mockedFs = vi.mocked(fs);
14 |
15 | // Mock swagger parser
16 | vi.mock('@apidevtools/swagger-parser', () => ({
17 | default: {
18 | parse: vi.fn()
19 | }
20 | }));
21 |
22 | // Mock fetch for remote URL testing
23 | global.fetch = vi.fn();
24 | const mockedFetch = vi.mocked(fetch);
25 |
26 | import SwaggerParser from '@apidevtools/swagger-parser';
27 | const mockedSwaggerParser = vi.mocked(SwaggerParser);
28 |
29 | import { OpenAPIAnalyzer } from '../src/index';
30 |
31 | // Mock console methods to avoid cluttering test output
32 | const mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
33 |
34 | describe('OpenAPIAnalyzer', () => {
35 | let analyzer: OpenAPIAnalyzer;
36 |
37 | beforeEach(() => {
38 | vi.clearAllMocks();
39 | mockConsoleError.mockClear();
40 | mockedSwaggerParser.parse.mockClear();
41 | mockedFetch.mockClear();
42 |
43 | // Create default analyzer with local folder for tests
44 | analyzer = new OpenAPIAnalyzer({
45 | specsFolder: '/test/folder'
46 | });
47 | });
48 |
49 | describe('loadSpecs', () => {
50 | it('should load valid OpenAPI specifications from local folder', async () => {
51 | const sampleApiSpec = {
52 | openapi: '3.0.0',
53 | info: { title: 'Test API', version: '1.0.0' },
54 | paths: {
55 | '/users': {
56 | get: { summary: 'Get users' }
57 | }
58 | }
59 | };
60 |
61 | // Mock local folder operations
62 | mockedFs.stat.mockResolvedValue({ isDirectory: () => true } as any);
63 | mockedFs.access.mockResolvedValue();
64 | mockedFs.readdir.mockResolvedValue(['sample-api.json', 'other-file.txt'] as any);
65 | mockedSwaggerParser.parse.mockResolvedValue(sampleApiSpec as any);
66 |
67 | await analyzer.loadSpecs();
68 |
69 | const specs = analyzer.listAllSpecs();
70 | expect(specs).toHaveLength(1);
71 | expect(specs[0].filename).toBe('sample-api.json');
72 | expect(specs[0].title).toBe('Test API');
73 | });
74 |
75 | it('should skip invalid JSON files', async () => {
76 | mockedFs.stat.mockResolvedValue({ isDirectory: () => true } as any);
77 | mockedFs.access.mockResolvedValue();
78 | mockedFs.readdir.mockResolvedValue(['invalid.json'] as any);
79 | mockedSwaggerParser.parse.mockRejectedValue(new Error('Invalid JSON'));
80 |
81 | await analyzer.loadSpecs();
82 |
83 | const specs = analyzer.listAllSpecs();
84 | expect(specs).toHaveLength(0);
85 | expect(mockConsoleError).toHaveBeenCalledWith('⚠️ Skipping invalid.json: Invalid JSON format or malformed OpenAPI spec');
86 | });
87 |
88 | it('should skip files without openapi or swagger field', async () => {
89 | mockedFs.stat.mockResolvedValue({ isDirectory: () => true } as any);
90 | mockedFs.access.mockResolvedValue();
91 | mockedFs.readdir.mockResolvedValue(['invalid-api.json'] as any);
92 | mockedSwaggerParser.parse.mockRejectedValue(new Error('Not an OpenAPI specification'));
93 |
94 | await analyzer.loadSpecs();
95 |
96 | const specs = analyzer.listAllSpecs();
97 | expect(specs).toHaveLength(0);
98 | expect(mockConsoleError).toHaveBeenCalledWith('⚠️ Skipping invalid-api.json: Invalid JSON format or malformed OpenAPI spec');
99 | });
100 |
101 | it('should load specs with swagger field', async () => {
102 | const swaggerApiSpec = {
103 | swagger: '2.0',
104 | info: { title: 'Swagger API', version: '1.0.0' },
105 | paths: {
106 | '/users': { get: { summary: 'Get users' } }
107 | }
108 | };
109 |
110 | mockedFs.stat.mockResolvedValue({ isDirectory: () => true } as any);
111 | mockedFs.access.mockResolvedValue();
112 | mockedFs.readdir.mockResolvedValue(['swagger-api.json'] as any);
113 | mockedSwaggerParser.parse.mockResolvedValue(swaggerApiSpec as any);
114 |
115 | await analyzer.loadSpecs();
116 |
117 | const specs = analyzer.listAllSpecs();
118 | expect(specs).toHaveLength(1);
119 | expect(specs[0].filename).toBe('swagger-api.json');
120 | expect(specs[0].title).toBe('Swagger API');
121 | });
122 |
123 | it('should handle specs without info section', async () => {
124 | const apiWithoutInfo = {
125 | openapi: '3.0.0',
126 | paths: {
127 | '/test': {
128 | get: { summary: 'Test endpoint' }
129 | }
130 | }
131 | };
132 |
133 | mockedFs.stat.mockResolvedValue({ isDirectory: () => true } as any);
134 | mockedFs.access.mockResolvedValue();
135 | mockedFs.readdir.mockResolvedValue(['no-info.json'] as any);
136 | mockedSwaggerParser.parse.mockResolvedValue(apiWithoutInfo as any);
137 |
138 | await analyzer.loadSpecs();
139 |
140 | const specs = analyzer.listAllSpecs();
141 | expect(specs).toHaveLength(1);
142 | expect(specs[0].title).toBe('No title'); // Default title when no info section
143 | });
144 |
145 | it('should handle specs with no paths', async () => {
146 | const apiWithoutPaths = {
147 | openapi: '3.0.0',
148 | info: { title: 'No Paths API', version: '1.0.0' }
149 | };
150 |
151 | mockedFs.stat.mockResolvedValue({ isDirectory: () => true } as any);
152 | mockedFs.access.mockResolvedValue();
153 | mockedFs.readdir.mockResolvedValue(['no-paths.json'] as any);
154 | mockedSwaggerParser.parse.mockResolvedValue(apiWithoutPaths as any);
155 |
156 | await analyzer.loadSpecs();
157 |
158 | const specs = analyzer.listAllSpecs();
159 | expect(specs).toHaveLength(1);
160 | expect(specs[0].title).toBe('No Paths API');
161 | });
162 | });
163 |
164 | describe('listAllSpecs', () => {
165 | it('should return empty array when no specs loaded', () => {
166 | const specs = analyzer.listAllSpecs();
167 | expect(specs).toHaveLength(0);
168 | });
169 |
170 | it('should return spec summaries', async () => {
171 | const apiSpec = {
172 | openapi: '3.0.0',
173 | info: {
174 | title: 'Test API',
175 | version: '1.0.0',
176 | description: 'A test API'
177 | },
178 | paths: {
179 | '/users': { get: {}, post: {} },
180 | '/posts': { get: {} }
181 | }
182 | };
183 |
184 | mockedFs.stat.mockResolvedValue({ isDirectory: () => true } as any);
185 | mockedFs.access.mockResolvedValue();
186 | mockedFs.readdir.mockResolvedValue(['test.json'] as any);
187 | mockedSwaggerParser.parse.mockResolvedValue(apiSpec as any);
188 | await analyzer.loadSpecs();
189 |
190 | const specs = analyzer.listAllSpecs();
191 | expect(specs).toHaveLength(1);
192 | expect(specs[0].filename).toBe('test.json');
193 | expect(specs[0].title).toBe('Test API');
194 | expect(specs[0].version).toBe('1.0.0');
195 | expect(specs[0].description).toBe('A test API');
196 | expect(specs[0].endpointCount).toBe(2); // 2 paths: /users and /posts
197 | });
198 | });
199 |
200 | describe('getSpecByFilename', () => {
201 | it('should return null for non-existent spec', () => {
202 | const spec = analyzer.getSpecByFilename('nonexistent.json');
203 | expect(spec).toBeNull();
204 | });
205 |
206 | it('should return the correct spec', async () => {
207 | const apiSpec = {
208 | openapi: '3.0.0',
209 | info: { title: 'Test API', version: '1.0.0' },
210 | paths: {}
211 | };
212 |
213 | mockedFs.stat.mockResolvedValue({ isDirectory: () => true } as any);
214 | mockedFs.access.mockResolvedValue();
215 | mockedFs.readdir.mockResolvedValue(['test.json'] as any);
216 | mockedSwaggerParser.parse.mockResolvedValue(apiSpec as any);
217 | await analyzer.loadSpecs();
218 |
219 | const spec = analyzer.getSpecByFilename('test.json');
220 | expect(spec).toEqual(apiSpec);
221 | });
222 | });
223 |
224 | describe('searchEndpoints', () => {
225 | beforeEach(async () => {
226 | const apiSpec = {
227 | openapi: '3.0.0',
228 | info: { title: 'Test API', version: '1.0.0' },
229 | paths: {
230 | '/users': {
231 | get: {
232 | summary: 'Get all users',
233 | description: 'Retrieve list of users',
234 | operationId: 'getUsers'
235 | },
236 | post: {
237 | summary: 'Create user',
238 | description: 'Create a new user',
239 | operationId: 'createUser'
240 | }
241 | },
242 | '/posts': {
243 | get: {
244 | summary: 'Get posts',
245 | description: 'Retrieve blog posts',
246 | operationId: 'getPosts'
247 | }
248 | }
249 | }
250 | };
251 |
252 | mockedFs.stat.mockResolvedValue({ isDirectory: () => true } as any);
253 | mockedFs.access.mockResolvedValue();
254 | mockedFs.readdir.mockResolvedValue(['test.json'] as any);
255 | mockedSwaggerParser.parse.mockResolvedValue(apiSpec as any);
256 | await analyzer.loadSpecs();
257 | });
258 |
259 | it('should find endpoints by path', () => {
260 | const results: SearchResult[] = analyzer.searchEndpoints('users');
261 | expect(results).toHaveLength(2);
262 | expect(results[0].path).toBe('/users');
263 | expect(results[1].path).toBe('/users');
264 | expect(results[0].method).toBe('GET');
265 | expect(results[1].method).toBe('POST');
266 | });
267 |
268 | it('should find endpoints by method', () => {
269 | const results: SearchResult[] = analyzer.searchEndpoints('get');
270 | expect(results).toHaveLength(2);
271 | expect(results[0].method).toBe('GET');
272 | expect(results[1].method).toBe('GET');
273 | });
274 |
275 | it('should find endpoints by summary', () => {
276 | const results: SearchResult[] = analyzer.searchEndpoints('Create user');
277 | expect(results).toHaveLength(1);
278 | expect(results[0].path).toBe('/users');
279 | expect(results[0].method).toBe('POST');
280 | });
281 |
282 | it('should find endpoints by operation ID', () => {
283 | const results: SearchResult[] = analyzer.searchEndpoints('createUser');
284 | expect(results).toHaveLength(1);
285 | expect(results[0].path).toBe('/users');
286 | expect(results[0].method).toBe('POST');
287 | });
288 |
289 | it('should return empty array for no matches', () => {
290 | const results: SearchResult[] = analyzer.searchEndpoints('nonexistent');
291 | expect(results).toHaveLength(0);
292 | });
293 |
294 | it('should be case insensitive', () => {
295 | const results: SearchResult[] = analyzer.searchEndpoints('USERS');
296 | expect(results).toHaveLength(2);
297 | });
298 | });
299 |
300 | describe('getApiStats', () => {
301 | it('should return empty stats for no specs', () => {
302 | const stats: ApiStats = analyzer.getApiStats();
303 | expect(stats.totalApis).toBe(0);
304 | expect(stats.totalEndpoints).toBe(0);
305 | expect(stats.methodCounts).toEqual({});
306 | expect(stats.versions).toEqual({});
307 | expect(stats.commonPaths).toEqual({});
308 | });
309 |
310 | it('should calculate correct statistics', async () => {
311 | const apiSpec = {
312 | openapi: '3.0.0',
313 | info: { title: 'Test API', version: '1.0.0' },
314 | paths: {
315 | '/users': {
316 | get: { summary: 'Get users' },
317 | post: { summary: 'Create user' },
318 | put: { summary: 'Update user' },
319 | delete: { summary: 'Delete user' }
320 | },
321 | '/posts': {
322 | get: { summary: 'Get posts' },
323 | post: { summary: 'Create post' }
324 | },
325 | '/comments': {
326 | get: { summary: 'Get comments' }
327 | }
328 | }
329 | };
330 |
331 | mockedFs.stat.mockResolvedValue({ isDirectory: () => true } as any);
332 | mockedFs.access.mockResolvedValue();
333 | mockedFs.readdir.mockResolvedValue(['test.json'] as any);
334 | mockedSwaggerParser.parse.mockResolvedValue(apiSpec as any);
335 | await analyzer.loadSpecs();
336 |
337 | const stats: ApiStats = analyzer.getApiStats();
338 |
339 | expect(stats.totalApis).toBe(1);
340 | expect(stats.totalEndpoints).toBe(7);
341 | expect(stats.methodCounts).toEqual({
342 | GET: 3,
343 | POST: 2,
344 | PUT: 1,
345 | DELETE: 1
346 | });
347 | expect(stats.versions).toEqual({
348 | '1.0.0': 1
349 | });
350 | expect(stats.commonPaths).toEqual({
351 | '/users': 1,
352 | '/posts': 1,
353 | '/comments': 1
354 | });
355 | });
356 |
357 | it('should handle multiple APIs with different versions', async () => {
358 | const api1Spec = {
359 | openapi: '3.0.0',
360 | info: { title: 'API 1', version: '1.0.0' },
361 | paths: { '/users': { get: {}, post: {} } }
362 | };
363 |
364 | const api2Spec = {
365 | openapi: '3.0.0',
366 | info: { title: 'API 2', version: '2.0.0' },
367 | paths: { '/products': { get: {} } }
368 | };
369 |
370 | mockedFs.stat.mockResolvedValue({ isDirectory: () => true } as any);
371 | mockedFs.access.mockResolvedValue();
372 | mockedFs.readdir.mockResolvedValue(['api1.json', 'api2.json'] as any);
373 | mockedSwaggerParser.parse
374 | .mockResolvedValueOnce(api1Spec as any)
375 | .mockResolvedValueOnce(api2Spec as any);
376 | await analyzer.loadSpecs();
377 |
378 | const stats: ApiStats = analyzer.getApiStats();
379 |
380 | expect(stats.totalApis).toBe(2);
381 | expect(stats.totalEndpoints).toBe(3);
382 | expect(stats.versions).toEqual({
383 | '1.0.0': 1,
384 | '2.0.0': 1
385 | });
386 | });
387 | });
388 |
389 | describe('findInconsistencies', () => {
390 | it('should find authentication inconsistencies', async () => {
391 | const api1Spec = {
392 | openapi: '3.0.0',
393 | info: { title: 'API 1', version: '1.0.0' },
394 | components: {
395 | securitySchemes: {
396 | bearerAuth: { type: 'http', scheme: 'bearer' }
397 | }
398 | }
399 | };
400 |
401 | const api2Spec = {
402 | openapi: '3.0.0',
403 | info: { title: 'API 2', version: '1.0.0' },
404 | components: {
405 | securitySchemes: {
406 | apiKeyAuth: { type: 'apiKey', in: 'header', name: 'X-API-Key' }
407 | }
408 | }
409 | };
410 |
411 | mockedFs.stat.mockResolvedValue({ isDirectory: () => true } as any);
412 | mockedFs.access.mockResolvedValue();
413 | mockedFs.readdir.mockResolvedValue(['api1.json', 'api2.json'] as any);
414 | mockedSwaggerParser.parse
415 | .mockResolvedValueOnce(api1Spec as any)
416 | .mockResolvedValueOnce(api2Spec as any);
417 | await analyzer.loadSpecs();
418 |
419 | const inconsistencies: Inconsistency[] = analyzer.findInconsistencies();
420 |
421 | expect(inconsistencies).toHaveLength(1);
422 | expect(inconsistencies[0].type).toBe('authentication');
423 | expect(inconsistencies[0].message).toContain('Multiple authentication schemes');
424 | });
425 |
426 | it('should return no inconsistencies for consistent auth', async () => {
427 | const api1Spec = {
428 | openapi: '3.0.0',
429 | info: { title: 'API 1', version: '1.0.0' },
430 | components: {
431 | securitySchemes: {
432 | bearerAuth: { type: 'http', scheme: 'bearer' }
433 | }
434 | }
435 | };
436 |
437 | const api2Spec = {
438 | openapi: '3.0.0',
439 | info: { title: 'API 2', version: '1.0.0' },
440 | components: {
441 | securitySchemes: {
442 | bearerAuth: { type: 'http', scheme: 'bearer' }
443 | }
444 | }
445 | };
446 |
447 | mockedFs.stat.mockResolvedValue({ isDirectory: () => true } as any);
448 | mockedFs.access.mockResolvedValue();
449 | mockedFs.readdir.mockResolvedValue(['api1.json', 'api2.json'] as any);
450 | mockedSwaggerParser.parse
451 | .mockResolvedValueOnce(api1Spec as any)
452 | .mockResolvedValueOnce(api2Spec as any);
453 | await analyzer.loadSpecs();
454 |
455 | const inconsistencies: Inconsistency[] = analyzer.findInconsistencies();
456 | expect(inconsistencies).toHaveLength(0);
457 | });
458 |
459 | it('should return no inconsistencies when no security schemes exist', async () => {
460 | const apiSpec = {
461 | openapi: '3.0.0',
462 | info: { title: 'API', version: '1.0.0' },
463 | paths: { '/test': { get: {} } }
464 | };
465 |
466 | mockedFs.stat.mockResolvedValue({ isDirectory: () => true } as any);
467 | mockedFs.access.mockResolvedValue();
468 | mockedFs.readdir.mockResolvedValue(['api.json'] as any);
469 | mockedSwaggerParser.parse.mockResolvedValue(apiSpec as any);
470 | await analyzer.loadSpecs();
471 |
472 | const inconsistencies: Inconsistency[] = analyzer.findInconsistencies();
473 | expect(inconsistencies).toHaveLength(0);
474 | });
475 | });
476 |
477 | describe('compareSchemas', () => {
478 | beforeEach(async () => {
479 | const api1Spec = {
480 | openapi: '3.0.0',
481 | info: { title: 'API 1', version: '1.0.0' },
482 | components: {
483 | schemas: {
484 | User: {
485 | type: 'object',
486 | properties: {
487 | id: { type: 'integer' },
488 | name: { type: 'string' }
489 | }
490 | },
491 | Product: {
492 | type: 'object',
493 | properties: {
494 | id: { type: 'integer' },
495 | title: { type: 'string' }
496 | }
497 | }
498 | }
499 | }
500 | };
501 |
502 | const api2Spec = {
503 | openapi: '3.0.0',
504 | info: { title: 'API 2', version: '1.0.0' },
505 | components: {
506 | schemas: {
507 | User: {
508 | type: 'object',
509 | properties: {
510 | id: { type: 'integer' },
511 | email: { type: 'string' }
512 | }
513 | }
514 | }
515 | }
516 | };
517 |
518 | mockedFs.stat.mockResolvedValue({ isDirectory: () => true } as any);
519 | mockedFs.access.mockResolvedValue();
520 | mockedFs.readdir.mockResolvedValue(['api1.json', 'api2.json'] as any);
521 | mockedSwaggerParser.parse
522 | .mockResolvedValueOnce(api1Spec as any)
523 | .mockResolvedValueOnce(api2Spec as any);
524 | await analyzer.loadSpecs();
525 | });
526 |
527 | it('should compare single schema across APIs', () => {
528 | const results: SchemaComparison[] = analyzer.compareSchemas('User', '');
529 | expect(results).toHaveLength(2);
530 | expect(results.every(r => r.schemaName === 'User')).toBe(true);
531 | });
532 |
533 | it('should compare two different schemas', () => {
534 | const results: SchemaComparison[] = analyzer.compareSchemas('User', 'Product');
535 | expect(results).toHaveLength(3); // User from both APIs + Product from API 1
536 | });
537 |
538 | it('should return empty array for non-existent schema', () => {
539 | const results: SchemaComparison[] = analyzer.compareSchemas('NonExistent', '');
540 | expect(results).toHaveLength(0);
541 | });
542 |
543 | it('should handle APIs without components/schemas', async () => {
544 | const apiWithoutSchemas = {
545 | openapi: '3.0.0',
546 | info: { title: 'Simple API', version: '1.0.0' },
547 | paths: { '/test': { get: {} } }
548 | };
549 |
550 | mockedFs.stat.mockResolvedValue({ isDirectory: () => true } as any);
551 | mockedFs.access.mockResolvedValue();
552 | mockedFs.readdir.mockResolvedValue(['simple.json'] as any);
553 | mockedSwaggerParser.parse.mockResolvedValue(apiWithoutSchemas as any);
554 |
555 | const simpleAnalyzer = new OpenAPIAnalyzer();
556 | await simpleAnalyzer.loadSpecs();
557 |
558 | const results: SchemaComparison[] = simpleAnalyzer.compareSchemas('User', '');
559 | expect(results).toHaveLength(0);
560 | });
561 | });
562 |
563 | describe('OpenAPIAnalyzer validation', () => {
564 | it('should not load anything when no configuration is provided', async () => {
565 | analyzer = new OpenAPIAnalyzer(); // No constructor options
566 |
567 | await analyzer.loadSpecs();
568 |
569 | const specs = analyzer.listAllSpecs();
570 | expect(specs).toHaveLength(0);
571 | });
572 |
573 | it('should handle non-existent directory gracefully', async () => {
574 | analyzer = new OpenAPIAnalyzer({ specsFolder: '/nonexistent' });
575 | mockedFs.stat.mockRejectedValue(Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }));
576 |
577 | await analyzer.loadSpecs();
578 |
579 | const specs = analyzer.listAllSpecs();
580 | expect(specs).toHaveLength(0);
581 | });
582 |
583 | it('should handle non-directory path gracefully', async () => {
584 | analyzer = new OpenAPIAnalyzer({ specsFolder: '/some/file.txt' });
585 | mockedFs.stat.mockResolvedValue({ isDirectory: () => false } as any);
586 |
587 | await analyzer.loadSpecs();
588 |
589 | const specs = analyzer.listAllSpecs();
590 | expect(specs).toHaveLength(0);
591 | });
592 |
593 | it('should handle permission denied gracefully', async () => {
594 | analyzer = new OpenAPIAnalyzer({ specsFolder: '/no/permission' });
595 | mockedFs.stat.mockResolvedValue({ isDirectory: () => true } as any);
596 | mockedFs.access.mockRejectedValue(Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }));
597 |
598 | await analyzer.loadSpecs();
599 |
600 | const specs = analyzer.listAllSpecs();
601 | expect(specs).toHaveLength(0);
602 | });
603 | });
604 | });
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5 | import {
6 | CallToolRequestSchema,
7 | ListToolsRequestSchema,
8 | Tool,
9 | } from '@modelcontextprotocol/sdk/types.js';
10 | import fs from 'fs/promises';
11 | import path from 'path';
12 | import SwaggerParser from '@apidevtools/swagger-parser';
13 |
14 | /**
15 | * OpenAPI Analyzer MCP Server - A Model Context Protocol server for analyzing OpenAPI specifications
16 | *
17 | * This server provides tools to load, analyze, and compare OpenAPI specification files,
18 | * enabling natural language queries about API structures, endpoints, and inconsistencies.
19 | */
20 |
21 | // Configuration - Priority order: Discovery URL -> Individual URLs -> Local folder
22 | const DISCOVERY_URL = process.env.OPENAPI_DISCOVERY_URL;
23 | const SPEC_URLS = process.env.OPENAPI_SPEC_URLS?.split(',').map(url => url.trim()).filter(Boolean) || [];
24 | const SPECS_FOLDER = process.env.OPENAPI_SPECS_FOLDER;
25 |
26 | /**
27 | * Validate that at least one configuration source is available
28 | */
29 | export async function validateConfiguration(): Promise<void> {
30 | if (!DISCOVERY_URL && SPEC_URLS.length === 0 && !SPECS_FOLDER) {
31 | console.error('❌ Error: No OpenAPI source configured');
32 | console.error('');
33 | console.error('Please set at least one of the following environment variables:');
34 | console.error('');
35 | console.error('1. OPENAPI_DISCOVERY_URL - URL to API registry (apis.json or custom format)');
36 | console.error(' Example: OPENAPI_DISCOVERY_URL=https://docs.company.com/apis.json');
37 | console.error('');
38 | console.error('2. OPENAPI_SPEC_URLS - Comma-separated list of OpenAPI spec URLs');
39 | console.error(' Example: OPENAPI_SPEC_URLS=https://api.com/v1/spec.yaml,https://api.com/v2/spec.yaml');
40 | console.error('');
41 | console.error('3. OPENAPI_SPECS_FOLDER - Local folder with OpenAPI files');
42 | console.error(' Example: OPENAPI_SPECS_FOLDER=/absolute/path/to/your/specs');
43 | console.error('');
44 | console.error('Priority order: Discovery URL → Individual URLs → Local folder');
45 | process.exit(1);
46 | }
47 | }
48 |
49 | /**
50 | * Validate the local specs folder if it's configured
51 | */
52 | async function validateSpecsFolder(): Promise<string> {
53 | if (!SPECS_FOLDER) {
54 | throw new Error('SPECS_FOLDER not configured');
55 | }
56 |
57 | try {
58 | // Check if the folder exists and is accessible
59 | const stats = await fs.stat(SPECS_FOLDER);
60 |
61 | if (!stats.isDirectory()) {
62 | console.error(`❌ Error: OPENAPI_SPECS_FOLDER is not a directory: ${SPECS_FOLDER}`);
63 | console.error('');
64 | console.error('Please provide a path to a directory containing your OpenAPI JSON files.');
65 | process.exit(1);
66 | }
67 |
68 | // Try to read the directory to check permissions
69 | await fs.access(SPECS_FOLDER, fs.constants.R_OK);
70 |
71 | return SPECS_FOLDER;
72 | } catch (error: any) {
73 | if (error.code === 'ENOENT') {
74 | console.error(`❌ Error: OPENAPI_SPECS_FOLDER does not exist: ${SPECS_FOLDER}`);
75 | console.error('');
76 | console.error('Please create the directory or provide a path to an existing directory.');
77 | } else if (error.code === 'EACCES') {
78 | console.error(`❌ Error: No read permission for OPENAPI_SPECS_FOLDER: ${SPECS_FOLDER}`);
79 | console.error('');
80 | console.error('Please check the folder permissions.');
81 | } else {
82 | console.error(`❌ Error accessing OPENAPI_SPECS_FOLDER: ${error.message}`);
83 | }
84 | process.exit(1);
85 | }
86 | }
87 |
88 | export interface OpenAPIInfo {
89 | title?: string;
90 | version?: string;
91 | description?: string;
92 | }
93 |
94 | export interface OpenAPISpec {
95 | filename: string;
96 | spec: Record<string, any>;
97 | info?: OpenAPIInfo;
98 | paths?: Record<string, any>;
99 | components?: Record<string, any>;
100 | source?: {
101 | type: 'local' | 'remote';
102 | url?: string;
103 | apiInfo?: any;
104 | };
105 | }
106 |
107 | export interface ApiSummary {
108 | filename: string;
109 | title: string;
110 | version: string;
111 | description: string;
112 | endpointCount: number;
113 | }
114 |
115 | export interface SearchResult {
116 | filename: string;
117 | api_title?: string;
118 | path: string;
119 | method: string;
120 | summary?: string;
121 | description?: string;
122 | operationId?: string;
123 | }
124 |
125 | export interface ApiStats {
126 | totalApis: number;
127 | totalEndpoints: number;
128 | methodCounts: Record<string, number>;
129 | commonPaths: Record<string, number>;
130 | versions: Record<string, number>;
131 | apis: Array<{
132 | filename: string;
133 | title?: string;
134 | version?: string;
135 | endpointCount: number;
136 | methods: string[];
137 | }>;
138 | }
139 |
140 | export interface Inconsistency {
141 | type: string;
142 | message: string;
143 | details: Record<string, any>;
144 | }
145 |
146 | export interface SchemaComparison {
147 | filename: string;
148 | api?: string;
149 | schemaName: string;
150 | schema: Record<string, any>;
151 | }
152 |
153 | export interface LoadSource {
154 | type: 'discovery' | 'urls' | 'folder';
155 | url?: string;
156 | urls?: string[];
157 | folder?: string;
158 | count: number;
159 | metadata?: any;
160 | }
161 |
162 | export interface ApiDiscovery {
163 | name?: string;
164 | description?: string;
165 | url?: string;
166 | apis: Array<{
167 | name: string;
168 | baseURL?: string;
169 | version?: string;
170 | description?: string;
171 | properties?: Array<{
172 | type: string;
173 | url: string;
174 | }>;
175 | spec_url?: string; // Custom format support
176 | docs_url?: string; // Custom format support
177 | status?: string; // Custom format support
178 | tags?: string[]; // Custom format support
179 | }>;
180 | }
181 |
182 | /**
183 | * Main analyzer class for OpenAPI specifications
184 | */
185 | export class OpenAPIAnalyzer {
186 | private specs: OpenAPISpec[] = [];
187 | private loadedSources: LoadSource[] = [];
188 | private discoveryUrl?: string;
189 | private specUrls: string[] = [];
190 | private specsFolder?: string;
191 |
192 | constructor(options?: {
193 | discoveryUrl?: string;
194 | specUrls?: string[];
195 | specsFolder?: string;
196 | }) {
197 | // Use constructor options for testing, or fall back to environment variables
198 | this.discoveryUrl = options?.discoveryUrl || DISCOVERY_URL;
199 | this.specUrls = options?.specUrls || SPEC_URLS;
200 | this.specsFolder = options?.specsFolder || SPECS_FOLDER;
201 | }
202 |
203 | /**
204 | * Load specs from all configured sources: Discovery URL + Individual URLs + Local folder
205 | */
206 | async loadSpecs(): Promise<void> {
207 | this.specs = [];
208 | this.loadedSources = [];
209 | let totalLoaded = 0;
210 |
211 | // Source 1: Discovery URL (apis.json or custom registry)
212 | if (this.discoveryUrl) {
213 | const beforeCount = this.specs.length;
214 | await this.loadFromDiscoveryUrl();
215 | const discoveryCount = this.specs.length - beforeCount;
216 | if (discoveryCount > 0) {
217 | console.error(`✅ Loaded ${discoveryCount} specs from discovery URL`);
218 | totalLoaded += discoveryCount;
219 | }
220 | }
221 |
222 | // Source 2: Individual URLs
223 | if (this.specUrls.length > 0) {
224 | const beforeCount = this.specs.length;
225 | await this.loadFromUrls();
226 | const urlsCount = this.specs.length - beforeCount;
227 | if (urlsCount > 0) {
228 | console.error(`✅ Loaded ${urlsCount} specs from individual URLs`);
229 | totalLoaded += urlsCount;
230 | }
231 | }
232 |
233 | // Source 3: Local folder
234 | if (this.specsFolder) {
235 | const beforeCount = this.specs.length;
236 | await this.loadFromLocalFolder();
237 | const localCount = this.specs.length - beforeCount;
238 | if (localCount > 0) {
239 | console.error(`✅ Loaded ${localCount} specs from local folder`);
240 | totalLoaded += localCount;
241 | }
242 | }
243 |
244 | if (totalLoaded > 0) {
245 | console.error(`🎉 Total loaded: ${totalLoaded} OpenAPI specifications from ${this.loadedSources.length} sources`);
246 | } else {
247 | console.error('⚠️ Warning: No OpenAPI specifications were loaded from any source');
248 | }
249 | }
250 |
251 | /**
252 | * Load specs from discovery URL (apis.json format or custom)
253 | */
254 | private async loadFromDiscoveryUrl(): Promise<void> {
255 | try {
256 | console.error(`📡 Fetching API registry from ${this.discoveryUrl}`);
257 |
258 | const response = await fetch(this.discoveryUrl!);
259 | if (!response.ok) {
260 | throw new Error(`HTTP ${response.status}: ${response.statusText}`);
261 | }
262 |
263 | const registry: ApiDiscovery = await response.json();
264 |
265 | if (!registry.apis || !Array.isArray(registry.apis)) {
266 | throw new Error('Invalid registry format: missing apis array');
267 | }
268 |
269 | console.error(`📋 Found ${registry.apis.length} APIs in registry`);
270 |
271 | // Load each spec from the registry
272 | for (const apiInfo of registry.apis) {
273 | await this.loadSingleRemoteSpec(apiInfo);
274 | }
275 |
276 | this.loadedSources.push({
277 | type: 'discovery',
278 | url: this.discoveryUrl!,
279 | count: this.specs.length,
280 | metadata: {
281 | name: registry.name,
282 | description: registry.description,
283 | total_apis: registry.apis.length
284 | }
285 | });
286 |
287 | } catch (error: any) {
288 | console.error(`⚠️ Warning: Failed to load from discovery URL: ${error.message}`);
289 | console.error(` Falling back to individual URLs or local folder`);
290 | }
291 | }
292 |
293 | /**
294 | * Load specs from individual URLs
295 | */
296 | private async loadFromUrls(): Promise<void> {
297 | console.error(`📡 Loading from ${this.specUrls.length} individual URLs`);
298 |
299 | for (const url of this.specUrls) {
300 | await this.loadSingleRemoteSpec({ name: url.split('/').pop() || 'remote-spec', spec_url: url });
301 | }
302 |
303 | this.loadedSources.push({
304 | type: 'urls',
305 | urls: this.specUrls,
306 | count: this.specs.length
307 | });
308 | }
309 |
310 | /**
311 | * Load a single remote OpenAPI spec
312 | */
313 | private async loadSingleRemoteSpec(apiInfo: ApiDiscovery['apis'][0]): Promise<void> {
314 | try {
315 | // Determine spec URL - support both apis.json format and custom format
316 | let specUrl: string | undefined;
317 |
318 | if (apiInfo.spec_url) {
319 | // Custom format
320 | specUrl = apiInfo.spec_url;
321 | } else if (apiInfo.properties) {
322 | // apis.json format - look for Swagger/OpenAPI property
323 | const openApiProperty = apiInfo.properties.find(p =>
324 | p.type.toLowerCase() === 'swagger' ||
325 | p.type.toLowerCase() === 'openapi'
326 | );
327 | specUrl = openApiProperty?.url;
328 | }
329 |
330 | if (!specUrl) {
331 | console.error(`⚠️ Skipping ${apiInfo.name}: No OpenAPI spec URL found`);
332 | return;
333 | }
334 |
335 | const response = await fetch(specUrl);
336 | if (!response.ok) {
337 | throw new Error(`HTTP ${response.status}: ${response.statusText}`);
338 | }
339 |
340 | // Use SwaggerParser.parse with URL directly to let it handle the fetching and parsing
341 | const spec = await SwaggerParser.parse(specUrl) as Record<string, any>;
342 |
343 | this.specs.push({
344 | filename: apiInfo.name,
345 | spec,
346 | info: spec.info,
347 | paths: spec.paths,
348 | components: spec.components,
349 | source: {
350 | type: 'remote',
351 | url: specUrl,
352 | apiInfo
353 | }
354 | });
355 |
356 | const title = spec.info?.title || apiInfo.name;
357 | const version = spec.info?.version || apiInfo.version || 'unknown';
358 | console.error(` ✓ Loaded ${apiInfo.name} (${title} v${version})`);
359 |
360 | } catch (error: any) {
361 | console.error(`⚠️ Skipping ${apiInfo.name}: ${error.message}`);
362 | }
363 | }
364 |
365 | /**
366 | * Load specs from local folder (existing implementation)
367 | */
368 | private async loadFromLocalFolder(): Promise<void> {
369 | try {
370 | const validatedFolder = await this.validateLocalFolder();
371 | console.error(`📁 Loading from local folder: ${validatedFolder}`);
372 |
373 | const files = await fs.readdir(validatedFolder);
374 | const specFiles = files.filter(file =>
375 | file.endsWith('.json') ||
376 | file.endsWith('.yaml') ||
377 | file.endsWith('.yml')
378 | );
379 |
380 | if (specFiles.length === 0) {
381 | console.error(`⚠️ Warning: No OpenAPI specification files found in ${validatedFolder}`);
382 | return;
383 | }
384 |
385 | console.error(`📁 Found ${specFiles.length} OpenAPI specification files`);
386 |
387 | for (const file of specFiles) {
388 | await this.loadSingleLocalSpec(file, validatedFolder);
389 | }
390 |
391 | this.loadedSources.push({
392 | type: 'folder',
393 | folder: validatedFolder,
394 | count: this.specs.length
395 | });
396 |
397 | } catch (error: any) {
398 | console.error(`⚠️ Warning: Failed to load from local folder: ${error.message}`);
399 | }
400 | }
401 |
402 | /**
403 | * Validate local specs folder
404 | */
405 | private async validateLocalFolder(): Promise<string> {
406 | if (!this.specsFolder) {
407 | throw new Error('SPECS_FOLDER not configured');
408 | }
409 |
410 | try {
411 | const stats = await fs.stat(this.specsFolder);
412 |
413 | if (!stats.isDirectory()) {
414 | throw new Error(`OPENAPI_SPECS_FOLDER is not a directory: ${this.specsFolder}`);
415 | }
416 |
417 | await fs.access(this.specsFolder, fs.constants.R_OK);
418 |
419 | return this.specsFolder;
420 | } catch (error: any) {
421 | if (error.code === 'ENOENT') {
422 | throw new Error(`OPENAPI_SPECS_FOLDER does not exist: ${this.specsFolder}`);
423 | } else if (error.code === 'EACCES') {
424 | throw new Error(`No read permission for OPENAPI_SPECS_FOLDER: ${this.specsFolder}`);
425 | } else {
426 | throw error;
427 | }
428 | }
429 | }
430 |
431 | /**
432 | * Load a single OpenAPI specification file from local folder
433 | */
434 | private async loadSingleLocalSpec(filename: string, specsFolder: string): Promise<void> {
435 | try {
436 | const filePath = path.join(specsFolder, filename);
437 |
438 | let spec: Record<string, any>;
439 | try {
440 | // Use Swagger parser to handle JSON/YAML parsing and $ref resolution
441 | spec = await SwaggerParser.parse(filePath) as Record<string, any>;
442 | } catch (parseError) {
443 | const fileExt = path.extname(filename);
444 | console.error(`⚠️ Skipping ${filename}: Invalid ${fileExt.substring(1).toUpperCase()} format or malformed OpenAPI spec`);
445 | return;
446 | }
447 |
448 | // Swagger parser already validates that it's a valid OpenAPI spec
449 | // Additional validation is now handled by the parser
450 |
451 | // Additional validation for common issues
452 | if (!spec.info) {
453 | console.error(`⚠️ Warning: ${filename} missing 'info' section, but will be loaded`);
454 | }
455 |
456 | if (!spec.paths || Object.keys(spec.paths).length === 0) {
457 | console.error(`⚠️ Warning: ${filename} has no paths defined`);
458 | }
459 |
460 | this.specs.push({
461 | filename,
462 | spec,
463 | info: spec.info,
464 | paths: spec.paths,
465 | components: spec.components,
466 | source: {
467 | type: 'local'
468 | }
469 | });
470 |
471 | console.error(` ✓ Loaded ${filename} (${spec.info?.title || 'Untitled'} v${spec.info?.version || 'unknown'})`);
472 | } catch (error: any) {
473 | console.error(`❌ Error loading ${filename}: ${error.message}`);
474 | }
475 | }
476 |
477 | /**
478 | * Get a summary of all loaded OpenAPI specifications
479 | */
480 | listAllSpecs(): ApiSummary[] {
481 | return this.specs.map(spec => ({
482 | filename: spec.filename,
483 | title: spec.info?.title || 'No title',
484 | version: spec.info?.version || 'No version',
485 | description: spec.info?.description || 'No description',
486 | endpointCount: spec.paths ? Object.keys(spec.paths).length : 0
487 | }));
488 | }
489 |
490 | /**
491 | * Get information about loaded sources
492 | */
493 | getLoadedSources(): LoadSource[] {
494 | return this.loadedSources;
495 | }
496 |
497 | /**
498 | * Get the full OpenAPI specification by filename
499 | */
500 | getSpecByFilename(filename: string): Record<string, any> | null {
501 | const spec = this.specs.find(s => s.filename === filename);
502 | return spec ? spec.spec : null;
503 | }
504 |
505 | /**
506 | * Search for endpoints across all APIs by keyword
507 | */
508 | searchEndpoints(query: string): SearchResult[] {
509 | const results: SearchResult[] = [];
510 | const lowerQuery = query.toLowerCase();
511 |
512 | for (const spec of this.specs) {
513 | if (!spec.paths) continue;
514 |
515 | for (const [path, pathItem] of Object.entries(spec.paths)) {
516 | for (const [method, operation] of Object.entries(pathItem as Record<string, any>)) {
517 | if (typeof operation !== 'object' || operation === null) continue;
518 |
519 | const operationObj = operation as Record<string, any>;
520 | const searchableText = [
521 | path,
522 | method,
523 | operationObj.summary || '',
524 | operationObj.description || '',
525 | operationObj.operationId || ''
526 | ].join(' ').toLowerCase();
527 |
528 | if (searchableText.includes(lowerQuery)) {
529 | results.push({
530 | filename: spec.filename,
531 | api_title: spec.info?.title,
532 | path,
533 | method: method.toUpperCase(),
534 | summary: operationObj.summary,
535 | description: operationObj.description,
536 | operationId: operationObj.operationId
537 | });
538 | }
539 | }
540 | }
541 | }
542 |
543 | return results;
544 | }
545 |
546 | /**
547 | * Generate comprehensive statistics about all loaded APIs
548 | */
549 | getApiStats(): ApiStats {
550 | const stats: ApiStats = {
551 | totalApis: this.specs.length,
552 | totalEndpoints: 0,
553 | methodCounts: {},
554 | commonPaths: {},
555 | versions: {},
556 | apis: []
557 | };
558 |
559 | const httpMethods = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head'];
560 |
561 | for (const spec of this.specs) {
562 | let endpointCount = 0;
563 | const apiMethods = new Set<string>();
564 |
565 | if (spec.paths) {
566 | for (const [path, pathItem] of Object.entries(spec.paths)) {
567 | for (const method of Object.keys(pathItem as Record<string, any>)) {
568 | if (httpMethods.includes(method.toLowerCase())) {
569 | endpointCount++;
570 | const upperMethod = method.toUpperCase();
571 | apiMethods.add(upperMethod);
572 | stats.methodCounts[upperMethod] = (stats.methodCounts[upperMethod] || 0) + 1;
573 | }
574 | }
575 |
576 | // Track common path patterns
577 | const pathPattern = path.replace(/\{[^}]+\}/g, '{id}');
578 | stats.commonPaths[pathPattern] = (stats.commonPaths[pathPattern] || 0) + 1;
579 | }
580 | }
581 |
582 | stats.totalEndpoints += endpointCount;
583 |
584 | const version = spec.info?.version || 'unknown';
585 | stats.versions[version] = (stats.versions[version] || 0) + 1;
586 |
587 | stats.apis.push({
588 | filename: spec.filename,
589 | title: spec.info?.title,
590 | version: spec.info?.version,
591 | endpointCount,
592 | methods: Array.from(apiMethods).sort()
593 | });
594 | }
595 |
596 | return stats;
597 | }
598 |
599 | /**
600 | * Find inconsistencies in authentication schemes and naming conventions across APIs
601 | */
602 | findInconsistencies(): Inconsistency[] {
603 | const inconsistencies: Inconsistency[] = [];
604 |
605 | // Check authentication schemes
606 | const authSchemes = new Map<string, string[]>();
607 |
608 | for (const spec of this.specs) {
609 | if (spec.spec.components?.securitySchemes) {
610 | for (const [name, scheme] of Object.entries(spec.spec.components.securitySchemes as Record<string, any>)) {
611 | const schemeObj = scheme as Record<string, any>;
612 | const type = schemeObj.type;
613 |
614 | if (type) {
615 | if (!authSchemes.has(type)) {
616 | authSchemes.set(type, []);
617 | }
618 | authSchemes.get(type)!.push(`${spec.filename}: ${name}`);
619 | }
620 | }
621 | }
622 | }
623 |
624 | // Report auth inconsistencies
625 | if (authSchemes.size > 1) {
626 | inconsistencies.push({
627 | type: 'authentication',
628 | message: 'Multiple authentication schemes found across APIs',
629 | details: Object.fromEntries(authSchemes)
630 | });
631 | }
632 |
633 | return inconsistencies;
634 | }
635 |
636 | /**
637 | * Compare schemas with the same name across different APIs
638 | */
639 | compareSchemas(schema1Name: string, schema2Name: string): SchemaComparison[] {
640 | const schemas: SchemaComparison[] = [];
641 | const schemaNames = schema2Name ? [schema1Name, schema2Name] : [schema1Name];
642 |
643 | for (const spec of this.specs) {
644 | if (spec.components?.schemas) {
645 | for (const schemaName of schemaNames) {
646 | if (spec.components.schemas[schemaName]) {
647 | schemas.push({
648 | filename: spec.filename,
649 | api: spec.info?.title,
650 | schemaName,
651 | schema: spec.components.schemas[schemaName]
652 | });
653 | }
654 | }
655 | }
656 | }
657 |
658 | return schemas;
659 | }
660 | }
661 |
662 | // Initialize the analyzer
663 | const analyzer = new OpenAPIAnalyzer();
664 |
665 | // Create the MCP server
666 | const server = new Server(
667 | {
668 | name: 'openapi-analyzer',
669 | version: '1.0.0',
670 | },
671 | {
672 | capabilities: {
673 | tools: {},
674 | },
675 | }
676 | );
677 |
678 | /**
679 | * Define all available tools for the MCP server
680 | */
681 | const tools: Tool[] = [
682 | {
683 | name: 'load_specs',
684 | description: 'Load all OpenAPI specifications from the configured folder',
685 | inputSchema: {
686 | type: 'object',
687 | properties: {},
688 | },
689 | },
690 | {
691 | name: 'list_apis',
692 | description: 'List all loaded API specifications with basic info',
693 | inputSchema: {
694 | type: 'object',
695 | properties: {},
696 | },
697 | },
698 | {
699 | name: 'get_api_spec',
700 | description: 'Get the full OpenAPI spec for a specific file',
701 | inputSchema: {
702 | type: 'object',
703 | properties: {
704 | filename: {
705 | type: 'string',
706 | description: 'The filename of the OpenAPI spec',
707 | },
708 | },
709 | required: ['filename'],
710 | },
711 | },
712 | {
713 | name: 'search_endpoints',
714 | description: 'Search for endpoints across all APIs by keyword',
715 | inputSchema: {
716 | type: 'object',
717 | properties: {
718 | query: {
719 | type: 'string',
720 | description: 'Search term to find in paths, methods, summaries, or descriptions',
721 | },
722 | },
723 | required: ['query'],
724 | },
725 | },
726 | {
727 | name: 'get_api_stats',
728 | description: 'Get comprehensive statistics about all loaded APIs',
729 | inputSchema: {
730 | type: 'object',
731 | properties: {},
732 | },
733 | },
734 | {
735 | name: 'find_inconsistencies',
736 | description: 'Find naming conventions and other inconsistencies across APIs',
737 | inputSchema: {
738 | type: 'object',
739 | properties: {},
740 | },
741 | },
742 | {
743 | name: 'compare_schemas',
744 | description: 'Compare schemas with the same name across different APIs',
745 | inputSchema: {
746 | type: 'object',
747 | properties: {
748 | schema1: {
749 | type: 'string',
750 | description: 'First schema name to compare',
751 | },
752 | schema2: {
753 | type: 'string',
754 | description: 'Second schema name to compare (optional)',
755 | },
756 | },
757 | required: ['schema1'],
758 | },
759 | },
760 | {
761 | name: 'get_load_sources',
762 | description: 'Get information about where OpenAPI specs were loaded from (discovery URL, individual URLs, or local folder)',
763 | inputSchema: {
764 | type: 'object',
765 | properties: {},
766 | },
767 | },
768 | ];
769 |
770 | // List available tools
771 | server.setRequestHandler(ListToolsRequestSchema, async () => {
772 | return { tools };
773 | });
774 |
775 | // Handle tool calls
776 | server.setRequestHandler(CallToolRequestSchema, async (request) => {
777 | const { name, arguments: args } = request.params;
778 |
779 | if (!args) {
780 | return {
781 | content: [
782 | {
783 | type: 'text',
784 | text: 'Error: No arguments provided',
785 | },
786 | ],
787 | };
788 | }
789 |
790 | try {
791 | switch (name) {
792 | case 'load_specs':
793 | await analyzer.loadSpecs();
794 | return {
795 | content: [
796 | {
797 | type: 'text',
798 | text: `Successfully loaded ${analyzer['specs'].length} OpenAPI specifications`,
799 | },
800 | ],
801 | };
802 |
803 | case 'list_apis':
804 | const apiList = analyzer.listAllSpecs();
805 | return {
806 | content: [
807 | {
808 | type: 'text',
809 | text: JSON.stringify(apiList, null, 2),
810 | },
811 | ],
812 | };
813 |
814 | case 'get_api_spec':
815 | const filename = args.filename as string;
816 | if (!filename) {
817 | return {
818 | content: [
819 | {
820 | type: 'text',
821 | text: 'Error: filename parameter is required',
822 | },
823 | ],
824 | };
825 | }
826 | const spec = analyzer.getSpecByFilename(filename);
827 | if (!spec) {
828 | return {
829 | content: [
830 | {
831 | type: 'text',
832 | text: `API spec not found: ${filename}`,
833 | },
834 | ],
835 | };
836 | }
837 | return {
838 | content: [
839 | {
840 | type: 'text',
841 | text: JSON.stringify(spec, null, 2),
842 | },
843 | ],
844 | };
845 |
846 | case 'search_endpoints':
847 | const query = args.query as string;
848 | if (!query) {
849 | return {
850 | content: [
851 | {
852 | type: 'text',
853 | text: 'Error: query parameter is required',
854 | },
855 | ],
856 | };
857 | }
858 | const searchResults = analyzer.searchEndpoints(query);
859 | return {
860 | content: [
861 | {
862 | type: 'text',
863 | text: JSON.stringify(searchResults, null, 2),
864 | },
865 | ],
866 | };
867 |
868 | case 'get_api_stats':
869 | const stats = analyzer.getApiStats();
870 | return {
871 | content: [
872 | {
873 | type: 'text',
874 | text: JSON.stringify(stats, null, 2),
875 | },
876 | ],
877 | };
878 |
879 | case 'find_inconsistencies':
880 | const inconsistencies = analyzer.findInconsistencies();
881 | return {
882 | content: [
883 | {
884 | type: 'text',
885 | text: JSON.stringify(inconsistencies, null, 2),
886 | },
887 | ],
888 | };
889 |
890 | case 'compare_schemas':
891 | const schema1 = args.schema1 as string;
892 | const schema2 = (args.schema2 as string) || schema1;
893 | if (!schema1) {
894 | return {
895 | content: [
896 | {
897 | type: 'text',
898 | text: 'Error: schema1 parameter is required',
899 | },
900 | ],
901 | };
902 | }
903 | const comparison = analyzer.compareSchemas(schema1, schema2);
904 | return {
905 | content: [
906 | {
907 | type: 'text',
908 | text: JSON.stringify(comparison, null, 2),
909 | },
910 | ],
911 | };
912 |
913 | case 'get_load_sources':
914 | const sources = analyzer.getLoadedSources();
915 | return {
916 | content: [
917 | {
918 | type: 'text',
919 | text: JSON.stringify(sources, null, 2),
920 | },
921 | ],
922 | };
923 |
924 | default:
925 | throw new Error(`Unknown tool: ${name}`);
926 | }
927 | } catch (error) {
928 | return {
929 | content: [
930 | {
931 | type: 'text',
932 | text: `Error: ${error}`,
933 | },
934 | ],
935 | };
936 | }
937 | });
938 |
939 | // Start the server
940 | async function main() {
941 | try {
942 | // Validate configuration before starting the server
943 | await validateConfiguration();
944 |
945 | const transport = new StdioServerTransport();
946 | await server.connect(transport);
947 | console.error('🚀 OpenAPI Analyzer MCP Server running...');
948 | console.error('📖 Ready to analyze OpenAPI specifications!');
949 | } catch (error: any) {
950 | console.error(`❌ Failed to start server: ${error.message}`);
951 | process.exit(1);
952 | }
953 | }
954 |
955 | // Only run the server if this file is being executed directly (not imported)
956 | if (import.meta.url === `file://${process.argv[1]}` ||
957 | process.argv[1]?.includes('openapi-analyzer-mcp')) {
958 | main().catch((error: any) => {
959 | console.error(`❌ Unexpected error: ${error.message}`);
960 | process.exit(1);
961 | });
962 | }
```