Skip to main content
Glama

OpenAPI MCP Server

openapi-loader.ts29.8 kB
import { OpenAPIV3 } from "openapi-types" import { readFile } from "fs/promises" import { Tool } from "@modelcontextprotocol/sdk/types.js" import yaml from "js-yaml" import crypto from "crypto" import { REVISED_COMMON_WORDS_TO_REMOVE, WORD_ABBREVIATIONS } from "./utils/abbreviations.js" import { generateToolId } from "./utils/tool-id.js" /** * Extended Tool interface with metadata for filtering * This extends the standard MCP Tool interface with additional properties * that are computed during tool creation to optimize filtering operations */ export interface ExtendedTool extends Tool { /** OpenAPI tags associated with this tool's operation */ tags?: string[] /** HTTP method for this tool (GET, POST, etc.) */ httpMethod?: string /** Primary resource name extracted from the path */ resourceName?: string /** Original OpenAPI path before toolId conversion */ originalPath?: string } /** * Spec input method type */ export type SpecInputMethod = "url" | "file" | "stdin" | "inline" /** * Class to load and parse OpenAPI specifications */ export class OpenAPISpecLoader { /** * Disable name optimization */ private disableAbbreviation: boolean constructor(config?: { disableAbbreviation?: boolean }) { this.disableAbbreviation = config?.disableAbbreviation ?? false } /** * Load an OpenAPI specification from various sources */ async loadOpenAPISpec( specPathOrUrl: string, inputMethod: SpecInputMethod = "url", inlineContent?: string, ): Promise<OpenAPIV3.Document> { let specContent: string try { switch (inputMethod) { case "url": specContent = await this.loadFromUrl(specPathOrUrl) break case "file": specContent = await this.loadFromFile(specPathOrUrl) break case "stdin": specContent = await this.loadFromStdin() break case "inline": if (!inlineContent) { throw new Error("Inline content is required when using 'inline' input method") } specContent = inlineContent break default: throw new Error(`Unsupported input method: ${inputMethod}`) } } catch (error) { if (error instanceof Error) { throw new Error(`Failed to load OpenAPI spec from ${inputMethod}: ${error.message}`) } throw error } return this.parseSpecContent(specContent, inputMethod) } /** * Load spec content from URL */ private async loadFromUrl(url: string): Promise<string> { const response = await fetch(url) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } return await response.text() } /** * Load spec content from local file */ private async loadFromFile(filePath: string): Promise<string> { return await readFile(filePath, "utf-8") } /** * Load spec content from standard input */ private async loadFromStdin(): Promise<string> { return new Promise((resolve, reject) => { let data = "" // Set stdin to read mode process.stdin.setEncoding("utf8") // Handle data chunks process.stdin.on("data", (chunk) => { data += chunk }) // Handle end of input process.stdin.on("end", () => { if (data.trim().length === 0) { reject(new Error("No data received from stdin")) } else { resolve(data) } }) // Handle errors process.stdin.on("error", (error) => { reject(new Error(`Error reading from stdin: ${error.message}`)) }) // Resume stdin to start reading process.stdin.resume() }) } /** * Parse spec content as JSON or YAML */ private parseSpecContent(specContent: string, source: string): OpenAPIV3.Document { if (!specContent || specContent.trim().length === 0) { throw new Error(`Empty or invalid spec content from ${source}`) } // Attempt to parse as JSON, then YAML if JSON parsing fails try { return JSON.parse(specContent) as OpenAPIV3.Document } catch (jsonError) { try { const yamlResult = yaml.load(specContent) as OpenAPIV3.Document if (!yamlResult || typeof yamlResult !== "object") { throw new Error("YAML parsing resulted in invalid object") } return yamlResult } catch (yamlError) { throw new Error( `Failed to parse as JSON or YAML. JSON error: ${ (jsonError as Error).message }. YAML error: ${(yamlError as Error).message}`, ) } } } /** * Inline `$ref` schemas from components and drop recursive cycles */ private inlineSchema( schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, components: Record<string, OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject> | undefined, visited: Set<string>, ): OpenAPIV3.SchemaObject { // Handle reference objects if ("$ref" in schema && typeof schema.$ref === "string") { const ref = schema.$ref const match = ref.match(/^#\/components\/schemas\/(.+)$/) if (match && components) { const name = match[1] if (visited.has(name)) { return {} as OpenAPIV3.SchemaObject } const comp = components[name] if (!comp) { return {} as OpenAPIV3.SchemaObject } visited.add(name) return this.inlineSchema(comp, components, visited) } // External references or malformed refs - return empty schema return {} as OpenAPIV3.SchemaObject } // We know it's a SchemaObject now since ReferenceObject only has $ref const schemaObj = schema as OpenAPIV3.SchemaObject // Handle schema composition keywords if (schemaObj.allOf) { // For allOf, merge all schemas into a single object schema const mergedSchema: OpenAPIV3.SchemaObject = { type: "object", properties: {}, required: [], } for (const subSchema of schemaObj.allOf) { const inlinedSubSchema = this.inlineSchema( subSchema as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, components, new Set(visited), ) // Merge properties if (inlinedSubSchema.properties) { mergedSchema.properties = { ...mergedSchema.properties, ...inlinedSubSchema.properties, } } // Merge required arrays if (inlinedSubSchema.required) { mergedSchema.required = [...(mergedSchema.required || []), ...inlinedSubSchema.required] } // Copy other properties from the first schema that has them for (const [key, value] of Object.entries(inlinedSubSchema)) { if (key !== "properties" && key !== "required" && !(key in mergedSchema)) { ;(mergedSchema as any)[key] = value } } } // Remove empty required array if (mergedSchema.required && mergedSchema.required.length === 0) { delete mergedSchema.required } return mergedSchema } if (schemaObj.oneOf) { // For oneOf, preserve the composition but inline nested schemas const inlinedOneOf = schemaObj.oneOf.map((subSchema) => this.inlineSchema( subSchema as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, components, new Set(visited), ), ) return { ...schemaObj, oneOf: inlinedOneOf, } } if (schemaObj.anyOf) { // For anyOf, preserve the composition but inline nested schemas const inlinedAnyOf = schemaObj.anyOf.map((subSchema) => this.inlineSchema( subSchema as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, components, new Set(visited), ), ) return { ...schemaObj, anyOf: inlinedAnyOf, } } if (schemaObj.not) { // For not, inline the nested schema const inlinedNot = this.inlineSchema( schemaObj.not as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, components, new Set(visited), ) return { ...schemaObj, not: inlinedNot, } } // Inline object schemas if (schemaObj.type === "object" && schemaObj.properties) { const newProps: Record<string, OpenAPIV3.SchemaObject> = {} for (const [propName, propSchema] of Object.entries(schemaObj.properties)) { newProps[propName] = this.inlineSchema( propSchema as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, components, new Set(visited), ) } return { ...schemaObj, properties: newProps } } // Inline array schemas if (schemaObj.type === "array" && schemaObj.items) { const inlinedItems = this.inlineSchema( schemaObj.items as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, components, new Set(visited), ) return { ...schemaObj, items: inlinedItems } } // For primitive types or schemas without nested references, return as-is return schemaObj } /** * Determine the appropriate JSON Schema type for a parameter * @param paramSchema The OpenAPI schema object after inlining * @param paramName The name of the parameter (for logging purposes) * @returns The determined type string, or undefined if the type should be omitted */ private determineParameterType( paramSchema: OpenAPIV3.SchemaObject, paramName: string, ): string | undefined { // Handle empty schema (potentially from cycle removal in inlineSchema) if (Object.keys(paramSchema).length === 0 && typeof paramSchema !== "boolean") { console.warn( `Parameter '${paramName}' schema was empty after inlining (potential cycle or unresolvable ref), defaulting to string.`, ) return "string" } // Handle boolean schema (true/false for allowing any/no value) if (typeof paramSchema === "boolean") { return "boolean" // Or alternate convention if needed } // Use explicit type if available if (paramSchema.type) { return paramSchema.type } // Determine if schema has structural elements that imply a type const hasProperties = "properties" in paramSchema && paramSchema.properties && Object.keys(paramSchema.properties).length > 0 const hasItems = paramSchema.type === "array" && !!(paramSchema as OpenAPIV3.ArraySchemaObject).items const hasComposition = ("allOf" in paramSchema && paramSchema.allOf && paramSchema.allOf.length > 0) || ("anyOf" in paramSchema && paramSchema.anyOf && paramSchema.anyOf.length > 0) || ("oneOf" in paramSchema && paramSchema.oneOf && paramSchema.oneOf.length > 0) // If no structural elements, default to string if (!hasProperties && !hasItems && !hasComposition) { return "string" } // For complex schemas with structural elements but no explicit type, // return undefined to omit the type field. // JSON Schema validators can infer type from structure: // - 'object' if properties exist // - 'array' if items exist // This behavior should be documented for consumers of the API return undefined } /** * Extract the primary resource name from an OpenAPI path * Examples: * - "/users" -> "users" * - "/users/{id}" -> "users" * - "/api/v1/users/{id}/posts" -> "posts" * - "/health" -> "health" */ private extractResourceName(path: string): string | undefined { // Remove leading slash and split by slash const segments = path.replace(/^\//, "").split("/") // Find the last segment that doesn't contain parameter placeholders for (let i = segments.length - 1; i >= 0; i--) { const segment = segments[i] // Skip parameter placeholders like {id}, {userId}, etc. if (!segment.includes("{") && !segment.includes("}") && segment.length > 0) { return segment } } // If no non-parameter segment found, return the first segment return segments[0] || undefined } /** * Parse an OpenAPI specification into a map of tools */ parseOpenAPISpec(spec: OpenAPIV3.Document): Map<string, Tool> { const tools = new Map<string, Tool>() // Convert each OpenAPI path to an MCP tool for (const [path, pathItem] of Object.entries(spec.paths)) { if (!pathItem) continue // Extract path-level parameters for inheritance const pathLevelParameters: OpenAPIV3.ParameterObject[] = [] if (pathItem.parameters) { for (const param of pathItem.parameters) { let paramObj: OpenAPIV3.ParameterObject | undefined // Handle parameter references if ("$ref" in param && typeof param.$ref === "string") { const refMatch = param.$ref.match(/^#\/components\/parameters\/(.+)$/) if (refMatch && spec.components?.parameters) { const paramNameFromRef = refMatch[1] const resolvedParam = spec.components.parameters[paramNameFromRef] if (resolvedParam && "name" in resolvedParam && "in" in resolvedParam) { paramObj = resolvedParam as OpenAPIV3.ParameterObject } else { console.warn( `Could not resolve path-level parameter reference or invalid structure: ${param.$ref}`, ) continue } } else { console.warn(`Could not parse path-level parameter reference: ${param.$ref}`) continue } } else if ("name" in param && "in" in param) { paramObj = param as OpenAPIV3.ParameterObject } else { console.warn( "Skipping path-level parameter due to missing 'name' or 'in' properties and not being a valid $ref:", param, ) continue } if (paramObj) { pathLevelParameters.push(paramObj) } } } for (const [method, operation] of Object.entries(pathItem)) { if (method === "parameters" || !operation) continue // Skip invalid HTTP methods if ( !["get", "post", "put", "patch", "delete", "options", "head"].includes( method.toLowerCase(), ) ) { console.warn(`Skipping non-HTTP method "${method}" for path ${path}`) continue } const op = operation as OpenAPIV3.OperationObject const toolId = generateToolId(method, path) const nameSource = op.operationId || op.summary || `${method.toUpperCase()} ${path}` const name = this.abbreviateOperationId(nameSource) const tool: ExtendedTool = { name, description: op.description || `Make a ${method.toUpperCase()} request to ${path}`, inputSchema: { type: "object", properties: {}, }, // Add metadata for filtering tags: op.tags || [], httpMethod: method.toUpperCase(), resourceName: this.extractResourceName(path), originalPath: path, } // Store the original path for API client use (backward compatibility) ;(tool as any)["x-original-path"] = path // Gather all required property names const requiredParams: string[] = [] // Create a map to track parameters by name+in for override logic const parameterMap = new Map<string, OpenAPIV3.ParameterObject>() // First, add path-level parameters for (const pathParam of pathLevelParameters) { const key = `${pathParam.name}::${pathParam.in}` parameterMap.set(key, pathParam) } // Then, add operation-level parameters (these can override path-level ones) if (op.parameters) { for (const param of op.parameters) { let paramObj: OpenAPIV3.ParameterObject | undefined // Handle parameter references if ("$ref" in param && typeof param.$ref === "string") { const refMatch = param.$ref.match(/^#\/components\/parameters\/(.+)$/) if (refMatch && spec.components?.parameters) { const paramNameFromRef = refMatch[1] const resolvedParam = spec.components.parameters[paramNameFromRef] if (resolvedParam && "name" in resolvedParam && "in" in resolvedParam) { paramObj = resolvedParam as OpenAPIV3.ParameterObject } else { console.warn( `Could not resolve parameter reference or invalid structure: ${param.$ref}`, ) continue } } else { console.warn(`Could not parse parameter reference: ${param.$ref}`) continue } } else if ("name" in param && "in" in param) { paramObj = param as OpenAPIV3.ParameterObject } else { console.warn( "Skipping parameter due to missing 'name' or 'in' properties and not being a valid $ref:", param, ) continue } if (paramObj) { const key = `${paramObj.name}::${paramObj.in}` parameterMap.set(key, paramObj) // This will override path-level parameters } } } // Process all parameters (path-level + operation-level, with operation-level taking precedence) for (const paramObj of parameterMap.values()) { if (paramObj.schema) { // Get the fully inlined schema with all nested references resolved const paramSchema = this.inlineSchema( paramObj.schema as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, spec.components?.schemas, new Set<string>(), ) // Create the parameter definition const paramDef: any = { description: paramObj.description || `${paramObj.name} parameter`, "x-parameter-location": paramObj.in, // Store parameter location (path, query, etc.) } // Determine and set the appropriate type const paramType = this.determineParameterType(paramSchema, paramObj.name) if (paramType !== undefined) { paramDef.type = paramType } // Copy all other relevant properties from the inlined schema to paramDef // Avoid overwriting already set 'description' or 'type' unless schema has them. if (typeof paramSchema === "object" && paramSchema !== null) { for (const [key, value] of Object.entries(paramSchema)) { if (key === "description" && paramDef.description) continue // Keep existing if already set if (key === "type" && paramDef.type) continue // Keep existing if already set paramDef[key] = value } } // Add the schema to the tool's input schema properties tool.inputSchema.properties![paramObj.name] = paramDef if (paramObj.required === true) { requiredParams.push(paramObj.name) } } } // Merge requestBody schema into inputSchema if (op.requestBody && "content" in op.requestBody) { const requestBodyObj = op.requestBody as OpenAPIV3.RequestBodyObject // Handle different content types let mediaTypeObj: OpenAPIV3.MediaTypeObject | undefined if (requestBodyObj.content["application/json"]) { mediaTypeObj = requestBodyObj.content["application/json"] } else if (Object.keys(requestBodyObj.content).length > 0) { // Take the first available content type const firstContentType = Object.keys(requestBodyObj.content)[0] mediaTypeObj = requestBodyObj.content[firstContentType] } if (mediaTypeObj?.schema) { // Handle schema inlining with proper types const inlinedSchema = this.inlineSchema( mediaTypeObj.schema as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, spec.components?.schemas, new Set<string>(), ) // Handle different schema types appropriately if (inlinedSchema.oneOf || inlinedSchema.anyOf) { // For oneOf/anyOf schemas, preserve them at the top level of inputSchema // We need to merge with existing properties if any const existingProperties = tool.inputSchema.properties || {} const hasExistingProperties = Object.keys(existingProperties).length > 0 if (hasExistingProperties) { // If we already have properties from parameters, we need to create an allOf // that combines the existing object schema with the oneOf/anyOf const objectSchema: any = { type: "object", properties: existingProperties, } if (tool.inputSchema.required) { objectSchema.required = tool.inputSchema.required } tool.inputSchema = { allOf: [objectSchema, inlinedSchema], } as any } else { // No existing properties, so we can use the oneOf/anyOf directly tool.inputSchema = inlinedSchema as any } } else if (inlinedSchema.type === "object" && inlinedSchema.properties) { // Handle object properties for (const [propName, propSchema] of Object.entries(inlinedSchema.properties)) { const paramName = tool.inputSchema.properties![propName] ? `body_${propName}` : propName tool.inputSchema.properties![paramName] = propSchema as OpenAPIV3.SchemaObject if (inlinedSchema.required && inlinedSchema.required.includes(propName)) { requiredParams.push(paramName) } } } else { // Use body wrapper for other non-object schemas (primitives, arrays, etc.) tool.inputSchema.properties!["body"] = inlinedSchema requiredParams.push("body") } } } // Only add the required array if there are required properties if (requiredParams.length > 0) { tool.inputSchema.required = requiredParams } tools.set(toolId, tool) } } return tools } // Helper function to generate a simple hash private generateShortHash(input: string, length: number = 4): string { return crypto.createHash("sha256").update(input).digest("hex").substring(0, length) } // Helper to split by underscore, camelCase, and numbers, then filter out empty strings private splitCombined(input: string): string[] { // Split by underscore first const underscoreParts = input.split("_") let combinedParts: string[] = [] underscoreParts.forEach((part) => { // Add space before uppercase letters (camelCase) and before numbers const spacedPart = part .replace(/([A-Z]+)/g, " $1") // Handles sequences of uppercase like "MYID" .replace(/([A-Z][a-z])/g, " $1") // Handles regular camelCase like "MyIdentifier" .replace(/([a-z])([0-9])/g, "$1 $2") // Handles case like "word123" .replace(/([0-9])([A-Za-z])/g, "$1 $2") // Handles case like "123word" const splitParts = spacedPart.split(" ").filter((p) => p.length > 0) combinedParts = combinedParts.concat(splitParts) }) return combinedParts.map((p) => p.trim()).filter((p) => p.length > 0) } // Helper to split only by underscore and camelCase, but not by numbers // Used when abbreviation is disabled to preserve number-letter combinations like "web3" private splitCombinedWithoutNumbers(input: string): string[] { // Split by underscore first const underscoreParts = input.split("_") let combinedParts: string[] = [] underscoreParts.forEach((part) => { // Add space before uppercase letters (camelCase) but NOT before/after numbers const spacedPart = part .replace(/([A-Z]+)/g, " $1") // Handles sequences of uppercase like "MYID" .replace(/([A-Z][a-z])/g, " $1") // Handles regular camelCase like "MyIdentifier" const splitParts = spacedPart.split(" ").filter((p) => p.length > 0) combinedParts = combinedParts.concat(splitParts) }) return combinedParts.map((p) => p.trim()).filter((p) => p.length > 0) } private _initialSanitizeAndValidate( originalId: string, maxLength: number, ): { currentName: string; originalWasLong: boolean; errorName?: string } { if (!originalId || originalId.trim().length === 0) return { currentName: "", originalWasLong: false, errorName: "unnamed-tool" } const originalWasLong = originalId.length > maxLength let currentName = originalId.replace(/[^a-zA-Z0-9_]/g, "-") currentName = currentName.replace(/-+/g, "-").replace(/^-+|-+$/g, "") if (currentName.length === 0) return { currentName: "", originalWasLong, errorName: "tool-" + this.generateShortHash(originalId, 8), } return { currentName, originalWasLong } } private _performSemanticAbbreviation(name: string): string { let parts = this.splitCombined(name) parts = parts.filter((part) => { const cleanPartForCheck = part.toLowerCase().replace(/-+$/, "") return !REVISED_COMMON_WORDS_TO_REMOVE.includes(cleanPartForCheck) }) parts = parts.map((part) => { const lowerPart = part.toLowerCase() if (WORD_ABBREVIATIONS[lowerPart]) { const abbr = WORD_ABBREVIATIONS[lowerPart] if ( part.length > 0 && part[0] === part[0].toUpperCase() && part.slice(1) === part.slice(1).toLowerCase() ) { return abbr[0].toUpperCase() + abbr.substring(1).toLowerCase() } else if (part === part.toUpperCase() && part.length > 1 && abbr.length > 1) { return abbr.toUpperCase() } else if (part.length > 0 && part[0] === part[0].toUpperCase()) { return abbr[0].toUpperCase() + abbr.substring(1).toLowerCase() } return abbr.toLowerCase() } return part }) return parts.join("-") } private _applyVowelRemovalIfOverLength(name: string, maxLength: number): string { let currentName = name if (currentName.length > maxLength) { const currentParts = currentName.split("-") const newParts = currentParts.map((part) => { const isAbbreviation = Object.values(WORD_ABBREVIATIONS).some( (abbr) => abbr.toLowerCase() === part.toLowerCase(), ) if (part.length > 5 && !isAbbreviation) { const newPart = part[0] + part.substring(1).replace(/[aeiouAEIOU]/g, "") if (newPart.length < part.length && newPart.length > 1) return newPart } return part }) currentName = newParts.join("-") } return currentName } private _truncateAndApplyHashIfNeeded( name: string, originalId: string, originalWasLong: boolean, maxLength: number, ): string { let currentName = name currentName = currentName.replace(/-+/g, "-").replace(/^-+|-+$/g, "") // Consolidate hyphens before length check for hashing const needsHash = originalWasLong || currentName.length > maxLength if (needsHash) { const hash = this.generateShortHash(originalId, 4) const maxLengthForBase = maxLength - hash.length - 1 if (currentName.length > maxLengthForBase) { currentName = currentName.substring(0, maxLengthForBase) currentName = currentName.replace(/-+$/, "") } currentName = currentName + "-" + hash } return currentName } private _finalizeNameFormatting(name: string, originalId: string, maxLength: number): string { let finalName = name.toLowerCase() finalName = finalName .replace(/[^a-z0-9-]/g, "-") .replace(/-+/g, "-") .replace(/^-+|-+$/g, "") if (finalName.length > maxLength) { finalName = finalName.substring(0, maxLength) finalName = finalName.replace(/-+$/, "") } if (finalName.length === 0) { return "tool-" + this.generateShortHash(originalId, 8) } return finalName } public abbreviateOperationId(originalId: string, maxLength: number = 64): string { maxLength = this.disableAbbreviation ? Number.MAX_SAFE_INTEGER : maxLength const { currentName: sanitizedName, originalWasLong, errorName, } = this._initialSanitizeAndValidate(originalId, maxLength) if (errorName) return errorName let processedName if (this.disableAbbreviation) { // When abbreviation is disabled, split combined words but preserve number-letter combinations processedName = this.splitCombinedWithoutNumbers(sanitizedName).join("-") } else { processedName = this._performSemanticAbbreviation(sanitizedName) processedName = this._applyVowelRemovalIfOverLength(processedName, maxLength) } processedName = this._truncateAndApplyHashIfNeeded( processedName, originalId, originalWasLong, maxLength, ) processedName = this._finalizeNameFormatting(processedName, originalId, maxLength) return processedName } }

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/ivo-toby/mcp-openapi-server'

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