@the_cfdude/productboard-mcp
Version:
Model Context Protocol server for Productboard REST API with dynamic tool loading
934 lines (933 loc) • 33.7 kB
JavaScript
/**
* Notes 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 { ProductboardError } from '../errors/index.js';
import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';
/**
* Setup notes tool definitions
*/
export function setupNotesTools() {
return [
// Core Notes operations
{
name: 'create_note',
description: 'Create a new note in Productboard',
inputSchema: {
type: 'object',
properties: {
title: {
type: 'string',
description: 'Note title',
},
content: {
type: 'string',
description: 'Note content/body',
},
displayUrl: {
type: 'string',
description: 'Display URL for the note',
},
userEmail: {
type: 'string',
description: 'Email of the user who created the note',
},
userName: {
type: 'string',
description: 'Name of the user',
},
userExternalId: {
type: 'string',
description: 'External ID for the user',
},
companyDomain: {
type: 'string',
description: 'Company domain to associate with the note',
},
ownerEmail: {
type: 'string',
description: 'Email of the note owner',
},
tags: {
type: 'array',
items: { type: 'string' },
description: 'Tags to apply to the note',
},
sourceOrigin: {
type: 'string',
description: 'Source origin (e.g., email, slack, api)',
},
sourceRecordId: {
type: 'string',
description: 'Source record ID for tracking',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
required: ['title', 'content'],
},
},
{
name: 'get_notes',
description: 'List all notes with filtering and pagination',
inputSchema: {
type: 'object',
properties: {
limit: {
type: 'number',
description: 'Maximum number of notes 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',
},
term: {
type: 'string',
description: 'Search term for fulltext search',
},
companyId: {
type: 'string',
description: 'Filter by company ID',
},
featureId: {
type: 'string',
description: 'Filter by linked feature ID',
},
ownerEmail: {
type: 'string',
description: 'Filter by owner email',
},
source: {
type: 'string',
description: 'Filter by source',
},
anyTag: {
type: 'string',
description: 'Filter by any of these tags (comma-separated)',
},
allTags: {
type: 'string',
description: 'Filter by all of these tags (comma-separated)',
},
createdFrom: {
type: 'string',
description: 'Filter notes created from this date (YYYY-MM-DD)',
},
createdTo: {
type: 'string',
description: 'Filter notes created to this date (YYYY-MM-DD)',
},
updatedFrom: {
type: 'string',
description: 'Filter notes updated from this date (YYYY-MM-DD)',
},
updatedTo: {
type: 'string',
description: 'Filter notes updated to this date (YYYY-MM-DD)',
},
dateFrom: {
type: 'string',
description: 'Filter notes by date from (YYYY-MM-DD)',
},
dateTo: {
type: 'string',
description: 'Filter notes by date to (YYYY-MM-DD)',
},
pageCursor: {
type: 'string',
description: 'Cursor for pagination',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
},
},
{
name: 'get_note',
description: 'Get a specific note by ID',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Note 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_note',
description: 'Update an existing note',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Note ID',
},
title: {
type: 'string',
description: 'Updated title',
},
content: {
type: 'string',
description: 'Updated content',
},
tags: {
type: 'array',
items: { type: 'string' },
description: 'Updated tags (replaces existing tags)',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
required: ['id'],
},
},
{
name: 'delete_note',
description: 'Delete a note',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Note ID',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
required: ['id'],
},
},
// Note followers operations
{
name: 'add_note_followers',
description: 'Add followers to a note',
inputSchema: {
type: 'object',
properties: {
noteId: {
type: 'string',
description: 'Note ID',
},
emails: {
type: 'array',
items: { type: 'string' },
description: 'Array of email addresses to add as followers',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
required: ['noteId', 'emails'],
},
},
{
name: 'remove_note_follower',
description: 'Remove a follower from a note',
inputSchema: {
type: 'object',
properties: {
noteId: {
type: 'string',
description: 'Note ID',
},
email: {
type: 'string',
description: 'Email address to remove from followers',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
required: ['noteId', 'email'],
},
},
// Note tags operations
{
name: 'list_note_tags',
description: 'List all tags on a note',
inputSchema: {
type: 'object',
properties: {
noteId: {
type: 'string',
description: 'Note ID',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
required: ['noteId'],
},
},
{
name: 'add_note_tag',
description: 'Add a tag to a note',
inputSchema: {
type: 'object',
properties: {
noteId: {
type: 'string',
description: 'Note ID',
},
tagName: {
type: 'string',
description: 'Tag name to add',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
required: ['noteId', 'tagName'],
},
},
{
name: 'remove_note_tag',
description: 'Remove a tag from a note',
inputSchema: {
type: 'object',
properties: {
noteId: {
type: 'string',
description: 'Note ID',
},
tagName: {
type: 'string',
description: 'Tag name to remove',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
required: ['noteId', 'tagName'],
},
},
// Note links operations
{
name: 'list_note_links',
description: 'List all links on a note',
inputSchema: {
type: 'object',
properties: {
noteId: {
type: 'string',
description: 'Note ID',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
required: ['noteId'],
},
},
{
name: 'create_note_link',
description: 'Create a link from a note to another entity',
inputSchema: {
type: 'object',
properties: {
noteId: {
type: 'string',
description: 'Note ID',
},
entityId: {
type: 'string',
description: 'ID of entity to link to (e.g., feature ID)',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
required: ['noteId', 'entityId'],
},
},
// Feedback form operations
{
name: 'list_feedback_form_configurations',
description: 'List all feedback form configurations',
inputSchema: {
type: 'object',
properties: {
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
},
},
{
name: 'get_feedback_form_configuration',
description: 'Get a specific feedback form configuration',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Feedback form configuration ID',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
required: ['id'],
},
},
{
name: 'submit_feedback_form',
description: 'Submit a feedback form',
inputSchema: {
type: 'object',
properties: {
formId: {
type: 'string',
description: 'Feedback form ID',
},
email: {
type: 'string',
description: 'Email of the person submitting feedback',
},
content: {
type: 'string',
description: 'Feedback content',
},
additionalFields: {
type: 'object',
description: 'Additional form fields as key-value pairs',
},
instance: {
type: 'string',
description: 'Productboard instance name (optional)',
},
workspaceId: {
type: 'string',
description: 'Workspace ID (optional)',
},
},
required: ['formId', 'email', 'content'],
},
},
];
}
/**
* Handle notes tool calls
*/
export async function handleNotesTool(name, args) {
try {
switch (name) {
// Core Notes operations
case 'create_note':
return await createNote(args);
case 'get_notes':
return await listNotes(args);
case 'get_note':
return await getNote(args);
case 'update_note':
return await updateNote(args);
case 'delete_note':
return await deleteNote(args);
// Note followers
case 'add_note_followers':
case 'bulk_add_note_followers':
return await addNoteFollowers(args);
case 'remove_note_follower':
return await removeNoteFollower(args);
// Note tags
case 'list_note_tags':
case 'list_tags':
return await listNoteTags(args);
case 'add_note_tag':
case 'create_note_tag':
return await addNoteTag(args);
case 'remove_note_tag':
case 'delete_note_tag':
return await removeNoteTag(args);
// Note links
case 'list_note_links':
case 'list_links':
return await listNoteLinks(args);
case 'create_note_link':
case 'create_link':
return await createNoteLink(args);
// Feedback forms
case 'list_feedback_form_configurations':
return await listFeedbackFormConfigurations(args);
case 'get_feedback_form_configuration':
return await getFeedbackFormConfiguration(args);
case 'submit_feedback_form':
return await submitFeedbackForm(args);
default:
throw new Error(`Unknown notes tool: ${name}`);
}
}
catch (error) {
const enterpriseInfo = isEnterpriseError(error);
if (enterpriseInfo.isEnterpriseFeature) {
throw new ProductboardError(ErrorCode.InvalidRequest, enterpriseInfo.message, error);
}
throw error;
}
}
// Core Notes implementations
async function createNote(args) {
return await withContext(async (context) => {
const body = {
title: args.title,
content: args.content,
};
// Add display URL
if (args.displayUrl) {
body.display_url = args.displayUrl;
}
// Add user information
if (args.userEmail || args.userName || args.userExternalId) {
body.user = {};
if (args.userEmail)
body.user.email = args.userEmail;
if (args.userName)
body.user.name = args.userName;
if (args.userExternalId)
body.user.external_id = args.userExternalId;
}
// Add company information (can be used with user.email based on CURL example)
if (args.companyDomain) {
body.company = { domain: args.companyDomain };
}
// Add owner information
if (args.ownerEmail) {
body.owner = { email: args.ownerEmail };
}
// Add source information
if (args.sourceOrigin || args.sourceRecordId) {
body.source = {};
if (args.sourceOrigin)
body.source.origin = args.sourceOrigin;
if (args.sourceRecordId)
body.source.record_id = args.sourceRecordId;
}
// Add optional fields
if (args.tags && args.tags.length > 0)
body.tags = args.tags;
const response = await context.axios.post('/notes', { data: body });
return {
content: [
{
type: 'text',
text: formatResponse({
success: true,
note: response.data,
}),
},
],
};
}, args.instance, args.workspaceId);
}
async function listNotes(args) {
return await withContext(async (context) => {
const normalized = normalizeListParams(args);
const params = {};
// Add filters (don't include pageCursor - handled by fetchAllPages)
if (args.term)
params.term = args.term;
if (args.companyId)
params.companyId = args.companyId;
if (args.featureId)
params.featureId = args.featureId;
if (args.ownerEmail)
params.ownerEmail = args.ownerEmail;
if (args.source)
params.source = args.source;
if (args.anyTag)
params.anyTag = args.anyTag;
if (args.allTags)
params.allTags = args.allTags;
// Date filters
if (args.createdFrom)
params.createdFrom = args.createdFrom;
if (args.createdTo)
params.createdTo = args.createdTo;
if (args.updatedFrom)
params.updatedFrom = args.updatedFrom;
if (args.updatedTo)
params.updatedTo = args.updatedTo;
if (args.dateFrom)
params.dateFrom = args.dateFrom;
if (args.dateTo)
params.dateTo = args.dateTo;
// Use proper pagination handler to fetch all pages
const paginatedResponse = await fetchAllPages(context.axios, '/notes', params, {
maxItems: normalized.limit > 100 ? normalized.limit : undefined,
onPageFetched: (_pageData, _pageNum, _totalSoFar) => {
// Progress tracking for paginated notes fetching
},
});
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, 'note', 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 getNote(args) {
return await withContext(async (context) => {
const normalizedParams = normalizeGetParams(args);
const response = await context.axios.get(`/notes/${args.id}`);
let result = response.data;
// Apply detail level filtering
if (!normalizedParams.includeSubData) {
result = filterByDetailLevel(result, 'note', normalizedParams.detail);
}
return {
content: [
{
type: 'text',
text: formatResponse(result),
},
],
};
}, args.instance, args.workspaceId);
}
async function updateNote(args) {
return await withContext(async (context) => {
const body = {};
if (args.title)
body.title = args.title;
if (args.content)
body.content = args.content;
if (args.tags)
body.tags = args.tags;
const response = await context.axios.patch(`/notes/${args.id}`, {
data: body,
});
return {
content: [
{
type: 'text',
text: formatResponse({
success: true,
note: response.data,
}),
},
],
};
}, args.instance, args.workspaceId);
}
async function deleteNote(args) {
return await withContext(async (context) => {
await context.axios.delete(`/notes/${args.id}`);
return {
content: [
{
type: 'text',
text: formatResponse({
success: true,
message: `Note ${args.id} deleted successfully`,
}),
},
],
};
}, args.instance, args.workspaceId);
}
// Note followers implementations
async function addNoteFollowers(args) {
return await withContext(async (context) => {
let body;
// Handle different parameter formats
if (args.body && typeof args.body === 'object') {
// From manifest tools: args.body contains the data
body = args.body;
}
else if (args.emails && Array.isArray(args.emails)) {
// From direct tool calls: args.emails is an array
body = {
userFollowers: args.emails.map((email) => ({ email })),
};
}
else {
throw new Error('Missing required parameter: emails or body with userFollowers');
}
const response = await context.axios.post(`/notes/${args.noteId}/user-followers`, { data: body });
const emailCount = body.userFollowers ? body.userFollowers.length : 0;
return {
content: [
{
type: 'text',
text: formatResponse({
success: true,
message: `Added ${emailCount} followers to note ${args.noteId}`,
data: response.data,
}),
},
],
};
}, args.instance, args.workspaceId);
}
async function removeNoteFollower(args) {
return await withContext(async (context) => {
await context.axios.delete(`/notes/${args.noteId}/user-followers/${args.email}`);
return {
content: [
{
type: 'text',
text: formatResponse({
success: true,
message: `Removed follower ${args.email} from note ${args.noteId}`,
}),
},
],
};
}, args.instance, args.workspaceId);
}
// Note tags implementations
async function listNoteTags(args) {
return await withContext(async (context) => {
const response = await context.axios.get(`/notes/${args.noteId}/tags`);
return {
content: [
{
type: 'text',
text: formatResponse(response.data),
},
],
};
}, args.instance, args.workspaceId);
}
async function addNoteTag(args) {
return await withContext(async (context) => {
await context.axios.post(`/notes/${args.noteId}/tags/${args.tagName}`, {
data: {},
});
return {
content: [
{
type: 'text',
text: formatResponse({
success: true,
message: `Added tag "${args.tagName}" to note ${args.noteId}`,
}),
},
],
};
}, args.instance, args.workspaceId);
}
async function removeNoteTag(args) {
return await withContext(async (context) => {
await context.axios.delete(`/notes/${args.noteId}/tags/${args.tagName}`);
return {
content: [
{
type: 'text',
text: formatResponse({
success: true,
message: `Removed tag "${args.tagName}" from note ${args.noteId}`,
}),
},
],
};
}, args.instance, args.workspaceId);
}
// Note links implementations
async function listNoteLinks(args) {
return await withContext(async (context) => {
const response = await context.axios.get(`/notes/${args.noteId}/links`);
return {
content: [
{
type: 'text',
text: formatResponse(response.data),
},
],
};
}, args.instance, args.workspaceId);
}
async function createNoteLink(args) {
return await withContext(async (context) => {
await context.axios.post(`/notes/${args.noteId}/links/${args.entityId}`, {
data: {},
});
return {
content: [
{
type: 'text',
text: formatResponse({
success: true,
message: `Created link from note ${args.noteId} to entity ${args.entityId}`,
}),
},
],
};
}, args.instance, args.workspaceId);
}
// Feedback form implementations
async function listFeedbackFormConfigurations(args) {
return await withContext(async (context) => {
const response = await context.axios.get('/feedback-form-configurations');
return {
content: [
{
type: 'text',
text: formatResponse(response.data),
},
],
};
}, args.instance, args.workspaceId);
}
async function getFeedbackFormConfiguration(args) {
return await withContext(async (context) => {
const response = await context.axios.get(`/feedback-form-configurations/${args.id}`);
return {
content: [
{
type: 'text',
text: formatResponse(response.data),
},
],
};
}, args.instance, args.workspaceId);
}
async function submitFeedbackForm(args) {
return await withContext(async (context) => {
const body = {
formId: args.formId,
user: {
email: args.email,
},
content: args.content,
};
if (args.additionalFields) {
body.fields = args.additionalFields;
}
const response = await context.axios.post('/feedback-forms', {
data: body,
});
return {
content: [
{
type: 'text',
text: formatResponse({
success: true,
message: 'Feedback form submitted successfully',
data: response.data,
}),
},
],
};
}, args.instance, args.workspaceId);
}