@the_cfdude/productboard-mcp
Version:
Model Context Protocol server for Productboard REST API with dynamic tool loading
415 lines (350 loc) • 12.6 kB
text/typescript
/**
* Intelligent message generation for search results
*/
import { SearchContext, EntityType } from '../types/search-types.js';
import { EntityFieldMappings } from './search-field-mappings.js';
export class SearchMessageGenerator {
private entityMappings = EntityFieldMappings;
/**
* Generate comprehensive message for search results
*/
generateMessage(context: SearchContext): string {
const parts: string[] = [];
// Core result summary
parts.push(this.generateResultSummary(context));
// Filter explanation
if (Object.keys(context.filters).length > 0) {
parts.push(this.generateFilterDescription(context));
}
// Parameter conflicts/warnings
if (context.warnings.length > 0) {
parts.push(this.generateWarningsMessage(context));
}
// Pagination info
if (context.hasMore) {
parts.push(this.generatePaginationHint(context));
}
return parts.join('. ');
}
/**
* Generate contextual hints for better usage
*/
generateContextualHints(context: SearchContext): string[] {
const hints: string[] = [];
// Suggest output optimization for large results
if (context.returnedRecords > 20 && context.output === 'full') {
hints.push(
`Consider using output parameter to select only needed fields for better performance (e.g., output: ["id", "name"])`
);
}
// Usage estimation warnings
const estimatedUsage = this.estimateUsageMetrics(context);
if (estimatedUsage > 50000) {
hints.push(
`⚠️ Large response detected (~${Math.round(estimatedUsage / 1000)}k units) - consider using output field selection or pagination to reduce size`
);
} else if (estimatedUsage > 20000) {
hints.push(
`Response size is significant (~${Math.round(estimatedUsage / 1000)}k units) - consider field selection for better efficiency`
);
}
// Suggest pagination for large result sets
if (context.totalRecords > 100 && context.returnedRecords === 100) {
hints.push(
`Large result set detected - consider using pagination with startWith parameter or more specific filters`
);
}
// Performance hints
if (context.queryTimeMs > 5000) {
hints.push(
`Query took ${context.queryTimeMs}ms - consider adding more specific filters or using pagination`
);
}
// No results hints
if (context.totalRecords === 0) {
hints.push(...this.generateNoResultsHints(context));
}
// Filter combination suggestions
if (Object.keys(context.filters).length > 5) {
hints.push(
`Complex filter combination detected - if results are unexpected, try simplifying filters`
);
}
return hints;
}
/**
* Generate result summary based on counts
*/
private generateResultSummary(context: SearchContext): string {
const { entityType, totalRecords, returnedRecords } = context;
// Handle multiple entity types
const entityTypeDisplay = Array.isArray(entityType)
? entityType.join(', ')
: entityType;
const isMultipleTypes = Array.isArray(entityType);
if (totalRecords === 0) {
return `No ${entityTypeDisplay} found matching the search criteria`;
}
let message: string;
if (totalRecords === returnedRecords) {
message = isMultipleTypes
? `Found ${totalRecords} items across ${entityTypeDisplay}`
: `Found ${totalRecords} ${entityType}`;
} else {
message = isMultipleTypes
? `Found ${totalRecords} items across ${entityTypeDisplay}, returning first ${returnedRecords}`
: `Found ${totalRecords} ${entityType}, returning first ${returnedRecords}`;
}
return message;
}
/**
* Generate description of applied filters
*/
private generateFilterDescription(context: SearchContext): string {
const descriptions: string[] = [];
for (const [field, value] of Object.entries(context.filters)) {
descriptions.push(this.describeFilter(field, value, context.entityType));
}
if (descriptions.length === 1) {
return `Filtered by: ${descriptions[0]}`;
}
return `Filtered by: ${descriptions.join(', ')}`;
}
/**
* Describe individual filter in human-readable format
*/
private describeFilter(
field: string,
value: any,
entityType: EntityType | EntityType[]
): string {
// For multiple entity types, use the field name directly
const fieldDisplayName = Array.isArray(entityType)
? field
: this.getFieldDisplayName(field, entityType);
// Handle empty/missing value searches
if (value === '' || value === null || value === undefined) {
return `missing ${fieldDisplayName}`;
}
// Handle array values
if (Array.isArray(value)) {
if (value.length === 1) {
return `${fieldDisplayName} = "${value[0]}"`;
}
return `${fieldDisplayName} in [${value.join(', ')}]`;
}
// Handle object values (for complex operators)
if (typeof value === 'object' && value.operator) {
return this.describeOperatorFilter(fieldDisplayName, value);
}
// Handle boolean values
if (typeof value === 'boolean') {
return `${fieldDisplayName} = ${value ? 'true' : 'false'}`;
}
// Default string representation
return `${fieldDisplayName} = "${value}"`;
}
/**
* Describe operator-based filters
*/
private describeOperatorFilter(fieldName: string, filterObj: any): string {
const { operator, value } = filterObj;
switch (operator) {
case 'contains':
return `${fieldName} contains "${value}"`;
case 'startsWith':
return `${fieldName} starts with "${value}"`;
case 'endsWith':
return `${fieldName} ends with "${value}"`;
case 'isEmpty':
return `missing ${fieldName}`;
case 'before':
return `${fieldName} before ${value}`;
case 'after':
return `${fieldName} after ${value}`;
default:
return `${fieldName} ${operator} "${value}"`;
}
}
/**
* Get human-readable field name
*/
private getFieldDisplayName(field: string, entityType: EntityType): string {
const entityConfig = this.entityMappings[entityType];
if (entityConfig?.displayNames?.[field]) {
return entityConfig.displayNames[field];
}
// Convert camelCase to readable format
return field
.replace(/([A-Z])/g, ' $1')
.replace(/^./, str => str.toLowerCase())
.replace(/\./g, ' ');
}
/**
* Generate warnings message
*/
private generateWarningsMessage(context: SearchContext): string {
const warningParts = context.warnings.map(warning => {
// Make warnings more user-friendly
return warning.charAt(0).toUpperCase() + warning.slice(1);
});
if (warningParts.length === 1) {
return `Note: ${warningParts[0]}`;
}
return `Notes: ${warningParts.join('; ')}`;
}
/**
* Generate pagination hint
*/
private generatePaginationHint(context: SearchContext): string {
const nextOffset =
(context.returnedRecords || 0) + (context.filters['startWith'] || 0);
return `Use startWith=${nextOffset} to get the next batch`;
}
/**
* Generate hints when no results are found
*/
private generateNoResultsHints(context: SearchContext): string[] {
const suggestions: string[] = [];
// Check for common empty value patterns
for (const [field, value] of Object.entries(context.filters)) {
if (value === '' || value === null) {
const fieldDisplayName = Array.isArray(context.entityType)
? field
: this.getFieldDisplayName(field, context.entityType);
suggestions.push(
`Remove the "${fieldDisplayName}" filter to see all ${context.entityType}, then filter client-side if needed`
);
}
}
// Check for overly restrictive combinations
if (Object.keys(context.filters).length > 3) {
suggestions.push(`Try removing some filters to broaden the search scope`);
}
// Entity-specific suggestions (only for single entity type)
const entitySpecificSuggestions = Array.isArray(context.entityType)
? []
: this.getEntitySpecificSuggestions(context);
suggestions.push(...entitySpecificSuggestions);
if (suggestions.length === 0) {
suggestions.push(
`Try using broader search criteria or check if the ${context.entityType} exist in your workspace`
);
}
return suggestions.slice(0, 3); // Limit to 3 suggestions to avoid overwhelming
}
/**
* Get entity-specific suggestions for no results scenarios
*/
private getEntitySpecificSuggestions(context: SearchContext): string[] {
const suggestions: string[] = [];
switch (context.entityType) {
case 'features':
suggestions.push(
`Try searching without the "archived" filter to include archived features`
);
if (context.filters['status.name']) {
suggestions.push(
`Check if the status "${context.filters['status.name']}" exists in your workspace`
);
}
break;
case 'notes':
if (context.filters['company.domain']) {
suggestions.push(`Try searching by company name instead of domain`);
}
break;
case 'users':
suggestions.push(
`Try searching without role or active status filters to see all users`
);
break;
case 'releases':
if (context.filters['state']) {
suggestions.push(
`Try searching without state filter - valid states are usually: planned, in_progress, released`
);
}
break;
default:
break;
}
return suggestions;
}
/**
* Generate performance metrics message
*/
generatePerformanceMessage(context: SearchContext): string | null {
if (context.queryTimeMs < 1000) {
return null; // Don't mention good performance
}
if (context.queryTimeMs < 5000) {
return `Query completed in ${context.queryTimeMs}ms`;
}
return `Query took ${context.queryTimeMs}ms - consider optimizing with more specific filters`;
}
/**
* Estimate usage metrics for response data
*/
private estimateUsageMetrics(context: SearchContext): number {
// Base estimation: 1 unit ≈ 4 characters for typical JSON data
// Estimate based on entity type and output mode
let baseUnitsPerRecord = 0;
// For multi-entity search, use a default estimate
const entityTypeForEstimate = Array.isArray(context.entityType)
? 'default'
: context.entityType;
switch (entityTypeForEstimate) {
case 'features':
baseUnitsPerRecord = context.output === 'full' ? 300 : 50;
break;
case 'notes':
baseUnitsPerRecord = context.output === 'full' ? 400 : 60;
break;
case 'companies':
baseUnitsPerRecord = context.output === 'full' ? 200 : 40;
break;
case 'users':
baseUnitsPerRecord = context.output === 'full' ? 150 : 30;
break;
case 'releases':
baseUnitsPerRecord = context.output === 'full' ? 250 : 45;
break;
default:
baseUnitsPerRecord = context.output === 'full' ? 200 : 40;
break;
}
// Adjust for output mode
if (context.output === 'ids-only') {
baseUnitsPerRecord = 10;
} else if (context.output === 'summary') {
baseUnitsPerRecord = Math.floor(baseUnitsPerRecord * 0.3);
} else if (Array.isArray(context.output)) {
// Field selection - estimate based on number of fields
const fieldCount = context.output.length;
baseUnitsPerRecord = Math.min(baseUnitsPerRecord, fieldCount * 15);
}
// Total estimated usage
const dataUnits = context.returnedRecords * baseUnitsPerRecord;
// Add metadata overhead (message, hints, etc.)
const metadataUnits = 200;
return dataUnits + metadataUnits;
}
/**
* Generate success metrics for the response
*/
generateSuccessMetrics(context: SearchContext): Record<string, any> {
return {
efficiency: context.returnedRecords / Math.max(context.totalRecords, 1),
resultDensity: context.totalRecords > 0 ? 'results_found' : 'no_results',
filterComplexity: Object.keys(context.filters).length,
hasOptimalPagination: context.returnedRecords <= 50,
queryPerformance:
context.queryTimeMs < 2000
? 'fast'
: context.queryTimeMs < 5000
? 'moderate'
: 'slow',
};
}
}