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:
@@ -39,9 +39,15 @@ RUN npx tsc && \
|
||||
# Switch to production for runtime
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Default port for HTTP transport
|
||||
ENV PORT=3001
|
||||
|
||||
# The API key should be passed at runtime
|
||||
# ENV OPENROUTER_API_KEY=your-api-key-here
|
||||
# ENV OPENROUTER_DEFAULT_MODEL=your-default-model
|
||||
|
||||
# Run the server
|
||||
# Expose HTTP port
|
||||
EXPOSE 3001
|
||||
|
||||
# Run the server in HTTP mode (default)
|
||||
CMD ["node", "dist/index.js"]
|
||||
|
||||
1009
package-lock.json
generated
1009
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -43,14 +43,16 @@
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.8.0",
|
||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||
"axios": "^1.8.4",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"openai": "^4.89.1",
|
||||
"sharp": "^0.33.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.13.14",
|
||||
"@types/sharp": "^0.32.0",
|
||||
"shx": "^0.3.4",
|
||||
|
||||
9
railway.toml
Normal file
9
railway.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[build]
|
||||
builder = "dockerfile"
|
||||
dockerfilePath = "Dockerfile"
|
||||
|
||||
[deploy]
|
||||
healthcheckPath = "/health"
|
||||
healthcheckTimeout = 30
|
||||
restartPolicyType = "on_failure"
|
||||
restartPolicyMaxRetries = 3
|
||||
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