UNPKG

@ithena-one/mcp-governance

Version:

Governance layer (Identity, RBAC, Credentials, Audit, Logging, Tracing) for Model Context Protocol (MCP) servers.

278 lines 15.1 kB
"use strict"; /* eslint-disable @typescript-eslint/no-explicit-any */ // src/core/governed-server.ts Object.defineProperty(exports, "__esModule", { value: true }); exports.GovernedServer = void 0; const types_js_1 = require("@modelcontextprotocol/sdk/types.js"); const zod_1 = require("zod"); const governance_pipeline_js_1 = require("./governance-pipeline.js"); // Import the new class const lifecycle_manager_js_1 = require("./lifecycle-manager.js"); // Import the new class const error_mapper_js_1 = require("../utils/error-mapper.js"); const helpers_js_1 = require("../utils/helpers.js"); const logger_js_1 = require("../defaults/logger.js"); const audit_js_1 = require("../defaults/audit.js"); const tracing_js_1 = require("../defaults/tracing.js"); const permissions_js_1 = require("../defaults/permissions.js"); const sanitization_js_1 = require("../defaults/sanitization.js"); /** * Wraps a base Model Context Protocol (MCP) Server to add a governance layer. */ class GovernedServer { constructor(baseServer, options = {}) { this.requestHandlers = new Map(); this.notificationHandlers = new Map(); this.baseServer = baseServer; this.options = { identityResolver: options.identityResolver, roleStore: options.roleStore, permissionStore: options.permissionStore, credentialResolver: options.credentialResolver, postAuthorizationHook: options.postAuthorizationHook, serviceIdentifier: options.serviceIdentifier, auditStore: options.auditStore ?? audit_js_1.defaultAuditStore, logger: options.logger ?? logger_js_1.defaultLogger, traceContextProvider: options.traceContextProvider ?? tracing_js_1.defaultTraceContextProvider, enableRbac: options.enableRbac ?? false, failOnCredentialResolutionError: options.failOnCredentialResolutionError ?? true, auditDeniedRequests: options.auditDeniedRequests ?? true, auditNotifications: options.auditNotifications ?? false, derivePermission: options.derivePermission ?? permissions_js_1.defaultDerivePermission, sanitizeForAudit: options.sanitizeForAudit ?? sanitization_js_1.defaultSanitizeForAudit, }; if (this.options.enableRbac && (!this.options.roleStore || !this.options.permissionStore)) { throw new Error("RoleStore and PermissionStore must be provided when RBAC is enabled."); } // Initialize LifecycleManager this.lifecycleManager = new lifecycle_manager_js_1.LifecycleManager(this.options.logger, [ this.options.logger, this.options.auditStore, this.options.identityResolver, this.options.roleStore, this.options.permissionStore, this.options.credentialResolver, ]); } get transport() { return this.transportInternal; } async connect(transport) { if (this.transportInternal) { throw new Error("GovernedServer is already connected."); } const logger = this.options.logger; logger.info("GovernedServer connecting..."); this.transportInternal = transport; try { // --- Initialize Components --- await this.lifecycleManager.initialize(); // --- Instantiate Pipeline --- // Pass necessary options and handler maps to the pipeline instance this.pipeline = new governance_pipeline_js_1.GovernancePipeline(this.options, this.requestHandlers, this.notificationHandlers); // --- Register Base Handlers --- this._registerBaseHandlers(); // --- Connect Base Server --- await this.baseServer.connect(transport); // --- Setup Governed Close Handling --- const originalBaseOnClose = this.baseServer.onclose; this.baseServer.onclose = () => { Promise.resolve().then(async () => { logger.info("Base server connection closed, running governed cleanup..."); await this.lifecycleManager.shutdown(); // Use manager for shutdown }).catch(err => { logger.error("Error during component shutdown on close", err); }).finally(() => { this.transportInternal = undefined; this.pipeline = undefined; // Clear pipeline instance originalBaseOnClose?.(); logger.debug("Governed onclose handler finished."); }); }; logger.info("GovernedServer connected successfully."); } catch (error) { logger.error("GovernedServer connection failed during initialization", error); await this.lifecycleManager.shutdown(); // Attempt cleanup on failure this.transportInternal = undefined; this.pipeline = undefined; throw error; } } async close() { const logger = this.options.logger; if (!this.transportInternal) { logger.info("GovernedServer close called, but already closed or not connected."); return; } logger.info("GovernedServer closing..."); // Shutdown components first using the manager await this.lifecycleManager.shutdown(); // Then close the base server (which should trigger our onclose handler) if (this.baseServer) { try { await this.baseServer.close(); } catch (err) { logger.error("Error during baseServer.close()", err); // Ensure state is cleared anyway this.transportInternal = undefined; this.pipeline = undefined; } } else { this.transportInternal = undefined; this.pipeline = undefined; } logger.info("GovernedServer closed."); } async notification(notification) { await this.baseServer.notification(notification); } // --- Handler Registration (remains the same, stores locally) --- setRequestHandler(requestSchema, handler) { const method = requestSchema.shape.method.value; if (this.transportInternal) { throw new Error(`Cannot register request handler for ${method} after connect() has been called.`); } if (this.requestHandlers.has(method)) { this.options.logger.warn(`Overwriting request handler for method: ${method}`); } this.requestHandlers.set(method, { handler: handler, schema: requestSchema }); this.options.logger.debug(`Stored governed request handler for: ${method}`); } setNotificationHandler(notificationSchema, handler) { const method = notificationSchema.shape.method.value; if (this.transportInternal) { throw new Error(`Cannot register notification handler for ${method} after connect() has been called.`); } if (this.notificationHandlers.has(method)) { this.options.logger.warn(`Overwriting notification handler for method: ${method}`); } this.notificationHandlers.set(method, { handler: handler, schema: notificationSchema }); this.options.logger.debug(`Stored governed notification handler for: ${method}`); } // --- Wrapper Handler Creation and Registration --- /** Registers wrapper functions with the baseServer for all stored handlers. */ /** Registers wrapper functions with the baseServer for all stored handlers. */ _registerBaseHandlers() { this.options.logger.debug("Registering base server handlers for governed methods..."); // Define a base schema that allows optional params // WORKAROUND: Registering with a schema that explicitly includes `params: z.any().optional()` // appears necessary to prevent the current version of the base SDK Server // from stripping the params object before calling this wrapper handler. // This is related to an upstream issue/PR: https://github.com/modelcontextprotocol/typescript-sdk/pull/248 // This workaround should be removed once the upstream fix is incorporated. const baseMethodSchema = (method) => zod_1.z.object({ jsonrpc: zod_1.z.literal("2.0").optional(), // Allow flexibility from base SDK parsing id: zod_1.z.union([zod_1.z.string(), zod_1.z.number()]).optional(), // Allow flexibility method: zod_1.z.literal(method), params: zod_1.z.any().optional() // <-- Explicitly allow optional params of any type }).passthrough(); // Allow other fields like _meta this.requestHandlers.forEach((_handlerInfo, method) => { const handler = this._createPipelineRequestHandler(method); const schemaForBaseServer = baseMethodSchema(method); // Register with the base server using the more permissive schema this.baseServer.setRequestHandler(schemaForBaseServer, handler); this.options.logger.debug(`Registered base request handler for: ${method}`); }); this.notificationHandlers.forEach((_handlerInfo, method) => { const handler = this._createPipelineNotificationHandler(method); // Notifications also might have params, allow them minimally const notificationSchemaForBaseServer = zod_1.z.object({ jsonrpc: zod_1.z.literal("2.0").optional(), method: zod_1.z.literal(method), params: zod_1.z.any().optional() }).passthrough(); this.baseServer.setNotificationHandler(notificationSchemaForBaseServer, handler); this.options.logger.debug(`Registered base notification handler for: ${method}`); }); this.options.logger.debug("Base handler registration complete."); } /** Creates the wrapper that calls the request pipeline. */ _createPipelineRequestHandler(method) { return async (request, baseExtra) => { if (!this.pipeline) { this.options.logger.error(`Request received for ${method} but pipeline is not initialized. Server not connected?`); throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, "GovernedServer pipeline not initialized."); } // --- Prepare Initial Context for Pipeline --- const eventId = (0, helpers_js_1.generateEventId)(); const startTime = Date.now(); const transportContext = (0, helpers_js_1.buildTransportContext)(this.transportInternal); const traceContext = this.options.traceContextProvider(transportContext, request); const baseLogger = this.options.logger; const requestLogger = baseLogger.child ? baseLogger.child({ eventId, requestId: request.id, method: request.method, ...(traceContext?.traceId && { traceId: traceContext.traceId }), ...(traceContext?.spanId && { spanId: traceContext.spanId }), ...(transportContext.sessionId && { sessionId: transportContext.sessionId }), }) : baseLogger; const operationContext = { eventId, timestamp: new Date(startTime), transportContext, traceContext, logger: requestLogger, mcpMessage: request, serviceIdentifier: this.options.serviceIdentifier, }; const auditRecord = { eventId, timestamp: new Date(startTime).toISOString(), serviceIdentifier: this.options.serviceIdentifier, transport: transportContext, mcp: { type: "request", method: request.method, id: request.id }, trace: traceContext, identity: null, }; // --- Execute Pipeline --- try { requestLogger.debug(`Pipeline request handler invoked for: ${method}`); // Delegate actual execution to the pipeline instance return await this.pipeline.executeRequestPipeline(request, baseExtra, operationContext, auditRecord); } catch (error) { // Catch errors from the pipeline execution itself and map for baseServer requestLogger.error(`Unhandled error in request pipeline execution for ${method}`, error); const payload = (0, error_mapper_js_1.mapErrorToPayload)(error, types_js_1.ErrorCode.InternalError, "Internal governance pipeline error"); throw new types_js_1.McpError(payload.code, payload.message, payload.data); } }; } /** Creates the wrapper that calls the notification pipeline. */ _createPipelineNotificationHandler(method) { return async (notification, baseExtra) => { if (!this.pipeline) { this.options.logger.error(`Notification received for ${method} but pipeline is not initialized. Server not connected?`); // Don't throw for notifications, just log return; } // --- Prepare Initial Context --- const eventId = (0, helpers_js_1.generateEventId)(); const startTime = Date.now(); const transportContext = (0, helpers_js_1.buildTransportContext)(this.transportInternal); const traceContext = this.options.traceContextProvider(transportContext, notification); const baseLogger = this.options.logger; const notificationLogger = baseLogger.child ? baseLogger.child({ /* ... context ... */}) : baseLogger; const operationContext = { eventId, timestamp: new Date(startTime), transportContext, traceContext, logger: notificationLogger, mcpMessage: notification, serviceIdentifier: this.options.serviceIdentifier, }; const auditRecord = { eventId, timestamp: new Date(startTime).toISOString(), serviceIdentifier: this.options.serviceIdentifier, transport: transportContext, mcp: { type: "notification", method: notification.method }, trace: traceContext, identity: null, }; // --- Execute Pipeline --- try { notificationLogger.debug(`Pipeline notification handler invoked for: ${method}`); await this.pipeline.executeNotificationPipeline(notification, baseExtra, operationContext, auditRecord); } catch (error) { // Log pipeline errors, but don't throw notificationLogger.error(`Unhandled error in notification pipeline execution for ${method}`, error); } }; } } // End GovernedServer class exports.GovernedServer = GovernedServer; //# sourceMappingURL=governed-server.js.map