import express, { Express, Request, Response, NextFunction } from 'express'; import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { z } from 'zod'; import { completable } from '@modelcontextprotocol/sdk/server/completable.js'; import { ListResourcesRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { MCPServerConfig, MCPServer, Logger, JSONSchema, CredentialFieldDefinition } from './types'; import { SubscriptionManager } from './subscriptions/SubscriptionManager'; import { WebhookManager } from './webhooks/WebhookManager'; import { MCPError, AuthenticationError, ValidationError } from './errors'; import { isValidUrl, isHttpsUrl } from './utils'; import cors from 'cors'; /** * Default console logger */ const defaultLogger: Logger = { debug: (msg, meta) => console.debug(msg, meta), info: (msg, meta) => console.info(msg, meta), warn: (msg, meta) => console.warn(msg, meta), error: (msg, meta) => console.error(msg, meta), }; /** * Convert JSON Schema to Zod schema (simplified version) * Handles basic types and nested objects */ function convertToZodSchema(jsonSchema: JSONSchema): Record { const schema: Record = {}; if (!jsonSchema || !jsonSchema.properties) { return schema; } const required = jsonSchema.required || []; for (const [key, value] of Object.entries(jsonSchema.properties)) { let zodType: z.ZodTypeAny; if (value.type === 'string') { zodType = z.string(); } else if (value.type === 'number' || value.type === 'integer') { zodType = z.number(); } else if (value.type === 'boolean') { zodType = z.boolean(); } else if (value.type === 'array') { // Handle array items if (value.items?.type === 'string') { zodType = z.array(z.string()); } else if (value.items?.type === 'number') { zodType = z.array(z.number()); } else if (value.items?.type === 'object') { zodType = z.array(z.object(convertToZodSchema(value.items as JSONSchema)).passthrough()); } else { zodType = z.array(z.any()); } } else if (value.type === 'object') { if (value.properties) { zodType = z.object(convertToZodSchema(value as JSONSchema)).passthrough(); } else { zodType = z.record(z.string(), z.any()); } } else { zodType = z.any(); } // Mark as optional if not in required array if (!required.includes(key)) { zodType = zodType.optional(); } schema[key] = zodType; } return schema; } const VALID_CREDENTIAL_FIELD_TYPES = new Set([ 'string', 'number', 'boolean', 'object', 'array', 'date', 'file', 'binary', ]); function sanitizeCredentialFields( fields: unknown, path = 'credentials' ): CredentialFieldDefinition[] { if (fields === undefined || fields === null) { return []; } if (!Array.isArray(fields)) { throw new ValidationError(`MCP configuration ${path} must be an array`); } return fields.map((field, index) => sanitizeCredentialField(field, `${path}[${index}]`) ); } function sanitizeCredentialField( field: unknown, path: string ): CredentialFieldDefinition { if (typeof field !== 'object' || field === null) { throw new ValidationError(`MCP configuration ${path} must be an object`); } const candidate = field as Record; const rawName = candidate.name; const name = typeof rawName === 'string' ? rawName.trim() : ''; if (!name) { throw new ValidationError(`MCP configuration ${path}.name is required`); } const rawType = typeof candidate.type === 'string' ? candidate.type.toLowerCase() : 'string'; const type = (VALID_CREDENTIAL_FIELD_TYPES.has(rawType) ? rawType : 'string') as CredentialFieldDefinition['type']; const description = typeof candidate.description === 'string' && candidate.description.trim().length > 0 ? candidate.description : undefined; const sanitized: CredentialFieldDefinition = { name, type, required: Boolean(candidate.required), }; if (description) { sanitized.description = description; } if (candidate.default !== undefined) { sanitized.default = candidate.default; } if (Array.isArray(candidate.innerFields) && candidate.innerFields.length > 0) { const inner = sanitizeCredentialFields(candidate.innerFields, `${path}.innerFields`); if (inner.length > 0) { sanitized.innerFields = inner; } } if (Array.isArray(candidate.or) && candidate.or.length > 0) { const alternatives = sanitizeCredentialFields(candidate.or, `${path}.or`); if (alternatives.length > 0) { sanitized.or = alternatives; } } if (candidate.config && typeof candidate.config === 'object') { sanitized.config = { ...candidate.config }; } if (candidate.metadata && typeof candidate.metadata === 'object') { sanitized.metadata = { ...candidate.metadata }; } return sanitized; } /** * Create MCP HTTP Webhook Server using standard MCP SDK */ export function createMCPServer(config: MCPServerConfig): MCPServer { const logger = config.logger || defaultLogger; const basePath = config.basePath || '/mcp'; const port = config.port || 3000; const host = config.host || '0.0.0.0'; // Validate configuration validateConfig(config); // Validate credential metadata upfront to surface configuration issues early sanitizeCredentialFields(config.credentials); // Create Express app const app: Express = express(); // Middleware app.use(express.json({ limit: '50mb' })); // allow cors app.use(cors()); // Custom middleware if (config.middleware) { config.middleware.forEach((mw) => app.use(mw)); } // Mutex to serialize connect/close on the shared McpServer instance. // The MCP SDK Protocol only allows one active transport at a time. let connectLock: Promise = Promise.resolve(); // Create MCP SDK server const sdkServer = new McpServer( { name: config.name, version: config.version, }, { capabilities: { resources: { subscribe: true, listChanged: true, }, experimental: { isConnector: { enabled: true, }, // Must be dictionary, not boolean }, "completions": {} }, } ); // Initialize managers for webhook subscriptions const subscriptionManager = new SubscriptionManager( config.store, config.resources, config.publicUrl, logger ); const webhookManager = new WebhookManager(config.webhooks, config.resources, logger); // Register tools with MCP SDK config.tools.forEach((toolDef) => { const zodSchema = convertToZodSchema(toolDef.inputSchema); (sdkServer as any).registerTool( toolDef.name, { title: toolDef.description, description: toolDef.description, inputSchema: zodSchema, }, async (input: any) => { // Get auth context from request metadata if available const context = (sdkServer as any)._currentContext || { userId: 'anonymous' }; const result = await toolDef.handler(input, context); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], structuredContent: result, }; } ); }); // Register resources with MCP SDK // Sort resources: Resources WITH list handlers first (for proper listing), // then by specificity (most specific first) for proper read matching const sortedResources = [...config.resources].sort((a, b) => { // Resources with list handlers come first const aHasList = !!a.list; const bHasList = !!b.list; if (aHasList && !bHasList) return -1; if (!aHasList && bHasList) return 1; // Within same category, sort by specificity (most specific first) const aSegments = a.uri.split('/').filter(s => !s.includes('{')).length; const bSegments = b.uri.split('/').filter(s => !s.includes('{')).length; return bSegments - aSegments; // Descending order (most specific first) }); logger.info('📋 Registering resources (list-enabled first, then by specificity):'); sortedResources.forEach((r, i) => { const fixedSegments = r.uri.split('/').filter(s => !s.includes('{')).length; const hasList = r.list ? '✓ list' : '✗ read-only'; logger.info(` ${i + 1}. ${r.uri} (specificity: ${fixedSegments}, ${hasList})`); }); sortedResources.forEach((resourceDef) => { // Check if URI is a template (contains {}) const isTemplate = resourceDef.uri.includes('{') && resourceDef.uri.includes('}'); if (isTemplate && resourceDef.list) { // Use ResourceTemplate for templated URIs WITH list handler const template = new ResourceTemplate(resourceDef.uri, { list: async (extra?: any) => { const context = (sdkServer as any)._currentContext || { userId: 'anonymous' }; console.log(`📄 MCP LIST_RESOURCES: Fetching page (cursor=${extra?._meta?.cursor || 'None'})`); // The MCP SDK doesn't pass cursor to the list callback - it's handled internally // by ResourceTemplate. We need to handle pagination in the MongoDB MCP itself. // For now, just fetch all resources without cursor support at this level. const cursor = extra?._meta?.cursor; // Build pagination options from cursor const options: any = {}; if (cursor) { // Parse cursor as page number for page-based pagination const page = parseInt(cursor, 10); options.page = !isNaN(page) ? page : 1; options.cursor = cursor; } const result = await resourceDef.list!(context, options); // Handle both array and paginated responses const resources = Array.isArray(result) ? result : result.resources; const pagination = Array.isArray(result) ? undefined : result.pagination; const nextCursor = Array.isArray(result) ? undefined : result.nextCursor; // Map to MCP SDK Resource format with index signature const response: any = { resources: resources.map((r: any) => ({ uri: r.uri, name: r.name, description: r.description, mimeType: resourceDef.mimeType, metadata: resourceDef.metadata, ...r, // Spread to add index signature })), }; // Add nextCursor if present (for cursor-based pagination) if (nextCursor) { response.nextCursor = nextCursor; logger.info(`📄 MCP Proxy: Returning ${response.resources.length} resources with nextCursor=${nextCursor}`); } else { logger.info(`📄 MCP Proxy: Returning ${response.resources.length} resources (no nextCursor - pagination complete)`); } // Debug: Log full response structure logger.debug(`📄 MCP Proxy: Full response:`, { resourceCount: response.resources.length, nextCursor: response.nextCursor, hasNextCursor: 'nextCursor' in response, responseKeys: Object.keys(response) }); // Add pagination metadata if present if (pagination) { response._meta = { pagination }; } return response; }, }); sdkServer.registerResource( resourceDef.name, template, { description: resourceDef.description, mimeType: resourceDef.mimeType || 'application/json', }, async (uri, _variables, _extra) => { const context = (sdkServer as any)._currentContext || { userId: 'anonymous' }; // Extract pagination options from _extra metadata const options: any = {}; const meta = _extra?._meta as any; if (meta?.page || meta?.limit || meta?.cursor) { options.pagination = { page: meta.page ? parseInt(String(meta.page), 10) : undefined, limit: meta.limit ? parseInt(String(meta.limit), 10) : undefined, cursor: meta.cursor, }; } const result = await resourceDef.read(uri.href, { ...context, ...(_variables || {}) }, options); const metadata = result.metadata || (resourceDef.getMetadata ? await resourceDef.getMetadata(uri.href, context) : resourceDef.metadata); const defaultMimeType = resourceDef.mimeType || 'text/plain'; const normalizeContent = (content: any) => ({ ...(typeof content === 'string' ? {} : { ...content }), uri: uri.href, text: typeof content === 'string' ? content : content.text, mimeType: (typeof content === 'string' ? resourceDef.mimeType : content.mimeType) || defaultMimeType, blob: typeof content === 'string' ? undefined : content.blob, }); const contents = Array.isArray(result.contents) ? result.contents.map((content: any) => normalizeContent(content)) : [normalizeContent(result.contents)]; const response: any = { contents, structuredContent: result.contents, }; if (metadata) { response.metadata = metadata; } // Build _meta with pagination info — this is the standard MCP metadata // field that survives Pydantic model serialization on the client side. // Top-level extra fields like nextCursorUrl can be stripped by SDK model_dump(). const metaObj: any = {}; if (result.nextCursorUrl) { response.nextCursorUrl = result.nextCursorUrl; metaObj.nextCursorUrl = result.nextCursorUrl; logger.info(`📄 MCP Proxy read (template): Returning data with nextCursorUrl=${result.nextCursorUrl}`); } if (result.nextCursor) { response.nextCursor = result.nextCursor; metaObj.nextCursor = result.nextCursor; logger.info(`📄 MCP Proxy read (template): Returning data with nextCursor=${result.nextCursor}`); } if (result.pagination) { metaObj.pagination = result.pagination; } if (Object.keys(metaObj).length > 0) { response._meta = metaObj; } return response; } ); } else if (isTemplate && !resourceDef.list) { // Template URI with NO list handler (e.g., page resource) // Register as ResourceTemplate with empty list (for read-only access) logger.info(`📄 Registering read-only template (no list): ${resourceDef.uri}`); const template = new ResourceTemplate(resourceDef.uri, { list: async () => ({ resources: [] }) // Empty list - template is read-only }); sdkServer.registerResource( resourceDef.name, template, { description: resourceDef.description, mimeType: resourceDef.mimeType || 'application/json', }, async (uri: any, _variables: any, _extra: any) => { const context = (sdkServer as any)._currentContext || { userId: 'anonymous' }; // Extract pagination options from _extra metadata const options: any = {}; const meta = _extra?._meta as any; if (meta?.page || meta?.limit || meta?.cursor) { options.pagination = { page: meta.page ? parseInt(String(meta.page), 10) : undefined, limit: meta.limit ? parseInt(String(meta.limit), 10) : undefined, cursor: meta.cursor, }; } const result = await resourceDef.read(uri.href, { ...context, ...(_variables || {}) }, options); const metadata = result.metadata || (resourceDef.getMetadata ? await resourceDef.getMetadata(uri.href, context) : resourceDef.metadata); const defaultMimeType = resourceDef.mimeType || 'text/plain'; const normalizeContent = (content: any) => ({ ...(typeof content === 'string' ? {} : { ...content }), uri: uri.href, text: typeof content === 'string' ? content : content.text, mimeType: (typeof content === 'string' ? resourceDef.mimeType : content.mimeType) || defaultMimeType, blob: typeof content === 'string' ? undefined : content.blob, }); const contents = Array.isArray(result.contents) ? result.contents.map((content: any) => normalizeContent(content)) : [normalizeContent(result.contents)]; const response: any = { contents, structuredContent: result.contents, }; if (metadata) { response.metadata = metadata; } // Build _meta with pagination info (see template handler above for rationale) const metaObj: any = {}; if (result.nextCursorUrl) { response.nextCursorUrl = result.nextCursorUrl; metaObj.nextCursorUrl = result.nextCursorUrl; logger.info(`📄 MCP Proxy read (read-only template): Returning data with nextCursorUrl=${result.nextCursorUrl}`); } if (result.nextCursor) { response.nextCursor = result.nextCursor; metaObj.nextCursor = result.nextCursor; logger.info(`📄 MCP Proxy read (read-only template): Returning data with nextCursor=${result.nextCursor}`); } if (result.pagination) { metaObj.pagination = result.pagination; } if (Object.keys(metaObj).length > 0) { response._meta = metaObj; } return response; } ); } else { // Static resource - simple URI (no template variables, no list handler) sdkServer.registerResource( resourceDef.name, resourceDef.uri, { description: resourceDef.description, mimeType: resourceDef.mimeType || 'application/json', }, async (uri: any, _extra: any) => { const context = (sdkServer as any)._currentContext || { userId: 'anonymous' }; // Extract pagination options from _extra metadata const options: any = {}; const meta = _extra?._meta as any; if (meta?.page || meta?.limit || meta?.cursor) { options.pagination = { page: meta.page ? parseInt(String(meta.page), 10) : undefined, limit: meta.limit ? parseInt(String(meta.limit), 10) : undefined, cursor: meta.cursor, }; } const result = await resourceDef.read(uri.href, context, options); const metadata = result.metadata || (resourceDef.getMetadata ? await resourceDef.getMetadata(uri.href, context) : resourceDef.metadata); const defaultMimeType = resourceDef.mimeType || 'application/json'; const normalizeContent = (content: any) => ({ ...(typeof content === 'string' ? {} : { ...content }), uri: uri.href, text: typeof content === 'string' ? content : content.text, mimeType: (typeof content === 'string' ? resourceDef.mimeType : content.mimeType) || defaultMimeType, blob: typeof content === 'string' ? undefined : content.blob, }); const contents = Array.isArray(result.contents) ? result.contents.map((content: any) => normalizeContent(content)) : [normalizeContent(result.contents)]; const response: any = { contents, structuredContent: result.contents, }; if (metadata) { response.metadata = metadata; } // Build _meta with pagination info (see template handler above for rationale) const metaObj: any = {}; if (result.nextCursorUrl) { response.nextCursorUrl = result.nextCursorUrl; metaObj.nextCursorUrl = result.nextCursorUrl; logger.info(`📄 MCP Proxy read (static): Returning data with nextCursorUrl=${result.nextCursorUrl}`); } if (result.nextCursor) { response.nextCursor = result.nextCursor; metaObj.nextCursor = result.nextCursor; logger.info(`📄 MCP Proxy read (static): Returning data with nextCursor=${result.nextCursor}`); } if (result.pagination) { metaObj.pagination = result.pagination; } if (Object.keys(metaObj).length > 0) { response._meta = metaObj; } return response; } ); } }); installPaginatedResourcesListHandler(); function installPaginatedResourcesListHandler() { const serverInstance: any = (sdkServer as any).server; if (!serverInstance?.setRequestHandler) { logger.warn('⚠️ Unable to install custom resources/list handler - missing server instance'); return; } serverInstance.removeRequestHandler?.('resources/list'); serverInstance.setRequestHandler( ListResourcesRequestSchema, async (request: any, extra: any = {}) => { const params = request.params || {}; const baseMeta = extra?._meta || {}; const paramsMeta = params?._meta || {}; const mergedMeta = { ...baseMeta, ...paramsMeta, } as Record; if (typeof params.cursor !== 'undefined') mergedMeta.cursor = params.cursor; if (typeof params.page !== 'undefined') mergedMeta.page = params.page; if (typeof params.limit !== 'undefined') mergedMeta.limit = params.limit; if (typeof params.uri !== 'undefined') mergedMeta.uri = params.uri; const extendedExtra = { ...extra, _meta: mergedMeta, params, }; const registeredResources = ((sdkServer as any)._registeredResources || {}) as Record; const registeredTemplates = ((sdkServer as any)._registeredResourceTemplates || {}) as Record; const staticResources = Object.entries(registeredResources) .filter(([, resource]) => resource.enabled) .map(([uri, resource]) => ({ uri, name: resource.name, description: resource.metadata?.description || resource.description, mimeType: resource.metadata?.mimeType, metadata: resource.metadata, })); const templates = Object.values(registeredTemplates).filter( (template: any) => template.enabled && template.resourceTemplate.listCallback ); const normalizeListResult = (result: any) => { if (!result) { return { resources: [] as any[] }; } if (Array.isArray(result)) { return { resources: result }; } return { resources: result.resources || [], nextCursor: result.nextCursor, pagination: result.pagination, _meta: result._meta, }; }; const buildResponse = (result: any) => { const normalized = normalizeListResult(result); const response: any = { resources: normalized.resources, }; if (normalized.nextCursor) { response.nextCursor = normalized.nextCursor; } if (normalized.pagination || normalized._meta) { response._meta = { ...(normalized._meta || {}), ...(normalized.pagination ? { pagination: normalized.pagination } : {}), }; } return response; }; if (params.uri) { const template = templates.find((t: any) => t.resourceTemplate.uriTemplate.match(params.uri) ); if (!template) { logger.warn('resources/list requested specific uri but no template matched', { uri: params.uri, }); return { resources: staticResources }; } const templateResult = await template.resourceTemplate.listCallback(extendedExtra); return buildResponse(templateResult); } const aggregatedResources = [...staticResources]; let res = {}; for (const template of templates) { const templateResult = await template.resourceTemplate.listCallback(extendedExtra); const normalized = normalizeListResult(templateResult); aggregatedResources.push(...normalized.resources); delete normalized.resources; // remove undefined values const norm: Record = {}; Object.keys(normalized).forEach(key => { if ((normalized as any)[key] !== undefined) { norm[key] = (normalized as any)[key]; } }); res = { ...res, ...norm }; } return { resources: aggregatedResources, ...res }; } ); } // Register prompts with MCP SDK if (config.prompts) { config.prompts.forEach((promptDef) => { const argsSchema: Record = {}; // Build args schema with completion support if (promptDef.arguments) { promptDef.arguments.forEach((arg) => { let argSchema = z.string(); // Add completion support if handler is provided if (promptDef.completion) { argSchema = completable(z.string(), async (value, context) => { const ctx = (sdkServer as any)._currentContext || { userId: 'anonymous' }; const ref = { type: 'ref/prompt' as const, name: promptDef.name, arguments: context?.arguments || {}, }; try { const completions = await promptDef.completion!(ref, arg.name, ctx); return completions.map(c => c.value); } catch (error) { logger.error('Completion error', { error }); return []; } }) as any; } argsSchema[arg.name] = argSchema; }); } sdkServer.registerPrompt( promptDef.name, { title: promptDef.name, description: promptDef.description, argsSchema, }, async (args: any) => { const context = (sdkServer as any)._currentContext || { userId: 'anonymous' }; const result = await promptDef.handler(args, context); // Convert to MCP SDK message format return { messages: result.messages.map((msg: any) => ({ role: msg.role, content: { type: 'text', text: msg.content, }, })), }; } ); }); } // Note: We register resources in specificity order (most specific first) above // The MCP SDK's ResourceTemplate will match against them in registration order // This ensures mongodb://.../page/2 matches the page template before the document template // Health check endpoints app.get('/health', (_req: any, res: any) => { res.json({ status: 'ok' }); }); app.get('/ready', async (_req: any, res: any) => { try { // Test store connectivity await config.store.set('health-check', 'ok', 10); await config.store.delete('health-check'); res.json({ status: 'ready' }); } catch (error) { res.status(503).json({ status: 'not ready', error: String(error) }); } }); // Standard MCP endpoint with StreamableHTTPServerTransport app.post( basePath, asyncHandler(async (req, res) => { // Authenticate and store context const context = await authenticate(req, config); (sdkServer as any)._currentContext = context; // Intercept resources/subscribe to support webhook callbacks if (req.body.method === 'resources/subscribe') { const { uri } = req.body.params || {}; const webhookUrl = req.body.params?._meta?.webhookUrl || req.body.params?.webhookUrl; const webhookSecret = req.body.params?._meta?.webhookSecret || req.body.params?.webhookSecret; const subscriptionSource = req.body.params?._meta?.source || req.body.params?.source; if (webhookUrl) { // Handle webhook-based subscription logger.info('Standard MCP subscribe with webhook support', { uri, webhookUrl, source: subscriptionSource }); if (!isValidUrl(webhookUrl)) { return res.json({ jsonrpc: '2.0', id: req.body.id, error: { code: -32602, message: 'Invalid webhookUrl in _meta', }, }); } try { const subscription = await subscriptionManager.createSubscription({ uri, clientCallbackUrl: webhookUrl, clientCallbackSecret: webhookSecret, source: subscriptionSource, context, }); // Return standard MCP response return res.json({ jsonrpc: '2.0', id: req.body.id, method: 'notifications/resources/updated', params: { uri: uri, ...subscription, }, }); } catch (error: any) { return res.json({ jsonrpc: '2.0', id: req.body.id, error: { code: -32603, message: error.message || 'Failed to create subscription', }, }); } } else { // No webhookUrl - throw error return res.json({ jsonrpc: '2.0', id: req.body.id, error: { code: -32602, message: 'Invalid webhookUrl in _meta', }, }); } } // Intercept resources/unsubscribe to support webhook cleanup if (req.body.method === 'resources/unsubscribe') { const subscriptionId = req.body.params?._meta?.subscriptionId || req.body.params?.subscriptionId; if (subscriptionId) { logger.info('Standard MCP unsubscribe with webhook cleanup', { subscriptionId }); try { await subscriptionManager.deleteSubscription(subscriptionId, context); return res.json({ jsonrpc: '2.0', id: req.body.id, result: { success: true, _meta: { webhookEnabled: true, }, }, }); } catch (error: any) { return res.json({ jsonrpc: '2.0', id: req.body.id, error: { code: -32603, message: error.message || 'Failed to unsubscribe', }, }); } } } // If a client provided the resource URI in a header (fallback), inject it // into the JSON-RPC params BEFORE creating the transport. This ensures // the SDK sees the uri parameter when it parses the request body. try { if (req.body && req.body.method === 'resources/list') { const headerUri = (req.headers && (req.headers['x-mcp-resource-uri'] || req.headers['X-MCP-Resource-Uri'])) as string | undefined; if (headerUri) { req.body.params = req.body.params || {}; if (!req.body.params.uri) { req.body.params.uri = headerUri; logger.info('💉 Injected uri from X-MCP-Resource-Uri header into JSON-RPC params', { uri: headerUri }); } } } } catch (err) { logger.debug('Failed to inject header uri into req.body.params', { err }); } // For resources/read, strip query params from URI and pass as _meta. // This allows pagination cursors (e.g. ?cursor=X) to work with static resources, // since the MCP SDK does exact URI matching and rejects URIs with query params. try { if (req.body && req.body.method === 'resources/read' && req.body.params?.uri) { const rawUri = req.body.params.uri as string; const qIndex = rawUri.indexOf('?'); if (qIndex !== -1) { const baseUri = rawUri.substring(0, qIndex); const queryString = rawUri.substring(qIndex + 1); const params = new URLSearchParams(queryString); req.body.params.uri = baseUri; req.body.params._meta = req.body.params._meta || {}; for (const [key, value] of params.entries()) { req.body.params._meta[key] = value; } logger.info(`📄 Stripped query params from resources/read URI: ${rawUri} → ${baseUri}`, { meta: req.body.params._meta }); } } } catch (err) { logger.debug('Failed to strip query params from resources/read URI', { err }); } // Standard MCP request handling const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true, }); res.on('close', () => { transport.close(); delete (sdkServer as any)._currentContext; }); // Serialize connect/close — the MCP SDK Protocol allows only one transport at a time. // Wait for any in-flight connect to finish, then close + reconnect atomically. connectLock = connectLock.then(async () => { try { await (sdkServer as any).close(); } catch { // No previous connection or already closed } await sdkServer.connect(transport); }); await connectLock; await transport.handleRequest(req, res, req.body); }) ); // Respond with 405 for GET on the MCP endpoint — the StreamableHTTP spec allows // clients to attempt GET for server-initiated SSE streams. 405 tells the client // this server doesn't support that (per MCP spec), so it silently moves on. app.get(basePath, (_req: any, res: any) => { res.status(405).set('Allow', 'POST').send('Method Not Allowed'); }); // Additional webhook subscription endpoints (extensions to MCP) app.post( `${basePath}/resources/subscribe`, asyncHandler(async (req, res) => { const context = await authenticate(req, config); const { uri, callbackUrl, callbackSecret, source } = req.body.params || req.body; if (!uri || !callbackUrl) { throw new ValidationError('uri and callbackUrl are required'); } if (!isValidUrl(callbackUrl)) { throw new ValidationError('Invalid callbackUrl'); } const result = await subscriptionManager.createSubscription({ uri, clientCallbackUrl: callbackUrl, clientCallbackSecret: callbackSecret, source, context, }); res.json(result); }) ); app.post( `${basePath}/resources/unsubscribe`, asyncHandler(async (req, res) => { const context = await authenticate(req, config); const { subscriptionId } = req.body.params || req.body; if (!subscriptionId) { throw new ValidationError('subscriptionId is required'); } await subscriptionManager.deleteSubscription(subscriptionId, context); res.json({ status: 'unsubscribed' }); }) ); app.get(`${basePath}/.well-known/credentials`, asyncHandler(async (req, res) => { const credentials = sanitizeCredentialFields(config.credentials); res.json({ version: config.version, credentials, }); })); // Webhook endpoints const webhookPath = config.webhooks?.incomingPath || '/webhooks/incoming'; app.post( `${webhookPath}/:subscriptionId`, asyncHandler(async (req, res) => { const { subscriptionId } = req.params; const payload = req.body; const headers = req.headers as Record; logger.debug('Received webhook', { subscriptionId }); // Load subscription const subscription = await subscriptionManager.getSubscription(subscriptionId); if (!subscription) { logger.warn('Subscription not found', { subscriptionId }); return res.status(200).json({ error: 'Subscription not found' }); } // Process webhook const changeInfo = await webhookManager.processIncomingWebhook( subscriptionId, payload, headers, subscription ); if (changeInfo) { // Notify the primary subscription's client webhookManager.notifyClient(subscription, changeInfo).catch((error) => { logger.error('Failed to notify client', { subscriptionId, error: error instanceof Error ? error.message : String(error), }); }); // Also notify ALL other subscriptions for the same URI (e.g. triggers + KDL) subscriptionManager.findAllSubscriptionsByUri(subscription.uri, subscriptionId) .then((otherSubs) => { for (const otherSub of otherSubs) { logger.info('Notifying additional subscriber', { subscriptionId: otherSub.subscriptionId, source: otherSub.source, url: otherSub.clientCallbackUrl, }); webhookManager.notifyClient(otherSub, changeInfo).catch((error) => { logger.error('Failed to notify additional subscriber', { subscriptionId: otherSub.subscriptionId, error: error instanceof Error ? error.message : String(error), }); }); } }) .catch((error) => { logger.error('Failed to find additional subscribers', { uri: subscription.uri, error: error instanceof Error ? error.message : String(error), }); }); } res.json({ received: true }); }) ); // Error handler app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { if (err instanceof MCPError) { logger.warn('MCP Error', { code: err.code, message: err.message }); return res.status(400).json(err.toJSON()); } logger.error('Unexpected error', { error: err.message, stack: err.stack, }); res.status(500).json({ error: { code: -1, message: err.message || 'Internal server error', }, }); }); // Server instance let server: any; const mcpServer: MCPServer = { async start() { return new Promise((resolve) => { server = app.listen(port, host, () => { logger.info(`MCP Server started`, { name: config.name, version: config.version, host, port, publicUrl: config.publicUrl, }); resolve(); }); }); }, async stop() { if (server) { return new Promise((resolve) => { server.close(() => { logger.info('MCP Server stopped'); resolve(); }); }); } }, getApp() { return app; }, }; return mcpServer; } /** * Validate server configuration */ function validateConfig(config: MCPServerConfig): void { if (!config.name) { throw new Error('Server name is required'); } if (!config.version) { throw new Error('Server version is required'); } if (!config.publicUrl) { throw new Error('publicUrl is required'); } if (!isValidUrl(config.publicUrl)) { throw new Error('Invalid publicUrl'); } if (process.env.NODE_ENV === 'production' && !isHttpsUrl(config.publicUrl)) { throw new Error('publicUrl must use HTTPS in production'); } if (!config.store) { throw new Error('Store is required'); } if (!Array.isArray(config.tools)) { throw new Error('tools must be an array'); } if (!Array.isArray(config.resources)) { throw new Error('resources must be an array'); } } /** * Authenticate request */ async function authenticate(req: Request, config: MCPServerConfig) { if (!config.authenticate) { if (process.env.NODE_ENV === 'production') { throw new AuthenticationError('No authentication configured. Set an authenticate function for production use.'); } // Development only: allow anonymous access with warning console.warn('WARNING: No authenticate function configured. Allowing anonymous access (dev mode only).'); return { userId: 'anonymous' }; } try { return await config.authenticate(req); } catch (error) { throw new AuthenticationError(error instanceof Error ? error.message : 'Authentication failed'); } } /** * Async handler wrapper */ function asyncHandler(fn: (req: Request, res: Response) => Promise) { return (req: Request, res: Response, next: NextFunction) => { Promise.resolve(fn(req, res)).catch(next); }; }