#
tokens: 7436/50000 6/7 files (page 1/2)
lines: off (toggle) GitHub
raw markdown copy
This is page 1 of 2. Use http://codebase.md/mailgun/mailgun-mcp-server?page={x} to view the full context.

# Directory Structure

```
├── .gitignore
├── jest.config.mjs
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── src
│   ├── mailgun-mcp.js
│   └── openapi-final.yaml
└── test
    └── mailgun-mcp.test.js
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
# Dependencies
node_modules/
npm-debug.log
yarn-debug.log
yarn-error.log
.pnpm-debug.log

# Build outputs
dist/
build/
out/

# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

# Coverage directory used by tools like istanbul
coverage/

# Logs
logs
*.log

# Cache directories
.npm
.eslintcache
.parcel-cache

# IDE specific files
.idea/
.vscode/
*.swp
*.swo
.DS_Store
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
# Mailgun MCP Server
[![MCP](https://img.shields.io/badge/MCP-Server-blue.svg)](https://github.com/modelcontextprotocol)

## Overview
A Model Context Protocol (MCP) server implementation for [Mailgun](https://mailgun.com), enabling MCP-compatible AI clients like Claude Desktop to interract with the service.

## Prerequisites

- Node.js (v18 or higher)
- Git
- Claude Desktop (for Claude integration)
- Mailgun account and an API key

## Quick Start

### Manual Installation
1. Clone the repository:
   ```bash
   git clone https://github.com/mailgun/mailgun-mcp-server.git
   cd mailgun-mcp-server
   ```

2. Install dependencies and build:
   ```bash
   npm install
   ```

3. Configure Claude Desktop:

   Create or modify the config file:
   - MacOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
   - Windows: `%APPDATA%/Claude/claude_desktop_config.json`

   Add the following configuration:
   ```json
   {
       "mcpServers": {
           "mailgun": {
               "command": "node",
               "args": ["CHANGE/THIS/PATH/TO/mailgun-mcp-server/src/mailgun-mcp.js"],
               "env": {
                   "MAILGUN_API_KEY": "YOUR-mailgun-api-key"
               }
           }
       }
   }
   ```

## Testing

Run the local test suite with:

```bash
NODE_ENV=test npm test
```

### Sample Prompts with Claude

#### Send an Email

> Note: sending an email currently (2025-03-18) seems to require a paid account with Anthropic. You'll get a silent failure on the free account

```
Can you send an email to EMAIL_HERE with a funny email body that makes it sound like it's from the IT Desk from Office Space?
Please use the sending domain DOMAIN_HERE, and make the email from "postmaster@DOMAIN_HERE"!
```

#### Fetch and Visualize Sending Statistics

```
Would you be able to make a chart with email delivery statistics for the past week?
```

## Debugging

The MCP server communicates over stdio, please refer to [Debugging](https://modelcontextprotocol.io/docs/tools/debugging) section of the Model Context Protocol.

## License

[LICENSE](LICENSE) file for details

## Contributing

We welcome contributions! Please feel free to submit a Pull Request.

```

--------------------------------------------------------------------------------
/jest.config.mjs:
--------------------------------------------------------------------------------

```
export default {
  testEnvironment: 'node',
  transform: {},
  moduleNameMapper: {
    '^(\\.{1,2}/.*)\\.js$': '$1',
  },
  collectCoverage: true,
  coverageReporters: ['text', 'lcov'],
  testMatch: ['**/test/**/*.test.js'],
};
```

--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------

```json
{
    "type": "module",
    "scripts": {
        "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
        "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch",
        "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage"
    },
    "devDependencies": {
        "@jest/globals": "^29.0.0",
        "jest": "^29.0.0"
    },
    "dependencies": {
        "@modelcontextprotocol/sdk": "^1.7.0",
        "js-yaml": "^4.1.0",
        "zod": "^3.24.2"
    }
}

```

--------------------------------------------------------------------------------
/test/mailgun-mcp.test.js:
--------------------------------------------------------------------------------

