#!/usr/bin/env node import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath, pathToFileURL } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, '..', '..'); const OVERRIDES_DIR = path.resolve(repoRoot, 'ee/docs/api-registry'); // Dual-edition: generate one registry per edition from its OpenAPI spec. // - CE registry feeds the CE `meta/mcp-registry` endpoint + local connector. // - EE registry additionally powers the in-app chat assistant. // Set MCP_REGISTRY_EDITION=ce|ee to generate just one. const TARGETS = [ { edition: 'ce', specPath: path.resolve(repoRoot, 'sdk/docs/openapi/alga-openapi.ce.json'), outputPath: path.resolve(repoRoot, 'server/src/lib/mcp/registry.generated.ts'), importPath: '@alga-psa/agent-tooling/registry/schema', }, { edition: 'ee', specPath: path.resolve(repoRoot, 'sdk/docs/openapi/alga-openapi.ee.json'), outputPath: path.resolve(repoRoot, 'ee/server/src/chat/registry/apiRegistry.generated.ts'), importPath: './apiRegistry.schema', }, ]; const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete']; const PLACEHOLDER_DESCRIPTION = 'This operation was generated automatically from the route inventory. Replace with canonical OpenAPI metadata.'; function singularize(segment) { if (segment.endsWith('ies')) { return `${segment.slice(0, -3)}y`; } if (segment.endsWith('ses')) { return segment.slice(0, -2); } if (segment.endsWith('s') && !segment.endsWith('ss')) { return segment.slice(0, -1); } return segment; } function humanizeSegment(segment) { return segment .replace(/[_-]+/g, ' ') .trim() .split(/\s+/) .filter(Boolean) .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); } function deriveResourceSegments(pathName) { const rawSegments = pathName.split('/').filter(Boolean); const apiIndex = rawSegments.findIndex((segment) => segment === 'api'); const segments = apiIndex === -1 ? rawSegments : rawSegments.slice(apiIndex + 1); if (segments[0] && /^v\d+$/i.test(segments[0])) { segments.shift(); } return segments; } function deriveFallbackNames(method, pathName) { const segments = deriveResourceSegments(pathName); const nonParamSegments = segments.filter((segment) => !segment.startsWith('{')); const pathParams = segments.filter((segment) => segment.startsWith('{') && segment.endsWith('}')); const primary = nonParamSegments[0] ?? 'resource'; const primaryLabel = humanizeSegment(singularize(primary)); const trailing = nonParamSegments.slice(1).map((segment) => humanizeSegment(singularize(segment))); const tailLabel = trailing.join(' '); if (method === 'get' && pathParams.length === 1 && nonParamSegments.length === 1) { return { displayName: `Get ${primaryLabel}`, summary: `Get ${primaryLabel.toLowerCase()} by ID`, }; } if (method === 'put' && pathParams.length === 1 && nonParamSegments.length === 1) { return { displayName: `Update ${primaryLabel}`, summary: `Update ${primaryLabel.toLowerCase()} by ID`, }; } if (method === 'patch' && pathParams.length === 1 && nonParamSegments.length === 1) { return { displayName: `Update ${primaryLabel}`, summary: `Update ${primaryLabel.toLowerCase()} fields by ID`, }; } if (method === 'delete' && pathParams.length === 1 && nonParamSegments.length === 1) { return { displayName: `Delete ${primaryLabel}`, summary: `Delete ${primaryLabel.toLowerCase()} by ID`, }; } if (method === 'get' && pathParams.length > 0 && tailLabel) { return { displayName: `List ${primaryLabel} ${tailLabel}`, summary: `List ${tailLabel.toLowerCase()} for a ${primaryLabel.toLowerCase()}`, }; } return null; } function inferPathParameters(pathName, existingParameters) { const existingPathParamNames = new Set( (existingParameters ?? []) .filter((param) => param.in === 'path') .map((param) => param.name), ); const inferred = []; for (const match of pathName.matchAll(/\{([^}]+)\}/g)) { const name = match[1]; if (!name || existingPathParamNames.has(name)) { continue; } inferred.push({ name, in: 'path', required: true, description: name === 'id' ? 'Resource identifier.' : `${humanizeSegment(name)} path parameter.`, schema: { type: 'string', ...(name === 'id' ? { format: 'uuid' } : {}), }, }); } return inferred; } function loadSpec(specPath) { if (!fs.existsSync(specPath)) { throw new Error(`OpenAPI spec not found at ${specPath}. Run npm run openapi:generate first.`); } const content = fs.readFileSync(specPath, 'utf-8'); return JSON.parse(content); } function resolveRef(spec, maybeRef) { if (!maybeRef || typeof maybeRef !== 'object' || !('$ref' in maybeRef)) { return maybeRef; } const ref = maybeRef.$ref; if (typeof ref !== 'string' || !ref.startsWith('#/')) { throw new Error(`Unsupported $ref format: ${ref}`); } const parts = ref.slice(2).split('/'); let current = spec; for (const part of parts) { current = current?.[part]; if (current === undefined) { throw new Error(`Failed to resolve $ref: ${ref}`); } } return current; } function cloneSchema(schema) { if (schema === undefined) return undefined; return JSON.parse(JSON.stringify(schema)); } function collectParameters(spec, pathItem, operation) { const params = [ ...(Array.isArray(pathItem?.parameters) ? pathItem.parameters : []), ...(Array.isArray(operation?.parameters) ? operation.parameters : []), ]; const seen = new Set(); const collected = []; for (const paramRef of params) { if (!paramRef) continue; const param = resolveRef(spec, paramRef); if (!param || typeof param !== 'object') continue; if ('in' in param === false || 'name' in param === false) continue; if (param.in !== 'query' && param.in !== 'path' && param.in !== 'header') continue; const key = `${param.in}:${param.name}`; if (seen.has(key)) continue; seen.add(key); collected.push({ name: param.name, in: param.in, required: Boolean(param.required), description: param.description, schema: cloneSchema(resolveRef(spec, param.schema ?? {})), }); } return collected; } function extractRequestBody(spec, operation) { if (!operation || !operation.requestBody) return { schema: undefined, example: undefined }; const requestBody = resolveRef(spec, operation.requestBody); if (!requestBody || typeof requestBody !== 'object' || !requestBody.content) { return { schema: undefined, example: undefined }; } const jsonContent = requestBody.content['application/json']; if (!jsonContent) return { schema: undefined, example: undefined }; const schema = jsonContent.schema ? resolveRef(spec, jsonContent.schema) : undefined; const example = jsonContent.example ?? Object.values(jsonContent.examples ?? {})[0]?.value; return { schema: cloneSchema(schema), example: cloneSchema(example), }; } function extractResponseBody(spec, operation) { const responses = operation?.responses ?? {}; const statuses = Object.keys(responses).filter((code) => code.startsWith('2')).sort(); if (statuses.length === 0) return undefined; const status = statuses[0]; const response = resolveRef(spec, responses[status]); if (!response || typeof response !== 'object') return undefined; if (!response.content) return undefined; const jsonContent = response.content['application/json']; if (!jsonContent) return undefined; const schema = jsonContent.schema ? resolveRef(spec, jsonContent.schema) : undefined; return cloneSchema(schema); } function loadOverrides(dir) { const byId = new Map(); const byMethodPath = new Map(); if (!fs.existsSync(dir)) { return { byId, byMethodPath }; } const files = fs.readdirSync(dir).filter((file) => file.endsWith('.json')); for (const file of files) { const filePath = path.join(dir, file); const content = fs.readFileSync(filePath, 'utf-8'); let parsed; try { parsed = JSON.parse(content); } catch (error) { throw new Error(`Failed to parse override file ${filePath}: ${error.message}`); } const entries = Array.isArray(parsed?.entries) ? parsed.entries : []; for (const entry of entries) { const match = entry?.match ?? {}; if (match.id) { const list = byId.get(match.id) ?? []; list.push(entry); byId.set(match.id, list); } if (match.method && match.path) { const key = `${String(match.method).toLowerCase()} ${match.path}`; const list = byMethodPath.get(key) ?? []; list.push(entry); byMethodPath.set(key, list); } } } return { byId, byMethodPath }; } function applyOverrides(entry, overrides) { const matches = []; if (overrides.byId.has(entry.id)) { matches.push(...(overrides.byId.get(entry.id) ?? [])); } const key = `${entry.method} ${entry.path}`; if (overrides.byMethodPath.has(key)) { matches.push(...(overrides.byMethodPath.get(key) ?? [])); } for (const override of matches) { const metadata = override?.metadata ?? {}; if (metadata.displayName) entry.displayName = metadata.displayName; if (metadata.summary) entry.summary = metadata.summary; if (metadata.description) entry.description = metadata.description; if (metadata.rbacResource) entry.rbacResource = metadata.rbacResource; if (typeof metadata.approvalRequired === 'boolean') { entry.approvalRequired = metadata.approvalRequired; } if (Array.isArray(metadata.playbooks)) entry.playbooks = metadata.playbooks; if (Array.isArray(metadata.examples)) { entry.examples = metadata.examples.map((example) => normalizeExample(example)); } if (Array.isArray(metadata.parameters)) entry.parameters = metadata.parameters; if (metadata.requestBodySchema !== undefined) entry.requestBodySchema = metadata.requestBodySchema; if (metadata.responseBodySchema !== undefined) entry.responseBodySchema = metadata.responseBodySchema; } } function normalizeExample(example) { if (!example || typeof example !== 'object') { return example; } const normalized = { ...example }; const request = normalized.request; if (!request || typeof request !== 'object' || Array.isArray(request)) { return normalized; } if ('path' in request && !('params' in request)) { const { path, ...rest } = request; normalized.request = { ...rest, params: path, }; return normalized; } normalized.request = { ...request }; return normalized; } function createEntryId(method, pathName, operationId) { if (operationId) return operationId; return `${method}-${pathName.replace(/[{}]/g, '').replace(/[\\/]/g, '_').replace(/[^a-zA-Z0-9_]/g, '')}`.toLowerCase(); } function collectOperations(spec) { const entries = []; const paths = spec.paths ?? {}; for (const [pathName, pathItem] of Object.entries(paths)) { if (!pathItem || typeof pathItem !== 'object') continue; for (const method of HTTP_METHODS) { const operation = pathItem[method]; if (!operation || typeof operation !== 'object') continue; const id = createEntryId(method, pathName, operation.operationId); const parameters = collectParameters(spec, pathItem, operation); const fallback = deriveFallbackNames(method, pathName); const { schema: requestBodySchema, example: requestExample } = extractRequestBody(spec, operation); const responseBodySchema = extractResponseBody(spec, operation); const displayName = operation['x-chat-display-name'] ?? operation.summary ?? fallback?.displayName ?? `${method.toUpperCase()} ${pathName}`; const summary = operation.summary ?? fallback?.summary ?? `${method.toUpperCase()} ${pathName}`; const description = operation.description; const normalizedDescription = description === PLACEHOLDER_DESCRIPTION && fallback ? undefined : description; const entry = { id, method, path: pathName, displayName, summary, description: normalizedDescription, tags: Array.isArray(operation.tags) ? operation.tags : [], rbacResource: operation['x-chat-rbac-resource'] ?? operation['x-rbac-resource'], approvalRequired: Boolean(operation['x-chat-approval-required']), parameters: [...parameters, ...inferPathParameters(pathName, parameters)], requestBodySchema, requestExample, responseBodySchema, }; entries.push(entry); } } return entries; } function writeOutput(entries, outputPath, importPath) { const header = `/* eslint-disable */\n// AUTO-GENERATED FILE. DO NOT EDIT.\n// Generated by ee/scripts/generate-chat-registry.mjs\n`; // `import type`: the schema is used only for the annotation, so it is erased // at runtime — the CE registry can live in the server bundle without pulling // @alga-psa/agent-tooling into the runtime graph. const importLine = `import type { ChatApiRegistryEntry } from '${importPath}';\n\n`; const sanitized = entries.map((entry) => JSON.parse(JSON.stringify(entry))); const body = `export const chatApiRegistry: ChatApiRegistryEntry[] = ${JSON.stringify(sanitized, null, 2)};\n\nexport default chatApiRegistry;\n`; fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, `${header}${importLine}${body}`); } function generateTarget(target, overrides) { const spec = loadSpec(target.specPath); const entries = collectOperations(spec); for (const entry of entries) { applyOverrides(entry, overrides); } writeOutput(entries, target.outputPath, target.importPath); console.log( `[${target.edition}] generated ${entries.length} entries -> ${path.relative(repoRoot, target.outputPath)}`, ); return entries; } function main() { const overrides = loadOverrides(OVERRIDES_DIR); const only = process.env.MCP_REGISTRY_EDITION; const targets = only ? TARGETS.filter((t) => t.edition === only) : TARGETS; if (targets.length === 0) { throw new Error(`No registry targets matched MCP_REGISTRY_EDITION="${only}".`); } const byEdition = {}; for (const target of targets) { byEdition[target.edition] = generateTarget(target, overrides); } // Invariant (T005): the CE surface must be a subset of EE — EE is a superset. if (byEdition.ce && byEdition.ee) { const eeIds = new Set(byEdition.ee.map((e) => e.id)); const ceOnly = byEdition.ce.filter((e) => !eeIds.has(e.id)).map((e) => e.id); if (ceOnly.length > 0) { throw new Error( `CE registry has ${ceOnly.length} endpoint(s) absent from EE (EE must be a superset): ` + ceOnly.slice(0, 10).join(', '), ); } console.log(`OK: CE (${byEdition.ce.length}) is a subset of EE (${byEdition.ee.length}).`); } } const arg1 = process.argv[1]; const arg1Url = arg1 ? pathToFileURL(path.resolve(arg1)).href : ''; if (arg1 && import.meta.url === arg1Url) { main(); } else { // Support running via `node ee/scripts/generate-chat-registry.mjs` const resolvedArg = arg1 ? path.resolve(arg1) : ''; if (resolvedArg === __filename) { main(); } }