openapi-directory-mcp
Version:
Model Context Protocol server for accessing enhanced triple-source OpenAPI directory (APIs.guru + additional APIs + custom imports)
420 lines • 15.1 kB
JavaScript
/**
* Manifest manager for custom OpenAPI specifications
* Handles indexing, storage paths, and metadata management
*/
/* eslint-disable no-console */
import { join, dirname, resolve, normalize } from "path";
import { homedir } from "os";
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, statSync, readdirSync, rmdirSync, } from "fs";
import { PathValidator, FileValidator } from "../utils/validation.js";
import { ValidationError, ErrorHandler } from "../utils/errors.js";
export class ManifestManager {
constructor() {
this.paths = this.initializePaths();
this.ensureDirectories();
this.manifest = this.loadManifest();
}
/**
* Initialize storage paths with validation
*/
initializePaths() {
try {
const baseDir = process.env.OPENAPI_DIRECTORY_CACHE_DIR
? join(process.env.OPENAPI_DIRECTORY_CACHE_DIR, "custom-specs")
: join(homedir(), ".cache", "openapi-directory-mcp", "custom-specs");
// Validate and normalize base directory path
PathValidator.validatePath(baseDir);
const validatedBaseDir = normalize(resolve(baseDir));
const specsDir = join(validatedBaseDir, "custom");
const manifestFile = join(validatedBaseDir, "manifest.json");
// Validate all paths
PathValidator.validatePath(specsDir);
PathValidator.validatePath(manifestFile);
return {
baseDir: validatedBaseDir,
manifestFile,
specsDir,
getSpecFile: (name, version) => {
// Validate input parameters
if (!name || !version) {
throw new ValidationError("Name and version are required for spec file path");
}
// Sanitize name and version to prevent path traversal
const safeName = name.replace(/[^a-zA-Z0-9_.-]/g, "_");
const safeVersion = version.replace(/[^a-zA-Z0-9_.-]/g, "_");
const specFile = join(specsDir, safeName, `${safeVersion}.json`);
PathValidator.validatePath(specFile);
return normalize(specFile);
},
};
}
catch (error) {
throw ErrorHandler.handleError(error, "Failed to initialize storage paths", "INIT_PATHS_ERROR");
}
}
/**
* Ensure directories exist
*/
ensureDirectories() {
if (!existsSync(this.paths.baseDir)) {
mkdirSync(this.paths.baseDir, { recursive: true });
}
if (!existsSync(this.paths.specsDir)) {
mkdirSync(this.paths.specsDir, { recursive: true });
}
}
/**
* Load manifest from disk with validation
*/
loadManifest() {
try {
// Validate manifest file path
PathValidator.validatePath(this.paths.manifestFile);
if (!existsSync(this.paths.manifestFile)) {
return this.createEmptyManifest();
}
const content = readFileSync(this.paths.manifestFile, "utf-8");
const manifest = JSON.parse(content);
// Validate manifest structure
if (!manifest.version || !manifest.specs || !manifest.lastUpdated) {
console.warn("Invalid manifest structure, creating new one");
return this.createEmptyManifest();
}
return manifest;
}
catch (error) {
console.warn(`Failed to load manifest: ${error}. Creating new one.`);
return this.createEmptyManifest();
}
}
/**
* Create empty manifest
*/
createEmptyManifest() {
return {
version: "1.0.0",
specs: {},
lastUpdated: new Date().toISOString(),
};
}
/**
* Save manifest to disk with validation
*/
saveManifest() {
try {
this.ensureDirectories();
// Validate manifest file path before writing
PathValidator.validatePath(this.paths.manifestFile);
this.manifest.lastUpdated = new Date().toISOString();
const content = JSON.stringify(this.manifest, null, 2);
// Validate content size before writing
FileValidator.validateFileSize(content.length);
writeFileSync(this.paths.manifestFile, content, "utf-8");
}
catch (error) {
throw ErrorHandler.handleError(error, "Failed to save manifest", "SAVE_MANIFEST_ERROR");
}
}
/**
* Add a spec to the manifest
*/
addSpec(entry) {
// Validate required fields
if (!entry.id || !entry.name || !entry.version) {
throw new ValidationError("Spec entry must have id, name, and version");
}
if (this.manifest.specs[entry.id]) {
throw new ValidationError(`Spec with ID ${entry.id} already exists`);
}
this.manifest.specs[entry.id] = entry;
this.saveManifest();
}
/**
* Remove a spec from the manifest
*/
removeSpec(id) {
if (this.manifest.specs[id]) {
delete this.manifest.specs[id];
this.saveManifest();
return true;
}
return false;
}
/**
* Update a spec in the manifest
*/
updateSpec(id, updates) {
if (this.manifest.specs[id]) {
this.manifest.specs[id] = { ...this.manifest.specs[id], ...updates };
this.saveManifest();
return true;
}
return false;
}
/**
* Get a spec from the manifest
*/
getSpec(id) {
return this.manifest.specs[id];
}
/**
* List all specs in the manifest
*/
listSpecs() {
// Always reload manifest from disk to get latest state
this.manifest = this.loadManifest();
return Object.values(this.manifest.specs);
}
/**
* Check if a spec exists
*/
hasSpec(id) {
return id in this.manifest.specs;
}
/**
* Check if a name/version combination exists
*/
hasNameVersion(name, version) {
const id = `custom:${name}:${version}`;
return this.hasSpec(id);
}
/**
* Get storage paths
*/
getPaths() {
return this.paths;
}
/**
* Store spec file on disk with validation
*/
storeSpecFile(name, version, content) {
try {
// Validate inputs
if (!name || !version || !content) {
throw new ValidationError("Name, version, and content are required");
}
// Validate content size and structure
FileValidator.validateFileSize(content.length);
FileValidator.validateOpenAPIContent(content);
// Get validated spec file path
const specFile = this.paths.getSpecFile(name, version);
const specDir = dirname(specFile);
// Validate the directory path before creating
PathValidator.validatePath(specDir);
// Ensure spec directory exists
if (!existsSync(specDir)) {
mkdirSync(specDir, { recursive: true });
}
// Write file with validated path
writeFileSync(specFile, content, "utf-8");
return specFile;
}
catch (error) {
throw ErrorHandler.handleError(error, `Failed to store spec file for ${name}:${version}`, "STORE_SPEC_ERROR");
}
}
/**
* Read spec file from disk with validation
*/
readSpecFile(name, version) {
try {
// Validate inputs
if (!name || !version) {
throw new ValidationError("Name and version are required");
}
// Get validated spec file path
const specFile = this.paths.getSpecFile(name, version);
// Additional path validation before file operations
PathValidator.validatePath(specFile);
if (!existsSync(specFile)) {
throw new ValidationError(`Spec file not found: ${specFile}`);
}
const content = readFileSync(specFile, "utf-8");
// Note: Content validation is handled by the CustomSpecClient
// which extracts the actual OpenAPI spec from the wrapper structure
return content;
}
catch (error) {
throw ErrorHandler.handleError(error, `Failed to read spec file for ${name}:${version}`, "READ_SPEC_ERROR");
}
}
/**
* Delete spec file from disk with validation
*/
deleteSpecFile(name, version) {
try {
// Validate inputs
if (!name || !version) {
throw new ValidationError("Name and version are required");
}
// Get validated spec file path
const specFile = this.paths.getSpecFile(name, version);
// Additional path validation before file operations
PathValidator.validatePath(specFile);
if (!existsSync(specFile)) {
return false;
}
unlinkSync(specFile);
// Try to remove directory if empty
const specDir = dirname(specFile);
try {
PathValidator.validatePath(specDir);
const files = readdirSync(specDir);
if (files.length === 0) {
rmdirSync(specDir);
}
}
catch {
// Ignore errors when trying to remove directory
}
return true;
}
catch (error) {
console.warn(`Failed to delete spec file for ${name}:${version}:`, error);
return false;
}
}
/**
* Get file size of a spec with validation
*/
getSpecFileSize(name, version) {
try {
// Validate inputs
if (!name || !version) {
return 0;
}
// Get validated spec file path
const specFile = this.paths.getSpecFile(name, version);
// Additional path validation before file operations
PathValidator.validatePath(specFile);
if (!existsSync(specFile)) {
return 0;
}
return statSync(specFile).size;
}
catch {
return 0;
}
}
/**
* Generate unique spec ID
*/
generateSpecId(name, version) {
return `custom:${name}:${version}`;
}
/**
* Parse spec ID into components
*/
parseSpecId(id) {
const parts = id.split(":");
if (parts.length !== 3 || parts[0] !== "custom") {
return null;
}
return {
provider: parts[0],
name: parts[1],
version: parts[2],
};
}
/**
* Get manifest statistics
*/
getStats() {
const specs = this.listSpecs();
return {
totalSpecs: specs.length,
totalSize: specs.reduce((sum, spec) => sum + spec.fileSize, 0),
byFormat: {
yaml: specs.filter((s) => s.originalFormat === "yaml").length,
json: specs.filter((s) => s.originalFormat === "json").length,
},
bySource: {
file: specs.filter((s) => s.sourceType === "file").length,
url: specs.filter((s) => s.sourceType === "url").length,
},
lastUpdated: this.manifest.lastUpdated,
};
}
/**
* Validate manifest integrity with path validation
*/
validateIntegrity() {
const issues = [];
try {
const specs = this.listSpecs();
for (const spec of specs) {
try {
// Check if spec file exists
const parsed = this.parseSpecId(spec.id);
if (!parsed) {
issues.push(`Invalid spec ID format: ${spec.id}`);
continue;
}
// Validate the spec file path
const specFile = this.paths.getSpecFile(parsed.name, parsed.version);
PathValidator.validatePath(specFile);
if (!existsSync(specFile)) {
issues.push(`Spec file missing for ${spec.id}: ${specFile}`);
continue;
}
// Validate required fields
if (!spec.name || !spec.version || !spec.title) {
issues.push(`Missing required fields for ${spec.id}`);
}
// Check file size consistency
const actualSize = this.getSpecFileSize(parsed.name, parsed.version);
if (actualSize !== spec.fileSize && actualSize > 0) {
issues.push(`File size mismatch for ${spec.id}: expected ${spec.fileSize}, found ${actualSize}`);
}
// Note: Spec content validation is handled by CustomSpecClient
// as it needs to extract the actual OpenAPI spec from the wrapper
}
catch (specError) {
issues.push(`Error validating spec ${spec.id}: ${specError}`);
}
}
}
catch (error) {
issues.push(`Error during integrity validation: ${error}`);
}
return {
valid: issues.length === 0,
issues,
};
}
/**
* Repair manifest integrity issues
*/
repairIntegrity() {
const repaired = [];
const failed = [];
const integrity = this.validateIntegrity();
if (integrity.valid) {
return { repaired, failed };
}
const specs = this.listSpecs();
for (const spec of specs) {
const parsed = this.parseSpecId(spec.id);
if (!parsed) {
this.removeSpec(spec.id);
repaired.push(`Removed spec with invalid ID: ${spec.id}`);
continue;
}
const specFile = this.paths.getSpecFile(parsed.name, parsed.version);
// Remove specs with missing files
if (!existsSync(specFile)) {
this.removeSpec(spec.id);
repaired.push(`Removed spec with missing file: ${spec.id}`);
continue;
}
// Update file size if incorrect
const actualSize = this.getSpecFileSize(parsed.name, parsed.version);
if (actualSize !== spec.fileSize && actualSize > 0) {
this.updateSpec(spec.id, { fileSize: actualSize });
repaired.push(`Updated file size for ${spec.id}`);
}
}
return { repaired, failed };
}
}
//# sourceMappingURL=manifest-manager.js.map