```javascript
import { jest } from '@jest/globals';
import * as serverModule from '../src/mailgun-mcp.js';

// Disable console.error during tests
const originalConsoleError = console.error;
console.error = jest.fn();

// Override process.exit during tests
const originalProcessExit = process.exit;
process.exit = jest.fn();

describe('Mailgun MCP Server', () => {
  // Focus on testing the pure utility functions that don't need mocks
  describe('processPathParameters()', () => {
    test('replaces path parameters with values', () => {
      const path = '/v3/{domain_name}/messages';
      const operation = {
        parameters: [
          {
            name: 'domain_name',
            in: 'path',
            required: true
          }
        ]
      };
      const params = { domain_name: 'example.com', to: '[email protected]' };
      
      const result = serverModule.processPathParameters(path, operation, params);
      
      expect(result.actualPath).toBe('/v3/example.com/messages');
      expect(result.remainingParams).toEqual({ to: '[email protected]' });
    });
    
    test('throws error if required path parameter is missing', () => {
      const path = '/v3/{domain_name}/messages';
      const operation = {
        parameters: [
          {
            name: 'domain_name',
            in: 'path',
            required: true
          }
        ]
      };
      const params = { to: '[email protected]' };
      
      expect(() => {
        serverModule.processPathParameters(path, operation, params);
      }).toThrow(/required path parameter.*missing/i);
    });
  });

  describe('separateParameters()', () => {
    test('separates query and body parameters', () => {
      const params = { 
        limit: 10, 
        page: 1,
        to: '[email protected]',
        from: '[email protected]'
      };
      
      const operation = {
        parameters: [
          { name: 'limit', in: 'query' },
          { name: 'page', in: 'query' }
        ]
      };
      
      const result = serverModule.separateParameters(params, operation, 'POST');
      
      expect(result.queryParams).toEqual({ limit: 10, page: 1 });
      expect(result.bodyParams).toEqual({ 
        to: '[email protected]',
        from: '[email protected]'
      });
    });
    
    test('moves all params to query for GET requests', () => {
      const params = { 
        limit: 10, 
        page: 1,
        to: '[email protected]',
        from: '[email protected]'
      };
      
      const operation = {
        parameters: [
          { name: 'limit', in: 'query' },
          { name: 'page', in: 'query' }
        ]
      };
      
      const result = serverModule.separateParameters(params, operation, 'GET');
      
      expect(result.queryParams).toEqual({ 
        limit: 10, 
        page: 1,
        to: '[email protected]',
        from: '[email protected]'
      });
      expect(result.bodyParams).toEqual({});
    });
  });

  describe('appendQueryString()', () => {
    test('appends query parameters to path', () => {
      const path = '/v3/domains';
      const queryParams = { limit: 10, skip: 0 };
      
      const result = serverModule.appendQueryString(path, queryParams);
      
      expect(result).toBe('/v3/domains?limit=10&skip=0');
    });
    
    test('returns original path if no query parameters', () => {
      const path = '/v3/domains';
      const queryParams = {};
      
      const result = serverModule.appendQueryString(path, queryParams);
      
      expect(result).toBe('/v3/domains');
    });
  });

  // Clean up after all tests
  afterAll(() => {
    console.error = originalConsoleError;
    process.exit = originalProcessExit;
  });
});

describe('openapiToZod()', () => {
    test('converts string schema correctly', () => {
      const schema = { type: 'string', description: 'A test string' };
      const result = serverModule.openapiToZod(schema, {});
      
      expect(result._def.typeName).toBe('ZodString');
      expect(result._def.description).toBe('A test string');
    });
    
    test('converts enum schema correctly', () => {
      const schema = { type: 'string', enum: ['yes', 'no', 'maybe'] };
      const result = serverModule.openapiToZod(schema, {});
      
      expect(result._def.typeName).toBe('ZodEnum');
      expect(result._def.values).toEqual(['yes', 'no', 'maybe']);
    });
    
    test('converts number schema with constraints', () => {
      const schema = { 
        type: 'number', 
        minimum: 1, 
        maximum: 100,
        description: 'A constrained number' 
      };
      const result = serverModule.openapiToZod(schema, {});
      
      expect(result._def.typeName).toBe('ZodNumber');
        // Check for min constraint
        expect(result._def.checks.some(check => 
            check.kind === 'min' && check.value === 1
        )).toBe(true);
        
        // Check for max constraint
        expect(result._def.checks.some(check => 
            check.kind === 'max' && check.value === 100
        )).toBe(true);
    });
    
    test('resolves references correctly', () => {
      const schema = { $ref: '#/components/schemas/TestType' };
      const fullSpec = {
        components: {
          schemas: {
            TestType: { type: 'string', description: 'Referenced type' }
          }
        }
      };
      
      const result = serverModule.openapiToZod(schema, fullSpec);
      
      expect(result._def.typeName).toBe('ZodString');
      expect(result._def.description).toBe('Referenced type');
    });
  });
  
  describe('getOperationDetails()', () => {
    test('returns operation details for valid path and method', () => {
      const openApiSpec = {
        paths: {
          '/test/path': {
            get: {
              operationId: 'getTest',
              summary: 'Test operation'
            }
          }
        }
      };
      
      const result = serverModule.getOperationDetails(openApiSpec, 'get', '/test/path');
      
      expect(result).toEqual({
        operation: {
          operationId: 'getTest',
          summary: 'Test operation'
        },
        operationId: 'get--test-path'
      });
    });
    
    test('returns null for invalid path', () => {
      const openApiSpec = {
        paths: {
          '/test/path': {
            get: {
              operationId: 'getTest',
              summary: 'Test operation'
            }
          }
        }
      };
      
      const result = serverModule.getOperationDetails(openApiSpec, 'get', '/nonexistent/path');
      
      expect(result).toBeNull();
    });
    
    test('returns null for invalid method', () => {
      const openApiSpec = {
        paths: {
          '/test/path': {
            get: {
              operationId: 'getTest',
              summary: 'Test operation'
            }
          }
        }
      };
      
      const result = serverModule.getOperationDetails(openApiSpec, 'post', '/test/path');
      
      expect(result).toBeNull();
    });
  });
  
  describe('resolveReference()', () => {
    test('resolves reference path correctly', () => {
      const openApiSpec = {
        components: {
          schemas: {
            TestSchema: { type: 'string', description: 'Test schema' }
          }
        }
      };
      
      const result = serverModule.resolveReference('#/components/schemas/TestSchema', openApiSpec);
      
      expect(result).toEqual({ type: 'string', description: 'Test schema' });
    });
    
    test('handles nested reference path', () => {
      const openApiSpec = {
        components: {
          schemas: {
            Parent: {
              NestedType: { type: 'number', minimum: 0 }
            }
          }
        }
      };
      
      const result = serverModule.resolveReference('#/components/schemas/Parent/NestedType', openApiSpec);
      
      expect(result).toEqual({ type: 'number', minimum: 0 });
    });
});
  

```

