@toolprint/hypertool-mcp
Version:
HyperTool MCP proxy server for routing requests between clients and multiple underlying MCP servers
432 lines • 16.5 kB
JavaScript
/**
* Extension Discovery Service
* Handles auto-discovery, unpacking, and validation of DXT extensions
*/
import { readdir, stat, readFile, writeFile, mkdir } from "fs/promises";
import { join, basename, extname } from "path";
import { homedir } from "os";
import { existsSync } from "fs";
import { parseManifest } from "../dxt/manifest.js";
import { extractDxt } from "../dxt/loader.js";
import { isDxtEnabledViaService } from "../config/featureFlagService.js";
/**
* Extension discovery and management service
*/
export class ExtensionDiscoveryService {
extensionsDir;
installedDir;
metadataFile;
metadata = {};
constructor(baseDir) {
const base = baseDir || join(homedir(), ".toolprint", "hypertool-mcp");
this.extensionsDir = join(base, "extensions");
this.installedDir = join(this.extensionsDir, "installed");
this.metadataFile = join(this.extensionsDir, ".metadata.json");
}
/**
* Initialize the extensions directory structure
*/
async initialize() {
// Skip initialization if DXT is disabled
if (!(await isDxtEnabledViaService())) {
return;
}
await mkdir(this.extensionsDir, { recursive: true });
await mkdir(this.installedDir, { recursive: true });
await this.loadMetadata();
}
/**
* Discover all DXT files in the extensions directory
*/
async discoverExtensions() {
try {
const files = await readdir(this.extensionsDir);
return files
.filter((file) => extname(file) === ".dxt")
.map((file) => join(this.extensionsDir, file));
}
catch (error) {
if (error.code === "ENOENT") {
return [];
}
throw error;
}
}
/**
* Discover already-installed extensions in the installed directory
*/
async discoverInstalledExtensions() {
try {
if (!existsSync(this.installedDir)) {
return [];
}
const dirs = await readdir(this.installedDir);
const extensionDirs = [];
for (const dir of dirs) {
const dirPath = join(this.installedDir, dir);
const stats = await stat(dirPath);
if (stats.isDirectory()) {
// Check if it has a manifest.json
const manifestPath = join(dirPath, "manifest.json");
if (existsSync(manifestPath)) {
extensionDirs.push(dir);
}
}
}
return extensionDirs;
}
catch (error) {
if (error.code === "ENOENT") {
return [];
}
throw error;
}
}
/**
* Check if an extension needs to be unpacked or re-unpacked
*/
async needsUnpacking(dxtPath) {
const filename = basename(dxtPath, ".dxt");
const metadata = this.metadata[filename];
if (!metadata) {
return true; // New extension
}
// Check if source file has been modified
try {
const stats = await stat(dxtPath);
return stats.mtimeMs > metadata.sourceModified;
}
catch {
return true; // Source file missing or inaccessible
}
}
/**
* Unpack a DXT extension to the installed directory
*/
async unpackExtension(dxtPath) {
const filename = basename(dxtPath, ".dxt");
const installedPath = join(this.installedDir, filename);
// Extract to installed directory (not temp)
const extractDir = await this.extractToInstalled(dxtPath, installedPath);
// Parse manifest to get extension info
const manifest = await parseManifest(extractDir);
// Get source file stats
const stats = await stat(dxtPath);
// Create metadata
const metadata = {
name: manifest.name,
version: manifest.version,
sourceFile: dxtPath,
sourceModified: stats.mtimeMs,
installedPath: extractDir,
installedAt: Date.now(),
};
// Store metadata
this.metadata[filename] = metadata;
await this.saveMetadata();
return metadata;
}
/**
* Get extension metadata by filename (without .dxt extension)
*/
getExtensionMetadata(filename) {
return this.metadata[filename];
}
/**
* Get all extension metadata
*/
getAllMetadata() {
return { ...this.metadata };
}
/**
* Load runtime configurations for all extensions
*/
async loadExtensionConfigs(config) {
const extensions = [];
const extensionConfig = config.extensions;
if (!extensionConfig || !extensionConfig.autoDiscovery) {
return extensions;
}
// Discover all DXT files
const dxtFiles = await this.discoverExtensions();
// Also discover already-installed extensions
const installedExtensions = await this.discoverInstalledExtensions();
// Process DXT files that need unpacking
for (const dxtPath of dxtFiles) {
const filename = basename(dxtPath, ".dxt");
try {
// Check if needs unpacking
if (await this.needsUnpacking(dxtPath)) {
await this.unpackExtension(dxtPath);
}
const metadata = this.getExtensionMetadata(filename);
if (!metadata) {
console.warn(`Extension ${filename}: Failed to load metadata`);
continue;
}
// Load manifest
const manifest = await parseManifest(metadata.installedPath);
// Get user settings
const userSettings = extensionConfig.settings?.[filename];
const isEnabled = userSettings?.isEnabled ?? true;
// Validate configuration
const validationResult = this.validateExtensionConfig(manifest, userSettings);
// Create runtime config
const serverConfig = this.buildServerConfig(manifest, userSettings, metadata.installedPath);
const runtimeConfig = {
name: filename,
enabled: isEnabled && validationResult.isValid,
manifest,
installedPath: metadata.installedPath,
serverConfig,
validationResult,
};
extensions.push(runtimeConfig);
// Log warnings for disabled extensions
if (!runtimeConfig.enabled && validationResult.errors.length > 0) {
console.warn(`Extension '${filename}' disabled: ${validationResult.errors.join(", ")}`);
}
}
catch (error) {
console.error(`Extension ${filename}: Failed to load - ${error.message}`);
}
}
// Process already-installed extensions
for (const extensionName of installedExtensions) {
try {
// Skip if already processed from DXT file
if (extensions.some((ext) => ext.name === extensionName)) {
continue;
}
const metadata = this.getExtensionMetadata(extensionName);
if (!metadata) {
console.warn(`Extension ${extensionName}: No metadata found`);
continue;
}
// Load manifest from installed directory
const manifest = await parseManifest(metadata.installedPath);
// Get user settings
const userSettings = extensionConfig.settings?.[extensionName];
const isEnabled = userSettings?.isEnabled ?? true;
// Validate configuration
const validationResult = this.validateExtensionConfig(manifest, userSettings);
// Create runtime config
const serverConfig = this.buildServerConfig(manifest, userSettings, metadata.installedPath);
const runtimeConfig = {
name: extensionName,
enabled: isEnabled && validationResult.isValid,
manifest,
installedPath: metadata.installedPath,
serverConfig,
validationResult,
};
extensions.push(runtimeConfig);
// Log warnings for disabled extensions
if (!runtimeConfig.enabled && validationResult.errors.length > 0) {
console.warn(`Extension '${extensionName}' disabled: ${validationResult.errors.join(", ")}`);
}
}
catch (error) {
console.error(`Extension ${extensionName}: Failed to load - ${error.message}`);
}
}
return extensions;
}
/**
* Validate extension configuration against manifest user_config schema
*/
validateExtensionConfig(manifest, userSettings) {
const errors = [];
const warnings = [];
if (!manifest.user_config) {
return { isValid: true, errors, warnings };
}
const userConfig = userSettings?.userConfig || {};
// Validate each user config parameter
for (const [key, paramDef] of Object.entries(manifest.user_config)) {
const value = userConfig[key];
// Check required fields
if (paramDef.required && (value === undefined || value === null)) {
errors.push(`Missing required config: ${key}`);
continue;
}
// Skip validation if value is not provided and not required
if (value === undefined || value === null) {
continue;
}
// Type validation
if (!this.validateParameterType(value, paramDef)) {
errors.push(`Invalid type for ${key}. Expected ${paramDef.type}, got ${typeof value}`);
}
// Range validation for numbers
if (paramDef.type === "number" && typeof value === "number") {
if (paramDef.min !== undefined && value < paramDef.min) {
errors.push(`Value for ${key} must be >= ${paramDef.min}`);
}
if (paramDef.max !== undefined && value > paramDef.max) {
errors.push(`Value for ${key} must be <= ${paramDef.max}`);
}
}
// Multiple value validation
if (paramDef.multiple && !Array.isArray(value)) {
errors.push(`Value for ${key} must be an array (multiple: true)`);
}
if (!paramDef.multiple && Array.isArray(value)) {
errors.push(`Value for ${key} must not be an array (multiple: false)`);
}
}
// Check for unknown config keys
for (const key of Object.keys(userConfig)) {
if (!manifest.user_config[key]) {
warnings.push(`Unknown config key: ${key}`);
}
}
return {
isValid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate parameter type
*/
validateParameterType(value, paramDef) {
if (paramDef.multiple && Array.isArray(value)) {
return value.every((v) => this.validateSingleType(v, paramDef.type));
}
return this.validateSingleType(value, paramDef.type);
}
/**
* Validate single value type
*/
validateSingleType(value, type) {
switch (type) {
case "string":
case "directory":
case "file":
return typeof value === "string";
case "number":
return typeof value === "number";
case "boolean":
return typeof value === "boolean";
default:
return false;
}
}
/**
* Build server configuration with template substitution
*/
buildServerConfig(manifest, userSettings, installedPath) {
const mcpConfig = manifest.server.mcp_config;
const userConfig = userSettings?.userConfig || {};
// Apply template substitution
const substitutedEnv = this.substituteTemplates(mcpConfig.env || {}, userConfig, installedPath);
let substitutedArgs = (mcpConfig.args || []).map((arg) => this.substituteTemplateString(arg, userConfig, installedPath));
// Resolve entry_point to absolute path if present
if (manifest.server.entry_point) {
// If entry_point exists, check if any args contain just the entry_point filename
// and resolve them to absolute paths
const entryPoint = manifest.server.entry_point;
substitutedArgs = substitutedArgs.map((arg) => {
// If the arg is exactly the entry_point filename, resolve to absolute path
if (arg === entryPoint) {
const resolvedPath = join(installedPath, entryPoint);
return resolvedPath;
}
return arg;
});
}
return {
command: mcpConfig.command,
args: substitutedArgs,
env: { ...process.env, ...substitutedEnv },
cwd: installedPath,
};
}
/**
* Substitute template variables in environment variables
*/
substituteTemplates(env, userConfig, installedPath) {
const result = {};
for (const [key, value] of Object.entries(env)) {
result[key] = this.substituteTemplateString(value, userConfig, installedPath);
}
return result;
}
/**
* Substitute template variables in a string
*/
substituteTemplateString(template, userConfig, installedPath) {
let result = template;
// Replace ${__dirname} with installed path
result = result.replace(/\$\{__dirname\}/g, installedPath);
// Replace ${user_config.param} with user config values
result = result.replace(/\$\{user_config\.([^}]+)\}/g, (match, paramName) => {
const value = userConfig[paramName];
if (value === undefined) {
return match; // Keep original if not found
}
if (Array.isArray(value)) {
return value.join(","); // Join arrays with commas
}
return String(value);
});
// Replace ${env:VAR} with environment variables
result = result.replace(/\$\{env:([^}]+)\}/g, (match, envVar) => {
return process.env[envVar] || match;
});
return result;
}
/**
* Extract DXT to installed directory instead of temp
*/
async extractToInstalled(dxtPath, targetDir) {
// Remove existing installation
if (existsSync(targetDir)) {
await this.removeDirectory(targetDir);
}
// Create target directory
await mkdir(targetDir, { recursive: true });
// Extract directly to target directory
return await extractDxt(dxtPath, targetDir);
}
/**
* Remove directory recursively
*/
async removeDirectory(dirPath) {
try {
const { rm } = await import("fs/promises");
await rm(dirPath, { recursive: true, force: true });
}
catch (error) {
// Ignore errors if directory doesn't exist
}
}
/**
* Load metadata from file
*/
async loadMetadata() {
try {
if (existsSync(this.metadataFile)) {
const content = await readFile(this.metadataFile, "utf-8");
this.metadata = JSON.parse(content);
}
}
catch (error) {
console.warn(`Failed to load extension metadata: ${error.message}`);
this.metadata = {};
}
}
/**
* Save metadata to file
*/
async saveMetadata() {
try {
await writeFile(this.metadataFile, JSON.stringify(this.metadata, null, 2));
}
catch (error) {
console.error(`Failed to save extension metadata: ${error.message}`);
}
}
}
//# sourceMappingURL=discovery.js.map