import { WebhookConfig, StoredSubscription, ResourceDefinition, WebhookChangeInfo, Logger, } from '../types'; import { WebhookError } from '../errors'; import { sleep, calculateBackoff, extractErrorMessage } from '../utils'; export class WebhookManager { private readonly timeout: number; private readonly retries: number; private readonly retryDelay: number; constructor( private config: WebhookConfig | undefined, private resources: ResourceDefinition[], private logger?: Logger ) { this.timeout = config?.outgoing?.timeout ?? 5000; this.retries = config?.outgoing?.retries ?? 3; this.retryDelay = config?.outgoing?.retryDelay ?? 1000; } /** * Process incoming webhook from third-party service */ async processIncomingWebhook( subscriptionId: string, payload: any, headers: Record, subscription: StoredSubscription ): Promise { this.logger?.debug('Processing incoming webhook', { subscriptionId, resourceType: subscription.resourceType, }); // Find resource definition const resource = this.findResourceByName(subscription.resourceType); if (!resource?.subscription) { throw new WebhookError('Resource subscription handler not found'); } // Verify signature if configured if (this.config?.verifyIncomingSignature) { const signature = headers['x-hub-signature-256'] || headers['x-signature']; if (signature && this.config.incomingSecret) { const isValid = this.config.verifyIncomingSignature( payload, signature, this.config.incomingSecret ); if (!isValid) { this.logger?.warn('Invalid webhook signature', { subscriptionId }); throw new WebhookError('Invalid webhook signature'); } } } try { // Call resource's onWebhook handler const changeInfo = await resource.subscription.onWebhook(subscriptionId, payload, headers); this.logger?.debug('Webhook processed', { subscriptionId, changeType: changeInfo?.changeType, }); return changeInfo; } catch (error) { this.logger?.error('Failed to process webhook', { subscriptionId, error: extractErrorMessage(error), }); throw new WebhookError('Failed to process webhook', { cause: error }); } } /** * Send notification to client webhook in standard MCP format */ async notifyClient( subscription: StoredSubscription, changeInfo: WebhookChangeInfo ): Promise { // Standard MCP notification format const payload = { jsonrpc: '2.0', method: 'notifications/resources/updated', params: { uri: changeInfo.resourceUri, title: changeInfo.data?.title, _meta: { changeType: changeInfo.changeType, subscriptionId: subscription.uri, timestamp: new Date().toISOString(), ...changeInfo.data } } }; this.logger?.info('Notifying client (MCP format)', { url: subscription.clientCallbackUrl, uri: changeInfo.resourceUri, changeType: changeInfo.changeType, }); // Sign payload if secret is provided let signature: string | undefined; if (subscription.clientCallbackSecret && this.config?.outgoing?.signPayload) { signature = this.config.outgoing.signPayload(payload, subscription.clientCallbackSecret); } // Call before hook this.config?.outgoing?.onBeforeCall?.(subscription.clientCallbackUrl, payload); // Attempt delivery with retries let lastError: Error | undefined; for (let attempt = 0; attempt < this.retries; attempt++) { try { const response = await this.callWebhook( subscription.clientCallbackUrl, payload, signature ); // Call after hook this.config?.outgoing?.onAfterCall?.(subscription.clientCallbackUrl, response); this.logger?.info('Client notified successfully (MCP format)', { url: subscription.clientCallbackUrl, attempt: attempt + 1, }); return; // Success } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); this.logger?.warn('Webhook delivery failed', { url: subscription.clientCallbackUrl, attempt: attempt + 1, error: extractErrorMessage(error), }); // Don't retry on client errors (4xx) if (error instanceof Error && 'status' in error) { const status = (error as any).status; if (status >= 400 && status < 500) { throw new WebhookError('Client error, not retrying', { status }); } } // Wait before retry (exponential backoff) if (attempt < this.retries - 1) { const delay = calculateBackoff(attempt, this.retryDelay); await sleep(delay); } } } // All retries failed this.config?.outgoing?.onAfterCall?.(subscription.clientCallbackUrl, null, lastError); throw new WebhookError('Failed to notify client after all retries', { attempts: this.retries, lastError: extractErrorMessage(lastError), }); } /** * Call webhook URL */ private async callWebhook( url: string, payload: any, signature?: string ): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { const headers: Record = { 'Content-Type': 'application/json', }; if (signature) { headers['X-MCP-Signature'] = signature; } const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(payload), signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { const error: any = new Error(`HTTP ${response.status}: ${response.statusText}`); error.status = response.status; throw error; } return { ok: true, status: response.status, }; } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.name === 'AbortError') { throw new Error(`Request timeout after ${this.timeout}ms`); } throw error; } } /** * Find resource by name */ private findResourceByName(name: string): ResourceDefinition | undefined { return this.resources.find((r) => r.name === name); } }