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
|
# Switch to production for runtime
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Default port for HTTP transport
|
||||||
|
ENV PORT=3001
|
||||||
|
|
||||||
# The API key should be passed at runtime
|
# The API key should be passed at runtime
|
||||||
# ENV OPENROUTER_API_KEY=your-api-key-here
|
# ENV OPENROUTER_API_KEY=your-api-key-here
|
||||||
# ENV OPENROUTER_DEFAULT_MODEL=your-default-model
|
# 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"]
|
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"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.8.0",
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "^4.21.2",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"openai": "^4.89.1",
|
"openai": "^4.89.1",
|
||||||
"sharp": "^0.33.5"
|
"sharp": "^0.33.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
"@types/node": "^22.13.14",
|
"@types/node": "^22.13.14",
|
||||||
"@types/sharp": "^0.32.0",
|
"@types/sharp": "^0.32.0",
|
||||||
"shx": "^0.3.4",
|
"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
|
#!/usr/bin/env node
|
||||||
// OpenRouter Multimodal MCP Server
|
// OpenRouter Multimodal MCP Server
|
||||||
|
// Supports both stdio (local) and Streamable HTTP (remote) transports
|
||||||
|
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.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';
|
import { ToolHandlers } from './tool-handlers.js';
|
||||||
|
|
||||||
@@ -15,15 +20,17 @@ interface ServerOptions {
|
|||||||
|
|
||||||
class OpenRouterMultimodalServer {
|
class OpenRouterMultimodalServer {
|
||||||
private server: Server;
|
private server: Server;
|
||||||
private toolHandlers!: ToolHandlers; // Using definite assignment assertion
|
private toolHandlers!: ToolHandlers;
|
||||||
|
private apiKey: string;
|
||||||
|
private defaultModel: string;
|
||||||
|
|
||||||
constructor(options?: ServerOptions) {
|
constructor(options?: ServerOptions) {
|
||||||
// Retrieve API key from options or environment variables
|
// Retrieve API key from options or environment variables
|
||||||
const apiKey = options?.apiKey || process.env.OPENROUTER_API_KEY;
|
this.apiKey = options?.apiKey || process.env.OPENROUTER_API_KEY || '';
|
||||||
const defaultModel = options?.defaultModel || process.env.OPENROUTER_DEFAULT_MODEL || DEFAULT_MODEL;
|
this.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 (!this.apiKey) {
|
||||||
throw new Error('OpenRouter API key is required. Provide it via options or OPENROUTER_API_KEY environment variable');
|
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(
|
this.server = new Server(
|
||||||
{
|
{
|
||||||
name: 'openrouter-multimodal-server',
|
name: 'openrouter-multimodal-server',
|
||||||
version: '1.0.0',
|
version: '1.5.0',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
capabilities: {
|
capabilities: {
|
||||||
@@ -39,78 +46,204 @@ class OpenRouterMultimodalServer {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set up error handling
|
// Set up error handling
|
||||||
this.server.onerror = (error) => console.error('[MCP Error]', error);
|
this.server.onerror = (error) => console.error('[MCP Error]', error);
|
||||||
|
|
||||||
// Initialize tool handlers
|
// Initialize tool handlers
|
||||||
this.toolHandlers = new ToolHandlers(
|
this.toolHandlers = new ToolHandlers(
|
||||||
this.server,
|
this.server,
|
||||||
apiKey,
|
this.apiKey,
|
||||||
defaultModel
|
this.defaultModel
|
||||||
);
|
);
|
||||||
|
|
||||||
process.on('SIGINT', async () => {
|
process.on('SIGINT', async () => {
|
||||||
await this.server.close();
|
await this.server.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async run() {
|
getServer(): Server {
|
||||||
|
return this.server;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultModel(): string {
|
||||||
|
return this.toolHandlers.getDefaultModel() || this.defaultModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
async runStdio() {
|
||||||
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 default model: ${this.getDefaultModel()}`);
|
||||||
// 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...');
|
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
|
// Get MCP configuration if provided
|
||||||
let mcpOptions: ServerOptions | undefined;
|
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) {
|
if (process.argv.length > 2) {
|
||||||
try {
|
try {
|
||||||
const configArg = process.argv.find(arg => arg.startsWith('--config='));
|
const configArg = process.argv.find(arg => arg.startsWith('--config='));
|
||||||
if (configArg) {
|
if (configArg) {
|
||||||
const configPath = configArg.split('=')[1];
|
const configPath = configArg.split('=')[1];
|
||||||
const configData = require(configPath);
|
const configData = require(configPath);
|
||||||
|
|
||||||
// Extract configuration
|
|
||||||
mcpOptions = {
|
mcpOptions = {
|
||||||
apiKey: configData.OPENROUTER_API_KEY || configData.apiKey,
|
apiKey: configData.OPENROUTER_API_KEY || configData.apiKey,
|
||||||
defaultModel: configData.OPENROUTER_DEFAULT_MODEL || configData.defaultModel
|
defaultModel: configData.OPENROUTER_DEFAULT_MODEL || configData.defaultModel
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mcpOptions.apiKey) {
|
if (mcpOptions.apiKey) {
|
||||||
console.error('Using API key from MCP configuration');
|
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) {
|
} 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