Skip to main content
When you use the OpenAPI Adapter to generate MCP tools from an API specification, you need to mock both the OpenAPI spec fetch and the actual API calls. This guide shows you how to write comprehensive tests for OpenAPI-generated tools.
Prerequisites:

What You’ll Learn

  • Mock OpenAPI spec loading
  • Test generated CRUD operations
  • Handle authentication in tests
  • Test error scenarios
  • Verify request/response transformations

The Challenge

OpenAPI-generated tools make HTTP requests to external APIs. Without mocking:
  1. Spec loading fails - The adapter fetches the OpenAPI spec at startup
  2. API calls fail - Generated tools call the real API
  3. Tests aren’t isolated - External state affects test results
  4. Tests are slow - Network latency adds up

Step 1: Set Up Test Configuration

First, configure your test file:
openapi-app.e2e.ts
import { test, expect, httpMock, httpResponse } from '@frontmcp/testing';

test.use({
  server: './src/main.ts',
  port: 3003,
});

// Reusable mock interceptor
let interceptor: ReturnType<typeof httpMock.interceptor>;

beforeEach(() => {
  interceptor = httpMock.interceptor();
});

afterEach(() => {
  interceptor.restore();
});

Step 2: Mock the OpenAPI Specification

The adapter fetches the OpenAPI spec when the server starts. Mock this first:
// Sample OpenAPI 3.0 specification
const openApiSpec = {
  openapi: '3.0.0',
  info: {
    title: 'Expense API',
    version: '1.0.0',
  },
  servers: [
    { url: 'https://api.example.com' },
  ],
  paths: {
    '/expenses': {
      get: {
        operationId: 'listExpenses',
        summary: 'List all expenses',
        responses: {
          '200': {
            description: 'List of expenses',
            content: {
              'application/json': {
                schema: {
                  type: 'array',
                  items: { $ref: '#/components/schemas/Expense' },
                },
              },
            },
          },
        },
      },
      post: {
        operationId: 'createExpense',
        summary: 'Create a new expense',
        requestBody: {
          required: true,
          content: {
            'application/json': {
              schema: { $ref: '#/components/schemas/CreateExpenseInput' },
            },
          },
        },
        responses: {
          '201': {
            description: 'Created expense',
            content: {
              'application/json': {
                schema: { $ref: '#/components/schemas/Expense' },
              },
            },
          },
        },
      },
    },
    '/expenses/{id}': {
      get: {
        operationId: 'getExpense',
        summary: 'Get expense by ID',
        parameters: [
          {
            name: 'id',
            in: 'path',
            required: true,
            schema: { type: 'string' },
          },
        ],
        responses: {
          '200': {
            description: 'Expense details',
            content: {
              'application/json': {
                schema: { $ref: '#/components/schemas/Expense' },
              },
            },
          },
          '404': {
            description: 'Expense not found',
          },
        },
      },
      delete: {
        operationId: 'deleteExpense',
        summary: 'Delete an expense',
        parameters: [
          {
            name: 'id',
            in: 'path',
            required: true,
            schema: { type: 'string' },
          },
        ],
        responses: {
          '204': { description: 'Deleted' },
        },
      },
    },
  },
  components: {
    schemas: {
      Expense: {
        type: 'object',
        properties: {
          id: { type: 'string' },
          amount: { type: 'number' },
          category: { type: 'string' },
          description: { type: 'string' },
          createdAt: { type: 'string', format: 'date-time' },
        },
      },
      CreateExpenseInput: {
        type: 'object',
        required: ['amount', 'category'],
        properties: {
          amount: { type: 'number' },
          category: { type: 'string' },
          description: { type: 'string' },
        },
      },
    },
  },
};

// Mock the spec endpoint before each test
beforeEach(() => {
  interceptor = httpMock.interceptor();

  // Mock OpenAPI spec fetch
  interceptor.get('https://api.example.com/openapi.json', {
    body: openApiSpec,
  });
});

Step 3: Test Tool Discovery

