Your OpenAPI document describes forty operations and an agent team wants them as MCP tools by Friday. Writing a server by hand means forty handlers, forty schemas, and a month of drift the moment the API moves. The generators promise a server in minutes, but you have no way to tell, before an agent connects, whether what they produce is usable.
What you get
You will end up with a stdio MCP server generated from your document, one written by hand for contrast, and a scripted client that lists and calls tools on both. This is for you if agents want your OpenAPI document as tools.
Short answer
Run openapi-mcp-generator with the document as input and a directory as output, then install
and build the TypeScript project it writes. The result is a stdio MCP server with one tool per
operation, named by operationId. Confirm it with a scripted client that lists the tools and
calls one against a mock of the API. Fix the document before generating, because a missing or
duplicated operationId becomes a tool name an agent cannot read.
You will need
An OpenAPI 3 document, Node 20 or later, and a terminal. Verified 2026-10-21 against Node 22.22.0,
openapi-mcp-generator 4.1.0, @modelcontextprotocol/sdk 1.32.1, @modelcontextprotocol/server
2.3.1, and Zod 3.25.76. The sample generates from meters.json, an eight-operation document with
one operationId missing and one duplicated on purpose. It builds the result and drives it from a
client over stdio against a mock of the API, so no request leaves the machine. The
tools the client lists
are the protocol’s unit of work, and their names are the first thing an agent reads.1
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A hand-written server on the official TypeScript MCP SDK | An agent’s job spans several endpoints, or a tool has to say whether it only reads | A handler, a schema, and a description per tool, and an edit every time the API changes shape | Forty endpoints and a deadline, where generated tools beat none |
openapi-mcp | No build step wanted: one Go binary reads the document and serves it, with tag and regular expression filters | A Go toolchain or a binary to distribute, a confirmation round trip on every write unless you turn it off, and no generated source to read | You want to read, edit, and commit the server’s code |
| openapi-mcp-generator | A TypeScript project you can read, build, and commit, written in seconds | One tool per endpoint, names copied from operationId however bad, and no annotations telling an agent what is destructive | The agent’s tasks do not map one to one onto endpoints |
Both generators give you every endpoint as a tool, which is the fast path and also the problem.
openapi-mcp-generator writes a TypeScript project that you then own. openapi-mcp serves the
document directly and asks for confirmation on every write unless told not to. The hand-written
server costs a handler per tool and buys tools shaped like the agent’s work, such as one call that
reads a meter together with its readings.
Generate the server from the document
Point the generator at the document and at an empty directory. The --force flag lets it
overwrite a previous run without a prompt, which is what you want in a script. It logs to stderr,
hence the redirect.
npx openapi-mcp-generator -i meters.json -o generated --force 2>&1
Creating project directory structure at: generated
-> Created generated/src/index.ts
-> Created generated/package.json
-> Created generated/tsconfig.json
-> Created generated/.gitignore
-> Created generated/.eslintrc.json
-> Created generated/.prettierrc
-> Created generated/jest.config.js
-> Created generated/.env.example
-> No OAuth2 security schemes found, skipping documentation
---
MCP server project 'meterco-api' successfully generated at: generated
The result is a small TypeScript project. src/index.ts holds a map of tool definitions, one per
operation, and a handler that turns a tool call into an HTTP request with axios. Its
package.json depends on the previous major of the official SDK.2 The sample keeps the
generated project in a directory of its own and builds it at install time with npm ci and
npm run build. The server reads API_BASE_URL from the
environment and falls back to the first entry in the document’s servers list.
The document is imperfect in two places on purpose.
"/meters/{meterId}/readings": {
"get": {
"summary": "List readings for a meter",
"tags": ["readings"],
"parameters": [{ "name": "meterId", "in": "path", "required": true, "schema": { "type": "string" } }],
"responses": { "200": { "description": "A page of readings" } }
},
"post": {
"operationId": "createMeter",
"summary": "Record a reading",
The GET has no operationId. The POST reuses createMeter, which already names the operation
that creates a meter. Both are valid enough for the generator to proceed.
List the tools from a scripted client
An agent host starts a stdio server as a child process and speaks JSON-RPC to it over the pipe.
The client below does the same with the SDK’s StdioClientTransport, so it sees what an agent
sees.
export async function connect(entry, env = {}) {
const client = new Client({ name: 'probe', version: '0.0.0' })
const args = entry.endsWith('.ts') ? ['--experimental-strip-types', entry] : [entry]
const transport = new StdioClientTransport({ command: process.execPath, args, env: { ...process.env, ...env }, stderr: 'ignore' })
await client.connect(transport)
return client
}
The script starts a mock of the API on a random port, passes its address as API_BASE_URL, lists
the tools, and calls one.
node list-tools.mjs generated/build/index.js listMeters '{"state":"installed"}'
8 tools from generated/build/index.js
listMeters List meters
createMeter Create a meter
getMeter Read one meter
deleteMeter Delete a meter
GetMetersReadings List readings for a meter
createMeter_b47431 Record a reading
listInvoices List invoices
purgeTenant [DEPRECATED] Purge a tenant
call listMeters {"state":"installed"}
the API received GET /meters?state=installed
the agent received: API Response (Status: 200):
Eight operations became eight tools, and the call did what the document says: a GET with the
query parameter in place, and the response body handed back as text. Two names show the document’s
flaws. The GET without an operationId became GetMetersReadings, a name built from the method
plus the path. The duplicated createMeter became createMeter_b47431, a suffix that keeps the names
unique and says nothing about which of the two records a reading. The deprecated operation is
still a tool, with the word [DEPRECATED] at the front of its description and nothing else to
mark it.3
Write the same server by hand, for contrast
The generated server answers which endpoints exist. An agent asks what a particular meter is doing, which is a different question, and a hand-written tool can answer it in one call.
server.registerTool(
'read_meter',
{
description: 'Read one meter and its recent readings in one call. Read only.',
inputSchema: z.object({ meter_id: meterId }),
annotations: { readOnlyHint: true },
},
async ({ meter_id }) => {
const id = encodeURIComponent(meter_id)
try {
const [meter, readings] = await Promise.all([get(`/meters/${id}`), get(`/meters/${id}/readings`)])
const last = readings.at(-1)
const latest = last ? `last reading ${last.kwh} kWh on ${last.at}` : 'no readings yet'
return text(`${meter.id} serial ${meter.serial}, ${meter.state}, ${latest}`)
} catch (err) {
// Only a 404 means there is no such meter. Any other failure is the API's, and an agent
// sent to look the meter up again would be chasing the wrong problem.
if (err.status !== 404) return failure(`Could not read meter ${meter_id}: ${err.message}. Try again later.`)
return failure(`No meter ${meter_id}: ${err.message}. Use find_meter to look one up.`)
}
},
)
The id reaches the path through encodeURIComponent, and meterId refuses an empty id, ., and
... Without both, a meter_id of ../admin/tenants/acme/purge?x= turns record_reading into a
POST to the purge endpoint. Only a 404 sends the agent to find_meter, and a meter with no readings
yet is read rather than reported missing.
The server is served with serveStdio, which the official quickstart uses because it negotiates
the protocol revision with each client.
if (import.meta.filename === process.argv[1]) {
serveStdio(createServer, { onerror: (err) => console.error('meterco-tasks:', err) })
}
node list-tools.mjs tasks/server.mjs read_meter '{"meter_id":"mtr_8f2"}'
3 tools from tasks/server.mjs
find_meter Find meters whose serial number starts with a prefix. Read only. {"readOnlyHint":true}
read_meter Read one meter and its recent readings in one call. Read only. {"readOnlyHint":true}
record_reading Record a new kWh reading against a meter. Writes data. {"readOnlyHint":false,"destructiveHint":false}
call read_meter {"meter_id":"mtr_8f2"}
the API received GET /meters/mtr_8f2
the API received GET /meters/mtr_8f2/readings
the agent received: mtr_8f2 serial SN-40199, installed, last reading 1260 kWh on 2026-10-01
Three tools instead of eight, each with an annotation saying whether it writes, and two HTTP
requests behind one call. The cost is the three handlers, and the drift between them and the API
is yours to notice from here on. tasks/ is a project of its own in the sample because the two
SDK generations do not share a zod major. That is a small fact with a large bearing on anyone
who puts generated and hand-written tools in one package.
Check it worked
Run the tests. They spawn each server over stdio and assert on what the mock received.
node --test generate.test.mjs
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 2637.914358
# pass 9 covers the eight generated names in order, the GET /meters?state=installed behind
listMeters, the absence of annotations on deleteMeter, the two requests behind read_meter,
and a tool error for a meter that does not exist. The other four hold the hand-written tools to the
API as documented. An id stays one path segment, find_meter filters serials itself, a meter with
no readings is read, and an outage is not reported as a missing meter.
When it goes wrong
Two tools look the same to the agent. createMeter and createMeter_b47431 have different
descriptions and the same stem, and an agent choosing by name has nothing to choose on. The cause
is in the document, so fix it there: give every operation a unique operationId that says what it
does, and regenerate. openapi-mcp takes the other position and refuses to build a tool for an
operation without an id, which is a slower start and a safer one.
The tool list does not fit. A document with two hundred operations generates two hundred tools,
and the agent receives every one of them before the first user message. The script below builds
such a document, generates from it, and measures the tools/list response.
node pitfall.mjs
big.json: 100 paths, 200 operations, every one with an operationId
meters.json 8 tools, tools/list is 1604 bytes in one response
big.json 200 tools, tools/list is 36831 bytes in one response
Nothing failed, which is the problem. Every name is clean, so the agent gets two hundred of them to choose between on every turn, with the whole list in its context before the first user message.4 Pruning the list to the tasks agents are asked to do is a separate job from generating it.
A write looks like a read. deleteMeter arrives with no annotations, so a client cannot tell it
from getMeter without parsing the description. The hand-written server sets readOnlyHint and
destructiveHint, which the
specification defines as
hints a client must treat as untrusted unless the server is trusted. Set them anyway. A hint a
client ignores costs nothing, and a missing one leaves the client guessing.
When not to do this
Do not generate from a document you have not read. The generator copies every name and every description into the tool list, and a document written for a reference site reads differently from a tool description an agent will act on.
Do not hand the generated server to an agent as its whole interface when the agent’s tasks span endpoints. Reading a meter took two generated tools and one hand-written one, and every extra call is a chance for the agent to pick the wrong tool or stop early.
Do not run a freshly generated server against production. API_BASE_URL falls back to the first
server in the document, which in most documents is production, and the generated deleteMeter
works on the first attempt. Its path parameters are escaped but not checked, so deleteMeter with a
meterId of .. sends DELETE /.
Do not ship a two-hundred-tool server. Generate it, measure it, and cut it to the tools the agent’s tasks need.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0, openapi-mcp-generator 4.1.0, @modelcontextprotocol/sdk
1.32.1, @modelcontextprotocol/server 2.3.1, Zod 3.25.76 in the generated project, and Zod 4.6.7 in
tasks/. Every output block is what the command preceding it printed. The generated server was
built and run, the hand-written one was run, and the mock stood in for the API throughout.
openapi-mcp was read from its documentation and not run.
Footnotes
-
The protocol’s rules for a tool name are all written as SHOULD. The tools specification asks for between 1 and 128 characters, drawn from letters, digits, underscore, hyphen, and dot, unique within a server. The generator stops at 64 characters and names Claude Desktop as the reason in its README, so a generated name obeys a client’s limit the specification never set.
createMeter_b47431satisfies every rule in the list, which is the trouble with rules about characters. ↩︎ Back to text -
The generator’s template depends on
@modelcontextprotocol/sdk, while the quickstart installs@modelcontextprotocol/server, a different package with a different major of Zod beneath it. The quickstart adds that a server wired by hand withStdioServerTransportserves only 2025-era clients, whereasserveStdionegotiates. The client on this page comes from the older package and talks to both servers, which is the same negotiation working in the other direction. ↩︎ Back to text -
OpenAPI’s
deprecatedfield is a boolean whose definition runs to three sentences: it declares the operation deprecated, consumers SHOULD refrain from using it, and the default is false. The generator turns the boolean into the word[DEPRECATED]at the front of the description and keeps the tool. The agent reads the warning in the same breath as the summary and may weigh them however it likes. ↩︎ Back to text -
The tools specification asks servers to return tools in a deterministic order, and gives its reason. Clients cache the list, and a prompt cache hits more often when the tool block does not move between requests. It also lets
tools/listpaginate. The generated server returned all two hundred in one page, in the document’s order, which meets both requests. The specification covers the order of the list. The choice among two hundred tools is left to the agent. ↩︎ Back to text