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
|
- Exponential backoff for retries
|
||||||
- Automatic rate limit handling
|
- 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
|
## Installation
|
||||||
|
|
||||||
### Option 1: Install via npm
|
### Option 1: Install via npm
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@stabgan/openrouter-mcp-multimodal",
|
"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",
|
"description": "MCP server for OpenRouter providing text chat and image analysis tools",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"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
|
// Define the default model to use when none is specified
|
||||||
const DEFAULT_MODEL = 'qwen/qwen2.5-vl-32b-instruct:free';
|
const DEFAULT_MODEL = 'qwen/qwen2.5-vl-32b-instruct:free';
|
||||||
|
|
||||||
|
interface ServerOptions {
|
||||||
|
apiKey?: string;
|
||||||
|
defaultModel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
class OpenRouterMultimodalServer {
|
class OpenRouterMultimodalServer {
|
||||||
private server: Server;
|
private server: Server;
|
||||||
private toolHandlers!: ToolHandlers; // Using definite assignment assertion
|
private toolHandlers!: ToolHandlers; // Using definite assignment assertion
|
||||||
|
|
||||||
constructor() {
|
constructor(options?: ServerOptions) {
|
||||||
// Retrieve API key and default model from environment variables
|
// Retrieve API key from options or environment variables
|
||||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
const apiKey = options?.apiKey || process.env.OPENROUTER_API_KEY;
|
||||||
const defaultModel = process.env.OPENROUTER_DEFAULT_MODEL || DEFAULT_MODEL;
|
const defaultModel = options?.defaultModel || process.env.OPENROUTER_DEFAULT_MODEL || DEFAULT_MODEL;
|
||||||
|
|
||||||
// Check if API key is provided
|
// Check if API key is provided
|
||||||
if (!apiKey) {
|
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
|
// Initialize the server
|
||||||
@@ -55,15 +60,57 @@ class OpenRouterMultimodalServer {
|
|||||||
const transport = new StdioServerTransport();
|
const transport = new StdioServerTransport();
|
||||||
await this.server.connect(transport);
|
await this.server.connect(transport);
|
||||||
console.error('OpenRouter Multimodal MCP server running on stdio');
|
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;
|
// Log model information
|
||||||
console.error(` Using default model: ${modelDisplay}`);
|
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...');
|
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);
|
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;
|
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> {
|
async function fetchImageAsBuffer(url: string): Promise<Buffer> {
|
||||||
try {
|
try {
|
||||||
// Handle data URLs
|
// Handle data URLs
|
||||||
@@ -44,15 +66,18 @@ async function fetchImageAsBuffer(url: string): Promise<Buffer> {
|
|||||||
return Buffer.from(matches[2], 'base64');
|
return Buffer.from(matches[2], 'base64');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize the path before proceeding
|
||||||
|
const normalizedUrl = normalizePath(url);
|
||||||
|
|
||||||
// Handle file URLs
|
// Handle file URLs
|
||||||
if (url.startsWith('file://')) {
|
if (normalizedUrl.startsWith('file://')) {
|
||||||
const filePath = url.replace('file://', '');
|
const filePath = normalizedUrl.replace('file://', '');
|
||||||
return await fs.readFile(filePath);
|
return await fs.readFile(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle http/https URLs
|
// Handle http/https URLs
|
||||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
if (normalizedUrl.startsWith('http://') || normalizedUrl.startsWith('https://')) {
|
||||||
const response = await fetch(url);
|
const response = await fetch(normalizedUrl);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
@@ -60,7 +85,20 @@ async function fetchImageAsBuffer(url: string): Promise<Buffer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle regular file paths
|
// 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) {
|
} catch (error) {
|
||||||
console.error(`Error fetching image from ${url}:`, error);
|
console.error(`Error fetching image from ${url}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -142,10 +180,13 @@ async function prepareImage(imagePath: string): Promise<{ base64: string; mimeTy
|
|||||||
return { base64: matches[2], mimeType: matches[1] };
|
return { base64: matches[2], mimeType: matches[1] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize the path first
|
||||||
|
const normalizedPath = normalizePath(imagePath);
|
||||||
|
|
||||||
// Check if image is a URL
|
// Check if image is a URL
|
||||||
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
|
if (normalizedPath.startsWith('http://') || normalizedPath.startsWith('https://')) {
|
||||||
try {
|
try {
|
||||||
const buffer = await fetchImageAsBuffer(imagePath);
|
const buffer = await fetchImageAsBuffer(normalizedPath);
|
||||||
const processed = await processImage(buffer);
|
const processed = await processImage(buffer);
|
||||||
return { base64: processed, mimeType: 'image/jpeg' }; // We convert everything to JPEG
|
return { base64: processed, mimeType: 'image/jpeg' }; // We convert everything to JPEG
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -154,22 +195,50 @@ async function prepareImage(imagePath: string): Promise<{ base64: string; mimeTy
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle file paths
|
// Handle file paths
|
||||||
let absolutePath = imagePath;
|
let absolutePath = normalizedPath;
|
||||||
|
|
||||||
// Ensure the image path is absolute if it's a file path
|
// For local file paths, ensure they are absolute
|
||||||
if (!imagePath.startsWith('data:') && !path.isAbsolute(imagePath)) {
|
// Don't check URLs or data URIs
|
||||||
throw new McpError(ErrorCode.InvalidParams, 'Image path must be absolute');
|
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 {
|
try {
|
||||||
// Check if the file exists
|
// Check if the file exists
|
||||||
await fs.access(absolutePath);
|
await fs.access(absolutePath);
|
||||||
} catch (error) {
|
} 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
|
// 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
|
// Determine MIME type from file extension
|
||||||
const extension = path.extname(absolutePath).toLowerCase();
|
const extension = path.extname(absolutePath).toLowerCase();
|
||||||
|
|||||||
@@ -59,6 +59,28 @@ export interface MultiImageAnalysisToolRequest {
|
|||||||
*/
|
*/
|
||||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
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
|
* 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');
|
return Buffer.from(matches[2], 'base64');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize the path before proceeding
|
||||||
|
const normalizedUrl = normalizePath(url);
|
||||||
|
|
||||||
// Handle file URLs with file:// protocol
|
// Handle file URLs with file:// protocol
|
||||||
if (url.startsWith('file://')) {
|
if (normalizedUrl.startsWith('file://')) {
|
||||||
const filePath = url.replace('file://', '');
|
const filePath = normalizedUrl.replace('file://', '');
|
||||||
try {
|
try {
|
||||||
return await fs.readFile(filePath);
|
return await fs.readFile(filePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -108,24 +133,34 @@ async function fetchImageAsBuffer(url: string): Promise<Buffer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle absolute and relative file paths
|
// 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 {
|
try {
|
||||||
return await fs.readFile(url);
|
// Try with normalized path
|
||||||
|
return await fs.readFile(normalizedUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error reading file at ${url}:`, error);
|
// Fallback to original path if normalized path doesn't work
|
||||||
throw new Error(`Failed to read file: ${url}`);
|
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
|
// 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++) {
|
for (let attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) {
|
||||||
try {
|
try {
|
||||||
// Use AbortController for timeout instead of timeout option
|
// Use AbortController for timeout instead of timeout option
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 15000);
|
const timeoutId = setTimeout(() => controller.abort(), 15000);
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(normalizedUrl, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'OpenRouter-MCP-Server/1.0'
|
'User-Agent': 'OpenRouter-MCP-Server/1.0'
|
||||||
@@ -141,7 +176,7 @@ async function fetchImageAsBuffer(url: string): Promise<Buffer> {
|
|||||||
|
|
||||||
return Buffer.from(await response.arrayBuffer());
|
return Buffer.from(await response.arrayBuffer());
|
||||||
} catch (error) {
|
} 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) {
|
if (attempt < MAX_RETRY_ATTEMPTS - 1) {
|
||||||
// Exponential backoff with jitter
|
// Exponential backoff with jitter
|
||||||
@@ -165,6 +200,13 @@ async function fetchImageAsBuffer(url: string): Promise<Buffer> {
|
|||||||
return Buffer.from([]);
|
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
|
* 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