Verify that tools are generated from the spec:
test.describe('OpenAPI Tool Generation', () => {
  test('generates tools from OpenAPI spec', async ({ mcp }) => {
    const tools = await mcp.tools.list();

    // Tools are named: {adapter-name}:{operationId}
    expect(tools).toContainTool('expense-api:listExpenses');
    expect(tools).toContainTool('expense-api:createExpense');
    expect(tools).toContainTool('expense-api:getExpense');
    expect(tools).toContainTool('expense-api:deleteExpense');
  });

  test('tools have correct descriptions', async ({ mcp }) => {
    const tools = await mcp.tools.list();

    const listTool = tools.find(t => t.name === 'expense-api:listExpenses');
    expect(listTool?.description).toContain('List all expenses');

    const createTool = tools.find(t => t.name === 'expense-api:createExpense');
    expect(createTool?.description).toContain('Create a new expense');
  });
});

Step 4: Test GET Operations

Test list and get-by-ID operations:
test.describe('GET Operations', () => {
  test('listExpenses returns array of expenses', async ({ mcp }) => {
    const mockExpenses = [
      { id: '1', amount: 50, category: 'Travel', description: 'Taxi' },
      { id: '2', amount: 120, category: 'Meals', description: 'Client dinner' },
    ];

    interceptor.get('https://api.example.com/expenses', {
      body: mockExpenses,
    });

    const result = await mcp.tools.call('expense-api:listExpenses', {});

    expect(result).toBeSuccessful();
    expect(result.json()).toHaveLength(2);
    expect(result.json()[0].category).toBe('Travel');
  });

  test('getExpense returns single expense', async ({ mcp }) => {
    const mockExpense = {
      id: 'exp-123',
      amount: 75.50,
      category: 'Office Supplies',
      description: 'Printer paper',
      createdAt: '2024-01-15T10:30:00Z',
    };

    interceptor.get('https://api.example.com/expenses/exp-123', {
      body: mockExpense,
    });

    const result = await mcp.tools.call('expense-api:getExpense', {
      id: 'exp-123',
    });

    expect(result).toBeSuccessful();
    expect(result.json().id).toBe('exp-123');
    expect(result.json().amount).toBe(75.50);
  });

  test('getExpense handles 404', async ({ mcp }) => {
    interceptor.get('https://api.example.com/expenses/not-found', {
      status: 404,
      body: { error: 'Expense not found' },
    });

    const result = await mcp.tools.call('expense-api:getExpense', {
      id: 'not-found',
    });

    expect(result).toBeError();
  });
});

Step 5: Test POST Operations

Test create operations with request body validation:
test.describe('POST Operations', () => {
  test('createExpense sends correct request body', async ({ mcp }) => {
    const createdExpense = {
      id: 'new-123',
      amount: 99.99,
      category: 'Travel',
      description: 'Airport parking',
      createdAt: '2024-01-20T14:00:00Z',
    };

    const postHandle = interceptor.post('https://api.example.com/expenses', {
      status: 201,
      body: createdExpense,
    });

    const result = await mcp.tools.call('expense-api:createExpense', {
      amount: 99.99,
      category: 'Travel',
      description: 'Airport parking',
    });

    expect(result).toBeSuccessful();
    expect(result.json().id).toBe('new-123');

    // Verify the request body was correct
    const calls = postHandle.calls();
    expect(calls).toHaveLength(1);
    expect(calls[0].body).toEqual({
      amount: 99.99,
      category: 'Travel',
      description: 'Airport parking',
    });
  });

  test('createExpense handles validation errors', async ({ mcp }) => {
    interceptor.post('https://api.example.com/expenses', {
      status: 400,
      body: {
        error: 'Validation failed',
        details: [{ field: 'amount', message: 'must be positive' }],
      },
    });

    const result = await mcp.tools.call('expense-api:createExpense', {
      amount: -50,
      category: 'Travel',
    });

    expect(result).toBeError();
  });
});

Step 6: Test DELETE Operations

