@xiaohui-wang/mcpadvisor
Version:
MCP Advisor & Installation - Find the right MCP server for your needs
242 lines (241 loc) • 9.41 kB
JavaScript
import { BaseResourceHandler } from './BaseResourceHandler.js';
import fs from 'fs/promises';
import path from 'path';
import { pathToFileURL, fileURLToPath } from 'url';
import logger from '../../../../utils/logger.js';
/**
* MCP Resource handler for reading log files
* Implements the BaseResourceHandler interface to provide log file access
*/
export class LogResourceHandler extends BaseResourceHandler {
logDirectories;
supportedExtensions = ['.log', '.txt'];
resourceCache = null;
CACHE_TTL_MS = parseInt(process.env.RESOURCE_CACHE_TTL || '30000'); // 30 seconds default
constructor() {
super();
this.logDirectories = this.getLogDirectories();
logger.info('LogResourceHandler initialized', 'LogResourceHandler', {
directories: this.logDirectories,
extensions: this.supportedExtensions,
cacheTtl: this.CACHE_TTL_MS
});
}
/**
* Get configured log directories from environment variables
*/
getLogDirectories() {
const logDir = process.env.LOG_DIR || process.env.LOGS_DIR;
if (!logDir) {
// Default log directories
const defaultDirs = [
path.join(process.cwd(), 'logs'),
'/var/log',
'/tmp'
];
logger.info('No LOG_DIR configured, using defaults', 'LogResourceHandler', { defaultDirs });
return defaultDirs;
}
// Support multiple directories separated by colon
const directories = logDir.split(':').filter(Boolean);
logger.info('Using configured log directories', 'LogResourceHandler', { directories });
return directories;
}
/**
* List all available log files as resources with caching
*/
async listResources() {
// Check if we have a valid cache
if (this.resourceCache && (Date.now() - this.resourceCache.lastUpdate) < this.CACHE_TTL_MS) {
logger.debug('Returning cached resources', 'LogResourceHandler', {
count: this.resourceCache.resources.length
});
return this.resourceCache.resources;
}
// Cache is invalid or doesn't exist, scan directories
logger.debug('Cache expired or invalid, scanning directories', 'LogResourceHandler');
const resources = [];
for (const directory of this.logDirectories) {
try {
await this.addResourcesFromDirectory(directory, resources);
}
catch (error) {
logger.warn(`Failed to read log directory: ${directory}`, 'LogResourceHandler', { error });
// Continue with other directories
}
}
// Update cache
this.resourceCache = {
resources,
lastUpdate: Date.now()
};
logger.info(`Found ${resources.length} log resources`, 'LogResourceHandler');
return resources;
}
/**
* Add resources from a specific directory
*/
async addResourcesFromDirectory(directory, resources) {
try {
const files = await fs.readdir(directory);
for (const file of files) {
const filePath = path.join(directory, file);
try {
const stat = await fs.stat(filePath);
if (stat.isFile() && this.isSupportedFile(file)) {
const resource = this.createResourceFromFile(directory, file);
resources.push(resource);
}
}
catch (error) {
logger.debug(`Failed to stat file: ${filePath}`, 'LogResourceHandler', { error });
// Continue with other files
}
}
}
catch (error) {
logger.warn(`Cannot access directory: ${directory}`, 'LogResourceHandler', { error });
throw error; // Re-throw to be caught by caller
}
}
/**
* Check if a file is supported based on its extension
*/
isSupportedFile(filename) {
const ext = path.extname(filename).toLowerCase();
return this.supportedExtensions.includes(ext);
}
/**
* Create a Resource object from a file
*/
createResourceFromFile(directory, filename) {
const filePath = path.join(directory, filename);
const uri = this.createFileUri(filePath);
return {
uri,
name: `Log: ${filename}`,
description: `Log file from ${directory}`,
mimeType: 'text/plain'
};
}
/**
* Create a file URI from a file path using Node.js URL utilities
*/
createFileUri(filePath) {
// Use Node.js built-in pathToFileURL for proper cross-platform URI generation
const absolutePath = path.resolve(filePath);
return pathToFileURL(absolutePath).href;
}
/**
* Read the content of a resource by URI
*/
async readResource(uri) {
logger.info(`Reading resource: ${uri}`, 'LogResourceHandler');
// Validate and parse URI
const filePath = this.parseFileUri(uri);
// Security check - ensure file is within allowed directories
await this.validateFilePath(filePath);
try {
// Check file size before reading
const stat = await fs.stat(filePath);
const maxSize = parseInt(process.env.MAX_LOG_SIZE || '10485760'); // 10MB default
if (stat.size > maxSize) {
throw new Error(`File too large: ${stat.size} bytes exceeds limit of ${maxSize} bytes`);
}
const content = await fs.readFile(filePath, 'utf-8');
logger.info(`Successfully read resource: ${uri}`, 'LogResourceHandler', {
size: content.length
});
return {
contents: [
{
uri,
mimeType: 'text/plain',
text: content
}
]
};
}
catch (error) {
const errorCode = error.code;
const errorMessage = error.message;
if (errorCode === 'ENOENT' || errorMessage.includes('ENOENT') || errorMessage.includes('no such file')) {
const notFoundError = new Error('File not found');
logger.error('File not found', 'LogResourceHandler', { error: notFoundError, uri });
throw notFoundError;
}
else if (errorCode === 'EACCES' || errorMessage.includes('EACCES') || errorMessage.includes('permission denied')) {
const permissionError = new Error('Permission denied');
logger.error('Permission denied', 'LogResourceHandler', { error: permissionError, uri });
throw permissionError;
}
else {
logger.error('Failed to read file', 'LogResourceHandler', { error, uri });
throw error;
}
}
}
/**
* Parse a file URI to get the local file path using Node.js URL utilities
*/
parseFileUri(uri) {
try {
// Use Node.js built-in fileURLToPath for proper cross-platform URI parsing
return fileURLToPath(uri);
}
catch (error) {
throw new Error(`Invalid file URI format: ${uri}`);
}
}
/**
* Validate that the file path is within allowed directories
* Uses fs.realpath to resolve symlinks and prevent directory traversal attacks
*/
async validateFilePath(filePath) {
try {
// Resolve the real path to handle symlinks and normalize the path
const realPath = await fs.realpath(filePath);
// Check if the real path is within any of the allowed log directories
const isInAllowedDirectory = await Promise.all(this.logDirectories.map(async (dir) => {
try {
const realDir = await fs.realpath(dir);
return realPath.startsWith(realDir + path.sep) || realPath === realDir;
}
catch {
// If directory doesn't exist or can't be resolved, it's not allowed
return false;
}
})).then(results => results.some(Boolean));
if (!isInAllowedDirectory) {
throw new Error(`Invalid file path: ${filePath} is not within allowed log directories`);
}
}
catch (error) {
if (error.code === 'ENOENT') {
throw new Error(`File not found: ${filePath}`);
}
throw error;
}
}
/**
* Check if this handler supports a given URI
*/
async supportsUri(uri) {
try {
const filePath = this.parseFileUri(uri);
await this.validateFilePath(filePath);
const filename = path.basename(filePath);
return this.isSupportedFile(filename);
}
catch {
return false;
}
}
/**
* Invalidate the resource cache to force a fresh scan on next request
*/
invalidateCache() {
this.resourceCache = null;
logger.debug('Resource cache invalidated', 'LogResourceHandler');
}
}