--------------------------------------------------------------------------------
/src/mailgun-mcp.js:
--------------------------------------------------------------------------------

```javascript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import https from "node:https";
import { URL } from "node:url";
import yaml from "js-yaml";
import fs from "node:fs";
import * as path from 'path';
import { fileURLToPath } from 'url';


// Resolve directory path when using ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// Initialize Model Context Protocol server
export const server = new McpServer({
  name: "mailgun",
  version: "1.0.0",
});

// Mailgun API configuration
const MAILGUN_API_KEY = process.env.MAILGUN_API_KEY;
const MAILGUN_API_HOSTNAME = "api.mailgun.net";
const OPENAPI_YAML = path.resolve(__dirname, 'openapi-final.yaml');


// Define Mailgun API endpoints supported by this integration
const endpoints = [
    "POST /v3/{domain_name}/messages",
    "GET /v4/domains",
    "GET /v4/domains/{name}",
    "GET /v3/domains/{name}/sending_queues",
    "GET /v5/accounts/subaccounts/ip_pools",
    "GET /v3/ips",
    "GET /v3/ips/{ip}",
    "GET /v3/ips/{ip}/domains",
    "GET /v3/ip_pools",
    "GET /v3/ip_pools/{pool_id}",
    "GET /v3/ip_pools/{pool_id}/domains",
    "GET /v3/{domain_name}/events",
    "GET /v3/{domain}/tags",
    "GET /v3/{domain}/tag",
    "GET /v3/{domain}/tag/stats/aggregates",
    "GET /v3/{domain}/tag/stats",
    "GET /v3/domains/{domain}/tag/devices",
    "GET /v3/domains/{domain}/tag/providers",
    "GET /v3/domains/{domain}/tag/countries",
    "GET /v3/stats/total",
    "GET /v3/{domain}/stats/total",
    "GET /v3/stats/total/domains",
    "GET /v3/stats/filter",
    "GET /v3/domains/{domain}/limits/tag",
    "GET /v3/{domain}/aggregates/providers",
    "GET /v3/{domain}/aggregates/devices",
    "GET /v3/{domain}/aggregates/countries",
    "POST /v1/analytics/metrics",
    "POST /v1/analytics/usage/metrics",
    "POST /v1/analytics/logs",
    "GET /v3/{domainID}/bounces/{address}",
    "GET /v3/{domainID}/bounces",
    "GET /v3/{domainID}/unsubscribes/{address}",
    "GET /v3/{domainID}/unsubscribes",
    "GET /v3/{domainID}/complaints/{address}",
    "GET /v3/{domainID}/complaints",
    "GET /v3/{domainID}/whitelists/{value}",
    "GET /v3/{domainID}/whitelists",
    "GET /v3/accounts/email_domain_suppressions/{email_domain}",
    "GET /v5/accounts/limit/custom/monthly",
];

/**
 * Makes an authenticated request to the Mailgun API
 * @param {string} method - HTTP method (GET, POST, etc.)
 * @param {string} path - API endpoint path
 * @param {Object} data - Request payload data (for POST/PUT requests)
 * @returns {Promise<Object>} - Response data as JSON
 */
export async function makeMailgunRequest(method, path, data = null) {
  return new Promise((resolve, reject) => {
    // Normalize path format (handle paths with or without leading slash)
    const cleanPath = path.startsWith('/') ? path.substring(1) : path;
    
    // Create basic auth credentials from API key
    const auth = Buffer.from(`api:${MAILGUN_API_KEY}`).toString("base64");
    const options = {
      hostname: MAILGUN_API_HOSTNAME,
      path: `/${cleanPath}`,
      method: method,
      headers: {
        "Authorization": `Basic ${auth}`,
        "Content-Type": "application/x-www-form-urlencoded"
      }
    };

    // Create and send the HTTP request
    const req = https.request(options, (res) => {
      let responseData = "";
      
      res.on("data", (chunk) => {
        responseData += chunk;
      });
      
      res.on("end", () => {
        try {
          const parsedData = JSON.parse(responseData);
          if (res.statusCode >= 200 && res.statusCode < 300) {
            resolve(parsedData);
          } else {
            reject(new Error(`Mailgun API error: ${parsedData.message || responseData}`));
          }
        } catch (e) {
          reject(new Error(`Failed to parse response: ${e.message}`));
        }
      });
    });
    
    req.on("error", (error) => {
      reject(error);
    });
    
    // For non-GET requests, serialize and send the form data
    if (data && method !== "GET") {
      // Convert object to URL encoded form data
      const formData = new URLSearchParams();
      for (const [key, value] of Object.entries(data)) {
        if (Array.isArray(value)) {
          for (const item of value) {
            formData.append(key, item);
          }
        } else if (value !== undefined && value !== null) {
          formData.append(key, value.toString());
        }
      }
      
      req.write(formData.toString());
    }
    
    req.end();
  });
}

/**
 * Loads and parses the OpenAPI specification from a YAML file
 * @param {string} filePath - Path to the OpenAPI YAML file
 * @returns {Object} - Parsed OpenAPI specification
 */
export function loadOpenApiSpec(filePath) {
  try {
    const fileContents = fs.readFileSync(filePath, 'utf8');
    return yaml.load(fileContents);
  } catch (error) {
    console.error(`Error loading OpenAPI spec: ${error.message}`);
    // Don't exit in test mode
    if (process.env.NODE_ENV !== 'test') {
      process.exit(1);
    }
    throw error; // Throw so tests can catch it
  }
}

/**
 * Converts OpenAPI schema definitions to Zod validation schemas
 * @param {Object} schema - OpenAPI schema object
 * @param {Object} fullSpec - Complete OpenAPI specification
 * @returns {z.ZodType} - Corresponding Zod schema
 */
export function openapiToZod(schema, fullSpec) {
  if (!schema) return z.any();
  
  // Handle schema references (e.g. #/components/schemas/...)
  if (schema.$ref) {
    // For #/components/schemas/EventSeverityType type references
    if (schema.$ref.startsWith('#/')) {
      const refPath = schema.$ref.substring(2).split('/');
      
      // Navigate through the object using the path segments
      let referenced = fullSpec;
      for (const segment of refPath) {
        if (!referenced || !referenced[segment]) {
          // If we can't resolve it but know it's EventSeverityType, use our knowledge
          if (segment === 'EventSeverityType' || schema.$ref.endsWith('EventSeverityType')) {
            return z.enum(['temporary', 'permanent'])
              .describe('Filter by event severity');
          }
          
          console.error(`Failed to resolve reference: ${schema.$ref}, segment: ${segment}`);
          return z.any().describe(`Failed reference: ${schema.$ref}`);
        }
        referenced = referenced[segment];
      }
      
      return openapiToZod(referenced, fullSpec);
    }
    
    // Handle other reference formats if needed
    console.error(`Unsupported reference format: ${schema.$ref}`);
    return z.any().describe(`Unsupported reference: ${schema.$ref}`);
  }
  
  // Convert different schema types to Zod equivalents
  switch (schema.type) {
    case 'string':
      let zodString = z.string();
      if (schema.enum) {
        return z.enum(schema.enum);
      }
      if (schema.format === 'email') {
        zodString = zodString.email();
      }
      if (schema.format === 'uri') {
        zodString = zodString.describe(`URI: ${schema.description || ''}`);
      }
      return zodString.describe(schema.description || '');
    
    case 'number':
    case 'integer':
      let zodNumber = z.number();
      if (schema.minimum !== undefined) {
        zodNumber = zodNumber.min(schema.minimum);
      }
      if (schema.maximum !== undefined) {
        zodNumber = zodNumber.max(schema.maximum);
      }
      return zodNumber.describe(schema.description || '');
    
    case 'boolean':
      return z.boolean().describe(schema.description || '');
    
    case 'array':
      return z.array(openapiToZod(schema.items, fullSpec)).describe(schema.description || '');
    
    case 'object':
      if (!schema.properties) return z.record(z.any());
      
      const shape = {};
      for (const [key, prop] of Object.entries(schema.properties)) {
        shape[key] = schema.required?.includes(key) 
          ? openapiToZod(prop, fullSpec)
          : openapiToZod(prop, fullSpec).optional();
      }
      return z.object(shape).describe(schema.description || '');
    
    default:
      // For schemas without a type but with properties
      if (schema.properties) {
        const shape = {};
        for (const [key, prop] of Object.entries(schema.properties)) {
          shape[key] = schema.required?.includes(key) 
            ? openapiToZod(prop, fullSpec)
            : openapiToZod(prop, fullSpec).optional();
        }
        return z.object(shape).describe(schema.description || '');
      }
      
      // For YAML that defines "oneOf", "anyOf", etc.
      if (schema.oneOf) {
        const unionTypes = schema.oneOf.map(s => openapiToZod(s, fullSpec));
        return z.union(unionTypes).describe(schema.description || '');
      }
      
      if (schema.anyOf) {
        const unionTypes = schema.anyOf.map(s => openapiToZod(s, fullSpec));
        return z.union(unionTypes).describe(schema.description || '');
      }
      
      return z.any().describe(schema.description || '');
  }
}

/**
 * Generates MCP tools from the OpenAPI specification
 * @param {Object} openApiSpec - Parsed OpenAPI specification
 */
export function generateToolsFromOpenApi(openApiSpec) {  
  for (const endpoint of endpoints) {
    try {
      const [method, path] = endpoint.split(' ');
      const operationDetails = getOperationDetails(openApiSpec, method, path);
      
      if (!operationDetails) {
        console.warn(`Could not match endpoint: ${method} ${path} in OpenAPI spec`);
        continue;
      }
      
      const { operation, operationId } = operationDetails;
      const paramsSchema = buildParamsSchema(operation, openApiSpec);
      const toolId = sanitizeToolId(operationId);
      const toolDescription = operation.summary || `${method.toUpperCase()} ${path}`;
      
      registerTool(toolId, toolDescription, paramsSchema, method, path, operation);
      
    } catch (error) {
      console.error(`Failed to process endpoint ${endpoint}: ${error.message}`);
    }
  }
  
  return;
}

/**
 * Retrieves operation details from the OpenAPI spec for a given method and path
 * @param {Object} openApiSpec - Parsed OpenAPI specification
 * @param {string} method - HTTP method (GET, POST, etc.)
 * @param {string} path - API endpoint path
 * @returns {Object|null} - Operation details or null if not found
 */
export function getOperationDetails(openApiSpec, method, path) {
  const lowerMethod = method.toLowerCase();
  
  if (!openApiSpec.paths?.[path]?.[lowerMethod]) {
    return null;
  }
  
  return {
    operation: openApiSpec.paths[path][lowerMethod],
    operationId: `${method}-${path.replace(/[^\w-]/g, '-').replace(/-+/g, '-')}`
  };
}

/**
 * Sanitizes an operation ID to be used as a tool ID
 * @param {string} operationId - The operation ID to sanitize
 * @returns {string} - Sanitized tool ID
 */
export function sanitizeToolId(operationId) {
  return operationId.replace(/[^\w-]/g, '-').toLowerCase();
}

/**
 * Builds a Zod parameter schema from an OpenAPI operation
 * @param {Object} operation - OpenAPI operation object
 * @param {Object} openApiSpec - Complete OpenAPI specification
 * @returns {Object} - Zod parameter schema
 */
export function buildParamsSchema(operation, openApiSpec) {
  const paramsSchema = {};
  
  // Process path parameters
  const pathParams = operation.parameters?.filter(p => p.in === 'path') || [];
  processParameters(pathParams, paramsSchema, openApiSpec);
  
  // Process query parameters
  const queryParams = operation.parameters?.filter(p => p.in === 'query') || [];
  processParameters(queryParams, paramsSchema, openApiSpec);
  
  // Process request body if it exists
  if (operation.requestBody) {
    processRequestBody(operation.requestBody, paramsSchema, openApiSpec);
  }
  
  return paramsSchema;
}

/**
 * Processes OpenAPI parameters into Zod schemas
 * @param {Array} parameters - OpenAPI parameter objects
 * @param {Object} paramsSchema - Target schema object to populate
 * @param {Object} openApiSpec - Complete OpenAPI specification
 */
export function processParameters(parameters, paramsSchema, openApiSpec) {
  for (const param of parameters) {
    const zodParam = openapiToZod(param.schema, openApiSpec);
    paramsSchema[param.name] = param.required ? zodParam : zodParam.optional();
  }
}

/**
 * Processes request body schema into Zod schemas
 * @param {Object} requestBody - OpenAPI request body object
 * @param {Object} paramsSchema - Target schema object to populate
 * @param {Object} openApiSpec - Complete OpenAPI specification
 */
export function processRequestBody(requestBody, paramsSchema, openApiSpec) {
  if (!requestBody.content) return;
  
  // Try different content types in priority order
  const contentTypes = [
    'application/json', 
    'multipart/form-data', 
    'application/x-www-form-urlencoded'
  ];
  
  for (const contentType of contentTypes) {
    if (!requestBody.content[contentType]) continue;
    
    let bodySchema = requestBody.content[contentType].schema;
    
    // Handle schema references
    if (bodySchema.$ref) {
      bodySchema = resolveReference(bodySchema.$ref, openApiSpec);
    }
    
    // Process schema properties
    if (bodySchema?.properties) {
      for (const [prop, schema] of Object.entries(bodySchema.properties)) {
        let propSchema = schema;
        
        // Handle nested references
        if (propSchema.$ref) {
          propSchema = resolveReference(propSchema.$ref, openApiSpec);
        }
        
        const zodProp = openapiToZod(propSchema, openApiSpec);
        paramsSchema[prop] = bodySchema.required?.includes(prop) 
          ? zodProp 
          : zodProp.optional();
      }
    }
    
    break; // We found and processed a content type
  }
}

/**
 * Resolves a schema reference within an OpenAPI spec
 * @param {string} ref - Reference string (e.g. #/components/schemas/ModelName)
 * @param {Object} openApiSpec - Complete OpenAPI specification
 * @returns {Object} - Resolved schema
 */
export function resolveReference(ref, openApiSpec) {
  const refPath = ref.replace('#/', '').split('/');
  return refPath.reduce((obj, path) => obj[path], openApiSpec);
}

/**
 * Registers a tool with the MCP server
 * @param {string} toolId - Unique tool identifier
 * @param {string} toolDescription - Human-readable description
 * @param {Object} paramsSchema - Zod schema for parameters
 * @param {string} method - HTTP method (GET, POST, etc.)
 * @param {string} path - API endpoint path
 * @param {Object} operation - OpenAPI operation object
 */
export function registerTool(toolId, toolDescription, paramsSchema, method, path, operation) {
  server.tool(
    toolId,
    toolDescription,
    paramsSchema,
    async (params) => {
      try {
        const { actualPath, remainingParams } = processPathParameters(path, operation, params);
        const { queryParams, bodyParams } = separateParameters(remainingParams, operation, method);
        const finalPath = appendQueryString(actualPath, queryParams);
        
        // Make the API request
        const result = await makeMailgunRequest(
          method.toUpperCase(), 
          finalPath, 
          method.toUpperCase() === 'GET' ? null : bodyParams
        );
        
        return {
          content: [
            {
              type: "text",
              text: `✅ ${method.toUpperCase()} ${finalPath} completed successfully:\n${JSON.stringify(result, null, 2)}`,
            },
          ],
        };
      } catch (error) {
        return {
          content: [
            {
              type: "text",
              text: `Error: ${error.message || String(error)}`,
            },
          ],
        };
      }
    }
  );
}

/**
 * Processes path parameters from the request parameters
 * @param {string} path - API endpoint path with placeholders
 * @param {Object} operation - OpenAPI operation object
 * @param {Object} params - Request parameters
 * @returns {Object} - Processed path and remaining parameters
 */
export function processPathParameters(path, operation, params) {
  let actualPath = path;
  const pathParams = operation.parameters?.filter(p => p.in === 'path') || [];
  const remainingParams = { ...params };
  
  for (const param of pathParams) {
    if (params[param.name]) {
      actualPath = actualPath.replace(
        `{${param.name}}`, 
        encodeURIComponent(params[param.name])
      );
      delete remainingParams[param.name];
    } else {
      throw new Error(`Required path parameter '${param.name}' is missing`);
    }
  }
  
  return { actualPath, remainingParams };
}

/**
 * Separates parameters into query parameters and body parameters
 * @param {Object} params - Request parameters
 * @param {Object} operation - OpenAPI operation object
 * @param {string} method - HTTP method (GET, POST, etc.)
 * @returns {Object} - Separated query and body parameters
 */
export function separateParameters(params, operation, method) {
  const queryParams = {};
  const bodyParams = {};
  
  // Get query parameters from operation definition
  const definedQueryParams = operation.parameters?.filter(p => p.in === 'query').map(p => p.name) || [];
  
  // Sort parameters into body or query
  for (const [key, value] of Object.entries(params)) {
    if (definedQueryParams.includes(key)) {
      queryParams[key] = value;
    } else {
      bodyParams[key] = value;
    }
  }
  
  // For GET requests, move all params to query
  if (method.toUpperCase() === 'GET') {
    Object.assign(queryParams, bodyParams);
    Object.keys(bodyParams).forEach(key => delete bodyParams[key]);
  }
  
  return { queryParams, bodyParams };
}

/**
 * Appends query string parameters to a path
 * @param {string} path - API endpoint path
 * @param {Object} queryParams - Query parameters
 * @returns {string} - Path with query string
 */
export function appendQueryString(path, queryParams) {
  if (Object.keys(queryParams).length === 0) {
    return path;
  }
  
  const queryString = new URLSearchParams();
  
  for (const [key, value] of Object.entries(queryParams)) {
    if (value !== undefined && value !== null) {
      queryString.append(key, value.toString());
    }
  }
  
  return `${path}?${queryString.toString()}`;
}

/**
 * Main function to initialize and start the MCP server
 */
export async function main() {
  try {
    // Load and parse OpenAPI spec
    const openApiSpec = loadOpenApiSpec(OPENAPI_YAML);
    
    // Generate tools from the spec
    generateToolsFromOpenApi(openApiSpec);
    
    // Connect to the transport
    const transport = new StdioServerTransport();
    await server.connect(transport);
    console.error("Mailgun MCP Server running on stdio");
  } catch (error) {
    console.error("Fatal error in main():", error);
    if (process.env.NODE_ENV !== 'test') {
      process.exit(1);
    }
  }
}

// Only auto-execute when not in test environment
if (process.env.NODE_ENV !== 'test') {
  main();
}
```
Page 1/2FirstPrevNextLast