@grebyn/toolflow-mcp-server
Version:
MCP server for managing other MCP servers - discover, install, organize into bundles, and automate with workflows. Uses StreamableHTTP transport with dual OAuth/API key authentication.
445 lines • 20.1 kB
JavaScript
import express from 'express';
import cors from 'cors';
import { randomUUID } from 'crypto';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest, CallToolRequestSchema, ListToolsRequestSchema, InitializeRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { InMemoryEventStore } from '@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js';
import { OAuthEndpointsHandler } from './auth/oauth-endpoints.js';
import { TokenPassthrough } from './auth/token-passthrough.js';
import { listTools, getTool } from './tools/index.js';
import { getServerConfig } from './config/server-config.js';
import { withLogging } from './utils/logger.js';
import { extractClientIdentifier } from './utils/shared-logging.js';
// MCP server with StreamableHTTP transport and OAuth support
// Create MCP server factory function with auth context access
function createMcpServer(getAuthContext, getRequestContext) {
const server = new Server({
name: 'toolflow-mcp',
version: '1.0.20',
}, {
capabilities: {
tools: {},
},
});
// Register handlers for MCP protocol
server.setRequestHandler(InitializeRequestSchema, async (request) => {
console.error(`MCP: Handling Initialize request from ${request.params.clientInfo?.name || 'unknown'}`);
return {
protocolVersion: request.params.protocolVersion,
capabilities: {
tools: {},
logging: {},
},
serverInfo: {
name: 'toolflow-mcp',
version: '1.0.20',
}
};
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
console.error('MCP: Handling ListTools request');
return { tools: listTools() };
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const toolName = request.params.name;
console.error(`MCP: Handling CallTool request for tool: ${toolName}`);
const tool = getTool(toolName);
if (!tool) {
throw new Error(`Tool not found: ${toolName}`);
}
// Get authenticated user context from the HTTP request
const authContext = getAuthContext();
const userContext = {
userId: authContext?.userId || 'anonymous',
organizationId: authContext?.organizationId,
email: authContext?.email,
sessionId: authContext?.sessionId,
tokenJti: authContext?.tokenJti,
token: authContext?.token,
apiKey: authContext?.apiKey
};
console.error(`Tool '${toolName}' called by user: ${userContext.userId}`);
// Get request context for logging
const requestContext = getRequestContext();
try {
// Execute tool with logging
const result = await withLogging(userContext, toolName, request.params.arguments || {}, 'streamable-http', {
sessionId: userContext.sessionId,
clientIdentifier: requestContext?.clientIdentifier,
ipAddress: requestContext?.ipAddress,
userAgent: requestContext?.userAgent
}, () => tool.execute(request.params.arguments || {}, userContext));
return result;
}
catch (error) {
throw new Error(`Tool execution failed: ${error.message}`);
}
});
return server;
}
async function main() {
const serverConfig = getServerConfig();
console.error('SERVER CONFIG:', JSON.stringify(serverConfig, null, 2));
console.error('TRANSPORT: streamable-http');
const oauthHandler = new OAuthEndpointsHandler(serverConfig);
const tokenPassthrough = new TokenPassthrough(serverConfig.apiEndpoint);
const app = express();
app.use(express.json());
// Basic request logging for all requests
app.use((req, res, next) => {
console.error(`=== INCOMING REQUEST ===`);
console.error(`${req.method} ${req.url}`);
console.error(`Headers:`, JSON.stringify(req.headers, null, 2));
if (req.body && Object.keys(req.body).length > 0) {
console.error(`Body:`, JSON.stringify(req.body, null, 2));
}
console.error(`========================`);
next();
});
// Configure CORS to expose Mcp-Session-Id header
app.use(cors({
origin: '*',
exposedHeaders: ['Mcp-Session-Id']
}));
// Handle OAuth metadata endpoints (must be public, no auth middleware)
app.get('/.well-known/oauth-protected-resource', (req, res) => {
console.error('OAuth: Serving protected resource metadata');
oauthHandler.handleProtectedResourceMetadata(req, res);
});
app.get('/.well-known/oauth-authorization-server', (req, res) => {
console.error('OAuth: Serving authorization server metadata');
oauthHandler.handleAuthorizationServerMetadata(req, res);
});
app.post('/.well-known/oauth-dynamic-client-registration', (req, res) => {
console.error('OAuth: Handling dynamic client registration');
oauthHandler.handleDynamicClientRegistration(req, res);
});
// API key validation function
const validateApiKey = async (apiKey) => {
if (!apiKey || !apiKey.startsWith('tfl_')) {
return { isValid: false, error: 'Invalid API key format' };
}
try {
// Hash the provided key to match against database
const encoder = new TextEncoder();
const data = encoder.encode(apiKey);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const keyHash = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
// Validate against our API endpoint
const response = await fetch(`${serverConfig.apiEndpoint}/proxy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
operation: 'validateApiKey',
params: { keyHash }
})
});
if (!response.ok) {
const errorText = await response.text();
return { isValid: false, error: `API validation failed: ${errorText}` };
}
const result = await response.json();
if (result.is_valid) {
return {
isValid: true,
userId: result.user_id,
organizationId: result.organization_id,
apiKey: apiKey
};
}
else {
return { isValid: false, error: 'Invalid or expired API key' };
}
}
catch (error) {
return { isValid: false, error: `API key validation error: ${error.message}` };
}
};
// Authentication middleware - supports both OAuth and API keys
const authMiddleware = async (req, res, next) => {
console.error(`HTTP: ${req.method} ${req.path} - Headers: ${JSON.stringify(req.headers)}`);
if (serverConfig.disableAuth) {
req.auth = { token: null, userId: 'dev-user' };
console.error('AUTH: Using dev-user (auth disabled)');
return next();
}
// Check for API key first
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer tfl_')) {
const apiKey = authHeader.substring(7);
console.error('AUTH: Attempting API key authentication');
const validation = await validateApiKey(apiKey);
if (validation.isValid) {
req.auth = {
token: null,
apiKey: validation.apiKey,
isAuthenticated: true,
userId: validation.userId,
organizationId: validation.organizationId
};
console.error(`AUTH: API key success - User: ${req.auth.userId}`);
return next();
}
else {
console.error(`AUTH: API key failed - ${validation.error}`);
return res.status(401).json({ error: validation.error });
}
}
// Fallback to OAuth authentication
console.error('AUTH: Attempting OAuth authentication');
const authContext = await tokenPassthrough.extractToken(req);
if (!authContext.isAuthenticated) {
const errorMsg = authContext.error || 'OAuth authentication required';
console.error(`AUTH: OAuth failed - ${errorMsg}`);
tokenPassthrough.sendUnauthorizedResponse(req, res, new Error(errorMsg));
return;
}
req.auth = {
token: authContext.token || null,
isAuthenticated: authContext.isAuthenticated,
userId: authContext.userId,
email: authContext.email,
organizationId: authContext.organizationId,
sessionId: authContext.sessionId,
tokenJti: authContext.tokenJti
};
console.error(`AUTH: OAuth success - User: ${req.auth.userId}`);
next();
};
// Map to store transports and their contexts by session ID
const transports = {};
// Session cleanup configuration
const SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
// Periodic cleanup of expired sessions
const cleanupInterval = setInterval(() => {
const now = Date.now();
const expiredSessions = [];
for (const [sessionId, session] of Object.entries(transports)) {
if (now - session.lastUsed > SESSION_TTL_MS) {
expiredSessions.push(sessionId);
}
}
for (const sessionId of expiredSessions) {
try {
console.error(`Cleaning up expired session: ${sessionId}`);
const session = transports[sessionId];
if (session?.transport) {
session.transport.close().catch(() => { }); // Silent cleanup
}
delete transports[sessionId];
}
catch (error) {
// Silent cleanup - don't let cleanup errors break the server
}
}
if (expiredSessions.length > 0) {
console.error(`Cleaned up ${expiredSessions.length} expired sessions`);
}
}, CLEANUP_INTERVAL_MS);
// MCP POST endpoint
const mcpPostHandler = async (req, res) => {
const sessionId = req.headers['mcp-session-id'];
try {
if (sessionId && transports[sessionId]) {
// Reuse existing transport with stored auth context
const transportData = transports[sessionId];
// Update auth context for this request (in case token was refreshed)
transportData.authContext = req.auth;
// Update request context
transportData.requestContext = {
ipAddress: req.ip || req.socket.remoteAddress,
userAgent: req.headers['user-agent'],
clientIdentifier: extractClientIdentifier(req.headers['user-agent'])
};
// Update last used timestamp for TTL cleanup
transportData.lastUsed = Date.now();
console.error(`MCP: Handling request for existing session ${sessionId}`);
await transportData.transport.handleRequest(req, res, req.body);
return;
}
if (!sessionId && isInitializeRequest(req.body)) {
// New initialization request - create fresh transport and server
console.error('MCP: Processing new initialization request');
console.error('MCP: Request body:', JSON.stringify(req.body));
const eventStore = new InMemoryEventStore();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
eventStore,
onsessioninitialized: (newSessionId) => {
console.error(`MCP: Session initialized with ID: ${newSessionId} for user: ${req.auth?.userId || 'anonymous'}`);
// Store transport with auth and request context
transports[newSessionId] = {
transport,
authContext: req.auth,
requestContext: {
ipAddress: req.ip || req.socket.remoteAddress,
userAgent: req.headers['user-agent'],
clientIdentifier: extractClientIdentifier(req.headers['user-agent'])
},
lastUsed: Date.now() // Initialize timestamp for TTL cleanup
};
},
onsessionclosed: (closedSessionId) => {
console.error(`MCP: Session closed: ${closedSessionId}`);
delete transports[closedSessionId];
}
});
// Set up cleanup on transport close
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && transports[sid]) {
console.error(`MCP: Transport closed for session ${sid}`);
delete transports[sid];
}
};
// Create auth context accessor that reads from the stored session data
const getAuthContext = () => {
const sid = transport.sessionId;
if (sid && transports[sid]) {
const authContext = transports[sid].authContext;
// Ensure sessionId is available for logging
return { ...authContext, sessionId: sid };
}
return req.auth; // Fallback during initialization
};
// Create request context accessor
const getRequestContext = () => {
const sid = transport.sessionId;
if (sid && transports[sid]) {
return transports[sid].requestContext;
}
// Fallback during initialization
return {
ipAddress: req.ip || req.socket.remoteAddress,
userAgent: req.headers['user-agent'],
clientIdentifier: extractClientIdentifier(req.headers['user-agent'])
};
};
// Create server and connect to transport
console.error('MCP: Creating server and connecting transport');
const server = createMcpServer(getAuthContext, getRequestContext);
// Connect server to transport first
await server.connect(transport);
console.error('MCP: Server connected successfully');
// Now handle the initialization request
console.error('MCP: Handling initialization request');
await transport.handleRequest(req, res, req.body);
console.error('MCP: Initialization request handled');
return;
}
// Invalid request - no session ID and not an initialization request
console.error('MCP: Invalid request - no session ID and not initialization');
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided and not an initialization request',
},
id: null,
});
}
catch (error) {
console.error('Error handling MCP request:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error',
},
id: null,
});
}
}
};
// MCP GET endpoint for SSE
const mcpGetHandler = async (req, res) => {
const sessionId = req.headers['mcp-session-id'];
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}
const transportData = transports[sessionId];
// Update last used timestamp
transportData.lastUsed = Date.now();
await transportData.transport.handleRequest(req, res);
};
// MCP DELETE endpoint for session termination
const mcpDeleteHandler = async (req, res) => {
const sessionId = req.headers['mcp-session-id'];
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}
console.error(`Session termination request for session ${sessionId}`);
try {
const transportData = transports[sessionId];
// Update last used timestamp before handling termination
transportData.lastUsed = Date.now();
await transportData.transport.handleRequest(req, res);
}
catch (error) {
console.error('Error handling session termination:', error);
if (!res.headersSent) {
res.status(500).send('Error processing session termination');
}
}
};
// Set up MCP routes with auth middleware
app.post('/mcp', authMiddleware, mcpPostHandler);
app.get('/mcp', authMiddleware, mcpGetHandler);
app.delete('/mcp', authMiddleware, mcpDeleteHandler);
// Start server with fixed port for mcp-remote compatibility
const port = process.env.PORT || 3000; // Fixed port for Claude Desktop + mcp-remote
const server = app.listen(port, () => {
const actualPort = server.address()?.port || port;
console.error(`ToolFlow MCP Server started on port ${actualPort} with StreamableHTTP transport`);
});
// Health check endpoint
app.get('/', (req, res) => {
const actualPort = server?.address()?.port || 'unknown';
res.json({
message: 'ToolFlow MCP Server',
transport: 'StreamableHTTP',
version: '1.0.20',
port: actualPort,
endpoints: {
mcp: '/mcp',
oauth_discovery: '/.well-known/oauth-protected-resource'
}
});
});
// Handle graceful shutdown
process.on('SIGINT', async () => {
console.error('Shutting down server...');
// Clear the cleanup interval
clearInterval(cleanupInterval);
// Close all active sessions
for (const sessionId in transports) {
try {
console.error(`Closing transport for session ${sessionId}`);
await transports[sessionId].transport.close();
delete transports[sessionId];
}
catch (error) {
console.error(`Error closing transport for session ${sessionId}:`, error);
}
}
console.error('Server shutdown complete');
process.exit(0);
});
}
// Run the server
main().catch(error => {
console.error("Server error:", error);
process.exit(1);
});
//# sourceMappingURL=index-streamable-http.js.map