/** * The OpenAPI document and the tools derived from it. * * Loaded from the application the server talks to, and — for a long-running * deployment — reloaded now and then, so that deploying a new endpoint to the * application makes it a tool here without also redeploying this. */ import { readFile } from 'node:fs/promises'; import { parse } from 'yaml'; import { buildTools } from './tools.mjs'; export class Catalogue { /** * @param {object} options * @param {string} options.source Path or URL of openapi.yaml. * @param {string} options.baseUrl Application root the document's server URL is relative to. * @param {number} [options.ttlMs] How long a loaded document is trusted. Infinity: load once. * @param {boolean} [options.localFiles] Offer uploads from a local path; see `buildTools`. */ constructor({ source, baseUrl, ttlMs = Infinity, localFiles = false }) { this.source = source; this.baseUrl = baseUrl.replace(/\/+$/, ''); this.ttlMs = ttlMs; this.localFiles = localFiles; this.current = null; this.loadedAt = 0; this.pending = null; } /** @returns {Promise<{spec: object, tools: import('./tools.mjs').Tool[], apiBase: string}>} */ async get() { if (this.current && Date.now() - this.loadedAt < this.ttlMs) return this.current; this.pending ??= this.load().finally(() => { this.pending = null; }); try { return await this.pending; } catch (error) { // An application that is redeploying, or briefly down, should not take // the tools with it: the last document that loaded is still the best // description available. Only the very first load has nothing to fall // back on. if (this.current) { console.error(`landing-mcp: keeping the previous OpenAPI document: ${error.message}`); this.loadedAt = Date.now(); return this.current; } throw error; } } async load() { const spec = parse(await this.read()); // The document's own server URL ("/api"), relative to the application root. const serverUrl = spec.servers?.[0]?.url ?? '/api'; this.current = { spec, tools: buildTools(spec, { localFiles: this.localFiles }), apiBase: /^https?:\/\//.test(serverUrl) ? serverUrl : `${this.baseUrl}${serverUrl}`, }; this.loadedAt = Date.now(); return this.current; } async read() { if (!/^https?:\/\//.test(this.source)) return readFile(this.source, 'utf8'); const response = await fetch(this.source, { signal: AbortSignal.timeout(15_000) }); if (!response.ok) throw new Error(`GET ${this.source} answered ${response.status}`); return response.text(); } }