@the_cfdude/productboard-mcp
Version:
Model Context Protocol server for Productboard REST API with dynamic tool loading
354 lines (353 loc) • 15.5 kB
JavaScript
/**
* Universal search tool for all Productboard entities
*/
import { withContext, formatResponse } from '../utils/tool-wrapper.js';
import { debugLog } from '../utils/debug-logger.js';
import { SearchEngine } from '../utils/search-engine.js';
import { SearchMessageGenerator } from '../utils/search-messaging.js';
import { ValidationError, ProductboardError } from '../errors/index.js';
import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';
/**
* Setup search tool definition
*/
export function setupSearchTools() {
return [
{
name: 'search',
description: 'Universal search across all Productboard entities with advanced pattern matching, flexible filtering and output control. Supports wildcard patterns (*,?), regular expressions, case-sensitive search, and searching multiple entity types simultaneously with intelligent suggestions.',
inputSchema: {
type: 'object',
properties: {
entityType: {
oneOf: [
{
type: 'string',
enum: [
'features',
'notes',
'companies',
'users',
'products',
'components',
'releases',
'release_groups',
'objectives',
'initiatives',
'key_results',
'custom_fields',
'webhooks',
'plugin_integrations',
'jira_integrations',
],
description: 'Single entity type to search',
},
{
type: 'array',
items: {
type: 'string',
enum: [
'features',
'notes',
'companies',
'users',
'products',
'components',
'releases',
'release_groups',
'objectives',
'initiatives',
'key_results',
'custom_fields',
'webhooks',
'plugin_integrations',
'jira_integrations',
],
},
description: 'Multiple entity types to search simultaneously',
},
],
description: 'Type(s) of entity to search - can be a single type or array of types',
},
filters: {
type: 'object',
description: 'Field filters as key-value pairs. Use empty string "" to find missing/empty fields',
additionalProperties: true,
},
operators: {
type: 'object',
description: 'Operators for each filter field: equals, contains, isEmpty, startsWith, endsWith, wildcard, regex',
additionalProperties: {
type: 'string',
enum: [
'equals',
'contains',
'isEmpty',
'startsWith',
'endsWith',
'before',
'after',
'wildcard',
'regex',
],
},
},
output: {
oneOf: [
{
type: 'array',
items: { type: 'string' },
description: 'Array of specific fields to return (supports dot notation like "owner.email")',
},
{
type: 'string',
enum: ['ids-only', 'summary', 'full'],
description: 'Preset output modes: ids-only returns simple array, summary returns key fields, full returns complete objects',
},
],
description: 'Controls which fields are returned. Overrides detail parameter when specified.',
},
limit: {
type: 'number',
minimum: 1,
maximum: 100,
description: 'Maximum number of results to return (default: 50)',
default: 50,
},
startWith: {
type: 'number',
minimum: 0,
description: 'Offset for pagination (default: 0)',
default: 0,
},
detail: {
type: 'string',
enum: ['basic', 'standard', 'full'],
description: 'Detail level when output parameter not specified (default: standard)',
default: 'standard',
},
includeSubData: {
type: 'boolean',
description: 'Include nested relationship data (default: false)',
default: false,
},
includeCustomFields: {
type: 'boolean',
description: 'Include custom fields information for features (default: false)',
default: false,
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
patternMatchMode: {
type: 'string',
enum: ['exact', 'wildcard', 'regex'],
description: 'Default pattern matching mode when no operator specified (default: wildcard)',
default: 'wildcard',
},
caseSensitive: {
type: 'boolean',
description: 'Case sensitive pattern matching (default: false)',
default: false,
},
suggestAlternatives: {
type: 'boolean',
description: 'Generate search suggestions when no results found (default: false)',
default: false,
},
maxSuggestions: {
type: 'number',
minimum: 1,
maximum: 10,
description: 'Maximum number of suggestions to return (default: 5)',
default: 5,
},
},
required: ['entityType'],
},
},
];
}
/**
* Handle search tool execution
*/
export async function handleSearchTool(name, args) {
debugLog('search', 'handleSearchTool called', { name, args });
if (name !== 'search') {
throw new Error(`Unknown search tool: ${name}`);
}
// Fix stringified array parameters (MCP protocol issue workaround)
const fixedArgs = { ...args };
// Handle stringified entityType array
if (fixedArgs.entityType && typeof fixedArgs.entityType === 'string') {
// Try to parse as JSON if it looks like an array
if (fixedArgs.entityType.startsWith('[') &&
fixedArgs.entityType.endsWith(']')) {
try {
const parsed = JSON.parse(fixedArgs.entityType);
if (Array.isArray(parsed)) {
fixedArgs.entityType = parsed;
}
}
catch {
// Ignore parse errors
}
}
}
// Handle stringified output array
if (fixedArgs.output && typeof fixedArgs.output === 'string') {
// Try to parse as JSON if it looks like an array
if (fixedArgs.output.startsWith('[') && fixedArgs.output.endsWith(']')) {
try {
const parsed = JSON.parse(fixedArgs.output);
if (Array.isArray(parsed)) {
fixedArgs.output = parsed;
}
}
catch {
// Ignore parse errors
}
}
}
// Handle stringified filters object
if (fixedArgs.filters && typeof fixedArgs.filters === 'string') {
const filtersStr = fixedArgs.filters;
// Try to parse as JSON if it looks like an object
if (filtersStr.startsWith('{') && filtersStr.endsWith('}')) {
try {
const parsed = JSON.parse(filtersStr);
if (typeof parsed === 'object' && parsed !== null) {
fixedArgs.filters = parsed;
}
}
catch {
// Ignore parse errors
}
}
}
// Handle stringified operators object
if (fixedArgs.operators && typeof fixedArgs.operators === 'string') {
const operatorsStr = fixedArgs.operators;
// Try to parse as JSON if it looks like an object
if (operatorsStr.startsWith('{') && operatorsStr.endsWith('}')) {
try {
const parsed = JSON.parse(operatorsStr);
if (typeof parsed === 'object' && parsed !== null) {
fixedArgs.operators = parsed;
}
}
catch {
// Ignore parse errors
}
}
}
try {
return await performSearch(fixedArgs);
}
catch (error) {
if (error instanceof ValidationError ||
error instanceof ProductboardError) {
throw error;
}
throw new ProductboardError(ErrorCode.InternalError, `Search failed: ${error.message}`, error);
}
}
/**
* Main search execution function
*/
async function performSearch(params) {
return await withContext(async (context) => {
const searchEngine = new SearchEngine();
const messageGenerator = new SearchMessageGenerator();
// Validate and normalize parameters
const normalizedParams = await searchEngine.validateAndNormalizeParams(params);
// Execute search against appropriate entity endpoint
const rawResults = await searchEngine.executeEntitySearch(context, normalizedParams);
// Apply filtering and output formatting
const processedResults = await searchEngine.processResults(rawResults, normalizedParams);
// Generate smart suggestions if no results found and suggestions are enabled
if (processedResults.data.length === 0 &&
normalizedParams.suggestAlternatives) {
const suggestions = await searchEngine.generateSmartSuggestions(normalizedParams);
if (suggestions.length > 0) {
processedResults.suggestions = suggestions.slice(0, normalizedParams.maxSuggestions);
}
}
// Build search context for messaging
// Use actual filtered count as totalRecords when client-side filtering was applied
const actualTotalRecords = processedResults.data.length;
const searchContext = {
entityType: normalizedParams.entityType,
totalRecords: actualTotalRecords,
returnedRecords: processedResults.data.length,
filters: normalizedParams.filters,
output: normalizedParams.output,
detail: normalizedParams.detail,
warnings: processedResults.warnings,
hasMore: processedResults.hasMore,
queryTimeMs: processedResults.queryTimeMs,
};
// Generate intelligent response message
const message = messageGenerator.generateMessage(searchContext);
const hints = messageGenerator.generateContextualHints(searchContext);
// Build final response
debugLog('search', 'Building final response', {
entityType: normalizedParams.entityType,
dataLength: processedResults.data.length,
totalRecords: actualTotalRecords,
});
const response = {
success: true,
data: processedResults.data,
metadata: {
totalRecords: actualTotalRecords,
returnedRecords: processedResults.data.length,
searchCriteria: {
entityType: normalizedParams.entityType,
filters: normalizedParams.filters,
output: normalizedParams.output,
detail: normalizedParams.detail,
},
message,
...(processedResults.warnings.length > 0 && {
warnings: processedResults.warnings,
}),
...(hints.length > 0 && { hints }),
...(processedResults.suggestions &&
processedResults.suggestions.length > 0 && {
suggestions: processedResults.suggestions,
}),
performance: {
queryTimeMs: processedResults.queryTimeMs,
},
},
...(processedResults.hasMore && {
pagination: {
hasNext: true,
nextOffset: (normalizedParams.startWith || 0) +
(normalizedParams.limit || 50),
totalPages: Math.ceil(actualTotalRecords / (normalizedParams.limit || 50)),
},
}),
};
const result = {
content: [
{
type: 'text',
text: formatResponse(response, false),
},
],
};
debugLog('search', 'Returning result', {
contentLength: typeof result.content[0].text === 'string'
? result.content[0].text.length
: 0,
hasData: response.data.length > 0,
});
return result;
}, params.instance, params.workspaceId);
}