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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| curl through the shell tool | 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 | 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 | 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 | 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.
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.
Related how-tos
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
-
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
-
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.logbefore, and expects to meet it again. ↩︎ Back to text