Test delete operations:
test.describe('DELETE Operations', () => {
  test('deleteExpense sends DELETE request', async ({ mcp }) => {
    const deleteHandle = interceptor.delete(
      'https://api.example.com/expenses/exp-456',
      { status: 204 }
    );

    const result = await mcp.tools.call('expense-api:deleteExpense', {
      id: 'exp-456',
    });

    expect(result).toBeSuccessful();

    // Verify DELETE was called
    expect(deleteHandle.callCount()).toBe(1);
  });

  test('deleteExpense handles not found', async ({ mcp }) => {
    interceptor.delete('https://api.example.com/expenses/missing', {
      status: 404,
      body: { error: 'Expense not found' },
    });

    const result = await mcp.tools.call('expense-api:deleteExpense', {
      id: 'missing',
    });

    expect(result).toBeError();
  });
});

Step 7: Test with Authentication

If your OpenAPI adapter uses authentication:
test.describe('Authenticated Requests', () => {
  test('includes auth header from token', async ({ mcp, auth }) => {
    const token = await auth.createToken({
      sub: 'user-123',
      scopes: ['expenses:read'],
    });
    await mcp.authenticate(token);

    const getHandle = interceptor.get('https://api.example.com/expenses', {
      body: [],
    });

    await mcp.tools.call('expense-api:listExpenses', {});

    // Verify Authorization header was sent
    const calls = getHandle.calls();
    expect(calls[0].headers['authorization']).toMatch(/^Bearer /);
  });

  test('includes tenant header from user claims', async ({ mcp, auth }) => {
    const token = await auth.createToken({
      sub: 'user-123',
      claims: { tenantId: 'tenant-abc' },
    });
    await mcp.authenticate(token);

    const getHandle = interceptor.get('https://api.example.com/expenses', {
      body: [],
    });

    await mcp.tools.call('expense-api:listExpenses', {});

    // Verify custom header (if using headersMapper)
    const calls = getHandle.calls();
    expect(calls[0].headers['x-tenant-id']).toBe('tenant-abc');
  });
});

Step 8: Test Error Scenarios

Cover various API error responses:
test.describe('Error Handling', () => {
  test('handles 401 Unauthorized', async ({ mcp }) => {
    interceptor.get('https://api.example.com/expenses', {
      status: 401,
      body: { error: 'Invalid or expired token' },
    });

    const result = await mcp.tools.call('expense-api:listExpenses', {});
    expect(result).toBeError();
  });

  test('handles 403 Forbidden', async ({ mcp }) => {
    interceptor.get('https://api.example.com/expenses', {
      status: 403,
      body: { error: 'Insufficient permissions' },
    });

    const result = await mcp.tools.call('expense-api:listExpenses', {});
    expect(result).toBeError();
  });

  test('handles 500 Server Error', async ({ mcp }) => {
    interceptor.get('https://api.example.com/expenses', {
      status: 500,
      body: { error: 'Internal server error' },
    });

    const result = await mcp.tools.call('expense-api:listExpenses', {});
    expect(result).toBeError();
  });

  test('handles network timeout', async ({ mcp }) => {
    interceptor.get('https://api.example.com/expenses', {
      delay: 60000,  // 60 second delay (will timeout)
      body: {},
    });

    const result = await mcp.tools.call('expense-api:listExpenses', {});
    expect(result).toBeError();
  });

  test('handles malformed JSON response', async ({ mcp }) => {
    interceptor.mock({
      match: { url: 'https://api.example.com/expenses' },
      response: {
        status: 200,
        headers: { 'content-type': 'application/json' },
        body: 'not valid json{{{',
      },
    });

    const result = await mcp.tools.call('expense-api:listExpenses', {});
    expect(result).toBeError();
  });
});

Step 9: Test Query Parameters

Test operations with query parameters:
test.describe('Query Parameters', () => {
  test('passes query parameters correctly', async ({ mcp }) => {
    // Assuming the spec has query params for filtering
    const getHandle = interceptor.get(/expenses\?/, {
      body: [{ id: '1', category: 'Travel' }],
    });

    await mcp.tools.call('expense-api:listExpenses', {
      category: 'Travel',
      limit: 10,
      offset: 0,
    });

    const calls = getHandle.calls();
    const url = new URL(calls[0].url);

    expect(url.searchParams.get('category')).toBe('Travel');
    expect(url.searchParams.get('limit')).toBe('10');
  });
});

Complete Test Suite Example

