Testing without the network
The runtime ships a test helper that exercises the same dispatch path as production HTTP — but in-process and with fetch you can mock.
The pattern
import { describe, it, expect, vi } from 'vitest';import { createTestClient } from '@mcify/runtime/test';import config from '../mcify.config.js';
describe('khipu_create_payment', () => { it('returns the upstream payment URL', async () => { const fetchMock = vi .fn() .mockImplementation(() => Promise.resolve( new Response( JSON.stringify({ payment_id: 'p_abc', payment_url: 'https://khipu.com/pay/abc' }), { status: 200, headers: { 'content-type': 'application/json' } }, ), ), );
const client = createTestClient(config, { auth: { type: 'bearer', token: 'test' }, fetch: fetchMock, });
const result = await client.callTool('khipu_create_payment', { subject: 'Order #1', currency: 'CLP', amount: 50000, });
expect(result.paymentId).toBe('p_abc'); expect(fetchMock).toHaveBeenCalledOnce(); });});createTestClient wires:
- The auth state your handler sees (
ctx.auth). - The
fetchyour handler calls viactx.fetch. - The same input/output validation, middleware chain, and error mapping as the HTTP path.
Why mockImplementation and not mockResolvedValue
Response.text() (and .json()) can be read once. If the same Response object comes back from a mock that reuses a single instance, the second test call gets an empty body. Use mockImplementation so each call constructs a fresh Response:
// Bad — second call reads an empty bodyvi.fn().mockResolvedValue(ok({ ... }));
// Good — fresh Response each callvi.fn().mockImplementation(() => Promise.resolve(ok({ ... })));This is the most common gotcha when porting tests from other frameworks.
Asserting on the request
The mock captures every call. You can check the URL, method, and body:
const [url, init] = fetchMock.mock.calls[0]!;expect(url).toBe('https://payment-api.khipu.com/v3/payments');expect((init as RequestInit).method).toBe('POST');const body = JSON.parse((init as RequestInit).body as string);expect(body.subject).toBe('Order #1');Errors
When the mocked upstream returns non-2xx, your handler should throw. The runtime wraps the thrown error into the MCP CallToolResult shape; from the test’s perspective client.callTool rejects:
fetchMock.mockImplementation(() => Promise.resolve(new Response('{"error":"Invalid"}', { status: 400 })),);
await expect( client.callTool('khipu_create_payment', { ... }),).rejects.toThrow(/Invalid/);When to mock vs hit the network
| Mock the upstream | Hit a sandbox |
|---|---|
| Unit tests (per-tool, per-error-path) | Integration test that runs once per release |
| Handler logic (mapping, branching) | Auth flow with real signatures |
| CI runs (no network credentials) | Pre-deploy smoke check |
The connector packages in packages/examples/* follow this split: every commit’s tests are mocked; the dogfooding loop in lelemon-app exercises real Khipu sandbox calls before ship.