# How to run an MCP server against a mock API while you build tools

> Point your MCP tools at a mock you can script, so a rate limit or a 500 is one line of fixture rather than a bad afternoon on the real service.

Source: https://voxgig.com/howto/run-an-mcp-server-against-a-mock-api

- Audience: agent-builder
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## Short answer

Take the API base URL as a parameter and start a mock that answers per route. Then drive the server by handing it message objects, without a transport, so listing tools and calling one are ordinary function calls. Script the failures you care about, because a tool's behavior on a 429 or a 404 is the part an agent depends on and the part you cannot produce on demand.

---
## You will need

Node 22 or later, and an MCP server whose tools call an HTTP API. The two methods exercised here are
`tools/list` and `tools/call` from [the Model Context
Protocol](https://modelcontextprotocol.io/specification/draft/server/tools), and the distinction
between a tool error and a protocol error is the one worth getting right first.[^1] The [tool result
shape](https://modelcontextprotocol.io/specification/draft/server/tools#tool-result) is where that
distinction lives.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A recorded fixture replayed](https://github.com/nock/nock) | You want the mock's answers to be what the real service said once | Recordings that go stale, and a re-record step nobody remembers | You need failures the service will not produce for you |
| [A scripted mock in the test process](https://nodejs.org/api/http.html#httpcreateserveroptions-requestlistener) | Building and testing tools, where you choose every answer | A mock that can drift from the real API unless something checks | The question is whether the real API behaves as you think |
| [The real API with a test account](https://docs.stripe.com/keys#test-live-modes) | The final check before shipping a tool | Rate limits, latency, and state that other people change | You are iterating on the shape of a tool |
| [The vendor sandbox](https://developer.paypal.com/tools/sandbox/) | Behavior you cannot guess, such as an unusual validation rule | A network round trip per iteration, and limits of its own | The behavior in question is a status code and a body |

None of these replaces the others, and the order people reach for them is usually wrong. A scripted
mock is where tool development belongs, because you control the answers and the loop is
milliseconds. The sandbox and a test account belong at the end, checking that the answers you
scripted are the ones the service really gives.

The mock's weakness is exactly that: it says whatever you told it to. Keep one test that runs the
same tools against the sandbox, even if it runs weekly, so a mock that drifted from reality is found
by something other than a customer.

## Take the base URL as a parameter

One parameter is the whole technique.

```js title="server.mjs"
export function mcpServer({ baseUrl, fetchImpl = fetch }) {
```

A server that reads its URL from the environment at import time cannot be pointed at a mock in the
same process. A test that sets an environment variable before importing breaks the day somebody
reorders the imports.

Drive the server with message objects rather than over a transport. `tools/list` and `tools/call`
are the surface an agent uses, and testing them as function calls means a failing test names the
tool rather than the pipe.

## Separate a tool error from a protocol error

They mean different things to an agent, and the sample keeps them apart.

```js title="server.mjs"
async function read(res, format) {
  const body = await res.json().catch(() => ({}))
  if (res.ok) return { isError: false, content: [{ type: 'text', text: format(body) }] }
  const retryable = res.status === 429 || res.status >= 500
  const wait = res.headers.get('retry-after')
```

A 404 from the API is a result the tool has to report: the agent asked for something that is not
there, and the call itself worked. An unknown tool name is a protocol error, because the agent sent
something the server cannot act on at all.

Spell the members the way the protocol spells them. A tool advertises `inputSchema`, holding a JSON
Schema object with a `type`, `properties` and any `required` names. A shorthand of your own, under a
member of your own naming, is the defect your tests cannot see. Your mock reads it back happily,
while a real client has nothing to validate an argument against. Check a new server against a
client, not only against its own tests.

Say whether a failure is worth retrying, and after how long.[^2] An agent that has to infer this from an
English sentence will infer it differently each time.

## Check it worked

List the tools, then call four of them against the scripted mock.

```bash
node demo.mjs
```

```text output
tools
  search_meters   Find meters by serial number prefix
  read_meter      Read one meter by id

calls against the mock
  search_meters   {"serial_prefix":"SN-40"}  ok     1 meters match SN-40
  read_meter      {"id":"mtr_8f2"}           ok     mtr_8f2 serial SN-40199
  read_meter      {"id":"mtr_missing"}       error  No such meter. Not retryable.
  read_meter      {"id":"mtr_busy"}          error  Too many requests. Retryable after 30s.
  retire_meter    {"id":"mtr_8f2"}           refused no tool named retire_meter
```

Rows three and four are the reason to have a mock at all. Both are errors and they tell an agent to
do opposite things: one is a dead end and the other is a wait of thirty seconds. Producing either
against a real service means either finding a missing record or exhausting a rate limit.[^3]

The last row went nowhere near the API, which the mock's own call log confirms. An unknown tool is
refused by the server, and a test that asserts the mock saw no request is how you know.

```bash
node --test server.test.mjs
```

```text output
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 308.895965
```

## When it goes wrong

The tools work against the mock and fail against the API. The mock is more forgiving, usually about
required parameters. Assert the mock received the request you expected, not only that it answered.
The call log the sample keeps is there for exactly that.

Every test starts a server and the suite crawls. One mock per test case is a port per case. Start
one per file, and script it per test.

A tool returns a raw payload. The mock returns JSON and the tool passes it through. Format for a
reader, because an agent reading a nested object spends tokens working out which field matters.

The agent retries a dead end forever. The tool said an error and not whether it was worth repeating.
Say so in the text, and keep the rule in one function, so every tool answers the same way.

## When not to do this

Do not ship tools tested only against a mock. The mock is your model of the API, and the whole
category of bug you are looking for is where that model is wrong.

Do not script every endpoint you have. A mock that grows to cover the whole API becomes a second,
untested implementation with defects of its own.

Do not test the transport by testing the tools. Message framing over standard input is worth one
test of its own, and putting it in the path of every tool test makes the suite slow and the failures
vague. Separate concerns fail separately, which is most of what a test suite is for.

## Related how-tos

- [Issue and rotate per-agent API keys for an MCP server](/howto/per-agent-api-keys-for-mcp)

- [Stub outbound HTTP calls in Node tests with nock](/howto/stub-outbound-node-http-calls-with-nock)

## Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

[^1]: An MCP protocol error is a JSON-RPC 2.0 error, since the
[base protocol](https://modelcontextprotocol.io/specification/draft/basic) requires every
message to follow that specification. The codes have a lineage.
[JSON-RPC 2.0](https://www.jsonrpc.org/specification) reserves -32768 to -32000 for predefined
errors and says they are nearly the same as those once suggested for XML-RPC, at an address on
SourceForge. The example the
[tools page](https://modelcontextprotocol.io/specification/draft/server/tools#error-handling)
gives for an unknown tool answers with -32602, which JSON-RPC calls `Invalid params`, and not
-32601, `Method not found`. The method was `tools/call`, and it was found. It was the tool that
was not, and a tool is a parameter.

[^2]: [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#section-10.2.3) allows `Retry-After` to
be either an HTTP-date or a number of seconds, and leaves the choice to the server. Its two
examples are `120` and `Fri, 31 Dec 1999 23:59:59 GMT`, so a client that wants the number has
to be ready for the date. The section describes the header beside a 503 and beside a redirect.
The 429 that borrows it lives in another RFC, with the ambiguity carried over intact.

[^3]: The 429 arrived in [RFC 6585](https://www.rfc-editor.org/rfc/rfc6585#section-4), April
2012, by Nottingham and Fielding, twelve years and ten months after RFC 2616, which it updates.
The section defines the code as too many requests in a given amount of time, then states that
it does not define how a server identifies the user or counts requests. Per resource, across the
server, or among a set of servers are all offered. The example response allows 50 requests per
hour, sets `Retry-After: 3600`, and closes by inviting the reader to try again.