feat: Enhanced cross-platform path handling and MCP configuration support
This commit is contained in:
22
README.md
22
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
69
src/index.ts
69
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);
|
||||
|
||||
@@ -344,4 +344,11 @@ export class ToolHandlers {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default model configured for this server
|
||||
*/
|
||||
getDefaultModel(): string | undefined {
|
||||
return this.defaultModel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user