@the_cfdude/productboard-mcp
Version:
Model Context Protocol server for Productboard REST API with dynamic tool loading
1,051 lines (986 loc) • 27.9 kB
text/typescript
/**
* Releases and Release Groups management tools
*/
import { withContext, formatResponse } from '../utils/tool-wrapper.js';
import {
normalizeListParams,
normalizeGetParams,
filterByDetailLevel,
filterArrayByDetailLevel,
isEnterpriseError,
} from '../utils/parameter-utils.js';
import { fetchAllPages } from '../utils/pagination-handler.js';
import {
StandardListParams,
StandardGetParams,
} from '../types/parameter-types.js';
import { ProductboardError } from '../errors/index.js';
import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';
export function setupReleasesTools() {
return [
// Release Groups operations
{
name: 'create_release_group',
description: 'Create a new release group',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Release group name',
},
description: {
type: 'string',
description: 'Release group description',
},
isDefault: {
type: 'boolean',
description: 'Whether this is the default release group',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
required: ['name'],
},
},
{
name: 'list_release_groups',
description: 'List all release groups',
inputSchema: {
type: 'object',
properties: {
limit: {
type: 'number',
description:
'Maximum number of release groups 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)',
},
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)',
},
},
},
},
{
name: 'get_release_group',
description: 'Get a specific release group by ID',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Release group ID',
},
detail: {
type: 'string',
enum: ['basic', 'standard', 'full'],
description: 'Level of detail (default: standard)',
},
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)',
},
},
required: ['id'],
},
},
{
name: 'update_release_group',
description: 'Update an existing release group',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Release group ID',
},
name: {
type: 'string',
description: 'Updated release group name',
},
description: {
type: 'string',
description: 'Updated description',
},
isDefault: {
type: 'boolean',
description: 'Updated default status',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
required: ['id'],
},
},
{
name: 'delete_release_group',
description: 'Delete a release group',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Release group ID',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
required: ['id'],
},
},
// Releases operations
{
name: 'create_release',
description: 'Create a new release',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Release name',
},
releaseGroupId: {
type: 'string',
description: 'Release group ID',
},
state: {
type: 'string',
enum: ['future', 'in_progress', 'released', 'archived'],
description: 'Release state',
},
description: {
type: 'string',
description: 'Release description',
},
startDate: {
type: 'string',
description: 'Release start date (YYYY-MM-DD)',
},
releaseDate: {
type: 'string',
description: 'Release date (YYYY-MM-DD)',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
required: ['name', 'releaseGroupId'],
},
},
{
name: 'list_releases',
description: 'List all releases',
inputSchema: {
type: 'object',
properties: {
limit: {
type: 'number',
description:
'Maximum number of releases 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)',
},
includeSubData: {
type: 'boolean',
description: 'Include nested complex JSON sub-data',
},
releaseGroupId: {
type: 'string',
description: 'Filter by release group ID',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
},
},
{
name: 'get_release',
description: 'Get a specific release by ID',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Release ID',
},
detail: {
type: 'string',
enum: ['basic', 'standard', 'full'],
description: 'Level of detail (default: standard)',
},
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)',
},
},
required: ['id'],
},
},
{
name: 'update_release',
description: 'Update an existing release',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Release ID',
},
name: {
type: 'string',
description: 'Updated release name',
},
state: {
type: 'string',
enum: ['future', 'in_progress', 'released', 'archived'],
description: 'Updated release state',
},
description: {
type: 'string',
description: 'Updated description',
},
startDate: {
type: 'string',
description: 'Updated start date (YYYY-MM-DD)',
},
releaseDate: {
type: 'string',
description: 'Updated release date (YYYY-MM-DD)',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
required: ['id'],
},
},
{
name: 'delete_release',
description: 'Delete a release',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Release ID',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
required: ['id'],
},
},
// Feature Release Assignments
{
name: 'list_feature_release_assignments',
description: 'List all feature release assignments',
inputSchema: {
type: 'object',
properties: {
limit: {
type: 'number',
description:
'Maximum number of assignments 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)',
},
includeSubData: {
type: 'boolean',
description: 'Include nested complex JSON sub-data',
},
featureId: {
type: 'string',
description: 'Filter by feature ID',
},
releaseId: {
type: 'string',
description: 'Filter by release ID',
},
releaseState: {
type: 'string',
description: 'Filter by release state',
},
releaseEndDateFrom: {
type: 'string',
description: 'Filter by release end date from (YYYY-MM-DD)',
},
releaseEndDateTo: {
type: 'string',
description: 'Filter by release end date to (YYYY-MM-DD)',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
},
},
{
name: 'get_feature_release_assignment',
description: 'Get a specific feature release assignment',
inputSchema: {
type: 'object',
properties: {
featureId: {
type: 'string',
description: 'Feature ID',
},
releaseId: {
type: 'string',
description: 'Release ID',
},
detail: {
type: 'string',
enum: ['basic', 'standard', 'full'],
description: 'Level of detail (default: standard)',
},
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)',
},
},
required: ['featureId', 'releaseId'],
},
},
{
name: 'update_feature_release_assignment',
description: 'Update or create a feature release assignment',
inputSchema: {
type: 'object',
properties: {
featureId: {
type: 'string',
description: 'Feature ID',
},
releaseId: {
type: 'string',
description: 'Release ID',
},
isAssigned: {
type: 'boolean',
description: 'Whether the feature is assigned to the release',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
required: ['featureId', 'releaseId', 'isAssigned'],
},
},
];
}
export async function handleReleasesTool(name: string, args: any) {
try {
switch (name) {
// Release Groups
case 'create_release_group':
return await createReleaseGroup(args);
case 'list_release_groups':
return await listReleaseGroups(args);
case 'get_release_group':
return await getReleaseGroup(args);
case 'update_release_group':
return await updateReleaseGroup(args);
case 'delete_release_group':
return await deleteReleaseGroup(args);
// Releases
case 'create_release':
return await createRelease(args);
case 'list_releases':
return await listReleases(args);
case 'get_release':
return await getRelease(args);
case 'update_release':
return await updateRelease(args);
case 'delete_release':
return await deleteRelease(args);
// Feature Release Assignments
case 'list_feature_release_assignments':
return await listFeatureReleaseAssignments(args);
case 'get_feature_release_assignment':
return await getFeatureReleaseAssignment(args);
case 'update_feature_release_assignment':
return await updateFeatureReleaseAssignment(args);
default:
throw new Error(`Unknown releases tool: ${name}`);
}
} catch (error: any) {
const enterpriseInfo = isEnterpriseError(error);
if (enterpriseInfo.isEnterpriseFeature) {
throw new ProductboardError(
ErrorCode.InvalidRequest,
enterpriseInfo.message,
error
);
}
throw error;
}
}
// Release Groups implementations
async function createReleaseGroup(args: any) {
return await withContext(
async context => {
const body: any = {
name: args.name,
};
if (args.description) body.description = args.description;
if (args.isDefault !== undefined) body.isDefault = args.isDefault;
const response = await context.axios.post('/release-groups', {
data: body,
});
return {
content: [
{
type: 'text',
text: formatResponse({
success: true,
releaseGroup: response.data,
}),
},
],
};
},
args.instance,
args.workspaceId
);
}
async function listReleaseGroups(args: StandardListParams & any) {
return await withContext(
async context => {
const normalized = normalizeListParams(args);
const params: any = {};
// Use proper pagination handler to fetch all pages
const paginatedResponse = await fetchAllPages(
context.axios,
'/release-groups',
params,
{
maxItems: normalized.limit > 100 ? normalized.limit : undefined,
onPageFetched: (_pageData, _pageNum, _totalSoFar) => {
// Page fetched successfully
},
}
);
const result = {
data: paginatedResponse.data,
links: paginatedResponse.links,
meta: {
...paginatedResponse.meta,
totalFetched: paginatedResponse.data.length,
},
};
// Apply detail level filtering after fetching all data
if (!normalized.includeSubData && result.data) {
result.data = filterArrayByDetailLevel(
result.data,
'releaseGroup',
normalized.detail
);
}
// Apply client-side limit after filtering (if requested limit < total available)
if (normalized.limit && normalized.limit < result.data.length) {
result.data = result.data.slice(
normalized.startWith || 0,
(normalized.startWith || 0) + normalized.limit
);
}
return {
content: [
{
type: 'text',
text: formatResponse(result),
},
],
};
},
args.instance,
args.workspaceId
);
}
async function getReleaseGroup(
args: StandardGetParams & {
id: string;
instance?: string;
workspaceId?: string;
}
) {
return await withContext(
async context => {
const normalizedParams = normalizeGetParams(args);
const response = await context.axios.get(`/release-groups/${args.id}`);
let result = response.data;
// Apply detail level filtering
if (!normalizedParams.includeSubData) {
result = filterByDetailLevel(
result,
'releaseGroup',
normalizedParams.detail
);
}
return {
content: [
{
type: 'text',
text: formatResponse(result),
},
],
};
},
args.instance,
args.workspaceId
);
}
async function updateReleaseGroup(args: any) {
return await withContext(
async context => {
const body: any = {};
if (args.name) body.name = args.name;
if (args.description) body.description = args.description;
if (args.isDefault !== undefined) body.isDefault = args.isDefault;
const response = await context.axios.patch(`/release-groups/${args.id}`, {
data: body,
});
return {
content: [
{
type: 'text',
text: formatResponse({
success: true,
releaseGroup: response.data,
}),
},
],
};
},
args.instance,
args.workspaceId
);
}
async function deleteReleaseGroup(args: any) {
return await withContext(
async context => {
await context.axios.delete(`/release-groups/${args.id}`);
return {
content: [
{
type: 'text',
text: formatResponse({
success: true,
message: `Release group ${args.id} deleted successfully`,
}),
},
],
};
},
args.instance,
args.workspaceId
);
}
// Releases implementations
async function createRelease(args: any) {
return await withContext(
async context => {
const body: any = {
name: args.name,
releaseGroup: { id: args.releaseGroupId },
};
if (args.state) body.state = args.state;
if (args.description) body.description = args.description;
if (args.startDate)
body.timeframe = { ...body.timeframe, startDate: args.startDate };
if (args.releaseDate)
body.timeframe = { ...body.timeframe, endDate: args.releaseDate };
const response = await context.axios.post('/releases', { data: body });
return {
content: [
{
type: 'text',
text: formatResponse({
success: true,
release: response.data,
}),
},
],
};
},
args.instance,
args.workspaceId
);
}
async function listReleases(args: StandardListParams & any) {
return await withContext(
async context => {
const normalized = normalizeListParams(args);
const params: any = {};
if (args.releaseGroupId) params['releaseGroup.id'] = args.releaseGroupId;
// Use proper pagination handler to fetch all pages
const paginatedResponse = await fetchAllPages(
context.axios,
'/releases',
params,
{
maxItems: normalized.limit > 100 ? normalized.limit : undefined,
onPageFetched: (_pageData, _pageNum, _totalSoFar) => {
// Page fetched successfully
},
}
);
const result = {
data: paginatedResponse.data,
links: paginatedResponse.links,
meta: {
...paginatedResponse.meta,
totalFetched: paginatedResponse.data.length,
},
};
// Apply detail level filtering after fetching all data
if (!normalized.includeSubData && result.data) {
result.data = filterArrayByDetailLevel(
result.data,
'release',
normalized.detail
);
}
// Apply client-side limit after filtering (if requested limit < total available)
if (normalized.limit && normalized.limit < result.data.length) {
result.data = result.data.slice(
normalized.startWith || 0,
(normalized.startWith || 0) + normalized.limit
);
}
return {
content: [
{
type: 'text',
text: formatResponse(result),
},
],
};
},
args.instance,
args.workspaceId
);
}
async function getRelease(
args: StandardGetParams & {
id: string;
instance?: string;
workspaceId?: string;
}
) {
return await withContext(
async context => {
const normalizedParams = normalizeGetParams(args);
const response = await context.axios.get(`/releases/${args.id}`);
let result = response.data;
// Apply detail level filtering
if (!normalizedParams.includeSubData) {
result = filterByDetailLevel(
result,
'release',
normalizedParams.detail
);
}
return {
content: [
{
type: 'text',
text: formatResponse(result),
},
],
};
},
args.instance,
args.workspaceId
);
}
async function updateRelease(args: any) {
return await withContext(
async context => {
const body: any = {};
if (args.name) body.name = args.name;
if (args.state) body.state = args.state;
if (args.description) body.description = args.description;
if (args.startDate || args.releaseDate) {
body.timeframe = {};
if (args.startDate) body.timeframe.startDate = args.startDate;
if (args.releaseDate) body.timeframe.endDate = args.releaseDate;
}
const response = await context.axios.patch(`/releases/${args.id}`, {
data: body,
});
return {
content: [
{
type: 'text',
text: formatResponse({
success: true,
release: response.data,
}),
},
],
};
},
args.instance,
args.workspaceId
);
}
async function deleteRelease(args: any) {
return await withContext(
async context => {
await context.axios.delete(`/releases/${args.id}`);
return {
content: [
{
type: 'text',
text: formatResponse({
success: true,
message: `Release ${args.id} deleted successfully`,
}),
},
],
};
},
args.instance,
args.workspaceId
);
}
// Feature Release Assignments implementations
async function listFeatureReleaseAssignments(args: StandardListParams & any) {
return await withContext(
async context => {
const normalized = normalizeListParams(args);
const params: any = {};
if (args.featureId) params['feature.id'] = args.featureId;
if (args.releaseId) params['release.id'] = args.releaseId;
if (args.releaseState) params['release.state'] = args.releaseState;
if (args.releaseEndDateFrom)
params['release.timeframe.endDate.from'] = args.releaseEndDateFrom;
if (args.releaseEndDateTo)
params['release.timeframe.endDate.to'] = args.releaseEndDateTo;
// Use proper pagination handler to fetch all pages
const paginatedResponse = await fetchAllPages(
context.axios,
'/feature-release-assignments',
params,
{
maxItems: normalized.limit > 100 ? normalized.limit : undefined,
onPageFetched: (_pageData, _pageNum, _totalSoFar) => {
// Page fetched successfully
},
}
);
const result = {
data: paginatedResponse.data,
links: paginatedResponse.links,
meta: {
...paginatedResponse.meta,
totalFetched: paginatedResponse.data.length,
},
};
// Apply detail level filtering after fetching all data
if (!normalized.includeSubData && result.data) {
result.data = filterArrayByDetailLevel(
result.data,
'featureReleaseAssignment',
normalized.detail
);
}
// Apply client-side limit after filtering (if requested limit < total available)
if (normalized.limit && normalized.limit < result.data.length) {
result.data = result.data.slice(
normalized.startWith || 0,
(normalized.startWith || 0) + normalized.limit
);
}
return {
content: [
{
type: 'text',
text: formatResponse(result),
},
],
};
},
args.instance,
args.workspaceId
);
}
async function getFeatureReleaseAssignment(
args: StandardGetParams & {
featureId: string;
releaseId: string;
instance?: string;
workspaceId?: string;
}
) {
return await withContext(
async context => {
const normalizedParams = normalizeGetParams(args);
const params = {
'release.id': args.releaseId,
'feature.id': args.featureId,
};
const response = await context.axios.get(
'/feature-release-assignments/assignment',
{ params }
);
let result = response.data;
// Apply detail level filtering
if (!normalizedParams.includeSubData) {
result = filterByDetailLevel(
result,
'featureReleaseAssignment',
normalizedParams.detail
);
}
return {
content: [
{
type: 'text',
text: formatResponse(result),
},
],
};
},
args.instance,
args.workspaceId
);
}
async function updateFeatureReleaseAssignment(args: any) {
return await withContext(
async context => {
const body = {
feature: { id: args.featureId },
release: { id: args.releaseId },
isAssigned: args.isAssigned,
};
const params = {
'release.id': args.releaseId,
'feature.id': args.featureId,
};
const response = await context.axios.put(
'/feature-release-assignments/assignment',
{ data: body },
{ params }
);
return {
content: [
{
type: 'text',
text: formatResponse({
success: true,
assignment: response.data,
}),
},
],
};
},
args.instance,
args.workspaceId
);
}