UNPKG

@the_cfdude/productboard-mcp

Version:

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

1,071 lines (1,010 loc) 27.9 kB
/** * 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 { StandardListParams, StandardGetParams, } from '../types/parameter-types.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: string, args: any) { 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: any) { const enterpriseInfo = isEnterpriseError(error); if (enterpriseInfo.isEnterpriseFeature) { throw new ProductboardError( ErrorCode.InvalidRequest, enterpriseInfo.message, error ); } throw error; } } // Core Notes implementations async function createNote(args: any) { return await withContext( async context => { const body: any = { 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: StandardListParams & any) { return await withContext( async context => { const normalized = normalizeListParams(args); const params: any = {}; // 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: StandardGetParams & { id: string; instance?: string; workspaceId?: string; } ) { 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: any) { return await withContext( async context => { const body: any = {}; 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: any) { 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: any) { return await withContext( async context => { let body: any; // 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: string) => ({ 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: any) { 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: any) { 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: any) { 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: any) { 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: any) { 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: any) { 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: any) { 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: any) { 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: any) { return await withContext( async context => { const body: any = { 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 ); }