Development

Serving API Reference to Agents: MCP Servers from OpenAPI Specs

Antonio Evans8 min read

A clean isometric scene: a large paper document titled with an OpenAPI-style path list sits on a desk.
Key points

Key points

  1. 1An MCP server generated from your OpenAPI document turns every operation into a typed tool that a coding agent can discover and call.
  2. 2One tool per operation, named from operationId and schematized from parameters and request body, is the mapping that keeps arguments predictable.
  3. 3Read operations should be exposed first; write operations need an approval step that shows method, URL and payload before anything is sent.
  4. 4The spec you already maintain becomes the agent-facing interface, so weak descriptions, duplicate operationId values and optional-looking required fields become agent failures.

Why the spec is the interface

An MCP server is a small program that advertises tools to a client. The protocol defines tool discovery through tools/list and invocation through tools/call, with each tool carrying a name, a description and a JSON Schema for its arguments (MCP tools documentation). That structure lines up almost exactly with an OpenAPI operation: a path, a method, a set of parameters, and optionally a request body. So instead of hand-writing tool definitions, you generate them from the document you already keep.

This is why the angle matters more than the plumbing. Once the spec drives the tool surface, every quality problem in the spec becomes a quality problem for the agent. The OpenAPI Initiative recommends treating the description as a first-class source file rather than a documentation artifact, committed to version control and used to drive automated processes including boilerplate generation and testing (OpenAPI best practices). A generated MCP server is exactly that kind of automated process.

The practical consequence: the work of "making an MCP server" is maybe a day of code, and the work of "making it good" is a week of spec edits. Size your estimate accordingly.

The mapping: one operation, one tool

The basic algorithm is short. Load the spec at startup, walk every path and method, and emit a tool per operation.

OpenAPI elementMCP tool elementRule
operationIdnameUse as-is; fall back to method plus path only when absent
summary / descriptiondescriptionPrefer the description; it must say when to use the operation
Path, query, header parametersinputSchema.propertiesPath params are required; query params optional unless marked required
requestBody JSON schemainputSchema.properties.bodyKeep the schema, including required
examplesAppended to the descriptionShows the agent a valid shape
Response schemaNot sent to the modelUse it to validate or truncate the reply
servers[0].url + pathThe HTTP target at call timeTemplated per call with path arguments

Naming deserves a rule of its own. When operationId is missing, generators typically synthesize a name from method and path, which means GET /users/{user_id}/repos becomes something like get_users_user_id_repos (Converting OpenAPI specs into MCP servers). Synthesized names are ugly but stable; the bigger risk is drift, where a path rename silently renames a tool and an agent that learned the old name fails. Operation IDs are preferred in code generation for the same reason (OpenAPI best practices, Speakeasy).

A minimal generator, using a small Python MCP server, looks like this:

import json, re, httpx
from mcp.server.fastmcp import FastMCP

SPEC = json.load(open("openapi.json"))
READ = {"get", "head", "options"}
BASE = SPEC["servers"][0]["url"]

def tool_name(op_id, method, path):
    if op_id:
        return op_id
    slug = re.sub(r"[^a-zA-Z0-9]+", "_", path).strip("_")
    return f"{method}_{slug}"

def input_schema(op):
    props, required = {}, []
    for p in op.get("parameters", []):
        props[p["name"]] = p.get("schema", {"type": "string"})
        if p.get("required") or p["in"] == "path":
            required.append(p["name"])
    body = (op.get("requestBody") or {}).get("content", {}).get("application/json")
    if body:
        props["body"] = body["schema"]
        required.append("body")
    return {"type": "object", "properties": props, "required": required}

def build(server, spec):
    for path, item in spec["paths"].items():
        for method, op in item.items():
            if method not in READ:      # writes handled separately
                continue
            name = tool_name(op.get("operationId"), method, path)
            desc = op.get("description") or op.get("summary") or ""
            def handler(arguments, _m=method, _p=path):
                url = BASE + _p
                for k, v in arguments.items():
                    url = url.replace("{" + k + "}", str(v))
                r = httpx.request(_m.upper(), url, params=arguments, timeout=30)
                return r.text[:4000]
            server.tool(name=name, description=desc)(handler)

The handler above is deliberately crude: it passes every argument as a query parameter, which is wrong for path parameters and bodies. Fix that before shipping. The important part is the shape of the mapping, not the twelve lines of URL templating.

