How-to › Use AI to do the integration

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.

Audience
API consumer
Level
intermediate
Topic
Write an integration with a coding agent
Languages
TypeScript
Verified

An agent writes a client from the OpenAPI document and the examples inside it. The first call against the real service returns a field the document never mentions and leaves out one the example showed, and the client throws on both. The document said what the API should return. The agent needed to see what it does return.

What you get

You will end up with a read-only MCP server that lets the agent GET real responses while it writes, and a check that compares three probed responses against the document. This is for you if an agent writes your client from the document’s examples alone.

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. The transport here is stdio, the one every editor supports, and its framing rules 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 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

ApproachWhen it fitsWhat it costs youWhen to pick something else
curl through the shell toolAny API, any agent with a shell, nothing to installText comes back, so the agent parses a transcript, and the key sits in the shell environmentThe agent has no shell, or the key must not reach it
Postman or Bruno fixturesDeterministic runs, CI, and APIs you may not call from a sessionA snapshot of one day: fields drift and values age until a breakage prompts a re-exportThe document is known to lag the service
The API’s MCP serverThe vendor ships one and marks its read tools with readOnlyHintEvery result spends context, and a server with write tools needs an allow list on the clientNo server exists, or its tools are not read only
voxgig-sdk catalog Go MCP serverThe API is in the catalog and you want one generated tool per operationA Go build, and a server that exposes every operation, writes included, until you restrict itThe 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.

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

  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 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:

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

node demo.mjs
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 would only refuse that last kind under additionalProperties: false.

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

node --test probe.test.mjs
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 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 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.

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.

Footnotes

  1. The RFC keeps the word in quotation marks. RFC 9110 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. ↩︎ Back to text

  2. The transport specification 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. ↩︎ 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.