@the_cfdude/productboard-mcp
Version:
Model Context Protocol server for Productboard REST API with dynamic tool loading
601 lines (600 loc) • 22.5 kB
JavaScript
/**
* 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),
},
],
};
}