feat: Enhanced cross-platform path handling and MCP configuration support

This commit is contained in:
stabgan
2025-03-28 13:00:11 +05:30
parent 8512f031f7
commit 50c2c43fd9
6 changed files with 221 additions and 47 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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);

View File

@@ -344,4 +344,11 @@ export class ToolHandlers {
}
});
}
/**
* Get the default model configured for this server
*/
getDefaultModel(): string | undefined {
return this.defaultModel;
}
}

View File

@@ -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<Buffer> {
try {
// Handle data URLs
@@ -44,15 +66,18 @@ async function fetchImageAsBuffer(url: string): Promise<Buffer> {
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<Buffer> {
}
// 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();

View File

@@ -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<Buffer> {
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<Buffer> {
}
// 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<Buffer> {
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<Buffer> {
return Buffer.from([]);
}
/**
* Fallback image processing when sharp isn't available
*/
function processImageFallback(buffer: Buffer, mimeType: string): Promise<string> {
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<string> {
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;
}
}