Skip to main content
Glama
spec.ts9.48 kB
/** * OpenAPI specification loader with caching and operation indexing. * * Fetches OpenAPI specs from hosted URLs, caches them locally, * and provides search/lookup functionality for operations. */ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { getCacheDir, getSpecUrl } from "./config.js"; export type ApiType = "v1" | "internal"; export type EnvType = "prod" | "staging"; export interface OpenApiSpec { openapi: string; info: { title: string; version: string; description?: string; }; paths: Record<string, PathItem>; components?: { schemas?: Record<string, SchemaObject>; securitySchemes?: Record<string, unknown>; }; tags?: Array<{ name: string; description?: string }>; } export interface PathItem { get?: Operation; post?: Operation; put?: Operation; patch?: Operation; delete?: Operation; parameters?: Parameter[]; } export interface Operation { operationId?: string; summary?: string; description?: string; tags?: string[]; parameters?: Parameter[]; requestBody?: RequestBody; responses?: Record<string, Response>; security?: Array<Record<string, string[]>>; } export interface Parameter { name: string; in: "path" | "query" | "header" | "cookie"; required?: boolean; schema?: SchemaObject; description?: string; } export interface RequestBody { required?: boolean; description?: string; content?: Record<string, { schema?: SchemaObject }>; } export interface Response { description: string; content?: Record<string, { schema?: SchemaObject }>; } export interface SchemaObject { type?: string; format?: string; properties?: Record<string, SchemaObject>; items?: SchemaObject; required?: string[]; enum?: unknown[]; description?: string; nullable?: boolean; additionalProperties?: boolean | SchemaObject; $ref?: string; } export interface OperationIndex { operationId: string; method: string; path: string; summary?: string; description?: string; tags: string[]; } // In-memory cache of loaded specs const specCache: Map<string, OpenApiSpec> = new Map(); const operationIndexCache: Map<string, OperationIndex[]> = new Map(); /** * Get the cache file path for a spec. */ function getCacheFilePath(api: ApiType, env: EnvType): string { return join(getCacheDir(), `${env}-${api}-openapi.json`); } /** * Fetch OpenAPI spec from URL. */ async function fetchSpec(url: string): Promise<OpenApiSpec> { const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch spec from ${url}: ${response.status}`); } return (await response.json()) as OpenApiSpec; } /** * Load spec from local cache file. */ function loadFromCache(api: ApiType, env: EnvType): OpenApiSpec | null { const cachePath = getCacheFilePath(api, env); if (!existsSync(cachePath)) { return null; } try { const content = readFileSync(cachePath, "utf-8"); return JSON.parse(content) as OpenApiSpec; } catch { return null; } } /** * Save spec to local cache file. */ function saveToCache(api: ApiType, env: EnvType, spec: OpenApiSpec): void { const cachePath = getCacheFilePath(api, env); try { writeFileSync(cachePath, JSON.stringify(spec, null, 2)); } catch (error) { console.error(`Failed to cache spec to ${cachePath}:`, error); } } /** * Build operation index from spec. */ function buildOperationIndex(spec: OpenApiSpec): OperationIndex[] { const operations: OperationIndex[] = []; const methods = ["get", "post", "put", "patch", "delete"] as const; for (const [path, pathItem] of Object.entries(spec.paths)) { for (const method of methods) { const operation = pathItem[method]; if (operation?.operationId) { operations.push({ operationId: operation.operationId, method: method.toUpperCase(), path, summary: operation.summary, description: operation.description, tags: operation.tags ?? [], }); } } } return operations; } /** * Sync (fetch and cache) an OpenAPI spec. * * @param api - Which API to sync ("v1" or "internal") * @param env - Environment ("prod" or "staging") * @param source - Where to load from ("url", "cache", or "repo") * @returns The loaded spec and operation count */ export async function syncSpec( api: ApiType, env: EnvType = "prod", source: "url" | "cache" = "url" ): Promise<{ spec: OpenApiSpec; operationCount: number; fromCache: boolean }> { const cacheKey = `${env}-${api}`; // Try to load from URL first (if requested) if (source === "url") { try { const url = getSpecUrl(api, env); const spec = await fetchSpec(url); // Cache it saveToCache(api, env, spec); specCache.set(cacheKey, spec); // Build operation index const operations = buildOperationIndex(spec); operationIndexCache.set(cacheKey, operations); return { spec, operationCount: operations.length, fromCache: false }; } catch (error) { console.error(`Failed to fetch spec, falling back to cache:`, error); // Fall through to cache } } // Try to load from cache const cachedSpec = loadFromCache(api, env); if (cachedSpec) { specCache.set(cacheKey, cachedSpec); const operations = buildOperationIndex(cachedSpec); operationIndexCache.set(cacheKey, operations); return { spec: cachedSpec, operationCount: operations.length, fromCache: true }; } throw new Error( `No spec available for ${api} (${env}). ` + `Run spec.sync with source="url" first.` ); } /** * Get a loaded spec from memory. */ export function getSpec(api: ApiType, env: EnvType = "prod"): OpenApiSpec | null { return specCache.get(`${env}-${api}`) ?? null; } /** * Get operations from the index. */ export function getOperations( api: ApiType, env: EnvType = "prod" ): OperationIndex[] { return operationIndexCache.get(`${env}-${api}`) ?? []; } /** * Search operations by keyword. * * Matches against operationId, summary, description, path, and tags. */ export function searchOperations( api: ApiType, query: string, env: EnvType = "prod", topK: number = 10 ): OperationIndex[] { const operations = getOperations(api, env); const queryLower = query.toLowerCase(); const queryWords = queryLower.split(/\s+/); // Score each operation const scored = operations.map((op) => { let score = 0; const searchText = [ op.operationId, op.summary, op.description, op.path, ...op.tags, ] .filter(Boolean) .join(" ") .toLowerCase(); // Exact operationId match if (op.operationId?.toLowerCase() === queryLower) { score += 100; } // OperationId contains query if (op.operationId?.toLowerCase().includes(queryLower)) { score += 50; } // Summary contains query if (op.summary?.toLowerCase().includes(queryLower)) { score += 30; } // Word matches for (const word of queryWords) { if (searchText.includes(word)) { score += 10; } } // Tag exact match for (const tag of op.tags) { if (tag.toLowerCase() === queryLower) { score += 40; } } return { op, score }; }); // Sort by score and return top K return scored .filter((s) => s.score > 0) .sort((a, b) => b.score - a.score) .slice(0, topK) .map((s) => s.op); } /** * Get operation by operationId. */ export function getOperationById( api: ApiType, operationId: string, env: EnvType = "prod" ): OperationIndex | null { const operations = getOperations(api, env); return operations.find((op) => op.operationId === operationId) ?? null; } /** * Get full operation details (including parameters and request body schema). */ export function describeOperation( api: ApiType, operationId: string, env: EnvType = "prod" ): { operation: OperationIndex; parameters: Parameter[]; requestBodySchema?: SchemaObject; responseSchema?: SchemaObject; } | null { const spec = getSpec(api, env); const opIndex = getOperationById(api, operationId, env); if (!spec || !opIndex) { return null; } const pathItem = spec.paths[opIndex.path]; const method = opIndex.method.toLowerCase() as keyof PathItem; const operation = pathItem[method] as Operation | undefined; if (!operation) { return null; } // Combine path-level and operation-level parameters const parameters: Parameter[] = [ ...(pathItem.parameters ?? []), ...(operation.parameters ?? []), ]; // Get request body schema let requestBodySchema: SchemaObject | undefined; if (operation.requestBody?.content) { const jsonContent = operation.requestBody.content["application/json"]; requestBodySchema = jsonContent?.schema; } // Get response schema (200 or 201) let responseSchema: SchemaObject | undefined; const successResponse = operation.responses?.["200"] ?? operation.responses?.["201"]; if (successResponse?.content) { const jsonContent = successResponse.content["application/json"]; responseSchema = jsonContent?.schema; } return { operation: opIndex, parameters, requestBodySchema, responseSchema, }; } /** * Clear all cached specs (useful for testing). */ export function clearSpecCache(): void { specCache.clear(); operationIndexCache.clear(); }

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/OnboardedInc/onboarded-mcp'

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