The basic algorithm is short. Load the spec at startup, walk every path and method, and emit a tool per operation.
| OpenAPI element | MCP tool element | Rule |
|---|
operationId | name | Use as-is; fall back to method plus path only when absent |
summary / description | description | Prefer the description; it must say when to use the operation |
| Path, query, header parameters | inputSchema.properties | Path params are required; query params optional unless marked required |
requestBody JSON schema | inputSchema.properties.body | Keep the schema, including required |
examples | Appended to the description | Shows the agent a valid shape |
| Response schema | Not sent to the model | Use it to validate or truncate the reply |
servers[0].url + path | The HTTP target at call time | Templated 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.