Swagger Explorer MCP

import express, { Request, Response } from 'express'; import { chromium, Browser, Page } from 'playwright'; import yaml from 'yaml'; import { createServer, Server } from 'net'; async function findAvailablePort(startPort: number = 3000, endPort: number = 65535): Promise<number> { for (let port = startPort; port <= endPort; port++) { try { const server = createServer(); const available = await new Promise<boolean>((resolve, reject) => { server.once('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EADDRINUSE') { resolve(false); } else { reject(err); } }); server.once('listening', () => { server.close(); resolve(true); }); server.listen(port); }); if (available) { return port; } } catch (err) { continue; } } throw new Error('No available ports found'); } declare global { var mcp: SwaggerExplorerMCP; } interface SwaggerExplorerConfig { baseUrl?: string; authToken?: string; port?: number; } interface SwaggerOptions { paths?: boolean; schemas?: boolean; methodFilter?: string[]; } interface ResponseFormat { contentType: string; schema: any; example?: any; encoding?: any; } interface PathResponse { code: string; description: string; formats: ResponseFormat[]; } interface SchemaProperty { type?: string; format?: string; description?: string; required?: boolean; enum?: unknown[]; items?: { type?: string; $ref?: string; }; $ref?: string; } interface SchemaDetails { type: string; properties: Record<string, SchemaProperty>; required?: string[]; description?: string; example?: any; responses?: PathResponse[]; } class SwaggerExplorerMCP { private app; private browser!: Browser; private config: SwaggerExplorerConfig; private schemaCache: Map<string, any>; private port?: number; private server?: Server; constructor(config: SwaggerExplorerConfig = {}) { this.config = { ...config }; this.app = express(); this.schemaCache = new Map(); this.setupMiddleware(); this.setupRoutes(); } private setupMiddleware() { this.app.use(express.json()); // Authentication middleware const authMiddleware = (req: Request, res: Response, next: express.NextFunction) => { const authHeader = req.headers.authorization; if (!this.config.authToken) { return next(); } if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'Missing or invalid authorization header' }); } const token = authHeader.split(' ')[1]; if (token !== this.config.authToken) { return res.status(401).json({ error: 'Invalid token' }); } next(); }; // Apply auth middleware to all routes except health this.app.use((req, res, next) => { if (req.path === '/health') { return next(); } authMiddleware(req, res, next); }); } private setupRoutes() { const basePath = this.config.baseUrl || ''; // Custom response format handler const formatResponse = (data: unknown, format?: string) => { switch (format?.toLowerCase()) { case 'minimal': return { status: 'success', data: this.minimizeResponse(data) }; case 'detailed': return { status: 'success', timestamp: new Date().toISOString(), data, metadata: { version: '1.0', format: 'detailed' } }; default: return data; } }; // Health check endpoint this.app.get('/health', (req, res) => { res.json({ status: 'healthy', port: this.port, baseUrl: this.config.baseUrl }); }); // Main API to explore Swagger this.app.post(`${basePath}/api/explore`, async (req, res) => { try { const { url, options = {} } = req.body; if (!url) { return res.status(400).json({ error: 'URL is required' }); } const page = await this.browser.newPage(); try { const swaggerData = await this.getSwaggerData(url, page); const result = await this.processSwaggerData(swaggerData, options as SwaggerOptions); res.json(formatResponse(result, req.body.format)); } finally { await page.close(); } } catch (error) { console.error('Error exploring Swagger:', error); res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }); } }); // Get response schemas for a path this.app.post(`${basePath}/api/response-schemas`, async (req, res) => { try { const { url, path, method, format } = req.body; if (!url || !path || !method) { return res.status(400).json({ error: 'URL, path, and method are required' }); } const page = await this.browser.newPage(); try { const swaggerData = await this.getSwaggerData(url, page); const responses = await this.extractResponseSchemas(swaggerData, path, method); res.json(formatResponse(responses, format)); } finally { await page.close(); } } catch (error) { console.error('Error getting response schemas:', error); res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }); } }); } private minimizeResponse(data: unknown): unknown { if (Array.isArray(data)) { return data.map(item => this.minimizeResponse(item)); } if (typeof data === 'object' && data !== null) { const minimized: Record<string, unknown> = {}; for (const [key, value] of Object.entries(data)) { if (value !== null && value !== undefined && value !== '') { minimized[key] = this.minimizeResponse(value); } } return minimized; } return data; } private async processSwaggerData(swaggerData: any, options: SwaggerOptions) { const result: any = {}; if (options.paths) { result.paths = Object.entries(swaggerData.paths || {}) .filter(([_, methods]) => { if (!options.methodFilter?.length) return true; const methodKeys = Object.keys(methods as object); return methodKeys.some(method => options.methodFilter?.includes(method.toLowerCase()) ?? false); }) .map(([path, methods]) => ({ path, methods: Object.keys(methods as object) })); } if (options.schemas) { const schemas = swaggerData.components?.schemas || swaggerData.definitions || {}; result.schemas = Object.keys(schemas); } return result; } private async extractResponseSchemas(swaggerData: any, path: string, method: string): Promise<PathResponse[]> { const responses = swaggerData.paths[path]?.[method]?.responses || {}; return Object.entries(responses).map(([code, response]: [string, any]) => ({ code, description: response.description || '', formats: Object.entries(response.content || {}).map(([contentType, content]: [string, any]) => ({ contentType, schema: content.schema, example: content.example, encoding: content.encoding })) })); } async start(): Promise<number> { try { this.browser = await chromium.launch(); // Find an available port if none is specified if (!this.port) { this.port = await findAvailablePort(); } return new Promise((resolve, reject) => { const server = this.app.listen(this.port) .once('listening', () => { console.log(`Swagger Explorer MCP running on port ${this.port}`); if (this.config.baseUrl) { console.log(`Base URL: ${this.config.baseUrl}`); } if (this.config.authToken) { console.log('Authentication enabled'); } resolve(this.port!); }) .once('error', async (err: NodeJS.ErrnoException) => { if (err.code === 'EADDRINUSE') { console.log(`Port ${this.port} in use, trying another port...`); this.port = undefined; // Reset port to try again try { const newPort = await this.start(); resolve(newPort); } catch (retryError) { reject(retryError); } } else { console.error('Failed to start MCP:', err); reject(err); } }); // Store server reference for proper shutdown this.server = server; }); } catch (error) { console.error('Failed to start MCP:', error); throw error; } } async stop() { if (this.browser) { await this.browser.close(); } if (this.server) { await new Promise<void>((resolve) => { this.server!.close(() => resolve()); }); } process.exit(0); } private async getSwaggerData(url: string, page: Page): Promise<any> { let swaggerData = null; // Listen for swagger spec in network requests const responsePromise = new Promise((resolve) => { page.on('response', async (response) => { const responseUrl = response.url(); if (responseUrl.includes('swagger') || responseUrl.includes('openapi')) { try { const data = await response.json(); resolve(data); } catch (e) { const text = await response.text(); if (text.includes('openapi:') || text.includes('swagger:')) { resolve(yaml.parse(text)); } } } }); }); await page.goto(url, { waitUntil: 'networkidle' }); // Try network response first swaggerData = await Promise.race([ responsePromise, new Promise(resolve => setTimeout(resolve, 5000)) ]); // Fallback to window object if needed if (!swaggerData) { swaggerData = await page.evaluate(() => { // @ts-ignore return window.ui?.spec?.json; }); } if (!swaggerData) { throw new Error('Could not find Swagger/OpenAPI specification'); } return swaggerData; } } // Handle graceful shutdown process.on('SIGTERM', async () => { console.log('Received SIGTERM. Shutting down gracefully...'); if (global.mcp) { await global.mcp.stop(); } }); process.on('SIGINT', async () => { console.log('Received SIGINT. Shutting down gracefully...'); if (global.mcp) { await global.mcp.stop(); } }); export { SwaggerExplorerMCP };