UNPKG

@the_cfdude/productboard-mcp

Version:

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

570 lines (569 loc) 23.2 kB
/** * Bulk operations tools with diff-only focus * Provides efficient bulk update capabilities with change tracking */ import { bulkOperationsEngine, DiffUtils } from '../utils/bulk-operations.js'; import { ValidationError } from '../errors/index.js'; import { withContext } from '../utils/tool-wrapper.js'; // Import tool handlers for entity operations import { handleFeaturesTool } from './features.js'; import { handleNotesTool } from './notes.js'; import { handleCompaniesTool } from './companies.js'; import { handleUsersTool } from './users.js'; import { handleObjectivesTool } from './objectives.js'; /** * Perform batch updates with diff tracking */ export async function performBulkUpdate(args) { const { entityType, updates, batchSize = 25, concurrency = 3, continueOnError = true, validateBeforeUpdate = true, trackChanges = true, diffFormat = 'summary', includeUnchanged = false, } = args; // Validate entity type const supportedTypes = [ 'features', 'notes', 'companies', 'users', 'objectives', ]; if (!supportedTypes.includes(entityType)) { throw new ValidationError(`Unsupported entity type. Must be one of: ${supportedTypes.join(', ')}`, 'entityType'); } // Validate updates if (!Array.isArray(updates) || updates.length === 0) { throw new ValidationError('updates must be a non-empty array', 'updates'); } if (updates.length > 500) { throw new ValidationError('Maximum 500 updates allowed per bulk operation', 'updates'); } try { // Get the appropriate entity handler const entityHandler = getEntityHandler(entityType); // Perform bulk update const result = await bulkOperationsEngine.performBulkUpdate({ entityType, updates, options: { batchSize: Math.min(batchSize, 50), concurrency: Math.min(concurrency, 5), continueOnError, validateBeforeUpdate, trackChanges, }, }, entityHandler); // Format response const response = { entityType, summary: result.summary, successful: result.successful, failed: result.failed.map(f => ({ id: f.id, error: f.error, })), ...(result.skipped.length > 0 && { skipped: result.skipped }), }; // Add change tracking if enabled if (trackChanges && result.changes.length > 0) { const entityDiffs = result.changes.map(change => { const diff = bulkOperationsEngine.createEntityDiff(change.id, change.before, change.after); return { ...diff, entityType, }; }); // Add diff report response.changeReport = DiffUtils.createDiffReport(entityDiffs, diffFormat); // Add detailed changes if requested or format is detailed if (diffFormat === 'detailed' || includeUnchanged) { response.changes = result.changes.map(change => ({ id: change.id, diff: change.diff, ...(includeUnchanged && { before: change.before, after: change.after, }), })); } // Add significant changes summary const significantChanges = entityDiffs.filter(diff => diff.significantChanges.length > 0); if (significantChanges.length > 0) { response.significantChanges = significantChanges.map(diff => ({ id: diff.id, fields: diff.significantChanges, })); } } return response; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Bulk update failed: ${errorMessage}`); } } /** * Compare entities and generate diff report without making changes */ export async function compareEntities(args) { const { entityType, comparisons, diffFormat = 'summary', highlightSignificant = true, includeUnchanged = false, } = args; // Validate inputs if (!Array.isArray(comparisons) || comparisons.length === 0) { throw new ValidationError('comparisons must be a non-empty array', 'comparisons'); } if (comparisons.length > 100) { throw new ValidationError('Maximum 100 comparisons allowed per request', 'comparisons'); } try { const entityHandler = getEntityHandler(entityType); const diffs = []; // Process each comparison for (const comparison of comparisons) { try { // Get current entity data const currentResponse = await entityHandler(`get_${entityType}`, { id: comparison.id, detail: 'standard', }); const currentData = extractEntityData(currentResponse); const proposedData = { ...currentData, ...comparison.proposedChanges }; // Generate diff const diff = bulkOperationsEngine.createEntityDiff(comparison.id, currentData, proposedData); if (diff.hasChanges || includeUnchanged) { diffs.push({ ...diff, entityType, proposedChanges: comparison.proposedChanges, }); } } catch (error) { diffs.push({ id: comparison.id, entityType, error: error instanceof Error ? error.message : 'Failed to fetch entity', hasChanges: false, changeCount: 0, operations: [], significantChanges: [], }); } } // Generate response const response = { entityType, totalComparisons: comparisons.length, entitiesWithChanges: diffs.filter(d => d.hasChanges).length, totalChanges: diffs.reduce((sum, d) => sum + (d.changeCount || 0), 0), diffReport: DiffUtils.createDiffReport(diffs, diffFormat), }; // Add detailed diffs if (diffFormat === 'detailed') { response.diffs = diffs.map(diff => ({ id: diff.id, hasChanges: diff.hasChanges, changeCount: diff.changeCount, operations: diff.operations, ...(highlightSignificant && diff.significantChanges.length > 0 && { significantChanges: diff.significantChanges, }), })); } // Add significant changes summary if (highlightSignificant) { const significantDiffs = diffs.filter(d => d.significantChanges && d.significantChanges.length > 0); if (significantDiffs.length > 0) { response.significantChanges = significantDiffs.map(diff => ({ id: diff.id, fields: diff.significantChanges, })); } } return response; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Entity comparison failed: ${errorMessage}`); } } /** * Validate bulk update request without executing */ export async function validateBulkUpdate(args) { const { entityType, updates, checkExistence = true, validateFields = true, } = args; // Validate inputs if (!Array.isArray(updates) || updates.length === 0) { throw new ValidationError('updates must be a non-empty array', 'updates'); } const validationResults = { valid: true, totalUpdates: updates.length, validUpdates: 0, invalidUpdates: 0, errors: [], warnings: [], summary: '', }; try { const entityHandler = getEntityHandler(entityType); // Validate each update for (let i = 0; i < updates.length; i++) { const update = updates[i]; let updateValid = true; // Basic validation if (!update.id || typeof update.id !== 'string') { validationResults.errors.push({ id: update.id || `update[${i}]`, error: 'ID is required and must be a string', severity: 'error', }); updateValid = false; } if (!update.changes || typeof update.changes !== 'object') { validationResults.errors.push({ id: update.id || `update[${i}]`, error: 'Changes object is required', severity: 'error', }); updateValid = false; } if (Object.keys(update.changes || {}).length === 0) { validationResults.errors.push({ id: update.id || `update[${i}]`, error: 'Changes cannot be empty', severity: 'error', }); updateValid = false; } // Check entity existence if (checkExistence && update.id) { try { await entityHandler(`get_${entityType}`, { id: update.id, detail: 'basic', }); } catch { validationResults.errors.push({ id: update.id, error: 'Entity not found', severity: 'error', }); updateValid = false; } } // Field validation (basic checks) if (validateFields && update.changes) { for (const [field, value] of Object.entries(update.changes)) { if (field === 'id') { validationResults.warnings.push({ id: update.id, field, message: 'ID field in changes will be ignored', }); } if (value === null || value === undefined) { validationResults.warnings.push({ id: update.id, field, message: 'Null/undefined value may clear field', }); } } } if (updateValid) { validationResults.validUpdates++; } else { validationResults.invalidUpdates++; } } // Set overall validity validationResults.valid = validationResults.invalidUpdates === 0; // Generate summary if (validationResults.valid) { validationResults.summary = `All ${validationResults.totalUpdates} updates are valid`; if (validationResults.warnings.length > 0) { validationResults.summary += ` (${validationResults.warnings.length} warnings)`; } } else { validationResults.summary = `${validationResults.invalidUpdates} of ${validationResults.totalUpdates} updates failed validation`; } return validationResults; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Bulk validation failed: ${errorMessage}`); } } /** * Get entity handler for the specified type */ function getEntityHandler(entityType) { return async (operation, params) => { return await withContext(async () => { switch (entityType) { case 'features': return await handleFeaturesTool(operation, params); case 'notes': return await handleNotesTool(operation, params); case 'companies': return await handleCompaniesTool(operation, params); case 'users': return await handleUsersTool(operation, params); case 'objectives': return await handleObjectivesTool(operation, params); default: throw new ValidationError(`Unsupported entity type: ${entityType}`, 'entityType'); } }); }; } /** * Extract entity data from tool response */ function extractEntityData(response) { if (typeof response === 'object' && response !== null) { const typedResponse = response; if (typedResponse.content?.[0]?.text) { try { const parsed = JSON.parse(typedResponse.content[0].text); return parsed.data || parsed || {}; } catch { return {}; } } } return {}; } /** * Tool handler function */ export async function handleBulkOperationsTool(operation, args) { switch (operation) { case 'perform_bulk_update': return performBulkUpdate(args); case 'compare_entities': return compareEntities(args); case 'validate_bulk_update': return validateBulkUpdate(args); default: throw new ValidationError(`Unknown bulk operations operation: ${operation}`, 'operation'); } } /** * Setup bulk operations tools definitions */ export function setupBulkOperationsTools() { return [ { name: 'perform_bulk_update', description: 'Perform batch updates with diff tracking and change analysis. Supports features, notes, companies, users, and objectives.', inputSchema: { type: 'object', properties: { entityType: { type: 'string', enum: ['features', 'notes', 'companies', 'users', 'objectives'], description: 'Type of entities to update', }, updates: { type: 'array', items: { type: 'object', properties: { id: { type: 'string', description: 'Entity ID to update', }, changes: { type: 'object', description: 'Fields to update with new values', additionalProperties: true, }, expectedVersion: { type: 'string', description: 'Expected version for optimistic locking', }, metadata: { type: 'object', description: 'Additional metadata for the update', additionalProperties: true, }, }, required: ['id', 'changes'], }, description: 'Array of updates to perform (max 500)', maxItems: 500, }, batchSize: { type: 'number', description: 'Number of updates per batch (default: 25, max: 50)', minimum: 1, maximum: 50, default: 25, }, concurrency: { type: 'number', description: 'Number of concurrent batches (default: 3, max: 5)', minimum: 1, maximum: 5, default: 3, }, continueOnError: { type: 'boolean', description: 'Continue processing if some updates fail', default: true, }, validateBeforeUpdate: { type: 'boolean', description: 'Validate all updates before processing', default: true, }, trackChanges: { type: 'boolean', description: 'Track and report changes made', default: true, }, diffFormat: { type: 'string', enum: ['summary', 'detailed', 'compact'], description: 'Format for change reporting', default: 'summary', }, includeUnchanged: { type: 'boolean', description: 'Include entities that had no changes in response', default: false, }, instance: { type: 'string', description: 'ProductBoard instance name', }, workspaceId: { type: 'string', description: 'Workspace ID', }, }, required: ['entityType', 'updates'], }, }, { name: 'compare_entities', description: 'Compare current entity state with proposed changes without making updates. Shows what would change.', inputSchema: { type: 'object', properties: { entityType: { type: 'string', enum: ['features', 'notes', 'companies', 'users', 'objectives'], description: 'Type of entities to compare', }, comparisons: { type: 'array', items: { type: 'object', properties: { id: { type: 'string', description: 'Entity ID to compare', }, proposedChanges: { type: 'object', description: 'Proposed changes to compare against current state', additionalProperties: true, }, }, required: ['id', 'proposedChanges'], }, description: 'Array of entities to compare (max 100)', maxItems: 100, }, diffFormat: { type: 'string', enum: ['summary', 'detailed', 'compact'], description: 'Format for diff reporting', default: 'summary', }, highlightSignificant: { type: 'boolean', description: 'Highlight significant changes (status, priority, etc.)', default: true, }, includeUnchanged: { type: 'boolean', description: 'Include entities with no changes in results', default: false, }, instance: { type: 'string', description: 'ProductBoard instance name', }, workspaceId: { type: 'string', description: 'Workspace ID', }, }, required: ['entityType', 'comparisons'], }, }, { name: 'validate_bulk_update', description: 'Validate bulk update request without executing. Checks for errors and potential issues.', inputSchema: { type: 'object', properties: { entityType: { type: 'string', enum: ['features', 'notes', 'companies', 'users', 'objectives'], description: 'Type of entities to validate', }, updates: { type: 'array', items: { type: 'object', properties: { id: { type: 'string', description: 'Entity ID to update', }, changes: { type: 'object', description: 'Fields to update with new values', additionalProperties: true, }, expectedVersion: { type: 'string', description: 'Expected version for optimistic locking', }, }, required: ['id', 'changes'], }, description: 'Array of updates to validate', }, checkExistence: { type: 'boolean', description: 'Check if entities exist before validation', default: true, }, validateFields: { type: 'boolean', description: 'Perform field-level validation', default: true, }, instance: { type: 'string', description: 'ProductBoard instance name', }, workspaceId: { type: 'string', description: 'Workspace ID', }, }, required: ['entityType', 'updates'], }, }, ]; }