DEV Community

AI Dev Hub
AI Dev Hub

Posted on

Turn a 41-endpoint OpenAPI spec into MCP tools in 2026

Turn a 41-endpoint OpenAPI spec into MCP tools in 2026

Map each OpenAPI operation to one MCP tool. Merge its parameters and request body into a single JSON Schema inputSchema, inline every $ref, and sanitize the tool names. A generator does this in seconds, and it keeps your tools in sync with the spec. You still have to prune the result. A model handed 41 endpoint-shaped tools picks the wrong one more often than you'd like.

The OpenAPI to MCP generator I link to below is one I built. I tried four other converters first, and each one either choked on nested $ref chains or produced tool names my client rejected. It's free, it runs in your browser, and it doesn't ask you to sign up or upload your spec to anyone's server. If you know a better one, tell me. I'd honestly rather use it.

Hand-writing MCP tools from a spec stopped working for me

In August 2026 I wanted Claude to poke at our internal billing API. Nothing fancy. Look up an invoice, maybe issue a credit note. The API has an OpenAPI 3.0.3 spec with 41 operations, and I figured I'd wrap the dozen I cared about in an MCP server by hand.

It took me 3 hours and 12 minutes to write 12 tools. Very little of that time went into Python. Most of it was copying parameter definitions out of the spec into JSON Schema and then chasing $ref pointers through components/schemas to find out what a NewInvoice actually contained.

Then the backend team renamed amountCents to amount_cents in the request body.

The spec got updated the same day. My server didn't, because my server was 300-odd lines of hand-copied schemas that nobody except me knew existed. The next morning I found 19 HTTP 422 responses in the logs, every one of them from the model dutifully sending the old field name that my stale inputSchema told it to send. The bug was on line 238. I stared at it for a while, mostly annoyed at myself.

That's the actual problem. An OpenAPI spec already describes every operation and the types of its parameters. An MCP tool definition wants the same information in a slightly different shape: a name, a description, and an inputSchema that's plain JSON Schema. Copying between the two by hand means you've created two sources of truth, and one of them will rot.

I tried the lazy workaround before writing anything serious. A ten-line script that dumped each requestBody schema straight into a tool definition. It broke immediately. Half the schemas were references, so my client received tool schemas pointing at #/components/schemas/NewInvoice, which is a location that doesn't exist inside a standalone tool definition. The model saw an empty object and guessed. Badly.

How the conversion works under the hood

The mapping is less magical than the word "generator" suggests. There are four steps, and once you see them you can sanity-check any converter's output in about a minute.

Walk the paths. Every key under a path item that's an HTTP method is an operation, and each operation becomes one tool. The path item can also carry its own parameters array, which applies to every operation beneath it. Operation-level parameters override those by name and location. Lots of quick scripts miss this, and you end up with a getInvoice tool that has no invoice_id argument.

Build one object schema per operation. Path, query and header parameters become top-level properties. Path parameters are always required (the OpenAPI spec mandates that anyway). I put the request body under a body key so it can't collide with a query parameter that happens to be called id.

Inline every $ref. MCP clients expect a self-contained schema per tool, so references have to be resolved before the definition leaves your hands.

Clean up names. Prefer operationId. Fall back to method plus path. Strip anything outside [A-Za-z0-9_], and cap the length at 64, since OpenAI's function-calling API rejects longer names and I've seen MCP clients refuse names containing slashes or braces.

Here's a minimal version you can run with nothing but the standard library:

import json
import re

SPEC = {
    "openapi": "3.0.3",
    "info": {"title": "Invoices API", "version": "1.4.0"},
    "paths": {
        "/invoices/{invoice_id}": {
            "parameters": [
                {"name": "invoice_id", "in": "path", "required": True,
                 "schema": {"type": "string"}}
            ],
            "get": {
                "operationId": "getInvoice",
                "summary": "Fetch a single invoice",
                "parameters": [
                    {"name": "expand", "in": "query",
                     "schema": {"type": "string", "enum": ["lines", "customer"]}}
                ],
            },
        },
        "/invoices": {
            "post": {
                "summary": "Create an invoice",
                "requestBody": {
                    "required": True,
                    "content": {"application/json": {
                        "schema": {"$ref": "#/components/schemas/NewInvoice"}}},
                },
            }
        },
    },
    "components": {
        "schemas": {
            "NewInvoice": {
                "type": "object",
                "required": ["customer_id", "amount_cents"],
                "properties": {
                    "customer_id": {"type": "string"},
                    "amount_cents": {"type": "integer", "minimum": 1},
                    "memo": {"type": "string"},
                },
            }
        }
    },
}

HTTP_METHODS = {"get", "put", "post", "delete", "patch", "head", "options"}


def resolve(node, spec):
    # Inline local $refs. Circular refs will recurse forever; real tools must guard this.
    if isinstance(node, dict):
        if "$ref" in node:
            target = spec
            for part in node["$ref"].lstrip("#/").split("/"):
                target = target[part]
            return resolve(target, spec)
        return {k: resolve(v, spec) for k, v in node.items()}
    if isinstance(node, list):
        return [resolve(v, spec) for v in node]
    return node


def tool_name(method, path, op):
    raw = op.get("operationId") or (method + path)
    return re.sub(r"[^A-Za-z0-9_]+", "_", raw).strip("_")[:64]