Here’s a complete, runnable test file:
expense-api.e2e.ts
import { test, expect, httpMock, httpResponse } from '@frontmcp/testing';

test.use({
  server: './src/main.ts',
  port: 3003,
});

let interceptor: ReturnType<typeof httpMock.interceptor>;

// OpenAPI spec - define once, reuse
const openApiSpec = {
  openapi: '3.0.0',
  info: { title: 'Expense API', version: '1.0.0' },
  servers: [{ url: 'https://api.example.com' }],
  paths: {
    '/expenses': {
      get: { operationId: 'listExpenses', summary: 'List expenses' },
      post: { operationId: 'createExpense', summary: 'Create expense' },
    },
    '/expenses/{id}': {
      get: { operationId: 'getExpense', summary: 'Get expense' },
      delete: { operationId: 'deleteExpense', summary: 'Delete expense' },
    },
  },
};

beforeEach(() => {
  interceptor = httpMock.interceptor();

  // Always mock the spec endpoint
  interceptor.get('https://api.example.com/openapi.json', {
    body: openApiSpec,
  });
});

afterEach(() => {
  interceptor.restore();
});

test.describe('Expense API OpenAPI Adapter', () => {
  test('lists all expenses', async ({ mcp }) => {
    interceptor.get('https://api.example.com/expenses', {
      body: [
        { id: '1', amount: 50, category: 'Travel' },
        { id: '2', amount: 100, category: 'Meals' },
      ],
    });

    const result = await mcp.tools.call('expense-api:listExpenses', {});

    expect(result).toBeSuccessful();
    expect(result.json()).toHaveLength(2);
  });

  test('creates an expense', async ({ mcp }) => {
    interceptor.post('https://api.example.com/expenses', {
      status: 201,
      body: { id: 'new-1', amount: 75, category: 'Office' },
    });

    const result = await mcp.tools.call('expense-api:createExpense', {
      amount: 75,
      category: 'Office',
    });

    expect(result).toBeSuccessful();
    expect(result.json().id).toBe('new-1');
  });

  test('gets expense by ID', async ({ mcp }) => {
    interceptor.get('https://api.example.com/expenses/123', {
      body: { id: '123', amount: 200, category: 'Equipment' },
    });

    const result = await mcp.tools.call('expense-api:getExpense', { id: '123' });

    expect(result).toBeSuccessful();
    expect(result.json().amount).toBe(200);
  });

  test('deletes an expense', async ({ mcp }) => {
    interceptor.delete('https://api.example.com/expenses/456', {
      status: 204,
    });

    const result = await mcp.tools.call('expense-api:deleteExpense', { id: '456' });

    expect(result).toBeSuccessful();
  });

  test('handles API errors gracefully', async ({ mcp }) => {
    interceptor.get('https://api.example.com/expenses', {
      status: 500,
      body: { error: 'Database connection failed' },
    });

    const result = await mcp.tools.call('expense-api:listExpenses', {});

    expect(result).toBeError();
  });
});

Best Practices

The OpenAPI spec is fetched when the server starts. Mock it in beforeEach:
beforeEach(() => {
  interceptor.get('/openapi.json', { body: openApiSpec });
});
Mock responses should match the schema defined in your OpenAPI spec:
// Match the schema
{ id: 'string', amount: 123.45, category: 'Travel' }

// Don't use invalid types
{ id: 123, amount: 'fifty dollars' }  // Wrong!
Use call tracking to verify headers, body, and URL:
const handle = interceptor.post('/api/data', { body: {} });
await mcp.tools.call('tool', input);

const calls = handle.calls();
expect(calls[0].headers['content-type']).toBe('application/json');
expect(calls[0].body).toEqual(expectedBody);
Cover 4xx and 5xx responses:
  • 400 Bad Request (validation errors)
  • 401 Unauthorized
  • 403 Forbidden
  • 404 Not Found
  • 429 Rate Limited
  • 500 Server Error

Next Steps

HTTP Mocking Reference

Complete HTTP mocking API

OpenAPI Adapter

Full adapter configuration

Testing Overview

Testing framework basics

Test Authentication

Testing with auth tokens