Skip to main content
Glama

MCP OpenAPI Server

by ReAPI-com
SpecService.ts14.4 kB
import fs from "fs/promises"; import Fuse from "fuse.js"; import { OpenAPIV3 } from "openapi-types"; import path from "path"; import { SimpleCache } from "./Cache"; import { ConsoleLogger, Logger } from "./Logger"; import { ISpecScanner } from "./interfaces/ISpecScanner"; import { ISpecStore, ISpecExplorer, SpecServiceConfig, SpecCatalogEntry, LoadSchemaResult, LoadOperationResult, IResultSerializer, SpecUri, SpecOperationEntry, SpecSchemaEntry } from "./interfaces/ISpecService"; /** * Custom error class for spec service related errors */ export class SpecServiceError extends Error { constructor( message: string, public readonly code: | "INIT_ERROR" | "PERSIST_ERROR" | "LOAD_ERROR" | "SCAN_ERROR", public readonly cause?: Error ) { super(message); this.name = "SpecServiceError"; } } export class FileSystemSpecService implements ISpecStore, ISpecExplorer { private readonly config: Required<SpecServiceConfig>; private readonly logger: Logger; private readonly specCache: SimpleCache<string, OpenAPIV3.Document>; private readonly folderPath: string; private readonly catalogPath: string; private readonly dereferencedPath: string; private specs: { [specId: string]: OpenAPIV3.Document } = {}; private catalog: SpecCatalogEntry[] = []; constructor( private readonly scanner: ISpecScanner, config: SpecServiceConfig, logger?: Logger ) { this.config = { basePath: config.basePath, catalogDir: config.catalogDir || "_catalog", dereferencedDir: config.dereferencedDir || "_dereferenced", retryAttempts: config.retryAttempts || 3, retryDelay: config.retryDelay || 1000, cache: { maxSize: config.cache?.maxSize || 500, ttl: config.cache?.ttl || 60 * 60 * 1000, // 1 hour }, }; this.logger = logger || new ConsoleLogger(); this.folderPath = this.config.basePath; this.catalogPath = path.join(this.folderPath, this.config.catalogDir); this.dereferencedPath = path.join( this.folderPath, this.config.dereferencedDir ); this.specCache = new SimpleCache<string, OpenAPIV3.Document>({ maxSize: this.config.cache.maxSize, ttl: this.config.cache.ttl, }); } private async ensureDirectory(dir: string) { try { await fs.mkdir(dir, { recursive: true }); } catch (error) { throw new SpecServiceError( `Failed to create directory ${dir}`, "INIT_ERROR", error instanceof Error ? error : new Error(String(error)) ); } } private async ensureDirectories() { this.logger.debug("Ensuring required directories exist"); await Promise.all([ this.ensureDirectory(this.folderPath), this.ensureDirectory(this.catalogPath), this.ensureDirectory(this.dereferencedPath), ]); } private resetState() { this.logger.debug("Resetting service state"); this.catalog = []; this.specs = {}; this.specCache.clear(); } private async loadExistingCatalog(): Promise<boolean> { try { this.logger.debug("Loading existing catalog"); this.catalog = await this.loadSpecCatalog(); const specs = await Promise.all( this.catalog.map((spec) => this.loadSpec(spec.uri.specId)) ); specs.forEach((spec, index) => { const specId = this.catalog[index].uri.specId; this.specs[specId] = spec; this.specCache.set(specId, spec); }); this.logger.info("Successfully loaded existing catalog"); return true; } catch (error) { this.logger.warn({ error }, "Failed to load existing catalog"); this.resetState(); return false; } } public async initialize() { this.logger.debug("Initializing FileSystemSpecService"); try { await this.ensureDirectories(); await this.loadExistingCatalog(); await this.scanAndSave(this.folderPath); this.logger.info("Successfully initialized FileSystemSpecService"); } catch (error) { this.logger.error( { error }, "Failed to initialize FileSystemSpecService" ); this.resetState(); throw new SpecServiceError( "Failed to initialize FileSystemSpecService", "INIT_ERROR", error instanceof Error ? error : new Error(String(error)) ); } } async scanAndSave(folderPath: string): Promise<void> { this.logger.debug({ folderPath }, "Starting scan and persist operation"); const tempCatalog: SpecCatalogEntry[] = []; const pendingOperations: Array<{ spec: OpenAPIV3.Document; entry: SpecCatalogEntry; }> = []; try { for await (const scanResult of this.scanner.scan(folderPath)) { const { filename, spec, specId, error } = scanResult; if (error) { this.logger.warn({ filename, error }, "Error scanning file"); continue; } try { const operations: SpecOperationEntry[] = []; const schemas: SpecSchemaEntry[] = []; // Extract operations for (const path in spec.paths) { const pathItem = spec.paths[path]; for (const method in pathItem) { if (method === "parameters" || method === "$ref") continue; const operation = pathItem[method] as OpenAPIV3.OperationObject; operations.push({ path, method, description: operation.description, operationId: operation.operationId, }); } } // Extract schemas if (spec.components?.schemas) { for (const [name, schema] of Object.entries( spec.components.schemas )) { schemas.push({ name, description: (schema as OpenAPIV3.SchemaObject).description, }); } } const entry: SpecCatalogEntry = { uri: { specId, type: "specification", identifier: specId, }, description: spec.info.description, operations, schemas, }; pendingOperations.push({ spec, entry }); } catch (error) { this.logger.warn( { filename, error }, "Error processing specification" ); } } // Atomic operation: persist all specs and update catalog await this.ensureDirectories(); await Promise.all( pendingOperations.map(async ({ spec, entry }) => { await this.saveSpec(spec, entry.uri.specId); tempCatalog.push(entry); }) ); await this.saveSpecCatalog(tempCatalog); this.catalog = tempCatalog; this.logger.info("Successfully completed scan and persist operation"); } catch (error) { this.logger.error({ error }, "Failed to scan and persist specifications"); throw new SpecServiceError( "Failed to scan and persist specifications", "SCAN_ERROR", error instanceof Error ? error : new Error(String(error)) ); } } async getApiCatalog(): Promise<SpecCatalogEntry[]> { return this.catalog; } async saveSpec(spec: OpenAPIV3.Document, specId: string): Promise<void> { this.logger.debug({ specId }, "Persisting specification"); try { await this.ensureDirectory(this.dereferencedPath); const specPath = path.join(this.dereferencedPath, `${specId}.json`); await fs.writeFile(specPath, JSON.stringify(spec, null, 2)); this.specs[specId] = spec; this.specCache.set(specId, spec); this.logger.info({ specId }, "Successfully persisted specification"); } catch (error) { this.logger.error({ specId, error }, "Failed to persist specification"); throw new SpecServiceError( `Failed to persist specification ${specId}`, "PERSIST_ERROR", error instanceof Error ? error : new Error(String(error)) ); } } async saveSpecCatalog(catalog: SpecCatalogEntry[]): Promise<void> { if (!this.catalogPath) { throw new Error("FileSystemSpecService not initialized"); } const catalogPath = path.join(this.catalogPath, "catalog.json"); await fs.writeFile(catalogPath, JSON.stringify(catalog, null, 2)); } async loadSpecCatalog(): Promise<SpecCatalogEntry[]> { try { const catalogPath = path.join(this.catalogPath, "catalog.json"); const catalog = await fs.readFile(catalogPath, "utf-8"); return JSON.parse(catalog); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return []; } throw error; } } async loadSpec(specId: string): Promise<OpenAPIV3.Document> { this.logger.debug({ specId }, "Loading specification"); // Check cache first const cached = this.specCache.get(specId); if (cached) { this.logger.debug({ specId }, "Returning cached specification"); return cached; } try { const specPath = path.join(this.dereferencedPath, `${specId}.json`); const spec = JSON.parse(await fs.readFile(specPath, "utf-8")); // Cache the loaded spec this.specCache.set(specId, spec); this.logger.info({ specId }, "Successfully loaded specification"); return spec; } catch (error) { this.logger.error({ specId, error }, "Failed to load specification"); if ((error as NodeJS.ErrnoException).code === "ENOENT") { throw new SpecServiceError( `Specification not found: ${specId}`, "LOAD_ERROR" ); } throw new SpecServiceError( `Failed to load specification ${specId}`, "LOAD_ERROR", error instanceof Error ? error : new Error(String(error)) ); } } public async refresh(): Promise<void> { if (!this.folderPath) { throw new Error("FileSystemSpecService not initialized"); } await this.initialize(); } async searchOperations( query: string, specId?: string ): Promise<LoadOperationResult[]> { const targetSpecs: SpecCatalogEntry[] = []; if (specId) { const spec = this.catalog.find((spec) => spec.uri.specId === specId); if (spec) { targetSpecs.push(spec); } } else { targetSpecs.push(...this.catalog); } const results: LoadOperationResult[] = []; for (const spec of targetSpecs) { const specDoc = this.specs[spec.uri.specId]; if (!specDoc?.paths) continue; for (const path in specDoc.paths) { const pathItem = specDoc.paths[path]; if (!pathItem) continue; for (const method in pathItem) { if (method === "parameters" || method === "$ref") continue; const operation = pathItem[method] as OpenAPIV3.OperationObject; if (!operation) continue; // Search in operationId, summary, description, and tags const searchText = [ operation.operationId, operation.summary, operation.description, ...(operation.tags || []), ] .filter(Boolean) .join(" ") .toLowerCase(); if (searchText.includes(query.toLowerCase())) { results.push({ path, method, operation, specId: spec.uri.specId, uri: `apis://${spec.uri.specId}/operations/${operation.operationId}`, }); } } } } return results; } async searchSchemas( query: string, specId?: string ): Promise<SpecSchemaEntry[]> { const targetSpecs: SpecCatalogEntry[] = []; if (specId) { const spec = this.catalog.find((spec) => spec.uri.specId === specId); if (spec) { targetSpecs.push(spec); } } else { targetSpecs.push(...this.catalog); } const schemaEntries: SpecSchemaEntry[] = []; for (const spec of targetSpecs) { schemaEntries.push( ...spec.schemas.map((schema) => ({ ...schema, specId: spec.uri.specId, })) ); } const fuse = new Fuse(schemaEntries, { includeScore: true, threshold: 0.2, keys: ["name", "description"], }); const results = fuse.search(query); return results.map((result) => result.item); } async findSchemaByName( specId: string, schemaName: string ): Promise<LoadSchemaResult | null> { const spec = this.specs[specId]; if (!spec) { return null; } const schema = spec.components?.schemas?.[schemaName]; if (!schema) { return null; } // all references must have been dereferenced return { name: schemaName, description: schema["description"], schema: schema as OpenAPIV3.SchemaObject, uri: `apis://${specId}/schemas/${schemaName}`, }; } async findOperationById( specId: string, operationId: string ): Promise<LoadOperationResult | null> { const spec = this.specs[specId]; if (!spec) { return null; } for (const path in spec.paths) { const pathItem = spec.paths[path]; for (const method in pathItem) { if (pathItem[method]["operationId"] === operationId) { return { path, method, operation: pathItem[method], specId, uri: `apis://${specId}/operations/${operationId}`, }; } } } return null; } async findOperationByPathAndMethod( specId: string, path: string, method: string ): Promise<LoadOperationResult | null> { const spec = this.specs[specId]; if (!spec) { return null; } const pathItem = spec.paths[path]; if (!pathItem) { return null; } const operation = pathItem[method]; if (!operation) { return null; } return { path, method, operation, specId, uri: `apis://${specId}/operations/${operation.operationId}`, }; } } /** * Persist OpenAPI specifications in a LanceDB * Use semantic search to query specifications * TODO: implement */ // export abstract class LanceDbSpecService // implements ISpecPersister, ISpecExplorer // { // constructor() {} // }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ReAPI-com/mcp-openapi'

If you have feedback or need assistance with the MCP directory API, please join our Discord server