# How to generate an MCP server from an OpenAPI document

> Run openapi-mcp-generator over an OpenAPI 3 document, build the stdio server it writes, and list its tools from a scripted client before any agent connects to it.

Source: https://voxgig.com/howto/generate-an-mcp-server-from-openapi

- Audience: api-producer
- Level: beginner
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-19

## 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](https://modelcontextprotocol.io/specification/2026-07-28/server/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](https://modelcontextprotocol.io/docs/2026-07-28/develop/build-server) | 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`](https://pkg.go.dev/github.com/ubermorgenland/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](https://github.com/harsha-iiiv/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.

```bash
npx openapi-mcp-generator -i meters.json -o generated --force 2>&1
```

```text output
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.

```json title="meters.json"
"/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.

```js title="list-tools.mjs"
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.

```bash
node list-tools.mjs generated/build/index.js listMeters '{"state":"installed"}'
```

```text output
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.

```js title="tasks/server.mjs"
  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.

```js title="tasks/server.mjs"
if (import.meta.filename === process.argv[1]) {
  serveStdio(createServer, { onerror: (err) => console.error('meterco-tasks:', err) })
}
```

```bash
node list-tools.mjs tasks/server.mjs read_meter '{"meter_id":"mtr_8f2"}'
```

```text output
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.

```bash
node --test generate.test.mjs
```

```text output
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.

```bash
node pitfall.mjs
```

```text output
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](https://modelcontextprotocol.io/specification/2026-07-28/server/tools) 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

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

- [Write operationIds that generate clean SDK method names](/howto/operationids-that-generate-clean-sdk-method-names)

- [Name tools so an agent picks the right one from fifty](/howto/name-tools-so-agents-pick-the-right-one)

- [Choose between stdio and Streamable HTTP for an MCP server](/howto/choose-an-mcp-transport)

- [Prune a generated MCP server to ten tools an agent can use](/howto/prune-a-generated-mcp-server-to-ten-tools)

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

[^1]: The protocol's rules for a tool name are all written as SHOULD. The
[tools specification](https://modelcontextprotocol.io/specification/2026-07-28/server/tools) 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](https://github.com/harsha-iiiv/openapi-mcp-generator), so a generated name obeys a
client's limit the specification never set. `createMeter_b47431` satisfies every rule in the list,
which is the trouble with rules about characters.

[^2]: The generator's template depends on `@modelcontextprotocol/sdk`, while
[the quickstart](https://modelcontextprotocol.io/docs/2026-07-28/develop/build-server) installs
`@modelcontextprotocol/server`, a different package with a different major of Zod beneath it. The
quickstart adds that a server wired by hand with `StdioServerTransport` serves only 2025-era
clients, whereas `serveStdio` negotiates. 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.

[^3]: OpenAPI's `deprecated` field is a boolean whose
[definition](https://spec.openapis.org/oas/v3.1.0.html#operation-object) 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.

[^4]: The [tools specification](https://modelcontextprotocol.io/specification/2026-07-28/server/tools)
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/list` paginate. 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.