@twofeetup/clickup-mcp
Version:
Optimized ClickUp MCP Server - High-performance AI integration with consolidated tools and response optimization
434 lines (433 loc) • 16.2 kB
JavaScript
/**
* SPDX-FileCopyrightText: © 2025 Sjoerd Tiemensma
* SPDX-License-Identifier: MIT
*
* Response Formatter Utilities
*
* Provides normalization, field selection, and detail level control for MCP responses
* Following MCP design principles for token efficiency and AI-first design
*/
import { Logger } from '../logger.js';
import { estimateTokensFromObject } from './token-utils.js';
const logger = new Logger('ResponseFormatter');
/**
* Field definitions for detail levels
*/
const FIELD_DEFINITIONS = {
task: {
minimal: ['id', 'name', 'status', 'list'],
standard: ['id', 'name', 'status', 'list', 'assignees', 'due_date', 'priority', 'tags', 'custom_fields'],
detailed: ['*'] // All fields
},
list: {
minimal: ['id', 'name', 'folder'],
standard: ['id', 'name', 'folder', 'space', 'task_count', 'archived'],
detailed: ['*']
},
folder: {
minimal: ['id', 'name', 'space'],
standard: ['id', 'name', 'space', 'lists', 'archived'],
detailed: ['*']
},
member: {
minimal: ['id', 'username', 'email'],
standard: ['id', 'username', 'email', 'role', 'date_joined'],
detailed: ['*']
},
document: {
minimal: ['id', 'name', 'type'],
standard: ['id', 'name', 'type', 'parent', 'date_created', 'date_updated'],
detailed: ['*']
}
};
/**
* Remove null, undefined, empty arrays, and empty objects from data
* Only applies at minimal and standard detail levels to save tokens
*/
export function removeEmptyFields(obj, recursive = true) {
const cleaned = {};
for (const [key, value] of Object.entries(obj)) {
// Skip null and undefined
if (value === null || value === undefined)
continue;
// Skip empty arrays
if (Array.isArray(value) && value.length === 0)
continue;
// Skip empty objects (but not Date objects, etc.)
if (typeof value === 'object' &&
!Array.isArray(value) &&
value.constructor === Object &&
Object.keys(value).length === 0)
continue;
// Recursively clean nested objects if requested
if (recursive && typeof value === 'object' && !Array.isArray(value) && value.constructor === Object) {
const cleanedNested = removeEmptyFields(value, true);
if (Object.keys(cleanedNested).length > 0) {
cleaned[key] = cleanedNested;
}
}
else if (recursive && Array.isArray(value)) {
// Clean array elements
const cleanedArray = value
.map(item => typeof item === 'object' && item.constructor === Object ? removeEmptyFields(item, true) : item)
.filter(item => item !== null && item !== undefined);
if (cleanedArray.length > 0) {
cleaned[key] = cleanedArray;
}
}
else {
cleaned[key] = value;
}
}
return cleaned;
}
/**
* Simplify nested objects to reduce token usage
* - status: object → string
* - assignees: full objects → usernames array
* - list/folder/space: full objects → name string
* - custom_fields: smart filtering based on includeEmptyFields flag
*/
export function simplifyNestedObjects(obj, detailLevel, includeEmptyFields = false) {
if (!obj || typeof obj !== 'object')
return obj;
const simplified = { ...obj };
// Smart custom_fields handling:
// - includeEmptyFields=false (default): only show fields with values
// - includeEmptyFields=true: show all fields, indicate which have values
if (Array.isArray(simplified.custom_fields)) {
if (includeEmptyFields) {
// Show ALL fields (empty + populated)
simplified.custom_fields = simplified.custom_fields.map((field) => {
const hasValue = field.value !== undefined && field.value !== null && field.value !== '';
const base = {
id: field.id,
name: field.name,
type: field.type
};
return hasValue ? { ...base, value: field.value } : base;
});
}
else {
// Default: only show fields with values (token-efficient)
const fieldsWithValues = simplified.custom_fields.filter((field) => field.value !== undefined && field.value !== null && field.value !== '');
if (fieldsWithValues.length === 0) {
// No values - remove entirely to save tokens
delete simplified.custom_fields;
}
else if (detailLevel === 'minimal' || detailLevel === 'standard') {
// Simplify to just essential data: {id, name, value}
simplified.custom_fields = fieldsWithValues.map((field) => ({
id: field.id,
name: field.name,
value: field.value
}));
}
else {
// Detailed level: simplify but keep type info, remove type_config bloat
simplified.custom_fields = fieldsWithValues.map((field) => ({
id: field.id,
name: field.name,
type: field.type,
value: field.value
}));
}
}
}
// Remove sharing object - it's workspace-level config, identical across all tasks
if (simplified.sharing) {
delete simplified.sharing;
}
// Only simplify user objects at minimal/standard levels
if (detailLevel === 'minimal' || detailLevel === 'standard') {
// Simplify status: {status: "open", ...} → "open"
if (simplified.status && typeof simplified.status === 'object') {
simplified.status = simplified.status.status || simplified.status;
}
// Simplify assignees: [{id, username, email, ...}] → ["username"]
if (Array.isArray(simplified.assignees)) {
simplified.assignees = simplified.assignees.map((a) => {
if (typeof a === 'object' && a.username) {
return a.username;
}
return a;
});
}
// Simplify list/folder/space: {id, name, ...} → "name"
if (simplified.list && typeof simplified.list === 'object') {
simplified.list = simplified.list.name || simplified.list;
}
if (simplified.folder && typeof simplified.folder === 'object') {
simplified.folder = simplified.folder.name || simplified.folder;
}
if (simplified.space && typeof simplified.space === 'object') {
simplified.space = simplified.space.name || simplified.space;
}
// Simplify creator/watchers - keep only username
if (simplified.creator && typeof simplified.creator === 'object') {
simplified.creator = simplified.creator.username || simplified.creator;
}
if (Array.isArray(simplified.watchers)) {
simplified.watchers = simplified.watchers.map((w) => typeof w === 'object' && w.username ? w.username : w);
}
}
return simplified;
}
/**
* Get fields to include based on detail level and entity type
*/
function getFieldsForDetailLevel(entityType, detailLevel) {
const fields = FIELD_DEFINITIONS[entityType]?.[detailLevel];
if (!fields)
return null;
return fields[0] === '*' ? null : fields; // null means all fields
}
/**
* Filter object to include only specified fields
*/
function filterFields(obj, fields) {
if (!fields || fields[0] === '*') {
return obj; // Include all fields
}
const filtered = {};
const fieldSet = new Set(fields);
for (const key of Object.keys(obj)) {
if (fieldSet.has(key)) {
filtered[key] = obj[key];
}
}
return filtered;
}
/**
* Normalize an array of objects by extracting common values
* Reduces token usage by 60-90% for repetitive data
*
* Example:
* Input: [{ name: "Task 1", status: "open", list: { id: "123", name: "Todo" } }, ...]
* Output: {
* common: { list: { id: "123", name: "Todo" } },
* items: [{ name: "Task 1", status: "open" }, ...]
* }
*/
export function normalizeArray(items, options = {}) {
if (items.length === 0) {
return { common: {}, items: [] };
}
const threshold = options.threshold || 0.8; // 80% of items must share value
const commonFields = {};
const normalizedItems = [];
// Find common fields (present in >= threshold % of items with same value)
const firstItem = items[0];
for (const key of Object.keys(firstItem)) {
const value = firstItem[key];
// Skip if value is null/undefined
if (value === null || value === undefined)
continue;
// Count how many items have the same value
const matchCount = items.filter(item => JSON.stringify(item[key]) === JSON.stringify(value)).length;
const matchRatio = matchCount / items.length;
// If this field has the same value in >= threshold % of items, extract it
if (matchRatio >= threshold) {
commonFields[key] = value;
}
}
// Remove common fields from items
for (const item of items) {
const normalized = {};
for (const key of Object.keys(item)) {
if (!(key in commonFields)) {
normalized[key] = item[key];
}
}
normalizedItems.push(normalized);
}
return { common: commonFields, items: normalizedItems };
}
/**
* Format response with detail level and field selection
*/
export function formatResponse(data, options = {}) {
const { detailLevel = 'standard', fields, maxTokens, includeMetadata = false, includeEmptyCustomFields = false } = options;
let processedData = data;
// Apply field filtering for arrays
if (Array.isArray(data) && data.length > 0) {
const firstItem = data[0];
// Determine entity type from first item
let entityType = null;
if (firstItem && typeof firstItem === 'object') {
if ('list' in firstItem || 'custom_fields' in firstItem) {
entityType = 'task';
}
else if ('task_count' in firstItem) {
entityType = 'list';
}
else if ('lists' in firstItem && 'space' in firstItem) {
entityType = 'folder';
}
else if ('username' in firstItem) {
entityType = 'member';
}
else if ('pages' in firstItem || 'parent' in firstItem) {
entityType = 'document';
}
}
// Get fields to include
const fieldsToInclude = fields ||
(entityType ? getFieldsForDetailLevel(entityType, detailLevel) : null);
// Process each item: filter fields, simplify objects, remove empty fields
processedData = data.map(item => {
let processed = item;
// Apply field filtering
if (fieldsToInclude) {
processed = filterFields(processed, fieldsToInclude);
}
// Simplify nested objects (at minimal/standard levels)
processed = simplifyNestedObjects(processed, detailLevel, includeEmptyCustomFields);
// Remove null/empty fields (at minimal/standard levels only)
if (detailLevel === 'minimal' || detailLevel === 'standard') {
processed = removeEmptyFields(processed);
}
return processed;
});
}
else if (data && typeof data === 'object' && !Array.isArray(data)) {
// Apply processing for single object
let processed = data;
if (fields) {
processed = filterFields(processed, fields);
}
// Simplify nested objects
processed = simplifyNestedObjects(processed, detailLevel, includeEmptyCustomFields);
// Remove null/empty fields (at minimal/standard levels only)
if (detailLevel === 'minimal' || detailLevel === 'standard') {
processed = removeEmptyFields(processed);
}
processedData = processed;
}
const response = {
data: processedData
};
// Add metadata if requested
if (includeMetadata) {
const estimatedTokens = estimateTokensFromObject(processedData);
response.metadata = {
estimatedTokens,
detailLevel,
fieldsIncluded: fields
};
// Check if response exceeds token limit
if (maxTokens && estimatedTokens > maxTokens) {
response.metadata.truncated = true;
logger.warn('Response exceeds token limit', {
estimated: estimatedTokens,
limit: maxTokens
});
}
}
return response;
}
/**
* Format response with normalization for arrays
* Reduces token usage by extracting common values
*/
export function formatNormalizedResponse(data, options = {}) {
const { detailLevel = 'standard', fields, includeMetadata = false } = options;
// Apply field filtering first if specified
let processedData = data;
if (fields) {
processedData = data.map(item => filterFields(item, fields));
}
// Normalize the array
const normalized = normalizeArray(processedData);
const response = {
data: normalized
};
// Add metadata if requested
if (includeMetadata) {
const estimatedTokens = estimateTokensFromObject(normalized);
response.metadata = {
estimatedTokens,
detailLevel,
fieldsIncluded: fields
};
}
return response;
}
/**
* Apply pagination to an array with metadata
*/
export function paginate(items, offset = 0, limit = 50) {
const total = items.length;
const paginatedItems = items.slice(offset, offset + limit);
const hasMore = offset + limit < total;
return {
items: paginatedItems,
pagination: {
offset,
limit,
total,
hasMore
}
};
}
/**
* Format a summary message for AI consumption
* Creates human-readable summaries that AI can use directly in responses
*/
export function formatSummary(operation, result, details) {
const parts = [operation];
if (typeof result === 'object' && result !== null) {
if ('total' in result) {
parts.push(`(${result.total} items)`);
}
else if (Array.isArray(result)) {
parts.push(`(${result.length} items)`);
}
}
if (details) {
parts.push('-', details);
}
return parts.join(' ');
}
/**
* Create an error response with suggestions
* Provides actionable guidance for AI to recover from errors
*/
export function formatError(error, suggestions) {
const errorMessage = typeof error === 'string' ? error : error.message;
const response = {
error: errorMessage
};
if (suggestions && suggestions.length > 0) {
response.suggestions = suggestions;
}
return response;
}
/**
* Estimate if operation should use normalized format
* Based on array size and estimated savings
*/
export function shouldNormalize(items) {
// Only normalize if we have multiple items
if (items.length < 3)
return false;
// Check if items have common fields
if (items.length > 0) {
const firstItem = items[0];
if (typeof firstItem !== 'object')
return false;
// Count repeated values
const keys = Object.keys(firstItem);
let commonFieldCount = 0;
for (const key of keys) {
const value = firstItem[key];
const matchCount = items.filter(item => JSON.stringify(item[key]) === JSON.stringify(value)).length;
if (matchCount >= items.length * 0.8) {
commonFieldCount++;
}
}
// If more than 30% of fields are common, normalize
return commonFieldCount / keys.length >= 0.3;
}
return false;
}