# How to let a coding agent probe a live API through an MCP server

> Give a coding agent a read-only probe of the real API while it writes the client, and check what came back against the document before the agent hard-codes it.

Source: https://voxgig.com/howto/probe-live-api-responses-through-an-mcp-server

- Audience: api-consumer
- Level: intermediate
- Languages: typescript
- Verified: 2026-09-24
- Published: 2026-09-24

## Short answer

Give the agent a probe tool that can only GET, holds the API key in its own process, and returns each response as structured JSON with the status and a byte count. Probe three records, not one, and check every response against the document's schema, listing the fields the document omits and the optional fields that happened to be present. Fields present by chance are the shape the agent will wrongly hard-code.

---
## You will need

Node 22 or later, the OpenAPI document for the API, a key for an account that can only read,
and a coding agent that speaks [MCP](https://modelcontextprotocol.io/specification/2025-11-25/server/tools).
The transport here is stdio, the one every editor supports, and its
[framing rules](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) are
short enough to implement by hand. Wiring the finished server into a specific editor is a
separate task; the sample registers with [Claude Code](https://code.claude.com/docs/en/mcp) in one
command.

Voxgig maintains the SDK catalog. This page compares its Go MCP server with the API's own MCP
server, curl through the shell tool, and fixtures exported from Postman or Bruno.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [curl through the shell tool](https://curl.se/docs/manpage.html) | Any API, any agent with a shell, nothing to install | Text comes back, so the agent parses a transcript, and the key sits in the shell environment | The agent has no shell, or the key must not reach it |
| [Postman or Bruno fixtures](https://learning.postman.com/docs/use/send-requests/response-data/examples/) | Deterministic runs, CI, and APIs you may not call from a session | A snapshot of one day: fields drift and values age until a breakage prompts a re-export | The document is known to lag the service |
| [The API's MCP server](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) | The vendor ships one and marks its read tools with `readOnlyHint` | Every result spends context, and a server with write tools needs an allow list on the client | No server exists, or its tools are not read only |
| [voxgig-sdk catalog Go MCP server](https://voxgig.com/voxgig-sdk) | The API is in the catalog and you want one generated tool per operation | A Go build, and a server that exposes every operation, writes included, until you restrict it | The API is not in the catalog, or the agent must be held to GET |

A shell tool is the cheapest probe and the leakiest: the agent reads a transcript, and whatever
key the shell holds is one `env` away. Fixtures cost nothing per probe and answer the same way
every run. That is the point in CI and the problem in a session: a fixture describes the API as
it was on the day of the export. An MCP server returns structured results the agent can reason
over, and pays for each one in context.

## Hold the key in the server, not the agent

The server takes the base URL and the key from its own environment. The agent sees a tool whose
one required argument is a path, and cannot choose a method because the tool takes no method
parameter.

```ts title="probe-server.mjs"
export async function probe({ path, query = {} }, { base = BASE, key = KEY, maxBytes = MAX_BYTES, fetchImpl = fetch } = {}) {
  // A relative path only. An absolute URL would turn a probe of your API into a request to
  // any host the agent names, carrying your key. The URL parser reads a backslash as a slash,
  // so `/\host` is `//host`, and a backslash is refused before the parser sees it.
  const refuse = (text) => ({ isError: true, content: [{ type: 'text', text }] })
  if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//')) return refuse('path must start with a single /')
  if (/\\|%5c/i.test(path)) return refuse('path must not contain a backslash')
  // The path is appended to the base as text, so a base with a path prefix keeps it, and the
  // parsed result must still sit on the base's origin.
  const url = new URL(base.replace(/\/$/, '') + path)
  if (url.origin !== new URL(base).origin) return refuse(`path resolves to ${url.origin}, not the API base`)
  for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v)
  let res
  let raw
  try {
    res = await fetchImpl(url, { method: 'GET', headers: { authorization: `Bearer ${key}` } })
    raw = Buffer.from(await res.arrayBuffer())
  } catch (e) {
    // The API is down, or the name does not resolve. That is a result, not a crash.
    return refuse(`fetch failed: ${e.cause?.message ?? e.message}`)
  }
  const truncated = raw.length > maxBytes
  const text = raw.subarray(0, maxBytes).toString('utf8')
  let body = text
  if (!truncated) {
    try { body = JSON.parse(text) } catch { /* not JSON: hand back the text as it came */ }
  }
  const result = { status: res.status, contentType: res.headers.get('content-type'), bytes: raw.length, truncated, body }
  return { isError: res.status >= 400, content: [{ type: 'text', text: JSON.stringify(result) }] }
}
```

Three decisions in that function do the work. The method is fixed at GET, which
[RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.1) defines as safe: the client
asks for no state change.[^1] The path must be relative, checked twice. It must start with a
single `/` and carry no backslash, literal or as `%5C`, because the
[URL parser](https://url.spec.whatwg.org/#path-state) reads `\` as `/` for `http`, so
`/\other.example` is `//other.example`. The function then appends it to the base as text and
refuses the result unless its `origin` equals the base's. Appending as text also keeps a base
prefix such as `/v2`, which `new URL(path, base)` would drop. A tool that lets either through is
a tool for sending your key anywhere. The body is cut at a byte budget and the result says
`truncated: true`, because a list endpoint returning forty kilobytes is a probe that costs more
than it teaches.

The tool declares `readOnlyHint: true` in its annotations. The specification says clients must
treat annotations as untrusted unless the server is trusted, so the hint informs the agent and
the missing method parameter is what enforces the rule.

## Speak the stdio transport

The transport is one JSON-RPC message per line, in on `stdin` and out on `stdout`, which must carry
nothing else at all.[^2]

```ts title="probe-server.mjs"
  const lines = createInterface({ input: process.stdin })
  lines.on('line', async (line) => {
    if (!line.trim()) return
    let msg
    try { msg = JSON.parse(line) } catch { return }
    // A handler that throws still answers, or the client waits on that id forever.
    const out = await handle(msg).catch((e) => ({ jsonrpc: '2.0', id: msg.id, error: { code: -32603, message: e.message } }))
    if (out) process.stdout.write(JSON.stringify(out) + '\n')
  })
```

`handle` answers `initialize`, `ping`, `tools/list` and `tools/call`, and returns nothing for a
notification, a message with no id, because
[JSON-RPC](https://www.jsonrpc.org/specification#notification) forbids a reply to one.
`notifications/cancelled`, which a client sends when it cancels a call, is one. A probe that
cannot reach the API returns `isError: true` with the reason, and a handler that throws anyway
gets an error reply, so no request is left pending. Register the server for a project with the
key in the server's environment, not the agent's:

```bash
claude mcp add --scope project --env API_BASE_URL=https://api.example.com --env API_KEY=YOUR_READ_ONLY_KEY probe -- node probe-server.mjs
```

The [`--env` flag](https://code.claude.com/docs/en/mcp) sets variables for the server process
alone. The agent's own context never carries the value, and a tool result cannot leak what the
process never handed over.

## Probe three, then check every one against the document

One response is an anecdote. The demo starts a stand-in API, spawns the probe server against it
the way an editor would, probes three records and checks each against the schema the document
declares for `GET /meters/{id}`.

```bash
node demo.mjs
```

```text output
tools
  probe  readOnlyHint=true  GET one path of the live API and return status, content-type and body. Read only.

three probes against GET /meters/{id}
  mtr_1  200  155 bytes  $.region is not in the document
  mtr_2  200  151 bytes  $.region is not in the document
  mtr_3  200  100 bytes  $.region is not in the document

optional fields, and how often the probes carried them
  region  present in 3 of 3
  installedAt  present in 3 of 3
  lastReading  present in 2 of 3

the shape inferred from the first probe alone
  required: id, serial, status, region, installedAt, lastReading
  applied to the third probe: missing $.lastReading

the fixture exported in June against the same record today
  fields only the live response has: region
  fields whose value moved: lastReading

what the server refuses
  {"path":"https://other.example/meters"}  isError=true  path must start with a single /
  {"path":"//other.example/meters"}  isError=true  path must start with a single /
  {"path":"/\\other.example/meters"}  isError=true  path must not contain a backslash
  requests the API saw: GET /meters/mtr_1, GET /meters/mtr_2, GET /meters/mtr_3
```

Read the second block as two findings that need different responses. `region` came back on
every probe and the document never mentions it: that is a gap in the document, so file it
against the document and let the client accept unknown fields. `lastReading` came back on two
probes of three, and the document lists it as optional. Its absence on the third is not an
error, and a client that treats it as required will throw on the first inactive meter.

The third block is the pitfall as a number. The shape inferred from the first probe has six
required fields, three more than the document, and it fails on the third probe. An agent that
writes the type from one response does exactly this, including the optional fields that were
present by chance, and the client passes every test that uses the same response.

The checker itself is two functions over the document. `schemaFor` follows the `$ref` in the
route's `200` response. `check` walks the value and reports a missing required field, a value
of the wrong type, and any field the schema does not list. The
[JSON Schema validation vocabulary](https://json-schema.org/draft/2020-12/json-schema-validation)
would only refuse that last kind under `additionalProperties: false`.

```ts title="validate.mjs"
  if (typeOf(value) === 'object' && schema.properties) {
    for (const k of schema.required || []) if (!(k in value)) findings.missing.push(`${at}.${k}`)
    for (const [k, v] of Object.entries(value)) {
      if (!(k in schema.properties)) findings.undocumented.push(`${at}.${k}`)
      else merge(check(doc, schema.properties[k], v, `${at}.${k}`))
    }
  }
```

The fixture block is the case for fixtures and against them in two lines. A file exported in
June still validates against the document, and it lacks the field the service grew since and
carries a reading three months stale. A [Bruno](https://docs.usebruno.com/bru-lang/overview)
collection or a Postman example is a fine input to a deterministic test, and a poor witness to
what the API returns today.

## Check it worked

Six tests cover the two properties the page is about and the transport underneath them.

```bash
node --test probe.test.mjs
```

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

Two matter most. The key test asserts the string `sk-live-secret-9x` appears nowhere in a
serialized tool result. The inference test asserts the shape taken from one probe reports
`$.lastReading` missing on the third. If the second passes without the first probe carrying
`lastReading`, the stand-in has changed and the test no longer says anything.

## When it goes wrong

The client throws on a record the probes never showed. The agent typed the response from one
probe, so an optional field became required. Run the three probes again, read the
`present in n of 3` line, and hold the client to the document's `required` list rather than to
what a probe happened to carry.

The agent stops probing after two calls. Each result lands in the context window, and
[Claude Code](https://code.claude.com/docs/en/mcp) warns at 10,000 tokens of MCP output and
refuses past 25,000 by default. Keep the byte budget small, probe single records rather than
lists, and pass a page size when you must probe a list.

The server starts and the agent reports no tools. Something wrote to `stdout` that was not a
message, usually a `console.log` left in for debugging. The transport rule is absolute: log to
`stderr`, which the [client may capture or ignore](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports)
and must not treat as an error.

Every probe answers 401. The key is in the agent's shell and not in the server's environment.
The server reads `API_KEY` from its own process, so pass it with `--env` at registration, and
check that the account behind it can only read.

## When not to do this

Do not run the probe against an account that can write. The tool refuses to issue anything but
GET. A vendor whose GET has side effects, or whose API tunnels writes through a query parameter,
is outside what a method check can hold. Issue a key scoped to reads, and rotate it as you
would any key an agent has used.

Do not reach for the catalog's Go MCP server as the probe. It exposes every operation in the SDK
as a tool, writes included, so an agent connected to it can drive the API rather than inspect
it. That is what it is for, and it is the wrong tool for the session where the agent is still
working out what a response looks like. Restrict which tools the client may call before you
connect it, or connect it after the client is written.

Do not probe an API whose terms forbid programmatic access from a development session, and do
not probe with a key you cannot revoke. Fixtures exist for exactly that case. Do not let probes
replace fixtures in CI either. A build that calls the live service is slower, flakier, and rate
limited, and the fixture only needs re-exporting when the schema check finds drift.

## Related how-tos

- [Measure drift between two AI-written clients](/howto/measure-drift-between-two-ai-written-clients)

- [Run an MCP server against a mock API while you build tools](/howto/run-an-mcp-server-against-a-mock-api)

- [Validate responses against the OpenAPI document at runtime](/howto/validate-responses-against-the-openapi-document)

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

## Last verified

Verified 2026-09-24 against Node 22.22.2. Every output block is what the command before it
printed. The live API is a stand-in on 127.0.0.1 whose drift from its own document is scripted,
so the fields and byte counts reproduce exactly. The catalog's Go MCP server was read from its
published description and not run; the vendor server row describes the protocol, not a
particular product.

[^1]: The RFC keeps the word in quotation marks.
[RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.1) defines a safe method as
one whose semantics are essentially read-only. The next paragraph explains that a server may do
harm on a GET anyway, and that the client cannot be held accountable for it. Its own example is
an access log that fills the disk. A probe tool that only issues GET is
read-only in the sense the RFC allows: it makes no request it could be blamed for.

[^2]: The [transport specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports)
puts it in capitals: the server MUST NOT write anything to its stdout that is not a valid MCP
message. Standard error, by contrast, is a place the client MAY capture, forward, or ignore. A
protocol that had to say this about a pipe has met the debugging `console.log` before, and
expects to meet it again.