Skip to main content
Glama
cyanheads

ClinicalTrials.gov MCP Server

httpTransport.ts•15.7 kB
/** * @fileoverview Configures and starts the HTTP MCP transport using Hono. * This implementation uses the official @hono/mcp package for a fully * web-standard, platform-agnostic transport layer. * * Implements MCP Specification 2025-06-18 Streamable HTTP Transport. * @see {@link https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http | MCP Streamable HTTP Transport} * @module src/mcp-server/transports/http/httpTransport */ import { StreamableHTTPTransport } from '@hono/mcp'; import { type ServerType, serve } from '@hono/node-server'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { Hono } from 'hono'; import { cors } from 'hono/cors'; import http from 'http'; import { config } from '@/config/index.js'; import { authContext, createAuthMiddleware, createAuthStrategy, } from '@/mcp-server/transports/auth/index.js'; import { httpErrorHandler } from '@/mcp-server/transports/http/httpErrorHandler.js'; import type { HonoNodeBindings } from '@/mcp-server/transports/http/httpTypes.js'; import { generateSecureSessionId } from '@/mcp-server/transports/http/sessionIdUtils.js'; import { SessionStore, type SessionIdentity, } from '@/mcp-server/transports/http/sessionStore.js'; import { type RequestContext, logger, logStartupBanner, } from '@/utils/index.js'; /** * Extends the base StreamableHTTPTransport to include a session ID. */ class McpSessionTransport extends StreamableHTTPTransport { public sessionId: string; constructor(sessionId: string) { super(); this.sessionId = sessionId; } } export function createHttpApp( mcpServer: McpServer, parentContext: RequestContext, ): Hono<{ Bindings: HonoNodeBindings }> { const app = new Hono<{ Bindings: HonoNodeBindings }>(); const transportContext = { ...parentContext, component: 'HttpTransportSetup', }; // Initialize session store for stateful mode const sessionStore = config.mcpSessionMode === 'stateful' ? new SessionStore(config.mcpStatefulSessionStaleTimeoutMs) : null; // CORS (with permissive fallback) const allowedOrigin = Array.isArray(config.mcpAllowedOrigins) && config.mcpAllowedOrigins.length > 0 ? config.mcpAllowedOrigins : '*'; app.use( '*', cors({ origin: allowedOrigin, allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], allowHeaders: [ 'Content-Type', 'Authorization', 'Mcp-Session-Id', 'MCP-Protocol-Version', ], exposeHeaders: ['Mcp-Session-Id'], credentials: true, }), ); // Centralized error handling app.onError(httpErrorHandler); // MCP Spec 2025-06-18: Origin header validation for DNS rebinding protection // https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#security-warning app.use(config.mcpHttpEndpointPath, async (c, next) => { const origin = c.req.header('origin'); if (origin) { const isAllowed = allowedOrigin === '*' || (Array.isArray(allowedOrigin) && allowedOrigin.includes(origin)); if (!isAllowed) { logger.warning('Rejected request with invalid Origin header', { ...transportContext, origin, allowedOrigins: allowedOrigin, }); return c.json( { error: 'Invalid origin. DNS rebinding protection.' }, 403, ); } } // Origin is valid or not present, continue return await next(); }); // Health and GET /mcp status remain unprotected for convenience app.get('/healthz', (c) => c.json({ status: 'ok' })); // RFC 9728 Protected Resource Metadata endpoint (MCP 2025-06-18) // Must be accessible without authentication for discovery // https://datatracker.ietf.org/doc/html/rfc9728 app.get('/.well-known/oauth-protected-resource', (c) => { if (!config.oauthIssuerUrl) { logger.debug( 'OAuth Protected Resource Metadata requested but OAuth not configured', transportContext, ); return c.json( { error: 'OAuth not configured on this server' }, { status: 404 }, ); } const origin = new URL(c.req.url).origin; const resourceIdentifier = config.mcpServerResourceIdentifier ?? config.oauthAudience ?? `${origin}/mcp`; // Per RFC 9728, this endpoint provides metadata about the protected resource const metadata = { resource: resourceIdentifier, authorization_servers: [config.oauthIssuerUrl], bearer_methods_supported: ['header'], resource_signing_alg_values_supported: ['RS256', 'ES256', 'PS256'], resource_documentation: `${origin}/docs`, ...(config.oauthJwksUri && { jwks_uri: config.oauthJwksUri }), }; // RFC 9728 recommends caching this metadata c.header('Cache-Control', 'public, max-age=3600'); c.header('Content-Type', 'application/json'); logger.debug('Serving OAuth Protected Resource Metadata', { ...transportContext, resourceIdentifier, }); return c.json(metadata); }); app.get(config.mcpHttpEndpointPath, (c) => { return c.json({ status: 'ok', server: { name: config.mcpServerName, version: config.mcpServerVersion, description: config.mcpServerDescription, environment: config.environment, transport: config.mcpTransportType, sessionMode: config.mcpSessionMode, }, }); }); // MCP Spec 2025-06-18: DELETE endpoint for session termination // Clients SHOULD send DELETE to explicitly terminate sessions // https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management app.delete(config.mcpHttpEndpointPath, (c) => { const sessionId = c.req.header('mcp-session-id'); if (!sessionId) { logger.warning('DELETE request without session ID', transportContext); return c.json({ error: 'Mcp-Session-Id header required' }, 400); } logger.info('Session termination requested', { ...transportContext, sessionId, }); // For stateless mode or if session management is disabled, return 405 if (config.mcpSessionMode === 'stateless' || !sessionStore) { return c.json( { error: 'Session termination not supported in stateless mode' }, 405, ); } // Terminate the session in the store sessionStore.terminate(sessionId); logger.info('Session terminated successfully', { ...transportContext, sessionId, }); return c.json({ status: 'terminated', sessionId }, 200); }); // Create auth strategy and middleware if auth is enabled const authStrategy = createAuthStrategy(); if (authStrategy) { const authMiddleware = createAuthMiddleware(authStrategy); app.use(config.mcpHttpEndpointPath, authMiddleware); logger.info( 'Authentication middleware enabled for MCP endpoint.', transportContext, ); } else { logger.info( 'Authentication is disabled; MCP endpoint is unprotected.', transportContext, ); } // JSON-RPC over HTTP (Streamable) app.all(config.mcpHttpEndpointPath, async (c) => { const protocolVersion = c.req.header('mcp-protocol-version') ?? '2025-03-26'; logger.debug('Handling MCP request.', { ...transportContext, path: c.req.path, method: c.req.method, protocolVersion, }); // Per MCP Spec 2025-06-18: MCP-Protocol-Version header MUST be validated // Server MUST respond with 400 Bad Request for unsupported versions // We default to 2025-03-26 for backward compatibility if not provided const supportedVersions = ['2025-03-26', '2025-06-18']; if (!supportedVersions.includes(protocolVersion)) { logger.warning('Unsupported MCP protocol version requested.', { ...transportContext, protocolVersion, supportedVersions, }); return c.json( { error: 'Unsupported MCP protocol version', protocolVersion, supportedVersions, }, 400, ); } const providedSessionId = c.req.header('mcp-session-id'); const sessionId = providedSessionId ?? generateSecureSessionId(); // Extract identity from auth context (if auth is enabled) // This MUST happen before session validation for security const authStore = authContext.getStore(); let sessionIdentity: SessionIdentity | undefined; if (authStore?.authInfo) { // Build identity object conditionally to satisfy exactOptionalPropertyTypes sessionIdentity = {}; if (authStore.authInfo.tenantId) sessionIdentity.tenantId = authStore.authInfo.tenantId; if (authStore.authInfo.clientId) sessionIdentity.clientId = authStore.authInfo.clientId; if (authStore.authInfo.subject) sessionIdentity.subject = authStore.authInfo.subject; } // MCP Spec 2025-06-18: Return 404 for invalid/terminated sessions // https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management // SECURITY: Validate session WITH identity binding to prevent hijacking if ( sessionStore && providedSessionId && !sessionStore.isValidForIdentity(providedSessionId, sessionIdentity) ) { logger.warning( 'Session validation failed - invalid or hijacked session', { ...transportContext, sessionId: providedSessionId, requestTenant: sessionIdentity?.tenantId, requestClient: sessionIdentity?.clientId, }, ); return c.json({ error: 'Session not found or expired' }, 404); } // Create or update session for stateful mode WITH identity binding if (sessionStore) { sessionStore.getOrCreate(sessionId, sessionIdentity); } const transport = new McpSessionTransport(sessionId); const handleRpc = async (): Promise<Response> => { await mcpServer.connect(transport); const response = await transport.handleRequest(c); // MCP Spec 2025-06-18: For stateful sessions, return Mcp-Session-Id header // in InitializeResponse (and all subsequent responses) if (response && config.mcpSessionMode === 'stateful') { response.headers.set('Mcp-Session-Id', sessionId); logger.debug('Added Mcp-Session-Id header to response', { ...transportContext, sessionId, }); } if (response) { return response; } return c.body(null, 204); }; // The auth logic is now handled by the middleware. We just need to // run the core RPC logic within the async-local-storage context that // the middleware has already populated. try { const store = authContext.getStore(); if (store) { return await authContext.run(store, handleRpc); } return await handleRpc(); } catch (err) { // Only close transport on error - success path needs to keep it open await transport.close?.().catch((closeErr) => { logger.warning('Failed to close transport after error', { ...transportContext, sessionId, error: closeErr instanceof Error ? closeErr.message : String(closeErr), }); }); throw err instanceof Error ? err : new Error(String(err)); } }); logger.info('Hono application setup complete.', transportContext); return app; } async function isPortInUse( port: number, host: string, parentContext: RequestContext, ): Promise<boolean> { const context = { ...parentContext, operation: 'isPortInUse', port, host }; logger.debug(`Checking if port ${port} is in use...`, context); return new Promise((resolve) => { const tempServer = http.createServer(); tempServer .once('error', (err: NodeJS.ErrnoException) => resolve(err.code === 'EADDRINUSE'), ) .once('listening', () => tempServer.close(() => resolve(false))) .listen(port, host); }); } function startHttpServerWithRetry( app: Hono<{ Bindings: HonoNodeBindings }>, initialPort: number, host: string, maxRetries: number, parentContext: RequestContext, ): Promise<ServerType> { const startContext = { ...parentContext, operation: 'startHttpServerWithRetry', }; logger.info( `Attempting to start HTTP server on port ${initialPort} with ${maxRetries} retries.`, startContext, ); return new Promise((resolve, reject) => { const tryBind = (port: number, attempt: number) => { if (attempt > maxRetries + 1) { const error = new Error( `Failed to bind to any port after ${maxRetries} retries.`, ); logger.fatal(error.message, { ...startContext, port, attempt }); return reject(error); } isPortInUse(port, host, { ...startContext, port, attempt }) .then((inUse) => { if (inUse) { logger.warning(`Port ${port} is in use, retrying...`, { ...startContext, port, attempt, }); setTimeout( () => tryBind(port + 1, attempt + 1), config.mcpHttpPortRetryDelayMs, ); return; } try { const serverInstance = serve( { fetch: app.fetch, port, hostname: host }, (info) => { const serverAddress = `http://${info.address}:${info.port}${config.mcpHttpEndpointPath}`; logger.info(`HTTP transport listening at ${serverAddress}`, { ...startContext, port, address: serverAddress, }); logStartupBanner( `\n🚀 MCP Server running at: ${serverAddress}`, ); }, ); resolve(serverInstance); } catch (err: unknown) { logger.warning( `Binding attempt failed for port ${port}, retrying...`, { ...startContext, port, attempt, error: String(err) }, ); setTimeout( () => tryBind(port + 1, attempt + 1), config.mcpHttpPortRetryDelayMs, ); } }) .catch((err) => reject(err instanceof Error ? err : new Error(String(err))), ); }; tryBind(initialPort, 1); }); } export async function startHttpTransport( mcpServer: McpServer, parentContext: RequestContext, ): Promise<ServerType> { const transportContext = { ...parentContext, component: 'HttpTransportStart', }; logger.info('Starting HTTP transport.', transportContext); const app = createHttpApp(mcpServer, transportContext); const server = await startHttpServerWithRetry( app, config.mcpHttpPort, config.mcpHttpHost, config.mcpHttpMaxPortRetries, transportContext, ); logger.info('HTTP transport started successfully.', transportContext); return server; } export async function stopHttpTransport( server: ServerType, parentContext: RequestContext, ): Promise<void> { const operationContext = { ...parentContext, operation: 'stopHttpTransport', transportType: 'Http', }; logger.info('Attempting to stop http transport...', operationContext); return new Promise((resolve, reject) => { server.close((err) => { if (err) { logger.error('Error closing HTTP server.', err, operationContext); return reject(err); } logger.info('HTTP server closed successfully.', operationContext); resolve(); }); }); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cyanheads/clinicaltrialsgov-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server