mcp-server.js•11.8 kB
/**
* server.js
*
* Express MCP Server (Streamable HTTP, Stateful)
*
* This code:
* - Declares MCP capabilities (tools, resources, prompts).
* - Creates resources: config://app (static) and users://{userId}/profile (dynamic).
* - Creates tools: calculate-bmi and fibonacci.
* - Creates a prompt: review-code.
* - Captures the client's headers at initialization time.
* - Manages per-session MCP server instances in memory.
* - Serves the index.html home page.
* - Includes security middleware and proper error handling.
*/
import express from 'express';
import { randomUUID } from 'crypto';
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import { readFileSync } from 'fs';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
// Get __dirname equivalent for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
// Security middleware
app.use(express.json({ limit: '10mb' })); // Limit request size
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// CORS configuration for MCP clients
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id, Authorization');
res.header('Access-Control-Max-Age', '86400');
if (req.method === 'OPTIONS') {
res.sendStatus(200);
} else {
next();
}
});
// Basic rate limiting (simple in-memory implementation)
const rateLimitStore = new Map();
const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
const RATE_LIMIT_MAX_REQUESTS = 1000; // 1000 requests per window
app.use((req, res, next) => {
const clientId = req.ip || req.connection.remoteAddress;
const now = Date.now();
if (!rateLimitStore.has(clientId)) {
rateLimitStore.set(clientId, { count: 1, resetTime: now + RATE_LIMIT_WINDOW });
} else {
const client = rateLimitStore.get(clientId);
if (now > client.resetTime) {
client.count = 1;
client.resetTime = now + RATE_LIMIT_WINDOW;
} else {
client.count++;
}
if (client.count > RATE_LIMIT_MAX_REQUESTS) {
return res.status(429).json({
jsonrpc: '2.0',
error: { code: -32029, message: 'Rate limit exceeded' },
id: null
});
}
}
next();
});
// Clean up rate limit store periodically
setInterval(() => {
const now = Date.now();
for (const [clientId, client] of rateLimitStore.entries()) {
if (now > client.resetTime) {
rateLimitStore.delete(clientId);
}
}
}, RATE_LIMIT_WINDOW);
/**
* In-memory session store:
* sessions[sessionId] = {
* server: McpServer instance for this session,
* transport: StreamableHTTPServerTransport bound to this session,
* createdAt: timestamp for session cleanup
* }
*/
const sessions = {};
// Clean up old sessions (older than 1 hour)
setInterval(() => {
const now = Date.now();
const SESSION_TIMEOUT = 60 * 60 * 1000; // 1 hour
for (const [sessionId, session] of Object.entries(sessions)) {
if (now - session.createdAt > SESSION_TIMEOUT) {
delete sessions[sessionId];
}
}
}, 15 * 60 * 1000); // Check every 15 minutes
/**
* Calculate the nth Fibonacci number
*/
function calculateFibonacci(n) {
if (n < 0) {
throw new Error('Input must be a non-negative integer');
}
if (n <= 1) {
return n;
}
let prev = 0;
let current = 1;
for (let i = 2; i <= n; i++) {
const temp = current;
current = prev + current;
prev = temp;
}
return current;
}
/**
* Factory to create and configure a new McpServer (tools/resources/prompts)
* Note: We explicitly pass `capabilities` so that the client knows we support tools.
*/
function createMcpServer() {
const server = new McpServer({
name: 'fibonacci-mcp-server',
version: '1.0.0',
description: 'A secure MCP server providing mathematical tools including Fibonacci calculations',
// Declare that this server supports tools, resources, and prompts
capabilities: {
tools: { listChanged: true },
resources: { listChanged: true },
prompts: { listChanged: true }
}
});
// --- Register a static resource at URI "config://app" ---
server.resource(
'config',
'config://app',
async (uri) => {
return {
contents: [
{ uri: uri.href, text: 'App configuration here' }
]
};
}
);
// --- Register a dynamic user-profile resource ---
server.resource(
'user-profile',
new ResourceTemplate('users://{userId}/profile', { list: undefined }),
async (uri, { userId }) => {
// Basic input validation
if (!userId || typeof userId !== 'string' || userId.length > 100) {
throw new Error('Invalid userId provided');
}
return {
contents: [
{
uri: uri.href,
text: `Profile data for user ${userId}`
}
]
};
}
);
// --- Register a "calculate-bmi" tool ---
// The client can call this tool via "mcp/callTool" method once initialized.
server.tool(
'calculate-bmi',
{ weightKg: z.number().min(0).max(1000), heightM: z.number().min(0.1).max(3.0) },
async ({ weightKg, heightM }) => {
const bmi = weightKg / (heightM * heightM);
return {
content: [
{ type: 'text', text: `BMI: ${bmi.toFixed(2)}` }
]
};
}
);
// --- Register a "fibonacci" tool ---
server.tool(
"fibonacci",
"Calculate the nth Fibonacci number",
{
n: z.number().int().min(0).max(1000).describe("The position in the Fibonacci sequence (0-indexed, max 1000)"),
},
async ({ n }) => {
try {
const result = calculateFibonacci(n);
return {
content: [
{
type: "text",
text: `The ${n}th Fibonacci number is: ${result}`,
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error: ${error.message}`,
},
],
};
}
},
);
// --- Register a "review-code" prompt ---
server.prompt(
'review-code',
{ code: z.string().max(10000) },
({ code }) => {
return {
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Please review this code:\n\n${code}`
}
}
]
};
}
);
return server;
}
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
sessions: Object.keys(sessions).length
});
});
// Handle home page
app.get('/', (req, res) => {
try {
const htmlPath = join(__dirname, 'index.html');
const htmlContent = readFileSync(htmlPath, 'utf8');
res.send(htmlContent);
} catch (error) {
res.status(500).send('Error loading home page');
}
});
/**
* Handler for POST /mcp:
* 1. If "mcp-session-id" header exists and matches a stored session, reuse that session.
* 2. If no "mcp-session-id" and request is initialize, create new session and handshake.
* 3. Otherwise, return a 400 error.
*/
app.post('/mcp', async (req, res) => {
try {
const sessionIdHeader = req.headers['mcp-session-id'];
let sessionEntry = null;
// Case 1: Existing session found
if (sessionIdHeader && sessions[sessionIdHeader]) {
sessionEntry = sessions[sessionIdHeader];
// Case 2: Initialization request → create new transport + server
} else if (!sessionIdHeader && isInitializeRequest(req.body)) {
const newSessionId = randomUUID();
// Create a new transport for this session
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => newSessionId,
onsessioninitialized: (sid) => {
// Store the Transport and Server instance once session is initialized
sessions[sid] = { server, transport, createdAt: Date.now() };
}
});
// When this transport closes, clean up the session entry
transport.onclose = () => {
if (transport.sessionId && sessions[transport.sessionId]) {
delete sessions[transport.sessionId];
}
};
// Create and configure the new McpServer
const server = createMcpServer();
await server.connect(transport);
// After `onsessioninitialized` fires, `sessions[newSessionId]` is set.
// But we can also assign it here for immediate access.
sessions[newSessionId] = { server, transport, createdAt: Date.now() };
sessionEntry = sessions[newSessionId];
} else {
// Neither a valid session nor an initialize request → return error
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
id: null
});
return;
}
// Forward the request to the transport of the retrieved/created session
await sessionEntry.transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('Error in POST /mcp:', error);
res.status(500).json({
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal server error' },
id: null
});
}
});
/**
* Handler for GET/DELETE /mcp:
* Used for server-to-client notifications (SSE) and session termination.
*/
async function handleSessionRequest(req, res) {
try {
const sessionIdHeader = req.headers['mcp-session-id'];
if (!sessionIdHeader || !sessions[sessionIdHeader]) {
res.status(400).send('Invalid or missing session ID');
return;
}
const { transport } = sessions[sessionIdHeader];
await transport.handleRequest(req, res);
} catch (error) {
console.error('Error in handleSessionRequest:', error);
res.status(500).send('Internal server error');
}
}
app.get('/mcp', handleSessionRequest);
app.delete('/mcp', handleSessionRequest);
// Global error handler
app.use((error, req, res, next) => {
console.error('Unhandled error:', error);
res.status(500).json({
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal server error' },
id: null
});
});
// 404 handler
app.use((req, res) => {
res.status(404).json({
jsonrpc: '2.0',
error: { code: -32601, message: 'Method not found' },
id: null
});
});
// Start the server on the port provided by Render or default to 7171
const PORT = process.env.PORT || 7171;
const HOST = process.env.NODE_ENV === 'production' ? '0.0.0.0' : 'localhost';
// Validate environment
if (process.env.NODE_ENV === 'production') {
console.log('Running in production mode');
// In production, you might want to enforce HTTPS
// app.use((req, res, next) => {
// if (req.headers['x-forwarded-proto'] !== 'https') {
// return res.redirect(`https://${req.headers.host}${req.url}`);
// }
// next();
// });
}
app.listen(PORT, HOST, () => {
console.log(`Fibonacci MCP Server listening on ${HOST}:${PORT}`);
console.log(`MCP endpoint: http://${HOST}:${PORT}/mcp`);
console.log(`Home page: http://${HOST}:${PORT}/`);
console.log(`Health check: http://${HOST}:${PORT}/health`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
});