UNPKG

@the_cfdude/productboard-mcp

Version:

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

256 lines (255 loc) 10.7 kB
/** * Context wrapper pattern for eliminating code duplication * Based on Jira MCP server analysis */ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { loadConfig, getInstance, getWorkspace } from '../config.js'; import axios from 'axios'; import { AuthenticationError, NetworkError, RateLimitError, sanitizeErrorMessage, } from '../errors/index.js'; /** * Create tool context with configuration and HTTP client */ export function createToolContext(instanceName, workspaceId) { try { const config = loadConfig(); const instance = getInstance(config, instanceName); // If workspace is provided, validate it exists if (workspaceId) { getWorkspace(config, workspaceId); } // Create configured axios instance const axiosInstance = axios.create({ baseURL: instance.baseUrl || 'https://api.productboard.com', headers: { Authorization: `Bearer ${instance.apiToken}`, 'Content-Type': 'application/json', Accept: 'application/json; charset=utf-8', 'X-Version': '1', }, timeout: 30000, }); // Add request interceptor for rate limiting and debugging axiosInstance.interceptors.request.use(config => { // Add workspace context if available if (workspaceId) { config.headers = config.headers || {}; config.headers['X-Workspace-Id'] = workspaceId; } // Debug logging disabled for production // Uncomment below for debugging API requests // console.error( // `[DEBUG] Making request: ${config.method?.toUpperCase()} ${config.url}${config.params ? '?' + new URLSearchParams(config.params).toString() : ''}` // ); // console.error(`[DEBUG] Request params:`, JSON.stringify(config.params)); // console.error(`[DEBUG] Request headers:`, JSON.stringify(config.headers)); return config; }); // Add response interceptor for error handling FIRST (so it runs last due to LIFO) axiosInstance.interceptors.response.use(response => { // console.error(`[DEBUG] Response from ${response.config.url}:`, { // status: response.status, // dataLength: response.data?.data?.length || 0, // totalCount: response.data?.totalCount, // hasData: !!response.data?.data // }); return response; }, error => { // In test mode, suppress network error logging // Uncomment below for debugging API errors // if (process.env.NODE_ENV !== 'test') { // console.error('[tool-wrapper] Interceptor caught error:', { // message: error.message, // code: error.code, // response: error.response // ? { // status: error.response.status, // data: error.response.data, // } // : 'No response', // }); // if (error.response?.data?.errors) { // console.error( // '[tool-wrapper] API errors:', // JSON.stringify(error.response.data.errors, null, 2) // ); // } // } if (error.response) { const status = error.response.status; const retryAfter = error.response.headers?.['retry-after']; const data = error.response.data; if (status === 401) { throw new AuthenticationError(); } else if (status === 403) { throw new McpError(ErrorCode.InvalidRequest, 'Access denied', { status: status, originalData: data, }); } else if (status === 404) { throw new McpError(ErrorCode.InvalidRequest, 'Resource not found', { status: status, originalData: data, }); } else if (status === 400) { // Handle 400 bad request errors with actual error details const errors = data?.errors || []; let message = 'Bad request'; let details = {}; if (errors.length > 0) { // Use the first error's detail or title message = errors[0]?.detail || errors[0]?.title || 'Bad request'; details = { errors: errors, originalData: data, }; } // Include the original error details in the data field throw new McpError(ErrorCode.InvalidRequest, message, details); } else if (status === 409) { // Handle specific 409 conflict errors with detailed messages let message = 'Conflict error'; const details = { errors: data?.errors || [], originalData: data, }; if (data?.errors) { const errors = data.errors; if (errors.user) { message = 'User conflict: email/external ID mismatch or company domain conflict'; } else if (errors.company) { message = 'Company conflict: domain does not match external ID'; } } throw new McpError(ErrorCode.InvalidRequest, message, details); } else if (status === 422) { // Handle specific 422 validation errors with detailed messages let message = 'Validation error'; const details = { errors: data?.errors || [], originalData: data, }; if (data?.errors) { const errors = data.errors; if (errors.source) { message = 'Source already exists'; } else if (errors.display_url) { message = 'Invalid URL format for display_url'; } else if (errors.user && errors.company) { message = 'Cannot specify both user.email and company.domain together'; } else if (errors.owner) { message = 'Owner user does not exist'; } else if (errors.company?.id) { message = 'Company does not exist or cannot set both company ID and domain'; } } throw new McpError(ErrorCode.InvalidRequest, message, details); } else if (status === 429) { throw new RateLimitError(retryAfter ? parseInt(retryAfter) : undefined); } else if (status >= 500) { throw new NetworkError('Server error', error); } // Uncomment for debugging status codes // if (process.env.NODE_ENV !== 'test') { // console.error( // '[tool-wrapper] Throwing generic InvalidRequest for status:', // status // ); // } const details = { status: status, errors: data?.errors || [], originalData: data, message: error.message, }; throw new McpError(ErrorCode.InvalidRequest, sanitizeErrorMessage(error, error.config?.toolName), details); } // Uncomment for debugging network errors // if (process.env.NODE_ENV !== 'test') { // console.error( // '[tool-wrapper] Throwing NetworkError for non-response error' // ); // } throw new NetworkError('Network error', error); }); const context = { config, instance, axios: axiosInstance, }; if (workspaceId) { context.workspaceId = workspaceId; } return context; } catch (error) { if (error instanceof McpError) { throw error; } throw new McpError(ErrorCode.InternalError, `Configuration error: ${error instanceof Error ? error.message : String(error)}`); } } /** * Wrapper function to handle common tool patterns */ export async function withContext(handler, instanceName, workspaceId, toolName) { const context = createToolContext(instanceName, workspaceId); // Add tool name to axios config for enhanced error messaging if (toolName) { // Store toolName in interceptor closure instead of axios config context.axios.interceptors.request.use(config => { config.toolName = toolName; return config; }); } return await handler(context); } /** * Helper to format API responses consistently */ export function formatResponse(data, includeRaw) { if (includeRaw) { return { formatted: JSON.stringify(data, null, 2), raw: data, }; } return JSON.stringify(data, null, 2); } /** * Helper to handle pagination */ export async function handlePagination(context, endpoint, params = {}, maxPages = 10) { const results = []; let page = 1; let hasMore = true; while (hasMore && page <= maxPages) { const response = await context.axios.get(endpoint, { params: { ...params, page, pageLimit: 100 }, }); const data = response.data; if (data.data && Array.isArray(data.data)) { results.push(...data.data); } hasMore = !!data.pageCursor; if (hasMore) { params.pageCursor = data.pageCursor; } page++; } return results; }