From 50c2c43fd94f691163d26da11ccc1ad0b4329746 Mon Sep 17 00:00:00 2001 From: stabgan Date: Fri, 28 Mar 2025 13:00:11 +0530 Subject: [PATCH] feat: Enhanced cross-platform path handling and MCP configuration support --- README.md | 22 ++++++ package.json | 2 +- src/index.ts | 69 +++++++++++++--- src/tool-handlers.ts | 7 ++ src/tool-handlers/analyze-image.ts | 95 +++++++++++++++++++---- src/tool-handlers/multi-image-analysis.ts | 73 +++++++++++------ 6 files changed, 221 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 33144cc..992bad5 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,28 @@ An MCP (Model Context Protocol) server that provides chat and image analysis cap - Exponential backoff for retries - Automatic rate limit handling +## What's New in 1.5.0 + +- **Improved OS Compatibility:** + - Enhanced path handling for Windows, macOS, and Linux + - Better support for Windows-style paths with drive letters + - Normalized path processing for consistent behavior across platforms + +- **MCP Configuration Support:** + - Cursor MCP integration without requiring environment variables + - Direct configuration via MCP parameters + - Flexible API key and model specification options + +- **Robust Error Handling:** + - Improved fallback mechanisms for image processing + - Better error reporting with specific diagnostics + - Multiple backup strategies for file reading + +- **Image Processing Enhancements:** + - More reliable base64 encoding for all image types + - Fallback options when Sharp module is unavailable + - Better handling of large images with automatic optimization + ## Installation ### Option 1: Install via npm diff --git a/package.json b/package.json index d2c25e0..2f02234 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@stabgan/openrouter-mcp-multimodal", - "version": "1.4.0", + "version": "1.5.0", "description": "MCP server for OpenRouter providing text chat and image analysis tools", "type": "module", "main": "dist/index.js", diff --git a/src/index.ts b/src/index.ts index c27c23c..20dd92e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,18 +8,23 @@ import { ToolHandlers } from './tool-handlers.js'; // Define the default model to use when none is specified const DEFAULT_MODEL = 'qwen/qwen2.5-vl-32b-instruct:free'; +interface ServerOptions { + apiKey?: string; + defaultModel?: string; +} + class OpenRouterMultimodalServer { private server: Server; private toolHandlers!: ToolHandlers; // Using definite assignment assertion - constructor() { - // Retrieve API key and default model from environment variables - const apiKey = process.env.OPENROUTER_API_KEY; - const defaultModel = process.env.OPENROUTER_DEFAULT_MODEL || DEFAULT_MODEL; + constructor(options?: ServerOptions) { + // Retrieve API key from options or environment variables + const apiKey = options?.apiKey || process.env.OPENROUTER_API_KEY; + const defaultModel = options?.defaultModel || process.env.OPENROUTER_DEFAULT_MODEL || DEFAULT_MODEL; // Check if API key is provided if (!apiKey) { - throw new Error('OPENROUTER_API_KEY environment variable is required'); + throw new Error('OpenRouter API key is required. Provide it via options or OPENROUTER_API_KEY environment variable'); } // Initialize the server @@ -55,15 +60,57 @@ class OpenRouterMultimodalServer { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('OpenRouter Multimodal MCP server running on stdio'); - console.error('Using API key from environment variable'); - console.error('Note: To use OpenRouter Multimodal, add the API key to your environment variables:'); - console.error(' OPENROUTER_API_KEY=your-api-key'); - const modelDisplay = process.env.OPENROUTER_DEFAULT_MODEL || DEFAULT_MODEL; - console.error(` Using default model: ${modelDisplay}`); + // Log model information + const modelDisplay = this.toolHandlers.getDefaultModel() || DEFAULT_MODEL; + console.error(`Using default model: ${modelDisplay}`); console.error('Server is ready to process tool calls. Waiting for input...'); } } -const server = new OpenRouterMultimodalServer(); +// Get MCP configuration if provided +let mcpOptions: ServerOptions | undefined; + +// Check if we're being run as an MCP server with configuration +if (process.argv.length > 2) { + try { + const configArg = process.argv.find(arg => arg.startsWith('--config=')); + if (configArg) { + const configPath = configArg.split('=')[1]; + const configData = require(configPath); + + // Extract configuration + mcpOptions = { + apiKey: configData.OPENROUTER_API_KEY || configData.apiKey, + defaultModel: configData.OPENROUTER_DEFAULT_MODEL || configData.defaultModel + }; + + if (mcpOptions.apiKey) { + console.error('Using API key from MCP configuration'); + } + } + } catch (error) { + console.error('Error parsing MCP configuration:', error); + } +} + +// Attempt to parse JSON from stdin to check for MCP server parameters +if (!mcpOptions?.apiKey) { + process.stdin.setEncoding('utf8'); + process.stdin.once('data', (data) => { + try { + const firstMessage = JSON.parse(data.toString()); + if (firstMessage.params && typeof firstMessage.params === 'object') { + mcpOptions = { + apiKey: firstMessage.params.OPENROUTER_API_KEY || firstMessage.params.apiKey, + defaultModel: firstMessage.params.OPENROUTER_DEFAULT_MODEL || firstMessage.params.defaultModel + }; + } + } catch (error) { + // Not a valid JSON message or no parameters, continue with environment variables + } + }); +} + +const server = new OpenRouterMultimodalServer(mcpOptions); server.run().catch(console.error); diff --git a/src/tool-handlers.ts b/src/tool-handlers.ts index 0adb55e..7dff527 100644 --- a/src/tool-handlers.ts +++ b/src/tool-handlers.ts @@ -344,4 +344,11 @@ export class ToolHandlers { } }); } + + /** + * Get the default model configured for this server + */ + getDefaultModel(): string | undefined { + return this.defaultModel; + } } diff --git a/src/tool-handlers/analyze-image.ts b/src/tool-handlers/analyze-image.ts index 785aa87..b6a6c6c 100644 --- a/src/tool-handlers/analyze-image.ts +++ b/src/tool-handlers/analyze-image.ts @@ -33,6 +33,28 @@ export interface AnalyzeImageToolRequest { model?: string; } +/** + * Normalizes a file path to be OS-neutral + * Handles Windows backslashes, drive letters, etc. + */ +function normalizePath(filePath: string): string { + // Skip normalization for URLs and data URLs + if (filePath.startsWith('http://') || + filePath.startsWith('https://') || + filePath.startsWith('data:')) { + return filePath; + } + + // Handle Windows paths and convert them to a format that's usable + // First normalize the path according to the OS + let normalized = path.normalize(filePath); + + // Make sure any Windows backslashes are handled + normalized = normalized.replace(/\\/g, '/'); + + return normalized; +} + async function fetchImageAsBuffer(url: string): Promise { try { // Handle data URLs @@ -44,15 +66,18 @@ async function fetchImageAsBuffer(url: string): Promise { return Buffer.from(matches[2], 'base64'); } + // Normalize the path before proceeding + const normalizedUrl = normalizePath(url); + // Handle file URLs - if (url.startsWith('file://')) { - const filePath = url.replace('file://', ''); + if (normalizedUrl.startsWith('file://')) { + const filePath = normalizedUrl.replace('file://', ''); return await fs.readFile(filePath); } // Handle http/https URLs - if (url.startsWith('http://') || url.startsWith('https://')) { - const response = await fetch(url); + if (normalizedUrl.startsWith('http://') || normalizedUrl.startsWith('https://')) { + const response = await fetch(normalizedUrl); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } @@ -60,7 +85,20 @@ async function fetchImageAsBuffer(url: string): Promise { } // Handle regular file paths - return await fs.readFile(url); + try { + return await fs.readFile(normalizedUrl); + } catch (error: any) { + // Try the original path as a fallback + if (normalizedUrl !== url) { + try { + return await fs.readFile(url); + } catch (secondError) { + console.error(`Failed to read file with normalized path (${normalizedUrl}) and original path (${url})`); + throw error; // Throw the original error + } + } + throw error; + } } catch (error) { console.error(`Error fetching image from ${url}:`, error); throw error; @@ -142,10 +180,13 @@ async function prepareImage(imagePath: string): Promise<{ base64: string; mimeTy return { base64: matches[2], mimeType: matches[1] }; } + // Normalize the path first + const normalizedPath = normalizePath(imagePath); + // Check if image is a URL - if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) { + if (normalizedPath.startsWith('http://') || normalizedPath.startsWith('https://')) { try { - const buffer = await fetchImageAsBuffer(imagePath); + const buffer = await fetchImageAsBuffer(normalizedPath); const processed = await processImage(buffer); return { base64: processed, mimeType: 'image/jpeg' }; // We convert everything to JPEG } catch (error: any) { @@ -154,22 +195,50 @@ async function prepareImage(imagePath: string): Promise<{ base64: string; mimeTy } // Handle file paths - let absolutePath = imagePath; + let absolutePath = normalizedPath; - // Ensure the image path is absolute if it's a file path - if (!imagePath.startsWith('data:') && !path.isAbsolute(imagePath)) { - throw new McpError(ErrorCode.InvalidParams, 'Image path must be absolute'); + // For local file paths, ensure they are absolute + // Don't check URLs or data URIs + if (!normalizedPath.startsWith('data:') && + !normalizedPath.startsWith('http://') && + !normalizedPath.startsWith('https://')) { + + if (!path.isAbsolute(normalizedPath)) { + throw new McpError(ErrorCode.InvalidParams, 'Image path must be absolute'); + } + + // For Windows paths that include a drive letter but aren't recognized as absolute + // by path.isAbsolute in some environments + if (/^[A-Za-z]:/.test(normalizedPath) && !path.isAbsolute(normalizedPath)) { + absolutePath = path.resolve(normalizedPath); + } } try { // Check if the file exists await fs.access(absolutePath); } catch (error) { - throw new McpError(ErrorCode.InvalidParams, `File not found: ${absolutePath}`); + // Try the original path as a fallback + try { + await fs.access(imagePath); + absolutePath = imagePath; // Use the original path if that works + } catch (secondError) { + throw new McpError(ErrorCode.InvalidParams, `File not found: ${absolutePath}`); + } } // Read the file as a buffer - const buffer = await fs.readFile(absolutePath); + let buffer; + try { + buffer = await fs.readFile(absolutePath); + } catch (error) { + // Try the original path as a fallback + try { + buffer = await fs.readFile(imagePath); + } catch (secondError) { + throw new McpError(ErrorCode.InvalidParams, `Failed to read file: ${absolutePath}`); + } + } // Determine MIME type from file extension const extension = path.extname(absolutePath).toLowerCase(); diff --git a/src/tool-handlers/multi-image-analysis.ts b/src/tool-handlers/multi-image-analysis.ts index 181dd14..07bd353 100644 --- a/src/tool-handlers/multi-image-analysis.ts +++ b/src/tool-handlers/multi-image-analysis.ts @@ -59,6 +59,28 @@ export interface MultiImageAnalysisToolRequest { */ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +/** + * Normalizes a file path to be OS-neutral + * Handles Windows backslashes, drive letters, etc. + */ +function normalizePath(filePath: string): string { + // Skip normalization for URLs and data URLs + if (filePath.startsWith('http://') || + filePath.startsWith('https://') || + filePath.startsWith('data:')) { + return filePath; + } + + // Handle Windows paths and convert them to a format that's usable + // First normalize the path according to the OS + let normalized = path.normalize(filePath); + + // Make sure any Windows backslashes are handled + normalized = normalized.replace(/\\/g, '/'); + + return normalized; +} + /** * Get MIME type from file extension or data URL */ @@ -96,9 +118,12 @@ async function fetchImageAsBuffer(url: string): Promise { return Buffer.from(matches[2], 'base64'); } + // Normalize the path before proceeding + const normalizedUrl = normalizePath(url); + // Handle file URLs with file:// protocol - if (url.startsWith('file://')) { - const filePath = url.replace('file://', ''); + if (normalizedUrl.startsWith('file://')) { + const filePath = normalizedUrl.replace('file://', ''); try { return await fs.readFile(filePath); } catch (error) { @@ -108,24 +133,34 @@ async function fetchImageAsBuffer(url: string): Promise { } // Handle absolute and relative file paths - if (url.startsWith('/') || url.startsWith('./') || url.startsWith('../') || /^[A-Za-z]:\\/.test(url)) { + if (normalizedUrl.startsWith('/') || normalizedUrl.startsWith('./') || normalizedUrl.startsWith('../') || /^[A-Za-z]:\\/.test(normalizedUrl) || /^[A-Za-z]:\//.test(normalizedUrl)) { try { - return await fs.readFile(url); + // Try with normalized path + return await fs.readFile(normalizedUrl); } catch (error) { - console.error(`Error reading file at ${url}:`, error); - throw new Error(`Failed to read file: ${url}`); + // Fallback to original path if normalized path doesn't work + if (normalizedUrl !== url) { + try { + return await fs.readFile(url); + } catch (secondError) { + console.error(`Failed to read file with both normalized path (${normalizedUrl}) and original path (${url})`); + throw new Error(`Failed to read file: ${url}`); + } + } + console.error(`Error reading file at ${normalizedUrl}:`, error); + throw new Error(`Failed to read file: ${normalizedUrl}`); } } // Handle http/https URLs - if (url.startsWith('http://') || url.startsWith('https://')) { + if (normalizedUrl.startsWith('http://') || normalizedUrl.startsWith('https://')) { for (let attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) { try { // Use AbortController for timeout instead of timeout option const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 15000); - const response = await fetch(url, { + const response = await fetch(normalizedUrl, { signal: controller.signal, headers: { 'User-Agent': 'OpenRouter-MCP-Server/1.0' @@ -141,7 +176,7 @@ async function fetchImageAsBuffer(url: string): Promise { return Buffer.from(await response.arrayBuffer()); } catch (error) { - console.error(`Error fetching URL (attempt ${attempt + 1}/${MAX_RETRY_ATTEMPTS}): ${url}`, error); + console.error(`Error fetching URL (attempt ${attempt + 1}/${MAX_RETRY_ATTEMPTS}): ${normalizedUrl}`, error); if (attempt < MAX_RETRY_ATTEMPTS - 1) { // Exponential backoff with jitter @@ -165,6 +200,13 @@ async function fetchImageAsBuffer(url: string): Promise { return Buffer.from([]); } +/** + * Fallback image processing when sharp isn't available + */ +function processImageFallback(buffer: Buffer, mimeType: string): Promise { + return Promise.resolve(buffer.toString('base64')); +} + /** * Process and optimize image for API consumption */ @@ -451,16 +493,3 @@ export async function handleMultiImageAnalysis( }; } } - -/** - * Processes an image with minimal processing when sharp isn't available - */ -async function processImageFallback(buffer: Buffer, mimeType: string): Promise { - try { - // Just return the buffer as base64 without processing - return buffer.toString('base64'); - } catch (error) { - console.error('Error in fallback image processing:', error); - throw error; - } -}