UNPKG

@the_cfdude/productboard-mcp

Version:

Model Context Protocol server for Productboard REST API with dynamic tool loading

715 lines (714 loc) 26.7 kB
/** * Utilities for handling standardized parameters */ import { DetailFieldMappings, } from '../types/parameter-types.js'; import { API_LIMITS } from '../constants.js'; import { ValidationError } from '../errors/index.js'; import { fieldSelector } from './field-selection.js'; /** * Apply default values and validate list parameters */ export function normalizeListParams(params = {}) { const normalized = { limit: params.limit ?? API_LIMITS.DEFAULT_PAGE_SIZE, startWith: params.startWith ?? API_LIMITS.DEFAULT_OFFSET, detail: params.detail ?? 'basic', includeSubData: params.includeSubData ?? false, fields: params.fields ?? [], exclude: params.exclude ?? [], validateFields: params.validateFields ?? true, outputFormat: params.outputFormat ?? 'json', // Optimization parameters ...(params.maxLength !== undefined && { maxLength: params.maxLength }), truncateFields: params.truncateFields ?? [], truncateIndicator: params.truncateIndicator ?? '...', includeDescription: params.includeDescription ?? true, includeCustomFields: params.includeCustomFieldsStrategy ?? 'all', includeCustomFieldsStrategy: params.includeCustomFieldsStrategy ?? 'all', includeLinks: params.includeLinks ?? true, includeEmpty: params.includeEmpty ?? true, includeMetadata: params.includeMetadata ?? true, }; // Validate limit if (normalized.limit < 1 || normalized.limit > 100) { throw new ValidationError('Limit must be between 1 and 100', 'limit'); } // Validate startWith if (normalized.startWith < 0) { throw new ValidationError('startWith must be non-negative', 'startWith'); } // Validate detail level if (!['basic', 'standard', 'full'].includes(normalized.detail)) { throw new ValidationError('Detail must be one of: basic, standard, full', 'detail'); } // Validate output format if (!['json', 'markdown', 'csv', 'summary'].includes(normalized.outputFormat)) { throw new ValidationError('Output format must be one of: json, markdown, csv, summary', 'outputFormat'); } // Validate fields array if (normalized.fields.length > 0 && normalized.exclude.length > 0) { throw new ValidationError('Cannot specify both fields and exclude parameters', 'fields'); } // Validate optimization parameters if (normalized.maxLength !== undefined) { if (normalized.maxLength < 100 || normalized.maxLength > 50000) { throw new ValidationError('maxLength must be between 100 and 50000 characters', 'maxLength'); } } if (!['all', 'onlyWithValues', 'none'].includes(normalized.includeCustomFields)) { throw new ValidationError('includeCustomFieldsStrategy must be one of: all, onlyWithValues, none', 'includeCustomFieldsStrategy'); } return normalized; } /** * Apply default values and validate get parameters */ export function normalizeGetParams(params = {}) { const normalized = { detail: params.detail ?? 'standard', includeSubData: params.includeSubData ?? false, fields: params.fields ?? [], exclude: params.exclude ?? [], validateFields: params.validateFields ?? true, outputFormat: params.outputFormat ?? 'json', // Optimization parameters ...(params.maxLength !== undefined && { maxLength: params.maxLength }), truncateFields: params.truncateFields ?? [], truncateIndicator: params.truncateIndicator ?? '...', includeDescription: params.includeDescription ?? true, includeCustomFields: params.includeCustomFieldsStrategy ?? 'all', includeCustomFieldsStrategy: params.includeCustomFieldsStrategy ?? 'all', includeLinks: params.includeLinks ?? true, includeEmpty: params.includeEmpty ?? true, includeMetadata: params.includeMetadata ?? true, }; // Validate detail level if (!['basic', 'standard', 'full'].includes(normalized.detail)) { throw new ValidationError('Detail must be one of: basic, standard, full', 'detail'); } // Validate output format if (!['json', 'markdown', 'csv', 'summary'].includes(normalized.outputFormat)) { throw new ValidationError('Output format must be one of: json, markdown, csv, summary', 'outputFormat'); } // Validate fields array if (normalized.fields.length > 0 && normalized.exclude.length > 0) { throw new ValidationError('Cannot specify both fields and exclude parameters', 'fields'); } // Validate optimization parameters if (normalized.maxLength !== undefined) { if (normalized.maxLength < 100 || normalized.maxLength > 50000) { throw new ValidationError('maxLength must be between 100 and 50000 characters', 'maxLength'); } } if (!['all', 'onlyWithValues', 'none'].includes(normalized.includeCustomFields)) { throw new ValidationError('includeCustomFieldsStrategy must be one of: all, onlyWithValues, none', 'includeCustomFieldsStrategy'); } return normalized; } /** * Filter response data based on detail level */ export function filterByDetailLevel(data, entityType, detailLevel, fields, exclude, outputFormat, optimization) { let filteredData; // If explicit fields are specified, use dynamic field selection if (fields && fields.length > 0) { // Validate fields if requested const validation = fieldSelector.validateFields(String(entityType), fields); if (!validation.valid && validation.suggestions) { // Log field validation issues for development (removed console.warn for production) // Invalid fields will be handled by the field selector internally } const selectConfig = { fields, validateFields: true, }; if (exclude) { selectConfig.exclude = exclude; } filteredData = fieldSelector.selectFields(data, selectConfig); } // If exclude fields are specified, apply exclusion else if (exclude && exclude.length > 0) { filteredData = fieldSelector.selectFields(data, { exclude, validateFields: true, }); } // Fall back to detail level filtering with essential fields else { let fieldsToUse; const predefinedFields = DetailFieldMappings[entityType]?.[detailLevel]; if (predefinedFields) { fieldsToUse = [...predefinedFields]; } else { // Use essential fields as fallback when no predefined mapping exists fieldsToUse = fieldSelector.getEssentialFields(String(entityType)); } const selectConfig2 = { fields: fieldsToUse, validateFields: false, // Skip validation for predefined fields }; if (exclude) { selectConfig2.exclude = exclude; } filteredData = fieldSelector.selectFields(data, selectConfig2); } // Apply response optimization if specified if (optimization) { filteredData = optimizeResponse(filteredData, optimization); } // Apply output formatting if specified if (outputFormat && outputFormat !== 'json') { return formatResponse(filteredData, outputFormat, String(entityType)); } return filteredData; } /** * Filter object by specific fields, supporting dot notation for nested fields */ /** * @deprecated Use fieldSelector.selectFields() from field-selection.ts instead * Legacy field filtering function - kept for backward compatibility */ export function filterByFields(data, fields) { return fieldSelector.selectFields(data, { fields }); } /** * Filter object by excluding specific fields */ /** * @deprecated Use fieldSelector.selectFields() with exclude option from field-selection.ts instead * Legacy field exclusion function - kept for backward compatibility */ export function filterByExclusion(data, excludeFields) { return fieldSelector.selectFields(data, { exclude: excludeFields, }); } /** * Get nested value from object using field path */ function getNestedValue(obj, path) { let current = obj; for (const key of path) { if (current && typeof current === 'object' && current[key] !== undefined) { current = current[key]; } else { return undefined; } } return current; } // Removed unused helper functions - functionality moved to field-selection.ts /** * Validate field names against entity schema and return suggestions */ export function validateFieldNames(entityType, requestedFields) { const entityMapping = DetailFieldMappings[entityType]; if (!entityMapping) { return { valid: requestedFields, invalid: [], suggestions: [] }; } // Get all valid fields from all detail levels const allValidFields = [ ...entityMapping.basic, ...entityMapping.standard, ...entityMapping.full, ]; const validFields = []; const invalidFields = []; const suggestions = []; for (const field of requestedFields) { const baseField = field.split('.')[0]; // For nested fields, check base field if (allValidFields.some(validField => validField.startsWith(baseField))) { validFields.push(field); } else { invalidFields.push(field); // Find closest match for suggestion const suggestion = findClosestFieldMatch(baseField, allValidFields); if (suggestion) { suggestions.push({ field, suggestion }); } } } return { valid: validFields, invalid: invalidFields, suggestions }; } /** * Find closest field match using simple string similarity */ function findClosestFieldMatch(field, validFields) { let bestMatch = null; let bestScore = 0; for (const validField of validFields) { const score = calculateStringSimilarity(field.toLowerCase(), validField.toLowerCase()); if (score > bestScore && score > 0.6) { // Minimum similarity threshold bestScore = score; bestMatch = validField; } } return bestMatch; } /** * Calculate string similarity using simple character matching */ function calculateStringSimilarity(str1, str2) { const len1 = str1.length; const len2 = str2.length; const maxLen = Math.max(len1, len2); if (maxLen === 0) return 1; let matches = 0; for (let i = 0; i < Math.min(len1, len2); i++) { if (str1[i] === str2[i]) matches++; } return matches / maxLen; } /** * Filter array of items by detail level */ export function filterArrayByDetailLevel(data, entityType, detailLevel, fields, exclude, outputFormat, optimization) { return data.map(item => filterByDetailLevel(item, entityType, detailLevel, fields, exclude, outputFormat, optimization)); } /** * Check if error is due to enterprise feature limitation */ export function isEnterpriseError(error) { const errorMessage = error?.message?.toLowerCase() || ''; const statusCode = error?.response?.status; // Common patterns for enterprise feature errors const enterprisePatterns = [ 'enterprise', 'subscription', 'plan', 'not available', 'upgrade required', 'premium feature', ]; const isEnterprise = statusCode === 403 || statusCode === 402 || // Payment Required enterprisePatterns.some(pattern => errorMessage.includes(pattern)); if (isEnterprise) { return { isEnterpriseFeature: true, message: 'This is an enterprise subscription feature only. Please upgrade your plan to access this functionality.', originalError: error, }; } return { isEnterpriseFeature: false, message: error?.message || 'Unknown error occurred', originalError: error, }; } /** * Convert old pagination params to new format */ export function convertPaginationParams(params) { const result = {}; // Handle pageLimit -> limit conversion if ('pageLimit' in params) { result.limit = params.pageLimit; } else if ('limit' in params) { result.limit = params.limit; } // Handle pageOffset -> startWith conversion if ('pageOffset' in params) { result.startWith = params.pageOffset; } else if ('startWith' in params) { result.startWith = params.startWith; } return result; } /** * Format response data according to the specified output format */ export function formatResponse(data, format = 'json', entityType) { if (format === 'json') return JSON.stringify(data, null, 2); try { const formatters = { markdown: formatAsMarkdown, csv: formatAsCSV, summary: formatAsSummary, }; return formatters[format](data, entityType); } catch { // Format conversion failed, fallback to JSON (removed console.warn for production) return JSON.stringify(data, null, 2); // Fallback to JSON string } } /** * Format data as Markdown */ function formatAsMarkdown(data, entityType) { if (Array.isArray(data)) { return data .map(item => formatSingleItemMarkdown(item, entityType)) .join('\n---\n'); } return formatSingleItemMarkdown(data, entityType); } /** * Format single item as Markdown */ function formatSingleItemMarkdown(item, entityType) { const templates = { feature: item => `## ${item.name || item.id}\n**Status:** ${item.status?.name || 'N/A'}\n**Owner:** ${item.owner?.email || 'Unassigned'}\n**Description:** ${truncate(item.description, 100)}`, component: item => `## ${item.name || item.id}\n**Product:** ${item.productId || 'N/A'}\n**Description:** ${item.description || 'No description'}`, note: item => `## ${item.title || item.id}\n**Source:** ${item.source || 'Unknown'}\n**Content:** ${truncate(item.content, 150)}`, product: item => `## ${item.name || item.id}\n**Description:** ${item.description || 'No description'}`, company: item => `## ${item.name || item.id}\n**Domain:** ${item.domain || 'N/A'}\n**Description:** ${item.description || 'No description'}`, user: item => `## ${item.name || item.email || item.id}\n**Email:** ${item.email || 'N/A'}\n**Company:** ${item.companyId || 'N/A'}`, }; const formatter = templates[entityType]; if (formatter) { return formatter(item); } // Generic markdown format for unknown entity types const title = item.name || item.title || item.id || 'Unknown'; const fields = Object.entries(item) .filter(([key, value]) => key !== 'name' && key !== 'title' && value) .slice(0, 5) // Limit to first 5 fields .map(([key, value]) => `**${key}:** ${truncate(String(value), 50)}`) .join('\n'); return `## ${title}\n${fields}`; } /** * Format data as CSV */ function formatAsCSV(data) { if (!Array.isArray(data) || data.length === 0) return ''; // Flatten nested objects using dot notation const flattened = data.map(item => flattenObject(item)); const headers = Object.keys(flattened[0]); const csvContent = [ headers.join(','), ...flattened.map(row => headers.map(header => escapeCsvValue(row[header])).join(',')), ].join('\n'); return csvContent; } /** * Flatten nested object using dot notation */ function flattenObject(obj, prefix = '') { const flattened = {}; for (const key in obj) { if (obj[key] === null || obj[key] === undefined) { continue; } const newKey = prefix ? `${prefix}.${key}` : key; if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) { Object.assign(flattened, flattenObject(obj[key], newKey)); } else { flattened[newKey] = Array.isArray(obj[key]) ? obj[key].join('|') : obj[key]; } } return flattened; } /** * Escape CSV values */ function escapeCsvValue(value) { if (value === null || value === undefined) return ''; const str = String(value); if (str.includes(',') || str.includes('"') || str.includes('\n')) { return `"${str.replace(/"/g, '""')}"`; } return str; } /** * Format data as Summary */ function formatAsSummary(data, entityType) { if (Array.isArray(data)) { const count = data.length; const summaryStats = generateSummaryStats(data, entityType); return `📋 ${count} ${entityType}s found\n${summaryStats}\n\nItems:\n${data .map(item => `• ${item.name || item.title || item.id}`) .join('\n')}`; } // Single item summary const essential = extractEssentialFields(data, entityType); return Object.entries(essential) .map(([key, value]) => `${key}: ${value}`) .join(' | '); } /** * Generate summary statistics for array data */ function generateSummaryStats(data, entityType) { const stats = []; if (entityType === 'feature') { const statusCounts = countBy(data, 'status.name'); if (statusCounts && Object.keys(statusCounts).length > 0) { stats.push(`Status: ${Object.entries(statusCounts) .map(([status, count]) => `${status}(${count})`) .join(', ')}`); } } if (entityType === 'note') { const sourceCounts = countBy(data, 'source'); if (sourceCounts && Object.keys(sourceCounts).length > 0) { stats.push(`Sources: ${Object.entries(sourceCounts) .map(([source, count]) => `${source}(${count})`) .join(', ')}`); } } return stats.join('\n'); } /** * Extract essential fields for summary view */ function extractEssentialFields(data, entityType) { const fieldMaps = { feature: ['name', 'status.name', 'owner.email'], component: ['name', 'productId'], note: ['title', 'source'], product: ['name'], company: ['name', 'domain'], user: ['name', 'email'], }; const essentialFields = fieldMaps[entityType] || ['name', 'id']; const result = {}; essentialFields.forEach(field => { const value = getNestedValue(data, field.split('.')); if (value) { result[field] = value; } }); return result; } /** * Count items by field value */ function countBy(data, field) { const counts = {}; data.forEach(item => { const value = getNestedValue(item, field.split('.')) || 'Unknown'; counts[value] = (counts[value] || 0) + 1; }); return counts; } /** * Truncate text to specified length */ function truncate(text, length) { if (!text) return 'N/A'; if (text.length <= length) return text; return text.substring(0, length) + '...'; } /** * Response Optimization Functions */ /** * Optimize response data using truncation and conditional inclusion */ export function optimizeResponse(data, optimization = {}) { if (!optimization || Object.keys(optimization).length === 0) { return data; // No optimization requested } let result = data; // Apply conditional inclusion first result = conditionallyIncludeFields(result, optimization); // Apply field truncation if specified result = applyFieldTruncation(result, optimization); return result; } /** * Apply conditional field inclusion based on optimization settings */ function conditionallyIncludeFields(data, optimization) { if (!data || typeof data !== 'object') return data; let result = { ...data }; // Remove description if not included if (optimization.includeDescription === false) { delete result.description; } // Handle custom fields if (optimization.includeCustomFieldsStrategy === 'none') { delete result.customFields; } else if (optimization.includeCustomFieldsStrategy === 'onlyWithValues') { if (result.customFields && typeof result.customFields === 'object') { result.customFields = Object.entries(result.customFields) .filter(([_, value]) => value !== null && value !== '' && value !== undefined) .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); // Remove customFields if no values remain if (Object.keys(result.customFields).length === 0) { delete result.customFields; } } } // Remove links if not included if (optimization.includeLinks === false) { delete result.links; delete result.relationships; } // Remove metadata if not included if (optimization.includeMetadata === false) { delete result.createdAt; delete result.updatedAt; delete result.version; delete result.lastModified; delete result.createdBy; delete result.updatedBy; } // Remove empty fields if not included if (optimization.includeEmpty === false) { result = removeEmptyFields(result); } return result; } /** * Apply field truncation based on optimization settings */ function applyFieldTruncation(data, optimization) { if (!optimization.truncateFields || optimization.truncateFields.length === 0) { return data; } if (!data || typeof data !== 'object') return data; let result = { ...data }; const indicator = optimization.truncateIndicator || '...'; // If maxLength is specified, calculate proportional truncation if (optimization.maxLength) { const currentLength = JSON.stringify(result).length; if (currentLength > optimization.maxLength) { result = proportionalTruncation(result, optimization); } } else { // Apply standard truncation to specified fields for (const field of optimization.truncateFields) { if (result[field] && typeof result[field] === 'string') { result[field] = truncateField(result[field], 500, indicator); // Default 500 char limit } } } return result; } /** * Truncate a single field with word preservation */ export function truncateField(value, maxLength, indicator = '...', preserveWords = true) { if (!value || value.length <= maxLength) return value; let truncated = value.substring(0, maxLength - indicator.length); if (preserveWords) { const lastSpace = truncated.lastIndexOf(' '); // Only preserve words if the last space is reasonably close to the end if (lastSpace > maxLength * 0.8) { truncated = truncated.substring(0, lastSpace); } } return truncated + indicator; } /** * Apply proportional truncation when total response exceeds maxLength */ function proportionalTruncation(data, optimization) { if (!optimization.maxLength || !optimization.truncateFields) return data; const result = { ...data }; const currentLength = JSON.stringify(result).length; const excessLength = currentLength - optimization.maxLength; if (excessLength <= 0) return result; // Calculate lengths of truncatable fields const fieldLengths = optimization.truncateFields .map(field => ({ field, length: typeof result[field] === 'string' ? result[field].length : 0, })) .filter(f => f.length > 0); const totalTruncatableLength = fieldLengths.reduce((sum, f) => sum + f.length, 0); if (totalTruncatableLength === 0) return result; const indicator = optimization.truncateIndicator || '...'; // Apply proportional reduction to each field fieldLengths.forEach(({ field, length }) => { const reductionRatio = length / totalTruncatableLength; const targetReduction = Math.floor(excessLength * reductionRatio); const newLength = Math.max(100, length - targetReduction); // Minimum 100 chars if (result[field] && typeof result[field] === 'string') { result[field] = truncateField(result[field], newLength, indicator); } }); return result; } /** * Remove fields with null, undefined, or empty string values */ function removeEmptyFields(obj) { if (!obj || typeof obj !== 'object') return obj; const result = {}; for (const [key, value] of Object.entries(obj)) { if (value !== null && value !== undefined && value !== '') { if (typeof value === 'object' && !Array.isArray(value)) { const cleaned = removeEmptyFields(value); if (Object.keys(cleaned).length > 0) { result[key] = cleaned; } } else if (Array.isArray(value)) { const cleanedArray = value.filter(item => item !== null && item !== undefined && item !== ''); if (cleanedArray.length > 0) { result[key] = cleanedArray; } } else { result[key] = value; } } } return result; } /** * Normalize optimization parameters with defaults and validation */ export function normalizeOptimizationParams(params = {}) { const normalized = { truncateFields: params.truncateFields || [], truncateIndicator: params.truncateIndicator || '...', includeDescription: params.includeDescription ?? true, includeCustomFieldsStrategy: params.includeCustomFieldsStrategy || 'all', includeLinks: params.includeLinks ?? true, includeEmpty: params.includeEmpty ?? true, includeMetadata: params.includeMetadata ?? true, }; // Only add maxLength if it's defined if (params.maxLength !== undefined) { normalized.maxLength = params.maxLength; } // Validate maxLength if (normalized.maxLength !== undefined) { if (normalized.maxLength < 100 || normalized.maxLength > 50000) { throw new ValidationError('maxLength must be between 100 and 50000 characters', 'maxLength'); } } // Validate custom field inclusion if (normalized.includeCustomFieldsStrategy && !['all', 'onlyWithValues', 'none'].includes(normalized.includeCustomFieldsStrategy)) { throw new ValidationError('includeCustomFieldsStrategy must be one of: all, onlyWithValues, none', 'includeCustomFieldsStrategy'); } return normalized; }