def to_tools(spec):
    tools = []
    for path, item in spec["paths"].items():
        shared = resolve(item.get("parameters", []), spec)
        for method, op in item.items():
            if method not in HTTP_METHODS:
                continue
            params = {(p["name"], p["in"]): p for p in shared}
            for p in resolve(op.get("parameters", []), spec):
                params[(p["name"], p["in"])] = p
            props, required = {}, []
            for (name, location), p in params.items():
                schema = dict(p.get("schema", {"type": "string"}))
                if p.get("description"):
                    schema["description"] = p["description"]
                props[name] = schema
                if p.get("required") or location == "path":
                    required.append(name)
            body = resolve(op.get("requestBody", {}), spec)
            body_schema = body.get("content", {}).get("application/json", {}).get("schema")
            if body_schema:
                props["body"] = body_schema
                if body.get("required"):
                    required.append("body")
            tools.append({
                "name": tool_name(method, path, op),
                "description": op.get("summary") or f"{method.upper()} {path}",
                "inputSchema": {"type": "object", "properties": props, "required": required},
            })
    return tools


if __name__ == "__main__":
    for tool in to_tools(SPEC):
        print(json.dumps(tool))

# Output:
# {"name": "getInvoice", "description": "Fetch a single invoice", "inputSchema": {"type": "object", "properties": {"invoice_id": {"type": "string"}, "expand": {"type": "string", "enum": ["lines", "customer"]}}, "required": ["invoice_id"]}}
# {"name": "post_invoices", "description": "Create an invoice", "inputSchema": {"type": "object", "properties": {"body": {"type": "object", "required": ["customer_id", "amount_cents"], "properties": {"customer_id": {"type": "string"}, "amount_cents": {"type": "integer", "minimum": 1}, "memo": {"type": "string"}}}}, "required": ["body"]}}
Enter fullscreen mode Exit fullscreen mode

Look at the second tool. No operationId, so it fell back to post_invoices, and the $ref got replaced by the real schema, minimum: 1 included. That inlined constraint is what would've saved me from the 422s, because regenerating from the updated spec picks up the rename for free.

The script skips plenty of things. Circular references will recurse until Python gives up. Two parameters with the same name in different locations (a path id and a query id) will clobber each other. Non-JSON bodies are silently dropped. Those edge cases are the boring 80% of the work, and they're the reason I stopped extending my script and built the OpenAPI to MCP generator instead. You paste a 3.x spec, get one tool per endpoint with its input schema filled in, and export it as MCP config JSON, a TypeScript server, or a Python server.

How it compares to the other options

I've used all four of these on real specs. None of them is wrong. They're aimed at different moments in a project.

Approach Output When schemas get built Keeping up with spec changes Where it fits
Hand-written tools Whatever you write Once, by you Only if you remember Three or four tools you'll tune heavily
FastMCP from_openapi Python server At server startup, from the spec Restart with the new spec You're already in Python and want it live fast
openapi-mcp-generator (npm CLI) TypeScript project At generation time Re-run the CLI You want a full Node project to own and edit
aidevhub OpenAPI to MCP Config JSON, TypeScript or Python At generation time, in the browser Paste the spec again Inspecting and pruning tools before writing code

FastMCP's approach is the one I'd pick for a quick internal prototype, since there's no generated code to keep in sync at all. The trade-off is that you can't easily see or edit the definitions before the model does. Last time I read the FastMCP docs, they themselves suggested that curated servers perform better than auto-converted ones, which matches my experience.

The npm CLI is good when you know you'll be living in that codebase for months. My tool sits earlier in the process. I use it to look at what 41 operations turn into, delete the 29 I don't want, and only then commit to code.

When not to use it

I'll be blunt here, because converter marketing usually skips this part.

Your spec is huge. A 300-operation spec becomes 300 tools. Every definition eats context, and tool selection gets noticeably worse as the list grows. I don't have a clean threshold. Somewhere past 30 or 40 tools I started seeing the model call listInvoices when it meant searchInvoices. Generate everything, then cut hard.

Your tasks don't match your endpoints. "Refund the last failed charge for this customer" is three API calls. Exposing those three endpoints and hoping the model chains them correctly works maybe most of the time. One hand-written tool called refund_last_failed_charge works every time. A converter gives you endpoint-shaped tools, and sometimes you need task-shaped ones.

The endpoint is destructive. DELETE /customers/{customer_id} will happily become a tool. Remove it from the list, or put a confirmation step in front of it. Please don't let a generator decide your blast radius.

Auth needs a human in the loop. If the API requires an OAuth consent flow per user, a generated server gives you a place to put a token. It doesn't give you a consent screen. You'll be writing that part yourself.

The spec is wrong. This one bit me last Thursday. A spec that's out of date produces tools that are wrong with total confidence, and the model has no way to know. The generator makes drift cheaper to fix, but only if somebody keeps the spec honest.

FAQ

Q: Does it work with Swagger 2.0 files?

A: It expects OpenAPI 3.x. If you've got a 2.0 file, convert it first (the swagger2openapi npm package does this in one command), then paste the result.

Q: Why one tool per endpoint instead of one tool per resource?

A: It's the most predictable mapping, and it keeps each inputSchema small and specific. Merging endpoints into resource tools usually means a mode or action argument, which models fill in less reliably. You can always combine tools by hand after generating.

Q: Is my spec sent anywhere?

A: No. The conversion runs client-side in your browser, so internal specs never leave your machine. Check the network tab if you'd like to confirm it yourself; I would.

Q: Do I still need to edit the output?

A: Almost always. Rewrite the descriptions so they say when to use each tool, delete the endpoints the model shouldn't touch, and regenerate whenever the spec changes. Ten minutes of pruning beats three hours of copying schemas.

Written with AI assistance and human review. Try the tool at aidevhub.io/openapi-to-mcp.

Top comments (0)