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
JavaScript
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