How-to › Expose your API to agents

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.

Audience
Agent builder
Level
intermediate
Topic
Build an MCP server
Languages
TypeScript and JavaScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A recorded fixture replayedYou want the mock’s answers to be what the real service said onceRecordings that go stale, and a re-record step nobody remembersYou need failures the service will not produce for you
A scripted mock in the test processBuilding and testing tools, where you choose every answerA mock that can drift from the real API unless something checksThe question is whether the real API behaves as you think
The real API with a test accountThe final check before shipping a toolRate limits, latency, and state that other people changeYou are iterating on the shape of a tool
The vendor sandboxBehavior you cannot guess, such as an unusual validation ruleA network round trip per iteration, and limits of its ownThe 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.

Last verified

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

Footnotes

  1. 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 was tools/call, and it was found. It was the tool that was not, and a tool is a parameter. ↩︎ Back to text

  2. RFC 9110 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. ↩︎ Back to text

  3. 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.