UNPKG

breathe-api

Version:

Model Context Protocol server for Breathe HR APIs with Swagger/OpenAPI support - also works with custom APIs

444 lines 20.6 kB
import { makeApiRequest } from '../tools/api-client.js'; import { parseSwagger } from '../tools/swagger-parser.js'; import { generateApiClient } from '../generators/index.js'; import { explainApiFeature } from '../tools/api-explainer.js'; import { ApiRequestSchema, SwaggerSpecSchema, CodeGenerationSchema, FeatureExplanationSchema, } from '../types/api.js'; import { getCacheStats, clearAllCaches, swaggerCache, apiResponseCache } from '../utils/cache.js'; import { getToolRateLimiter, RateLimitError, getAllRateLimitStatus } from '../utils/rate-limiter.js'; import { sanitizeUrl, sanitizeHeaders, sanitizeParams, sanitizeRequestBody } from '../utils/sanitizer.js'; import { createBasicAuthHeader } from '../types/security.js'; import { credentialManager } from '../utils/credential-manager.js'; import { ProgressTracker } from '../utils/progress-manager.js'; import { retry, circuitBreakerManager } from '../utils/retry.js'; import { ApiError, formatErrorForMcp } from '../utils/errors.js'; export async function handleToolCall(request, _extra, progressManager) { const toolName = request.params.name; const rateLimiter = getToolRateLimiter(toolName); const progressToken = request.params._meta?.progressToken; let progressTracker; if (progressToken && progressManager) { progressManager.startProgress(progressToken); progressTracker = new ProgressTracker(progressManager, progressToken); } try { const result = await rateLimiter.add(async () => { switch (toolName) { case 'cache_stats': { const stats = getCacheStats(); return { content: [ { type: 'text', text: JSON.stringify(stats, null, 2), }, ], }; } case 'rate_limit_status': { const status = getAllRateLimitStatus(); return { content: [ { type: 'text', text: JSON.stringify(status, null, 2), }, ], }; } case 'clear_cache': { const args = request.params.arguments; const cacheType = args?.cacheType || 'all'; switch (cacheType) { case 'swagger': swaggerCache.flushAll(); break; case 'api': apiResponseCache.flushAll(); break; case 'all': default: clearAllCaches(); break; } return { content: [ { type: 'text', text: `Cache cleared: ${cacheType}`, }, ], }; } case 'explain_api_feature': { const args = FeatureExplanationSchema.parse(request.params.arguments); const swaggerUrl = args.swaggerUrl; const headers = args.swaggerHeaders; const platformForExplain = args.platform === 'react-native' ? 'react-native' : args.platform === 'nextjs' ? 'nextjs' : args.platform === 'ruby' ? 'ruby' : 'both'; if (!swaggerUrl) { const lower = args.feature.toLowerCase(); if (lower.includes('breathe')) { const apis = [ { name: 'Main Breathe HR API', url: 'https://hr.breathehrstaging.com/api-docs/v2/swagger.json', useMainAuth: true }, { name: 'ELMO Roster API', url: 'https://staging-rota-api.breathehrstaging.com/api-specs/api-spec.yaml', useMainAuth: false } ]; const results = []; for (const api of apis) { const apiHeaders = { ...headers }; if (api.useMainAuth) { const breatheCreds = credentialManager.getBreatheCredentials(); if (breatheCreds) { apiHeaders.Authorization = createBasicAuthHeader(breatheCreds.username, breatheCreds.password); } } else { const elmoCreds = credentialManager.getElmoCredentials(); if (elmoCreds) { apiHeaders.Authorization = createBasicAuthHeader(elmoCreds.username, elmoCreds.password); } } try { const result = await explainApiFeature(api.url, apiHeaders, args.feature, platformForExplain); if (result.relatedEndpoints && result.relatedEndpoints.length > 0) { results.push({ apiName: api.name, ...result }); } } catch (error) { const errorMsg = error?.response?.status === 401 ? `Authentication failed for ${api.name}. Please check your ${api.useMainAuth ? 'BREATHE_API_USERNAME/PASSWORD' : 'ELMO_API_USERNAME/PASSWORD'} environment variables.` : `Failed to check ${api.name}: ${error.message || error}`; console.error(errorMsg); results.push({ apiName: api.name, error: errorMsg, isError: true }); } } const successfulResults = results.filter(r => !r.isError); if (successfulResults.length === 0) { let errorMsg = 'Failed to access Breathe APIs:\n\n'; for (const result of results) { if (result.isError) { errorMsg += `- ${result.error}\n`; } } throw new Error(errorMsg); } return { content: [ { type: 'text', text: formatCombinedExplanation(successfulResults, args.feature), }, ], }; } } if (swaggerUrl) { const result = await explainApiFeature(swaggerUrl, headers, args.feature, platformForExplain); return { content: [ { type: 'text', text: formatFeatureExplanation(result), }, ], }; } throw new Error('Please provide a swaggerUrl or use "breathe" in your feature query'); } case 'api_request': { const args = ApiRequestSchema.parse(request.params.arguments); const sanitizedRequest = { ...args, url: sanitizeUrl(args.url), headers: args.headers ? sanitizeHeaders(args.headers) : undefined, params: args.params ? sanitizeParams(args.params) : undefined, data: args.data ? sanitizeRequestBody(args.data) : undefined, }; const serviceName = new URL(sanitizedRequest.url).hostname; try { const result = await circuitBreakerManager.execute(serviceName, async () => { return retry(() => makeApiRequest(sanitizedRequest), { maxRetries: 3, progressTracker, onRetry: (error, attempt) => { console.error(`API request retry ${attempt}: ${error.message}`); }, }); }); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { if (!(error instanceof ApiError) && error instanceof Error) { throw ApiError.fromAxiosError(error); } throw error; } } case 'parse_swagger': { const args = SwaggerSpecSchema.parse(request.params.arguments); const sanitizedArgs = { ...args, url: args.url ? sanitizeUrl(args.url) : undefined, headers: args.headers ? sanitizeHeaders(args.headers) : undefined, }; const result = await parseSwagger(sanitizedArgs); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } case 'generate_api_client': { const args = CodeGenerationSchema.parse(request.params.arguments); if (progressTracker) { await progressTracker.update(0.1, 'Starting code generation...'); } try { const result = await generateApiClient(args, progressTracker); if (progressTracker) { progressTracker.complete(); } return { content: [ { type: 'text', text: result, }, ], }; } catch (error) { if (progressTracker) { progressTracker.cancel(); } throw error; } } case 'list_environments': { const { listEnvironmentsTool } = await import('../tools/resource-tools.js'); const result = await listEnvironmentsTool.handler({}); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } case 'switch_environment': { const { switchEnvironmentTool } = await import('../tools/resource-tools.js'); const result = await switchEnvironmentTool.handler(request.params.arguments); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } case 'discover_endpoints': { const { discoverEndpointsTool } = await import('../tools/resource-tools.js'); const result = await discoverEndpointsTool.handler(request.params.arguments); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } case 'read_resource': { const { readResourceTool } = await import('../tools/resource-tools.js'); const result = await readResourceTool.handler(request.params.arguments); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } case 'configure_environment': { const { configureEnvironmentTool } = await import('../tools/resource-tools.js'); const result = await configureEnvironmentTool.handler(request.params.arguments); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } case 'test_environment': { const { testEnvironmentTool } = await import('../tools/resource-tools.js'); const result = await testEnvironmentTool.handler(request.params.arguments); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } default: throw new Error(`Unknown tool: ${toolName}`); } }); if (!result) { throw new Error('Tool returned no result'); } if (progressTracker) { progressTracker.complete(); } return result; } catch (error) { if (error instanceof RateLimitError) { return { content: [ { type: 'text', text: `Rate limit exceeded for ${error.toolName}: ${error.message}\n\nCurrent limits:\n${JSON.stringify(getAllRateLimitStatus(), null, 2)}`, }, ], isError: true, }; } const errorInfo = formatErrorForMcp(error); return { content: [ { type: 'text', text: `Error: ${errorInfo.message}${errorInfo.code ? ` (${errorInfo.code})` : ''}`, }, ], isError: true, }; } } function formatFeatureExplanation(explanation) { let output = `# ${explanation.feature} Feature Explanation\n\n`; output += `## Overview\n${explanation.description}\n\n`; output += `## Related API Endpoints\n`; for (const endpoint of explanation.relatedEndpoints) { output += `\n### ${endpoint.method} ${endpoint.path}\n`; output += `- **Description**: ${endpoint.description}\n`; output += `- **Authentication**: ${endpoint.authentication}\n`; if (endpoint.parameters && endpoint.parameters.length > 0) { output += `- **Parameters**: ${endpoint.parameters.join(', ')}\n`; } } output += `\n## Workflow\n`; for (const step of explanation.workflow) { output += `${step}\n`; } output += `\n## Data Models\n`; if (explanation.dataModels.length > 0) { output += `The following data models are used:\n`; for (const model of explanation.dataModels) { output += `- ${model}\n`; } } if (explanation.codeExamples.reactNative) { output += `\n## React Native Implementation\n\n\`\`\`typescript\n${explanation.codeExamples.reactNative}\n\`\`\`\n`; } if (explanation.codeExamples.nextjs) { output += `\n## Next.js Implementation\n\n\`\`\`typescript\n${explanation.codeExamples.nextjs}\n\`\`\`\n`; } if (explanation.codeExamples.ruby) { output += `\n## Ruby Implementation\n\n\`\`\`ruby\n${explanation.codeExamples.ruby}\n\`\`\`\n`; } output += `\n## Important Considerations\n`; for (const consideration of explanation.considerations) { output += `- ${consideration}\n`; } return output; } function formatCombinedExplanation(results, feature) { let output = `# ${feature} Feature Explanation\n\n`; if (results.length === 1) { output += `## Found in: ${results[0].apiName}\n\n`; return output + formatFeatureExplanation(results[0]).split('\n').slice(2).join('\n'); } output += `## Found in multiple APIs:\n\n`; for (const result of results) { output += `### ${result.apiName}\n\n`; output += `**Overview**: ${result.description}\n\n`; output += `**Related Endpoints**:\n`; for (const endpoint of result.relatedEndpoints) { output += `- ${endpoint.method} ${endpoint.path} - ${endpoint.description}\n`; } output += '\n'; } const hasReactNative = results.some(r => r.codeExamples?.reactNative); const hasNextjs = results.some(r => r.codeExamples?.nextjs); const hasRuby = results.some(r => r.codeExamples?.ruby); if (hasReactNative) { output += `## React Native Implementation\n\n`; for (const result of results) { if (result.codeExamples?.reactNative) { output += `### From ${result.apiName}:\n\`\`\`typescript\n${result.codeExamples.reactNative}\n\`\`\`\n\n`; } } } if (hasNextjs) { output += `## Next.js Implementation\n\n`; for (const result of results) { if (result.codeExamples?.nextjs) { output += `### From ${result.apiName}:\n\`\`\`typescript\n${result.codeExamples.nextjs}\n\`\`\`\n\n`; } } } if (hasRuby) { output += `## Ruby Implementation\n\n`; for (const result of results) { if (result.codeExamples?.ruby) { output += `### From ${result.apiName}:\n\`\`\`ruby\n${result.codeExamples.ruby}\n\`\`\`\n\n`; } } } output += `## Important Considerations\n`; const allConsiderations = new Set(); for (const result of results) { if (result.considerations) { result.considerations.forEach((c) => allConsiderations.add(c)); } } for (const consideration of allConsiderations) { output += `- ${consideration}\n`; } return output; } //# sourceMappingURL=handler.js.map