Add Streamable HTTP transport for remote MCP access
Some checks failed
CI/CD Pipeline / build (push) Has been cancelled
CI/CD Pipeline / publish-npm (push) Has been cancelled
CI/CD Pipeline / docker (push) Has been cancelled

- 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:
Krishna Kumar
2025-12-31 11:56:21 -06:00
parent 50c2c43fd9
commit 3e0ed2d6c6
5 changed files with 925 additions and 320 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

@@ -0,0 +1,9 @@
[build]
builder = "dockerfile"
dockerfilePath = "Dockerfile"
[deploy]
healthcheckPath = "/health"
healthcheckTimeout = 30
restartPolicyType = "on_failure"
restartPolicyMaxRetries = 3

View File

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