How-to › Expose your API to agents

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.

Audience
API producer
Level
beginner
Topic
Build an MCP server
Languages
TypeScript and JavaScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A hand-written server on the official TypeScript MCP SDKAn agent’s job spans several endpoints, or a tool has to say whether it only readsA handler, a schema, and a description per tool, and an edit every time the API changes shapeForty endpoints and a deadline, where generated tools beat none
openapi-mcpNo build step wanted: one Go binary reads the document and serves it, with tag and regular expression filtersA Go toolchain or a binary to distribute, a confirmation round trip on every write unless you turn it off, and no generated source to readYou want to read, edit, and commit the server’s code
openapi-mcp-generatorA TypeScript project you can read, build, and commit, written in secondsOne tool per endpoint, names copied from operationId however bad, and no annotations telling an agent what is destructiveThe 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.

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

  1. 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_b47431 satisfies every rule in the list, which is the trouble with rules about characters. ↩︎ Back to text

  2. 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 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. ↩︎ Back to text

  3. OpenAPI’s deprecated field 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

  4. 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/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. ↩︎ 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.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.