Skip to main content
Glama
openapi.ts11.6 kB
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpServer, ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { CallToolResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; import { compileErrors } from '@readme/openapi-parser'; import type { LogLayer } from 'loglayer'; import Oas from 'oas'; import type { OASDocument } from 'oas/types'; import OASNormalize from 'oas-normalize'; import type { z } from 'zod'; import { OperationClient } from './client.ts'; import type { PathOperation } from './client.ts'; import { log } from './log.ts'; import type { App } from './main.ts'; import { CustomExtensions } from './operation/custom-extensions.ts'; import type { CustomExtensionsInterface } from './operation/custom-extensions.ts'; import { McpifyOperation } from './operation/ext.ts'; import { getParameters } from './parameter-mapper.ts'; import { HttpVerb } from './safety.ts'; export interface OpenApiSpecOptions { app: App; baseUrl?: string; } export class OpenApiSpec { static async load(path: string, options: OpenApiSpecOptions): Promise<OpenApiSpec> { const { spec } = await parseSpecPath(path); return new OpenApiSpec(spec, options); } static async parse(spec: object, options: OpenApiSpecOptions): Promise<OpenApiSpec> { const { spec: oasSpec } = await parseSpec(spec); return new OpenApiSpec(oasSpec, options); } /** * Create a new OpenApiSpec instance from a parsed OpenAPI document. This is * asynchronous because it dereferences any references in the document. */ static from(spec: Oas, options: OpenApiSpecOptions): OpenApiSpec { return new OpenApiSpec(spec, options); } readonly #spec: Oas; readonly #options: OpenApiSpecOptions; private constructor(spec: Oas, options: OpenApiSpecOptions) { this.#spec = spec; this.#options = options; } get #app(): App { return this.#options.app; } get #log(): LogLayer { return this.#app.log; } get spec(): Oas { return this.#spec; } #operation(method: string, operation: PathOperation): OperationClient | null { const verb = HttpVerb.from(method); if (!verb) return null; const extensions = normalizeExtensions(this.#spec.getExtension('x-mcpify', operation)); return OperationClient.tool(this.#app, McpifyOperation.from(operation, extensions, this.#app)); } get #tools(): OperationClient<CallToolResult>[] { const paths = this.#spec.getPaths(); if (Object.keys(paths).length === 0) { this.#log.warn('No paths found in the OpenAPI specification'); } return Object.values(paths) .flatMap((path) => Object.entries(path).map(([method, operation]) => this.#operation(method, operation)), ) .filter(Boolean); } createResources(server: McpServer): void { const resources = this.#tools.filter((client) => client.op.isResource); for (const client of resources) { const path = client.op.path; if (/[{]/.exec(path)) { const uriTemplate = new ResourceTemplate(`${this.#spec.url()}${path}`, { list: undefined, }); this.#log.debug( `Converting ${client.op.describe()} → ${client.op.verb.describe()} resource "${client.op.id}"`, ); server.resource(client.op.id, uriTemplate, async (_, args): Promise<ReadResourceResult> => { return client.toResource().invoke(args); }); } else { this.#log.debug( `Converting ${client.op.describe()} → ${client.op.verb.describe()} resource "${client.op.id}"`, ); server.resource( client.op.id, `${this.#spec.url()}${path}`, async (_, args): Promise<ReadResourceResult> => { return client.toResource().invoke(args); }, ); } } } createTools(server: McpServer): void { let endpointCount = 0; const tools = this.#tools.filter((client) => !client.op.ignoredWhen({ type: 'tool' })); for (const client of tools) { endpointCount++; this.#log.debug( `Converting ${client.op.describe()} → ${client.op.verb.describe()} tool "${client.op.id}"`, ); const action: ToolCallback<z.ZodRawShape> = async ( args: z.objectOutputType<z.ZodRawShape, z.ZodTypeAny>, ): Promise<CallToolResult> => { this.#log.info(`Request from ${client.op.describe()}:`, JSON.stringify(args, null, 2)); return client.invoke(args); }; // Extract parameter schemas with full type information const parameterSchemas = getParameters(client.op.inner, this.#app); // Debug: Log the parameters being registered this.#log.debug( `Registering tool '${client.op.id}' with parameters`, JSON.stringify(parameterSchemas), ); if (parameterSchemas) { // Create tool with proper MCP SDK annotations server.tool( client.op.id, client.op.description, parameterSchemas, client.op.verb.hints, action, ); } else { server.tool(client.op.id, client.op.description, client.op.verb.hints, action); } } this.#log.info(`Created ${endpointCount} MCP tools from OpenAPI specification`); } } /** * Interface for a normalizer that can validate, dereference, and bundle an OpenAPI spec */ export interface SpecNormalizer { validate(): Promise<{ valid: boolean; errors?: unknown[] }>; convert(): Promise<OASDocument>; dereference(): Promise<OASDocument>; bundle(): Promise<OASDocument>; } /** * Dependencies needed by the parseSpec function, abstracted to support testing */ export interface ParseSpecDependencies { createNormalizer: (specPath: string | object) => SpecNormalizer; createOas: (doc: unknown) => Oas; compileErrors: (validation: unknown) => string; logger: { error: (message: string) => void }; } // Define default dependencies that use the actual implementation const defaultDependencies: ParseSpecDependencies = { createNormalizer: (specPath: string | object) => new OASNormalize(specPath) as SpecNormalizer, createOas: (doc: unknown) => new Oas(doc as OASDocument), // Type assertion is unavoidable when adapting between the generic interface and the specific implementation compileErrors: (validation: unknown) => { if (!validation || typeof validation !== 'object') { return `Invalid validation result: ${String(validation)}`; } // At runtime, we expect validation from OASNormalize which should match what compileErrors expects // We need to bypass TypeScript's type checking here // eslint-disable-next-line @typescript-eslint/no-explicit-any return compileErrors(validation as any); }, logger: log, }; function createNormalizer(value: unknown): OASNormalize { const normalizer = new OASNormalize(value, { parser: { validate: { errors: { colorize: true } } }, }); return normalizer; } /** * Parse the OpenAPI specification */ async function parseSpecPath( specPath: string, deps: ParseSpecDependencies = defaultDependencies, ): Promise<{ spec: Oas }> { try { const normalizer = createNormalizer(specPath); const doc = normalizer.convert(); const validation = await createNormalizer(doc).validate(); if (!validation.valid) { const msg = deps.compileErrors(validation); console.error(validation); throw new Error(msg); } const dereffed = await createNormalizer(doc).dereference(); const bundled = await createNormalizer(dereffed).bundle(); const spec = deps.createOas(bundled); return { spec }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); deps.logger.error(`Failed to parse OpenAPI spec: ${errorMessage}`); throw error; } } /** * Parse the OpenAPI specification with full reference resolution */ export async function parseSpec( spec: object, dependencies: Partial<ParseSpecDependencies> = defaultDependencies, ): Promise<{ spec: Oas }> { const deps = { ...defaultDependencies, ...dependencies, }; try { const normalizer = deps.createNormalizer(spec); const validation = await normalizer.validate(); if (!validation.valid) { const msg = deps.compileErrors(validation); throw new Error(msg); } // Convert the spec to an OAS document const doc = await normalizer.convert(); // Create a new normalizer from the converted document const processedNormalizer = deps.createNormalizer(doc); // First dereference to resolve all $refs await processedNormalizer.dereference(); // Then bundle to ensure proper structure const bundled = await processedNormalizer.bundle(); // Create the final OAS spec from the fully processed document const oasSpec = deps.createOas(bundled); return { spec: oasSpec }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); deps.logger.error(`Failed to parse OpenAPI spec: ${errorMessage}`); throw error; } } export function normalizeExtensions(extensions: unknown): CustomExtensions { if (typeof extensions !== 'object' || extensions === null) { return CustomExtensions.of({}); } // Handle boolean case where x-mcpify: false or x-mcpify: true if (typeof extensions === 'boolean' && extensions === false) { return CustomExtensions.of({ ignore: true }); } const result: CustomExtensionsInterface = {}; for (const [key, value] of Object.entries(extensions) as [string, unknown][]) { switch (key) { case 'operationId': if (typeof value === 'string') { result.operationId = value; } break; case 'ignore': // Handle both boolean and string values with explicit type checks if (value === true) { result.ignore = true; } else if (value === 'resource') { result.ignore = 'resource'; } else if (value === 'tool') { result.ignore = 'tool'; } break; case 'annotations': if (typeof value === 'object' && value !== null) { // Use a safer type casting approach // First check if it's a record with string keys // Then create a properly typed object const annotations: Record<string, unknown> = {}; // Only copy properties that exist for (const [k, v] of Object.entries(value)) { if (Object.prototype.hasOwnProperty.call(value, k)) { annotations[k] = v; } } // Process safety annotations if ('readOnlyHint' in annotations || 'destructiveHint' in annotations) { // Use type-safe access with bracket notation const isReadOnly = annotations['readOnlyHint'] === true; const isDestructive = annotations['destructiveHint'] === true; const isIdempotent = annotations['idempotentHint'] === true; // Structure the safety object according to ChangeSafety type if (isReadOnly) { result.safety = { access: 'readonly' }; } else if (isDestructive) { result.safety = { access: 'delete' }; } else { result.safety = { access: 'update', idempotent: isIdempotent }; } } } break; case 'description': if (typeof value === 'string') { result.description = value; } break; } } return CustomExtensions.of(result); }

Latest Blog Posts

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/wycats/mcpify'

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