Auth, errors and the parts that do not translate cleanly

Auth lives in the server process, not in the tool schema. Give the MCP server the credential (a scoped token, not a user's session), inject it as a header on outbound calls, and never expose it as a tool argument. Agents write tool arguments into conversation history and logs, so credentials passed as arguments leak by design.

Error handling matters because an agent will retry badly. Return a structured message that names the status code and echoes the API's own error body, and mark 4xx failures as non-retryable in the text. A 400 from a bad payload means the agent should reread the schema; a 429 means back off. If you collapse both into "request failed", the agent guesses.

Some OpenAPI features are lossy in the conversion. Pagination is the standard example: a limit/offset pair converts into two optional integer arguments, and the agent has no reason to know that a page size of 10,000 will time out. Multipart uploads, webhooks and streaming responses have no clean tool equivalent (OpenAPI to MCP conversion). Decide explicitly for each: cap the page size in your handler, skip file uploads entirely for now, or add a separate tool that takes a URL instead of raw bytes. Skipping an operation is a legitimate outcome of the conversion, and it protects the rest of the surface.

There is also a context cost. Every tool definition you expose is context the model has to carry. The MCP client guidance notes that hosts connecting to many servers with hundreds of tools can spend the majority of the window on definitions alone, and recommends progressive discovery: a small search tool that returns names, then a details call for the one tool needed (MCP client best practices). For a single internal API with thirty operations you can ignore this; for a suite of ten internal APIs exposed at once, you cannot.

Gate every write behind an approval

The default exposure set should be reads only: GET, HEAD and OPTIONS. Writes go behind a confirmation step. The protocol itself frames tools as model-controlled, invoked with a human in the loop to grant approval (MCP tools documentation). Treat that as a floor, not a ceiling: an approval dialog that says "the agent wants to call update_account" tells a human nothing useful.

The write tool should do this instead:

  1. Plan. Compute the method, the fully resolved URL and the serialized payload from the arguments.
  2. Return the plan. Send back a readable block with the exact method, URL, headers minus the auth header, and the JSON body, plus a one-line summary of what changes.
  3. Require a token. Accept a confirmation argument (a nonce the user prints back, or an approval flag set by a human) and refuse to send without it.
  4. Send once. On success, return the API response; on failure, return the error and do not silently retry.
{
  "tool": "update_account",
  "method": "PATCH",
  "url": "https://api.example.com/v2/accounts/4481",
  "body": { "billing_email": "[email protected]" },
  "effect": "Changes the billing contact for account 4481.",
  "requires_confirmation": true
}

A second decision rule: writes that are idempotent and cheap (adding a label, posting a comment) can be one-step with a dry-run mode; writes that are destructive or financial (DELETE, refunds, plan changes) should require the exact URL and payload to appear in the approval text the human sees. If those two strings are not on screen when the human clicks approve, the gate is theater.

If your write endpoints call a model themselves (a summarizer, a classifier, an embedding step inside your own service), that is a separate credential from the one the MCP server holds. A single prepaid, OpenAI-compatible endpoint for every model type keeps that plumbing small: Quanta Inference covers text, image, video, audio and embedding models through one API key at https://quantaintelligence.ai/inference.

Now fix the spec for its new reader

The spec now has a second reader, and that reader is literal, tireless, and never asks a human for clarification. These edits pay back within a week.

Make operation IDs unique and stable. The operationId must be unique in the document and is case sensitive, and it becomes the generated method or tool name (Speakeasy on OpenAPI best practices). Prefix by resource (accounts_get, accounts_list, invoices_refund) so the agent's tool choice is decidable from the name alone.

Rewrite descriptions to say when to use the operation. A description like "Gets an account" is useless when the API also has accounts_get_by_external_id and accounts_search. Say which one to reach for and which to avoid. API documentation guidance is consistent that reference material must describe what each endpoint is intended to do, not just its method and parameters (Postman on API documentation), and the same standard applies to the tool description because it is the only text the model sees at selection time.

Mark required fields properly. Parameters go in the required array; body properties need required inside the schema. An agent reading an optional-looking field will omit it and get a 422, which is a wasted round trip and a confusing error. The OpenAPI Initiative's guidance to keep a single source of truth matters here: if you generate the spec from code annotations, the required lists must come from the same validation the code runs (OpenAPI best practices).

Use shared component schemas. Inline request body schemas get inferred names and are discouraged by API tooling vendors because those names collide (14 best practices for OpenAPI). A $ref to a named component gives you a clean, reusable argument schema.

Keep examples real and runnable. An example body in the spec becomes the agent's template. Placeholder strings like "string" produce malformed requests.

Spec smellWhat the agent doesFix
Missing operationIdUses a synthesized name that changes on path renameAdd stable IDs, prefix by resource
Vague descriptionPicks the wrong sibling operationAdd "use when..." lines
Unmarked required fieldsOmits them, gets 422Fill required arrays
Inline body schemaReceives a generic objectMove to components/schemas and $ref
Fake examplesCopies them into requestsEdit examples to be runnable

Treat these as review gates. Docs-as-code practice shows the payoff of blocking merges when docs are missing, since documentation tracked in the same pull request as code stays current (Write the Docs on docs as code). Do the same for the spec: a pull request that changes an endpoint and not its description fails review. The UK government's technical writing team reports that running documentation through the same Git workflow as code keeps it in sync with the product and lets anyone contribute corrections (GOV.UK Technology on docs as code).

Validate, regenerate, and put it on the desk of an agent

Three checks decide whether the server is ready.

First, schema validation. Run the spec through a validator in CI before generating the server, so you never ship a tool with a broken argument schema; validators also catch drift between the implementation and the description when run against live responses (OpenAPI best practices). Second, dry-run every read tool against the real API in a staging environment and look at the arguments the agent actually sends, not the arguments you expected. Third, read the tool list the way the model sees it: names, descriptions, schemas, nothing else. If you cannot decide from that list which tool to call for a given task, neither can the agent.

When you are ready to let a coding agent use the server, point it at the local build first. Clients that support progressive discovery will keep the context cost low, and the layered catalog, inspect, execute pattern from the MCP client guidance is worth imitating even in a single-server setup: search the tool catalog, inspect one definition, then execute (MCP client best practices).

If the goal is the other direction, letting a coding agent build and maintain the API around the spec, the skill library at https://quantaintelligence.ai/skills lets you install one into a coding agent so the same generation and review steps run as part of its workflow.

FAQ

FAQ

Do I need to write a separate server for each API?

No, but you should scope each server to one API surface. One spec, one generated server, one credential scope is the pattern that keeps tool names from colliding and keeps approvals meaningful. If you have several internal APIs, generate several servers and let the client aggregate them. A host connecting to many servers at once should adopt progressive discovery, since tool definitions can dominate the context window (MCP client best practices).

Should the agent see response schemas?

Not as tool inputs, but they are useful on your side. Use the response schema in the MCP server to validate the API reply and to truncate large payloads before they reach the model. The tool definition the model receives needs a name, a description and an input schema (MCP tools documentation), so keep the reply handling in the handler.

How do I handle pagination?

Expose page size and cursor as optional integer and string arguments, but clamp the page size server-side. Document the cap in the tool description so the model knows why a large request was trimmed. Pagination is one of the known lossy cases in OpenAPI-to-MCP conversion, along with multipart uploads and streaming (OpenAPI to MCP conversion).

What should the approval prompt show?

The exact method, URL and serialized body, plus a one-line statement of the effect. The protocol expects a human in the loop for tool calls (MCP tools documentation), and an approval that shows only a tool name cannot be checked against intent. Show the strings the request will actually contain.

Does this replace the human docs?

No, it adds a reader. Human-facing reference material still needs authentication instructions, examples and error guidance (Stoplight's API documentation guide), and a well-maintained README and reference remain the fastest way for a new developer to get oriented (UC Berkeley documentation guide). The MCP server is a third consumer of the same source file, which is the strongest argument for treating the spec as the single source of truth (OpenAPI best practices).

Sources

Sources

  1. Tools
  2. Best Practices - OpenAPI Documentation
  3. Converting OpenAPI Specs into MCP Servers, Step by Step
  4. OpenAPI overview and best practices - Speakeasy
  5. Client Best Practices
  6. API Documentation: How to Write, Examples & Best Practices | Postman
  7. 14 Best Practices to Write OpenAPI for Better API Consumption - APIMatic
  8. Docs as Code - Write the Docs
  9. Why we use a 'docs as code' approach for technical documentation
  10. API Documentation Best Practices Guide - Stoplight.io
  11. How to Write Good Documentation: Home - UC Berkeley Library Guides
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles