You are three hours into writing tools and every iteration means a round trip to a vendor sandbox that rate limits after forty calls. The error path you most want to get right is what a tool says when the API refuses for rate. You cannot produce that on demand, so it gets written from the documentation and tested by nobody.
What you get
You will end up with an MCP server that takes its API base URL as a parameter, and a mock that answers per route. Tools then become ordinary function calls. Failures become fixtures. This is for you if you are building tools and the real API is slow, rate limited, or expensive.
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, and the distinction
between a tool error and a protocol error is the one worth getting right first.1 The tool result
shape is where that
distinction lives.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A recorded fixture replayed | 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 | 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 | 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 | 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.
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.
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.
node demo.mjs
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.
node --test server.test.mjs
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
Last verified
Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.
Footnotes
-
An MCP protocol error is a JSON-RPC 2.0 error, since the base protocol requires every message to follow that specification. The codes have a lineage. JSON-RPC 2.0 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 gives for an unknown tool answers with -32602, which JSON-RPC calls
Invalid params, and not -32601,Method not found. The method wastools/call, and it was found. It was the tool that was not, and a tool is a parameter. ↩︎ Back to text -
RFC 9110 allows
Retry-Afterto be either an HTTP-date or a number of seconds, and leaves the choice to the server. Its two examples are120andFri, 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. ↩︎ Back to text -
The 429 arrived in RFC 6585, 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. ↩︎ Back to text