UNPKG

@the_cfdude/productboard-mcp

Version:

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

601 lines (600 loc) 22.5 kB
/** * Features management tools */ import { normalizeListParams, normalizeGetParams, formatResponse, filterArrayByDetailLevel, filterByDetailLevel, isEnterpriseError, } from '../utils/parameter-utils.js'; import { fetchAllPages } from '../utils/pagination-handler.js'; import { fieldSelector } from '../utils/field-selection.js'; import { withContext } from '../utils/tool-wrapper.js'; import { ProductboardError } from '../errors/index.js'; import { ErrorCode } from '@modelcontextprotocol/sdk/types.js'; /** * Features Tools */ export function setupFeaturesTools() { return [...getFeatureToolSchemas(), ...getUtilityToolSchemas()]; } function getFeatureToolSchemas() { return [ { name: 'create_feature', description: 'Create a new feature in Productboard', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Feature name', }, description: { type: 'string', description: 'Feature description (HTML format required, e.g., "<p>Description text</p>")', }, status: { type: 'object', description: 'Feature status (required - use get_feature_statuses to see available options)', properties: { id: { type: 'string', description: 'Status ID (UUID)' }, name: { type: 'string', description: 'Status name' }, }, required: ['id', 'name'], }, owner: { type: 'object', properties: { email: { type: 'string', description: 'Owner email' }, }, }, parent: { type: 'object', description: 'Parent entity to associate this feature with', properties: { id: { type: 'string', description: 'ID of the parent entity (product, component, or feature)', }, type: { type: 'string', enum: ['product', 'component', 'feature'], description: 'Type of parent entity', }, }, required: ['id', 'type'], }, instance: { type: 'string', description: 'Productboard instance name (optional)', }, workspaceId: { type: 'string', description: 'Workspace ID (optional)', }, }, required: ['name', 'status'], additionalProperties: true, }, }, { name: 'get_features', description: 'List all features in Productboard', inputSchema: { type: 'object', properties: { ...getStandardListProperties(), archived: { type: 'boolean', description: 'Filter by archived status', }, noteId: { type: 'string', description: 'Filter by associated note ID', }, ownerEmail: { type: 'string', description: 'Filter by owner email', }, parentId: { type: 'string', description: 'Filter by parent feature ID', }, statusId: { type: 'string', description: 'Filter by status ID', }, statusName: { type: 'string', description: 'Filter by status name', }, timeframeDuration: { type: 'string', description: 'Filter by exact timeframe duration (e.g., "2w", "1m", "3w2d"). Range: 1w-12m', }, timeframeDurationMin: { type: 'string', description: 'Filter by minimum timeframe duration (e.g., "1w", "2m"). Range: 1w-12m', }, timeframeDurationMax: { type: 'string', description: 'Filter by maximum timeframe duration (e.g., "4w", "6m"). Range: 1w-12m', }, ...getResponseOptimizationProperties(), }, }, }, { name: 'get_feature', description: 'Get a specific feature by ID', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Feature ID', }, ...getStandardGetProperties(), includeCustomFields: { type: 'boolean', description: 'Include available custom fields and their current values for this feature', }, ...getResponseOptimizationProperties(), }, required: ['id'], }, }, { name: 'update_feature', description: 'Update a feature. Supports both standard fields and custom fields - pass custom field names as additional parameters (e.g., "T-Shirt Sizing": "large").', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Feature ID', }, name: { type: 'string', description: 'Feature name', }, description: { type: 'string', description: 'Feature description (HTML format required, e.g., "<p>Description text</p>")', }, status: { type: 'object', properties: { id: { type: 'string', description: 'Status ID' }, name: { type: 'string', description: 'Status name' }, }, }, owner: { type: 'object', properties: { email: { type: 'string', description: 'Owner email' }, }, }, archived: { type: 'boolean', description: 'Archive status', }, timeframe: { type: 'object', description: 'Feature timeframe with start and end dates', properties: { startDate: { type: 'string', description: 'Start date (YYYY-MM-DD)', }, endDate: { type: 'string', description: 'End date (YYYY-MM-DD)' }, granularity: { type: 'string', description: 'Timeframe granularity (optional)', }, }, }, parentId: { type: 'string', description: 'Parent feature ID (for sub-features)', }, componentId: { type: 'string', description: 'Component ID (to move feature to a different component)', }, productId: { type: 'string', description: 'Product ID (to move feature to a different product)', }, instance: { type: 'string', description: 'Productboard instance name (optional)', }, workspaceId: { type: 'string', description: 'Workspace ID (optional)', }, }, additionalProperties: true, required: ['id'], }, }, { name: 'delete_feature', description: 'Delete a feature', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Feature ID', }, instance: { type: 'string', description: 'Productboard instance name (optional)', }, workspaceId: { type: 'string', description: 'Workspace ID (optional)', }, }, required: ['id'], }, }, ]; } function getUtilityToolSchemas() { return [ { name: 'get_available_fields', description: 'Get available fields for precise field selection. Shows all possible fields for an entity type with examples and usage patterns.', inputSchema: { type: 'object', properties: { entityType: { type: 'string', enum: [ 'feature', 'note', 'company', 'component', 'product', 'user', 'objective', 'initiative', 'keyResult', 'release', 'releaseGroup', ], description: 'Entity type to get available fields for', }, instance: { type: 'string', description: 'Productboard instance name (optional)', }, workspaceId: { type: 'string', description: 'Workspace ID (optional)', }, }, required: ['entityType'], }, }, ]; } function getStandardListProperties() { return { limit: { type: 'number', description: 'Maximum number of items to return (1-100, default: 100)', }, startWith: { type: 'number', description: 'Offset for pagination (default: 0)', }, detail: { type: 'string', enum: ['basic', 'standard', 'full'], description: 'Level of detail (default: basic). DEPRECATED: Use fields parameter for precise selection.', }, outputFormat: { type: 'string', enum: ['json', 'markdown', 'csv', 'summary'], description: 'Output format for response data. JSON (default), Markdown (human-readable), CSV (tabular), Summary (condensed)', }, includeSubData: { type: 'boolean', description: 'Include nested complex JSON sub-data', }, instance: { type: 'string', description: 'Productboard instance name (optional)', }, workspaceId: { type: 'string', description: 'Workspace ID (optional)', }, }; } function getStandardGetProperties() { return { detail: { type: 'string', enum: ['basic', 'standard', 'full'], description: 'Level of detail (default: standard). DEPRECATED: Use fields parameter for precise selection.', }, outputFormat: { type: 'string', enum: ['json', 'markdown', 'csv', 'summary'], description: 'Output format for response data. JSON (default), Markdown (human-readable), CSV (tabular), Summary (condensed)', }, includeSubData: { type: 'boolean', description: 'Include nested complex JSON sub-data', }, instance: { type: 'string', description: 'Productboard instance name (optional)', }, workspaceId: { type: 'string', description: 'Workspace ID (optional)', }, }; } function getResponseOptimizationProperties() { return { maxLength: { type: 'number', description: 'Maximum response length in characters (100-50000). Enables smart truncation of long fields.', }, truncateFields: { type: 'array', items: { type: 'string' }, description: 'Fields to truncate if response is too long (e.g., ["description", "notes"])', }, truncateIndicator: { type: 'string', description: 'Indicator to append to truncated fields (default: "...")', }, includeDescription: { type: 'boolean', description: 'Include description fields in response (default: true)', }, includeCustomFieldsStrategy: { type: 'string', enum: ['all', 'onlyWithValues', 'none'], description: 'Custom field inclusion strategy (default: "all")', }, includeLinks: { type: 'boolean', description: 'Include links and relationships in response (default: true)', }, includeEmpty: { type: 'boolean', description: 'Include fields with empty values (default: true)', }, includeMetadata: { type: 'boolean', description: 'Include metadata fields like createdAt, updatedAt (default: true)', }, }; } export async function handleFeaturesTool(name, args) { try { switch (name) { // Features case 'create_feature': return await createFeature(args); case 'get_features': return await listFeatures(args); case 'get_feature': return await getFeature(args); case 'update_feature': case 'update_feature_deprecated': return await updateFeature(args); case 'delete_feature': return await deleteFeature(args); // Available fields case 'get_available_fields': return await getAvailableFields(args); default: throw new ProductboardError(ErrorCode.InvalidRequest, `Unknown tool: ${name}`); } } catch (error) { if (isEnterpriseError(error)) { throw new ProductboardError(ErrorCode.InvalidRequest, error.message, error); } throw error; } } /** * Get available fields for field selection */ /** * Create a new feature in Productboard */ async function createFeature(args) { return await withContext(async (context) => { const body = { name: args.name, type: 'feature', // Required field for ProductBoard API status: args.status, // Required field - must have both id and name }; if (args.description) body.description = args.description; if (args.owner) body.owner = args.owner; if (args.parent) body.parent = args.parent; const response = await context.axios.post('/features', { data: body }); return { content: [ { type: 'text', text: JSON.stringify(formatResponse({ success: true, feature: response.data, }, 'json', 'feature')), }, ], }; }, args.instance, args.workspaceId, 'create_feature'); } /** * List features in Productboard */ async function listFeatures(args) { return await withContext(async (context) => { const normalizedParams = normalizeListParams(args); const params = {}; // Add filters (no pagination parameters - handled by fetchAllPages) if (args.archived !== undefined) params.archived = args.archived; if (args.noteId) params['linkedNote.id'] = args.noteId; if (args.ownerEmail) params['owner.email'] = args.ownerEmail; if (args.parentId) params['parent.id'] = args.parentId; if (args.statusId) params['status.id'] = args.statusId; if (args.statusName) params['status.name'] = args.statusName; // Use proper pagination handler to fetch all pages const paginatedResponse = await fetchAllPages(context.axios, '/features', params, { maxItems: normalizedParams.limit > 100 ? normalizedParams.limit : undefined, onPageFetched: (_pageData, _pageNum, _totalSoFar) => { // Page fetched successfully }, }); const result = filterArrayByDetailLevel(paginatedResponse.data, 'feature', normalizedParams.detail); // Return complete response structure with proper pagination info const responseData = { data: result, meta: { totalRecords: result.length, totalPages: paginatedResponse.meta?.totalPages || 1, wasLimited: paginatedResponse.meta?.wasLimited || false, }, links: {}, // Links are no longer relevant since we fetched all pages }; return { content: [ { type: 'text', text: JSON.stringify(responseData), }, ], }; }, args.instance, args.workspaceId, 'get_features'); } /** * Get a specific feature by ID */ async function getFeature(args) { return await withContext(async (context) => { const normalizedParams = normalizeGetParams(args); const params = {}; if (args.includeCustomFields) params.includeCustomFields = true; const response = await context.axios.get(`/features/${args.id}`, { params, }); const result = filterByDetailLevel(response.data, 'feature', normalizedParams.detail); return { content: [ { type: 'text', text: formatResponse(result, args.outputFormat || 'json', 'feature'), }, ], }; }, args.instance, args.workspaceId, 'get_feature'); } /** * Update an existing feature */ async function updateFeature(args) { return await withContext(async (context) => { const { id, ...updateData } = args; const response = await context.axios.patch(`/features/${id}`, { data: updateData, }); return { content: [ { type: 'text', text: formatResponse({ success: true, feature: response.data, }, 'json', 'feature'), }, ], }; }, args.instance, args.workspaceId, 'update_feature'); } /** * Delete a feature */ async function deleteFeature(args) { return await withContext(async (context) => { await context.axios.delete(`/features/${args.id}`); return { content: [ { type: 'text', text: formatResponse({ success: true, message: `Feature ${args.id} deleted successfully`, }, 'json', 'feature'), }, ], }; }, args.instance, args.workspaceId, 'delete_feature'); } async function getAvailableFields(args) { const { entityType } = args; // Validate entity type const supportedEntities = [ 'feature', 'note', 'company', 'component', 'product', 'user', 'objective', 'initiative', 'keyResult', 'release', 'releaseGroup', ]; if (!supportedEntities.includes(entityType)) { throw new Error(`Unsupported entity type: ${entityType}. Supported types: ${supportedEntities.join(', ')}`); } const availableFields = fieldSelector.getAvailableFields(entityType); const essentialFields = fieldSelector.getEssentialFields(entityType); const result = { entityType, availableFields: availableFields.sort(), essentialFields, fieldCount: availableFields.length, examples: { basicSelection: essentialFields, nestedSelection: availableFields .filter(field => field.includes('.')) .slice(0, 5), commonFields: ['id', 'name', 'createdAt', 'updatedAt'].filter(field => availableFields.includes(field)), }, usage: { includeSpecific: `fields: ["id", "name", "status.name"]`, excludeFields: `exclude: ["description", "customFields"]`, nestedAccess: `fields: ["owner.email", "timeframe.startDate"]`, }, }; return { content: [ { type: 'text', text: JSON.stringify(result), }, ], }; }