Add Streamable HTTP transport for remote MCP access
- Convert from stdio-only to dual-mode (stdio + HTTP) - Add Express server with /mcp endpoint for Streamable HTTP - Add /health endpoint for Railway health checks - Update MCP SDK to v1.12.0 for Streamable HTTP support - Add railway.toml for Railway deployment - Default to HTTP mode, use --stdio flag for local mode
This commit is contained in:
215
src/index.ts
215
src/index.ts
@@ -1,7 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
// OpenRouter Multimodal MCP Server
|
||||
// Supports both stdio (local) and Streamable HTTP (remote) transports
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import express, { Request, Response } from 'express';
|
||||
|
||||
import { ToolHandlers } from './tool-handlers.js';
|
||||
|
||||
@@ -15,15 +20,17 @@ interface ServerOptions {
|
||||
|
||||
class OpenRouterMultimodalServer {
|
||||
private server: Server;
|
||||
private toolHandlers!: ToolHandlers; // Using definite assignment assertion
|
||||
private toolHandlers!: ToolHandlers;
|
||||
private apiKey: string;
|
||||
private defaultModel: string;
|
||||
|
||||
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;
|
||||
this.apiKey = options?.apiKey || process.env.OPENROUTER_API_KEY || '';
|
||||
this.defaultModel = options?.defaultModel || process.env.OPENROUTER_DEFAULT_MODEL || DEFAULT_MODEL;
|
||||
|
||||
// Check if API key is provided
|
||||
if (!apiKey) {
|
||||
if (!this.apiKey) {
|
||||
throw new Error('OpenRouter API key is required. Provide it via options or OPENROUTER_API_KEY environment variable');
|
||||
}
|
||||
|
||||
@@ -31,7 +38,7 @@ class OpenRouterMultimodalServer {
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'openrouter-multimodal-server',
|
||||
version: '1.0.0',
|
||||
version: '1.5.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
@@ -39,78 +46,204 @@ class OpenRouterMultimodalServer {
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
// Set up error handling
|
||||
this.server.onerror = (error) => console.error('[MCP Error]', error);
|
||||
|
||||
|
||||
// Initialize tool handlers
|
||||
this.toolHandlers = new ToolHandlers(
|
||||
this.server,
|
||||
apiKey,
|
||||
defaultModel
|
||||
this.apiKey,
|
||||
this.defaultModel
|
||||
);
|
||||
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
await this.server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
async run() {
|
||||
getServer(): Server {
|
||||
return this.server;
|
||||
}
|
||||
|
||||
getDefaultModel(): string {
|
||||
return this.toolHandlers.getDefaultModel() || this.defaultModel;
|
||||
}
|
||||
|
||||
async runStdio() {
|
||||
const transport = new StdioServerTransport();
|
||||
await this.server.connect(transport);
|
||||
console.error('OpenRouter Multimodal MCP server running on stdio');
|
||||
|
||||
// Log model information
|
||||
const modelDisplay = this.toolHandlers.getDefaultModel() || DEFAULT_MODEL;
|
||||
console.error(`Using default model: ${modelDisplay}`);
|
||||
console.error(`Using default model: ${this.getDefaultModel()}`);
|
||||
console.error('Server is ready to process tool calls. Waiting for input...');
|
||||
}
|
||||
|
||||
async runHttp(port: number) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Store transports by session ID for stateful mode
|
||||
const transports: Record<string, StreamableHTTPServerTransport> = {};
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
server: 'openrouter-multimodal-mcp',
|
||||
version: '1.5.0',
|
||||
defaultModel: this.getDefaultModel()
|
||||
});
|
||||
});
|
||||
|
||||
// Main MCP endpoint - handles POST and GET
|
||||
app.all('/mcp', async (req: Request, res: Response) => {
|
||||
console.log(`[MCP] ${req.method} request received`);
|
||||
|
||||
// Handle GET for SSE stream (part of Streamable HTTP spec)
|
||||
if (req.method === 'GET') {
|
||||
console.log('[MCP] SSE stream requested');
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
|
||||
if (!sessionId || !transports[sessionId]) {
|
||||
res.status(400).json({ error: 'No active session. Send POST first.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Let the transport handle the SSE stream
|
||||
const transport = transports[sessionId];
|
||||
await transport.handleRequest(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle DELETE for session termination
|
||||
if (req.method === 'DELETE') {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (sessionId && transports[sessionId]) {
|
||||
await transports[sessionId].close();
|
||||
delete transports[sessionId];
|
||||
res.status(200).json({ message: 'Session terminated' });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle POST requests
|
||||
if (req.method !== 'POST') {
|
||||
res.status(405).json({ error: 'Method not allowed' });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
let transport: StreamableHTTPServerTransport;
|
||||
|
||||
// Check if this is an initialization request
|
||||
if (isInitializeRequest(req.body)) {
|
||||
console.log('[MCP] Initialize request - creating new session');
|
||||
|
||||
// Create new transport for stateless mode (no session persistence)
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID(),
|
||||
onsessioninitialized: (newSessionId) => {
|
||||
console.log(`[MCP] Session initialized: ${newSessionId}`);
|
||||
transports[newSessionId] = transport;
|
||||
}
|
||||
});
|
||||
|
||||
// Connect a new server instance for this transport
|
||||
const sessionServer = new Server(
|
||||
{ name: 'openrouter-multimodal-server', version: '1.5.0' },
|
||||
{ capabilities: { tools: {} } }
|
||||
);
|
||||
sessionServer.onerror = (error) => console.error('[MCP Session Error]', error);
|
||||
|
||||
// Initialize tool handlers for this session
|
||||
new ToolHandlers(sessionServer, this.apiKey, this.defaultModel);
|
||||
|
||||
await sessionServer.connect(transport);
|
||||
} else {
|
||||
// Existing session - find transport
|
||||
if (!sessionId || !transports[sessionId]) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid or missing session ID',
|
||||
message: 'Send initialize request first to create a session'
|
||||
});
|
||||
return;
|
||||
}
|
||||
transport = transports[sessionId];
|
||||
}
|
||||
|
||||
// Handle the request
|
||||
try {
|
||||
await transport.handleRequest(req, res);
|
||||
} catch (error) {
|
||||
console.error('[MCP] Request handling error:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Legacy SSE endpoint for backwards compatibility
|
||||
app.get('/sse', (_req: Request, res: Response) => {
|
||||
res.status(410).json({
|
||||
error: 'SSE transport is deprecated',
|
||||
message: 'Use Streamable HTTP at /mcp endpoint instead'
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(port, () => {
|
||||
console.log(`OpenRouter Multimodal MCP server running on HTTP port ${port}`);
|
||||
console.log(`MCP endpoint: http://localhost:${port}/mcp`);
|
||||
console.log(`Health check: http://localhost:${port}/health`);
|
||||
console.log(`Using default model: ${this.getDefaultModel()}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Determine transport mode from environment or arguments
|
||||
const transportMode = process.env.MCP_TRANSPORT || 'http';
|
||||
const httpPort = parseInt(process.env.PORT || '3001', 10);
|
||||
|
||||
// Get MCP configuration if provided
|
||||
let mcpOptions: ServerOptions | undefined;
|
||||
|
||||
// Check if we're being run as an MCP server with configuration
|
||||
// Check for config argument
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for --stdio flag
|
||||
if (process.argv.includes('--stdio')) {
|
||||
const server = new OpenRouterMultimodalServer(mcpOptions);
|
||||
server.runStdio().catch(console.error);
|
||||
} else {
|
||||
const server = new OpenRouterMultimodalServer(mcpOptions);
|
||||
server.runHttp(httpPort);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing MCP configuration:', error);
|
||||
console.error('Error parsing configuration:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// Default: HTTP mode unless MCP_TRANSPORT=stdio
|
||||
const server = new OpenRouterMultimodalServer(mcpOptions);
|
||||
if (transportMode === 'stdio') {
|
||||
server.runStdio().catch(console.error);
|
||||
} else {
|
||||
server.runHttp(httpPort);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
Reference in New Issue
Block a user