@the_cfdude/productboard-mcp
Version:
Model Context Protocol server for Productboard REST API with dynamic tool loading
715 lines (714 loc) • 26.7 kB
JavaScript
/**
* 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;
}