UNPKG

firewalla-mcp-server

Version:

Model Context Protocol (MCP) server for Firewalla MSP API - Provides real-time network monitoring, security analysis, and firewall management through 28 specialized tools compatible with any MCP client

462 lines 17.6 kB
/** * Universal pagination utilities for cursor-based and offset-based pagination * Provides consistent pagination interface across all MCP tools * * This module combines functionality from the original pagination.ts and pagination-manager.ts * to provide a unified, comprehensive pagination solution. */ import { config } from '../config/config.js'; import { SafeAccess } from '../validation/error-handler.js'; /** * Default pagination configuration loaded from main configuration * Falls back to environment variables for backward compatibility */ const DEFAULT_PAGINATION_CONFIG = { maxPageSize: config.maxPageSize || parseInt(process.env.MAX_PAGE_SIZE || '10000', 10), defaultPageSize: config.defaultPageSize || parseInt(process.env.DEFAULT_PAGE_SIZE || '100', 10), useCursor: true, useOffset: false, includeTotalCount: false, }; /** * Current pagination configuration (can be updated at runtime) */ let currentPaginationConfig = DEFAULT_PAGINATION_CONFIG; /** * Update pagination configuration at runtime */ export function updatePaginationConfig(newConfig) { currentPaginationConfig = { ...currentPaginationConfig, ...newConfig, }; } /** * Get current pagination configuration */ export function getPaginationConfig() { return currentPaginationConfig; } /** * Get default page size with validation */ export function getDefaultPageSize(requestedSize) { const config = getPaginationConfig(); if (requestedSize) { // Validate requested size against max return Math.min(requestedSize, config.maxPageSize); } return config.defaultPageSize; } /** * Encodes a `CursorData` object into a base64 string for use as a pagination cursor. * * @param data - The cursor data to encode * @returns The base64-encoded string representing the cursor * @throws If the cursor data cannot be serialized or encoded */ export function encodeCursor(data) { try { const json = JSON.stringify(data); return Buffer.from(json, 'utf-8').toString('base64'); } catch (error) { throw new Error(`Failed to encode cursor: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Decodes a base64-encoded cursor string into a validated `CursorData` object. * * Throws an error if the cursor is not valid base64, cannot be parsed as JSON, or does not contain required pagination fields. * * @param cursor - The base64-encoded cursor string to decode * @returns The decoded and validated cursor data */ export function decodeCursor(cursor) { try { const json = Buffer.from(cursor, 'base64').toString('utf-8'); const data = JSON.parse(json); // Validate cursor data structure if (!data || typeof data !== 'object') { throw new Error('Invalid cursor data structure'); } if (typeof data.offset !== 'number' || data.offset < 0) { throw new Error('Invalid cursor offset'); } if (typeof data.page_size !== 'number' || data.page_size < 1) { throw new Error('Invalid cursor page_size'); } return data; } catch (error) { throw new Error(`Failed to decode cursor: ${error instanceof Error ? error.message : 'Invalid cursor format'}`); } } /** * Performs client-side cursor-based pagination and optional sorting on an array of items. * * Decodes the provided cursor to determine the current offset and page size, sorts the array by the specified field and order if requested, and returns a paginated result with metadata and a next cursor if more items remain. * * @param items - The array of items to paginate * @param cursor - Optional base64-encoded cursor string indicating the current pagination state * @param page_size - Number of items per page (default: configured DEFAULT_PAGE_SIZE or 100) * @param sort_by - Optional field name to sort by * @param sort_order - Sort order, either 'asc' or 'desc' (default is 'asc') * @returns A paginated result containing the current page of items, pagination metadata, and a next cursor if more items are available */ export function paginateArray(items, cursor, page_size = getDefaultPageSize(), sort_by, sort_order = 'asc') { let offset = 0; // Decode cursor if provided if (cursor) { try { const cursorData = decodeCursor(cursor); const { offset: cursorOffset, page_size: cursorPageSize } = cursorData; offset = cursorOffset; // Use cursor's page_size if available and consistent if (cursorPageSize === page_size) { page_size = cursorPageSize; } } catch { // Invalid cursor, start from beginning offset = 0; } } // Sort items if sort_by is specified const sortedItems = [...items]; if (sort_by) { sortedItems.sort((a, b) => { const aVal = a[sort_by]; const bVal = b[sort_by]; if (aVal === bVal) { return 0; } // Case-insensitive string comparison for consistent sorting const aStr = String(aVal).toLowerCase(); const bStr = String(bVal).toLowerCase(); const comparison = aStr < bStr ? -1 : 1; return sort_order === 'desc' ? -comparison : comparison; }); } // Calculate pagination const total_count = sortedItems.length; const start_index = offset; const end_index = Math.min(start_index + page_size, total_count); const results = sortedItems.slice(start_index, end_index); const has_more = end_index < total_count; // Generate next cursor if there are more items let next_cursor; if (has_more) { const nextCursorData = { offset: end_index, page_size, total_items: total_count, sort_by, sort_order, }; next_cursor = encodeCursor(nextCursorData); } return { results, next_cursor, total_count, page_size, has_more, }; } /** * Fetches all items using the provided data fetcher and returns a paginated result based on the given cursor, page size, and sorting options. * * @param dataFetcher - A function that asynchronously retrieves all items to be paginated * @param cursor - An optional base64-encoded cursor string representing the current pagination state * @param page_size - The number of items per page (default: configured DEFAULT_PAGE_SIZE or 100) * @param sort_by - Optional field name to sort the items by * @param sort_order - Sort order, either 'asc' or 'desc' (default is 'asc') * @returns A paginated result containing the current page of items, pagination metadata, and next cursor if more items remain * @throws If data fetching or pagination fails */ export async function createPaginatedResponse(dataFetcher, cursor, page_size = getDefaultPageSize(), sort_by, sort_order = 'asc') { try { const allItems = await dataFetcher(); return paginateArray(allItems, cursor, page_size, sort_by, sort_order); } catch (error) { throw new Error(`Failed to create paginated response: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Formats a paginated result into a standardized response object for MCP tools. * * @param paginatedResult - The paginated data and metadata to include in the response * @param query - The original query string associated with the request * @param execution_time_ms - The time taken to execute the query, in milliseconds * @returns An object containing the current page of results, counts, pagination metadata, the original query, and execution time */ export function formatPaginationResponse(paginatedResult, query, execution_time_ms) { return { results: paginatedResult.results, count: paginatedResult.results.length, total_count: paginatedResult.total_count, next_cursor: paginatedResult.next_cursor, has_more: paginatedResult.has_more, query, execution_time_ms, }; } /** * Pagination manager for consistent pagination handling */ export class PaginationManager { constructor(config = {}) { this.config = { ...DEFAULT_PAGINATION_CONFIG, ...config }; } /** * Normalize pagination parameters from user input */ normalizePaginationParams(params) { const warnings = []; // Normalize limit let limit = params.limit || this.config.defaultPageSize; if (limit > this.config.maxPageSize) { warnings.push(`Requested limit ${limit} exceeds maximum ${this.config.maxPageSize}, using maximum`); limit = this.config.maxPageSize; } if (limit < 1) { warnings.push(`Invalid limit ${limit}, using default ${this.config.defaultPageSize}`); limit = this.config.defaultPageSize; } // Normalize cursor const cursor = params.cursor && params.cursor.trim().length > 0 ? params.cursor : undefined; // Normalize offset (for backward compatibility) let offset = params.offset || 0; if (offset < 0) { warnings.push(`Invalid offset ${offset}, using 0`); offset = 0; } // Warn about mixed pagination types if (cursor && offset > 0) { warnings.push('Both cursor and offset provided, preferring cursor-based pagination'); offset = 0; } // Warn about deprecated offset usage if (!cursor && offset > 0 && this.config.useCursor) { warnings.push('Offset-based pagination is deprecated, consider using cursor-based pagination'); } const includeTotalCount = Boolean(params.include_total_count); return { limit, cursor, offset, includeTotalCount, warnings, }; } /** * Create standardized pagination response */ createPaginationResponse(results, params, apiResponse = {}) { const normalized = this.normalizePaginationParams(params); const paginationResponse = { limit: normalized.limit, count: results.length, has_more: Boolean(apiResponse.next_cursor || apiResponse.hasMore), }; // Add cursor information (preferred method) if (this.config.useCursor) { paginationResponse.next_cursor = apiResponse.next_cursor || null; } // Add offset information (legacy support) if (this.config.useOffset || (!this.config.useCursor && normalized.offset >= 0)) { paginationResponse.offset = normalized.offset; paginationResponse.page = Math.floor(normalized.offset / normalized.limit) + 1; } // Add total count if available if (normalized.includeTotalCount || apiResponse.total !== undefined) { paginationResponse.total = SafeAccess.getNestedValue(apiResponse, 'total', SafeAccess.getNestedValue(apiResponse, 'count', results.length)); } // Add metadata if available if (apiResponse.pages_traversed || apiResponse.estimated_total || normalized.warnings.length > 0) { paginationResponse.metadata = {}; if (apiResponse.pages_traversed) { paginationResponse.metadata.pages_traversed = apiResponse.pages_traversed; } if (apiResponse.estimated_total) { paginationResponse.metadata.estimated_total = apiResponse.estimated_total; } if (normalized.warnings.length > 0) { paginationResponse.metadata.warning = normalized.warnings.join('; '); } } return paginationResponse; } /** * Extract pagination information from API response */ extractPaginationFromApiResponse(apiResponse) { const hasMoreIndicators = [ 'hasMore', 'has_more', 'next_cursor', 'nextCursor', 'more', ]; const hasMore = hasMoreIndicators.some(key => { const value = SafeAccess.getNestedValue(apiResponse, key, false); return Boolean(value); }); const nextCursor = SafeAccess.getNestedValue(apiResponse, 'next_cursor', SafeAccess.getNestedValue(apiResponse, 'nextCursor', null)); const total = SafeAccess.getNestedValue(apiResponse, 'total', SafeAccess.getNestedValue(apiResponse, 'totalCount', undefined)); const count = SafeAccess.getNestedValue(apiResponse, 'count', SafeAccess.getNestedValue(apiResponse, 'size', undefined)); return { hasMore, nextCursor, total, count, }; } /** * Get configuration for specific tool types */ static getConfigForTool(toolName) { const configs = { // Search tools - prefer cursor pagination search_flows: { maxPageSize: 1000, defaultPageSize: 100, useCursor: true, useOffset: false, }, search_alarms: { maxPageSize: 1000, defaultPageSize: 100, useCursor: true, useOffset: false, }, search_devices: { maxPageSize: 1000, defaultPageSize: 100, useCursor: true, useOffset: false, }, // Basic listing tools - simpler pagination get_active_alarms: { maxPageSize: 1000, defaultPageSize: 50, useCursor: true, useOffset: true, // Support both for compatibility }, get_device_status: { maxPageSize: 1000, defaultPageSize: 50, useCursor: true, useOffset: true, }, // Analytics tools - often don't need pagination get_bandwidth_usage: { maxPageSize: 500, defaultPageSize: 20, useCursor: false, useOffset: false, includeTotalCount: false, }, // Large result sets - need efficient pagination get_flow_data: { maxPageSize: 1000, defaultPageSize: 100, useCursor: true, useOffset: false, includeTotalCount: false, // Performance consideration }, }; return configs[toolName] || {}; } /** * Create pagination manager for specific tool */ static forTool(toolName) { const toolConfig = PaginationManager.getConfigForTool(toolName); return new PaginationManager(toolConfig); } } /** * Global pagination manager with default configuration */ export const globalPaginationManager = new PaginationManager(); /** * Convenience function for creating standardized pagination responses */ export function createStandardPaginationResponse(results, params, apiResponse = {}, toolName) { const manager = toolName ? PaginationManager.forTool(toolName) : globalPaginationManager; return manager.createPaginationResponse(results, params, apiResponse); } /** * Validate pagination parameters */ export function validatePaginationParams(params, toolName) { const manager = toolName ? PaginationManager.forTool(toolName) : globalPaginationManager; const errors = []; // Basic validation if (params.limit !== undefined && (typeof params.limit !== 'number' || params.limit < 1)) { errors.push('limit must be a positive number'); } if (params.offset !== undefined && (typeof params.offset !== 'number' || params.offset < 0)) { errors.push('offset must be a non-negative number'); } if (params.cursor !== undefined && typeof params.cursor !== 'string') { errors.push('cursor must be a string'); } const normalized = manager.normalizePaginationParams(params); return { isValid: errors.length === 0, errors, warnings: normalized.warnings, normalized, }; } /** * Migration utility for converting offset-based to cursor-based pagination */ export function convertOffsetToCursorParams(params) { const warnings = []; // If offset is provided and greater than 0, create a cursor if (params.offset && params.offset > 0) { warnings.push('Converting offset-based pagination to cursor-based. ' + 'Consider using cursor-based pagination directly for better performance.'); // Create cursor data representing the offset const cursorData = { offset: params.offset, page_size: params.limit || getDefaultPageSize(), sort_by: params.sort_by, sort_order: params.sort_order || 'asc', }; // Encode the cursor const cursor = encodeCursor(cursorData); return { limit: params.limit, cursor, warnings, }; } // No offset or offset is 0, no cursor needed return { limit: params.limit, cursor: undefined, warnings, }; } //# sourceMappingURL=pagination.js.map