@the_cfdude/productboard-mcp
Version:
Model Context Protocol server for Productboard REST API with dynamic tool loading
691 lines (612 loc) ⢠20.6 kB
text/typescript
/**
* 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();