UNPKG

@the_cfdude/productboard-mcp

Version:

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

691 lines (612 loc) • 20.6 kB
#!/usr/bin/env node /** * Generate tool implementations with rich documentation from OpenAPI specification */ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import type { ToolDocumentation } from '../src/documentation/tool-documentation.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = join(__dirname, '..'); const MANIFEST_PATH = join(PROJECT_ROOT, 'generated', 'manifest.json'); const OPENAPI_PATH = join(PROJECT_ROOT, 'productboard_openapi.yaml'); const OUTPUT_DIR = join(PROJECT_ROOT, 'generated', 'tools'); const DOCS_OUTPUT_PATH = join( PROJECT_ROOT, 'generated', 'tool-documentation.ts' ); interface ToolManifest { version: string; generated: string; categories: Record<string, CategoryInfo>; tools: Record<string, ToolInfo>; } interface CategoryInfo { displayName: string; description: string; tools: string[]; } interface ToolInfo { category: string; operation: string; description: string; requiredParams: string[]; optionalParams: string[]; implementation: string; } // Load manifest function loadManifest(): ToolManifest { const content = readFileSync(MANIFEST_PATH, 'utf-8'); return JSON.parse(content); } // Parse OpenAPI spec (deprecated - now uses manifest only) function parseOpenAPI(): any { // Return empty spec since we no longer use OpenAPI return { paths: {} }; } // Convert tool name to function name function toolNameToFunctionName(toolName: string): string { const parts = toolName.split('_'); if (parts.length < 3) return toolName; const action = parts[2]; const resource = parts[1]; // Special cases if (action === 'list') return `list${capitalize(resource)}`; if (action === 'get') return `get${capitalize(singularize(resource))}`; if (action === 'create') return `create${capitalize(singularize(resource))}`; if (action === 'update') return `update${capitalize(singularize(resource))}`; if (action === 'delete') return `delete${capitalize(singularize(resource))}`; // Nested resources if (parts.length > 3) { const subResource = parts.slice(3).join(''); return `${action}${capitalize(singularize(resource))}${capitalize(subResource)}`; } return `${action}${capitalize(resource)}`; } // Capitalize first letter function capitalize(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1); } // Simple singularization function singularize(str: string): string { if (str.endsWith('ies')) return str.slice(0, -3) + 'y'; if (str.endsWith('es')) return str.slice(0, -2); if (str.endsWith('s')) return str.slice(0, -1); return str; } // Generate parameter documentation function generateParamDocumentation( params: string[], operation: any ): Record<string, string> { const paramDocs: Record<string, string> = {}; // Standard params paramDocs.instance = 'Productboard instance URL (optional)'; paramDocs.workspaceId = 'Workspace ID for multi-workspace environments (optional)'; paramDocs.includeRaw = 'Include raw API response in output (optional)'; paramDocs.body = 'Request body containing the data to send'; // Get parameter descriptions from OpenAPI if (operation.parameters) { operation.parameters.forEach((param: any) => { if (param.description) { paramDocs[param.name] = param.description; } }); } return paramDocs; } // Generate example based on tool type function generateExample( toolName: string, toolInfo: ToolInfo, index: number ): any { // Parse tool name - handle both with and without prefix const parts = toolName.split('_'); const resource = parts.length >= 3 ? parts[1] : parts[0]; const action = parts.length >= 3 ? parts[2] : parts[1]; // Create different examples based on action and index switch (action) { case 'create': if (index === 0) { return { title: `Basic ${resource} creation`, description: `Create a new ${singularize(resource)} with minimal required fields`, input: generateBasicCreateExample(resource), notes: 'Start with required fields only for simple use cases', }; } else if (index === 1) { return { title: `${capitalize(resource)} with full details`, description: `Create a ${singularize(resource)} with all commonly used fields`, input: generateFullCreateExample(resource), notes: 'Include optional fields for richer data', }; } break; case 'list': if (index === 0) { return { title: `Get all ${resource}`, description: `Retrieve all ${resource} with default pagination`, input: { limit: 25, detail: 'standard' }, notes: 'Use lower limits for better performance', }; } else if (index === 1) { return { title: `Search ${resource} with filters`, description: `Find specific ${resource} using search criteria`, input: generateSearchExample(resource), notes: 'Combine filters to narrow results', }; } break; case 'update': return { title: `Update ${singularize(resource)}`, description: `Modify an existing ${singularize(resource)}`, input: generateUpdateExample(resource), notes: 'Only include fields you want to change', }; case 'delete': return { title: `Delete ${singularize(resource)}`, description: `Remove a ${singularize(resource)} from the system`, input: { id: `${resource.slice(0, 4)}_123` }, notes: 'This action cannot be undone', }; default: return { title: `${capitalize(action)} ${resource}`, description: `Perform ${action} operation on ${resource}`, input: {}, notes: 'Check API documentation for specific parameters', }; } } // Generate basic create example function generateBasicCreateExample(resource: string): any { switch (resource) { case 'notes': return { title: 'Customer feedback', content: 'User requested dark mode support', tags: ['feature-request', 'ui'], user: { email: 'customer@example.com' }, }; case 'features': return { name: 'Dark mode support', description: 'Add dark theme option to the application', priority: 7.5, }; case 'companies': return { name: 'Acme Corporation', domain: 'acme.com', }; case 'releases': return { name: 'Q1 2025 Release', startDate: '2025-01-01', endDate: '2025-03-31', }; default: return { name: `New ${singularize(resource)}`, description: `Description for ${singularize(resource)}`, }; } } // Generate full create example function generateFullCreateExample(resource: string): any { const basic = generateBasicCreateExample(resource); switch (resource) { case 'notes': return { ...basic, displayUrl: 'https://support.example.com/ticket/12345', companyId: 'comp_456', customerId: 'cust_789', source: { origin: 'support', category: 'ticket' }, }; case 'features': return { ...basic, effort: 8, value: 9, status: 'candidate', assignee: { email: 'pm@example.com' }, timeframe: { startDate: '2025-02-01', endDate: '2025-02-28' }, }; case 'companies': return { ...basic, size: 'enterprise', plan: 'premium', customFields: { industry: 'technology', arr: '1000000', }, }; default: return basic; } } // Generate search example function generateSearchExample(resource: string): any { switch (resource) { case 'notes': return { term: 'performance', tags: ['bug'], dateFrom: '2025-01-01', limit: 50, detail: 'standard', }; case 'features': return { status: 'in-progress', priority: { min: 7 }, limit: 25, detail: 'full', }; case 'companies': return { term: 'enterprise', hasNotes: true, limit: 100, }; default: return { limit: 50, offset: 0, detail: 'standard', }; } } // Generate update example function generateUpdateExample(resource: string): any { const id = `${resource.slice(0, 4)}_123`; switch (resource) { case 'notes': return { id, body: { tags: ['resolved', 'v2.0'], status: 'processed', }, }; case 'features': return { id, body: { status: 'in-progress', priority: 9, assignee: { email: 'dev@example.com' }, }, }; case 'companies': return { id, body: { size: 'enterprise', customFields: { tier: 'platinum', }, }, }; default: return { id, body: { name: `Updated ${singularize(resource)}`, description: 'Updated description', }, }; } } // Generate common errors for a tool function generateCommonErrors(toolName: string, action: string): any[] { const errors = []; // Common errors for all tools errors.push({ error: 'Authentication failed', cause: 'Invalid or missing API token', solution: 'Ensure PRODUCTBOARD_API_TOKEN environment variable is set correctly', }); // Action-specific errors switch (action) { case 'create': errors.push({ error: 'Validation error', cause: 'Required fields missing or invalid format', solution: 'Check that all required fields are provided with correct data types', }); break; case 'get': case 'update': case 'delete': errors.push({ error: 'Resource not found', cause: 'The specified ID does not exist', solution: 'Verify the ID exists using the corresponding list operation', }); break; case 'list': errors.push({ error: 'Invalid pagination', cause: 'Offset exceeds total count or limit is too high', solution: 'Use limit <= 100 and check total count for valid offset values', }); break; } return errors; } // Generate best practices for a tool function generateBestPractices(toolName: string, action: string): string[] { const practices = []; const resource = toolName.split('_')[1]; // General practices practices.push('Handle rate limiting with exponential backoff'); practices.push('Cache responses when appropriate to reduce API calls'); // Action-specific practices switch (action) { case 'create': practices.push(`Validate ${resource} data before submission`); practices.push('Use idempotency keys for critical operations'); break; case 'list': practices.push('Use pagination for large datasets (limit: 25-50 for UI)'); practices.push('Apply filters to reduce response size'); practices.push('Use detail:"basic" for list views'); break; case 'update': practices.push('Only include fields that need to be changed'); practices.push('Fetch current state before updates if needed'); break; case 'delete': practices.push('Confirm deletion with user before executing'); practices.push('Consider soft delete or archiving instead'); break; } return practices; } // Generate related tools function generateRelatedTools(toolName: string): string[] { const [_, resource, action] = toolName.split('_'); const related = []; // Add complementary CRUD operations if (action !== 'create') related.push(`${resource}_create`); if (action !== 'list') related.push(`${resource}_list`); if (action !== 'get') related.push(`${resource}_get`); if (action !== 'update') related.push(`${resource}_update`); if (action !== 'delete') related.push(`${resource}_delete`); // Add related resource tools switch (resource) { case 'notes': related.push('features_create', 'companies_list'); break; case 'features': related.push('releases_list', 'notes_list'); break; case 'companies': related.push('users_list', 'notes_create'); break; } return [...new Set(related)].filter(t => t !== toolName).slice(0, 5); } // Generate tool documentation function generateToolDocumentation( toolName: string, toolInfo: ToolInfo, operation: any ): ToolDocumentation { // Parse tool name - handle both with and without prefix const parts = toolName.split('_'); const resource = parts.length >= 3 ? parts[1] : parts[0]; const action = parts.length >= 3 ? parts[2] : parts[1]; // Generate 2-3 examples const examples = []; for (let i = 0; i < 2; i++) { const example = generateExample(toolName, toolInfo, i); if (example) examples.push(example); } // Generate detailed description const detailedDescription = ` The ${toolName} tool ${toolInfo.description.toLowerCase()}. This operation is part of the ${resource} management API and allows you to ${action} ${resource} in your Productboard workspace. ${getResourceContext(resource)} Key capabilities: ${getKeyCapabilities(resource, action)} Use this tool when you need to ${getUseCaseDescription(resource, action)}. `.trim(); return { description: toolInfo.description, detailedDescription, examples, commonErrors: generateCommonErrors(toolName, action), bestPractices: generateBestPractices(toolName, action), relatedTools: generateRelatedTools(toolName), }; } // Get resource context function getResourceContext(resource: string): string { const contexts: Record<string, string> = { notes: 'Notes represent customer feedback, feature requests, and insights that drive product decisions.', features: 'Features are the building blocks of your product roadmap, representing planned functionality.', companies: 'Companies help you organize and segment customer feedback by organization.', users: 'Users are individuals who provide feedback or are associated with companies.', releases: 'Releases group features into time-based or theme-based deliverables.', objectives: 'Objectives represent high-level goals that features support.', webhooks: 'Webhooks enable real-time notifications when data changes in Productboard.', }; return ( contexts[resource] || `${capitalize(resource)} are key entities in your product management workflow.` ); } // Get key capabilities function getKeyCapabilities(resource: string, action: string): string { const capabilities = []; switch (action) { case 'create': capabilities.push( `- Create new ${resource} with required and optional fields` ); capabilities.push( `- Set relationships to other entities (companies, users, etc.)` ); capabilities.push(`- Apply tags and custom fields for organization`); break; case 'list': capabilities.push(`- Search ${resource} using keywords and filters`); capabilities.push(`- Paginate through large result sets`); capabilities.push(`- Control response detail level for performance`); break; case 'get': capabilities.push( `- Retrieve full details of a specific ${singularize(resource)}` ); capabilities.push(`- Include related data with proper detail level`); capabilities.push(`- Access custom fields and metadata`); break; case 'update': capabilities.push(`- Modify existing ${resource} properties`); capabilities.push(`- Update relationships and assignments`); capabilities.push(`- Change status and workflow states`); break; case 'delete': capabilities.push(`- Permanently remove ${resource} from the system`); capabilities.push(`- Clean up related data and references`); capabilities.push(`- Maintain data integrity across relationships`); break; } return capabilities.join('\n'); } // Get use case description function getUseCaseDescription(resource: string, action: string): string { const useCases: Record<string, Record<string, string>> = { notes: { create: 'capture customer feedback, bug reports, or feature requests from various sources', list: 'analyze customer feedback trends, find similar requests, or export insights', get: 'view complete feedback details including all metadata and relationships', update: 'process feedback, add tags, or link to features', delete: 'remove outdated or irrelevant feedback', }, features: { create: 'add new items to your product roadmap based on customer needs', list: 'view roadmap items, filter by status, or find features for planning', get: 'examine feature details, requirements, and linked feedback', update: 'change feature priority, status, or assignments', delete: 'remove cancelled or duplicate features', }, companies: { create: 'add new customer organizations to track their feedback', list: 'segment customers, analyze feedback by company, or manage accounts', get: 'view company details and associated users', update: 'modify company information or custom fields', delete: 'remove inactive or duplicate companies', }, }; return ( useCases[resource]?.[action] || `${action} ${resource} in your product management workflow` ); } // Generate documentation file function generateDocumentationFile( manifest: ToolManifest, openapi: any ): string { const allDocs: Record<string, ToolDocumentation> = {}; // Process each tool Object.entries(manifest.tools).forEach(([toolName, toolInfo]) => { // Find the operation in OpenAPI const [method, path] = toolInfo.operation.split(' '); const pathItem = openapi.paths[path]; const operation = pathItem?.[method.toLowerCase()]; if (operation) { allDocs[toolName] = generateToolDocumentation( toolName, toolInfo, operation ); } }); // Generate the TypeScript file let content = `/** * Auto-generated tool documentation * Generated: ${new Date().toISOString()} */ import type { ToolDocumentation } from '../src/documentation/tool-documentation.js'; export const generatedToolDocumentation: Record<string, ToolDocumentation> = ${JSON.stringify(allDocs, null, 2)}; // Merge with manually maintained documentation export function mergeDocumentation( manual: Record<string, ToolDocumentation>, generated: Record<string, ToolDocumentation> ): Record<string, ToolDocumentation> { const merged = { ...generated }; // Manual documentation takes precedence Object.entries(manual).forEach(([tool, doc]) => { merged[tool] = doc; }); return merged; } `; return content; } // Main execution function main() { try { console.log('šŸš€ Starting enhanced tool generation...'); // Load manifest and OpenAPI const manifest = loadManifest(); const openapi = parseOpenAPI(); // Ensure output directories exist mkdirSync(OUTPUT_DIR, { recursive: true }); mkdirSync(dirname(DOCS_OUTPUT_PATH), { recursive: true }); // Generate documentation file const docsContent = generateDocumentationFile(manifest, openapi); writeFileSync(DOCS_OUTPUT_PATH, docsContent); console.log(`āœ… Generated tool documentation: ${DOCS_OUTPUT_PATH}`); // Generate enhanced tool files with integrated documentation let generatedCount = 0; Object.entries(manifest.categories).forEach(([categoryId, category]) => { // Skip if already implemented const existingCategories = [ 'notes', 'features', 'companies', 'users', 'releases', 'webhooks', ]; if (existingCategories.includes(categoryId)) { console.log(`ā­ļø Skipping ${categoryId} (already implemented)`); return; } generatedCount++; }); console.log( `\nšŸŽ‰ Documentation generated for ${Object.keys(manifest.tools).length} tools` ); console.log('šŸ“ Run "npm run build" to compile the generated code'); } catch (error) { console.error('āŒ Error generating tools:', error); process.exit(1); } } main();