Point it at an API's specification and it becomes an MCP server. One tool per operation, no code to write.
api-mcp --spec https://api.exemplo.com/openapi.yamlIt reads OpenAPI 3.x, Swagger 2.0, GraphQL and Google's Discovery Document — as
JSON, YAML or SDL — from a file,
a URL or stdin. The dialect is detected from the content; --type forces it when detection gets
it wrong.
A JSON document is read with a JSON parser, not as YAML. The two agree almost everywhere — YAML is a superset — except on a key repeated inside the same object: JSON keeps the last one, YAML rejects the file. Published specs do it, and reading JSON as YAML made one repeated key enough to lose the whole document.
A spec fetched over http(s) has two minutes to arrive. Specs are getting large — several megabytes is now ordinary — and the download happens once, when the server starts.
Plenty of APIs have no MCP server, and the ones that do are not always auditable: using a server hosted by a third party means handing it the credentials of the people you serve. With the specification in hand the server needs neither to be written nor trusted to anyone — it is generated, it runs wherever you put it, and the credentials never leave.
go install github.com/rosaldo/api-mcp@latestOr download a binary for your platform from the
releases — Linux, macOS and Windows,
amd64 and arm64, with SHA256SUMS to verify them.
# see what the spec yields, without starting anything
api-mcp --spec ./openapi.yaml --list
# stdio (default) — this is how an MCP client starts the server
api-mcp --spec ./openapi.yaml --auth bearer --bearer "$TOKEN"
# GraphQL: a schema does not say where the API lives, so the endpoint is required
api-mcp --spec ./schema.graphql --endpoint https://api.example.com/graphql
# HTTP, if you would rather have a server running
api-mcp --spec ./openapi.yaml --mode http --addr :8080In an MCP client:
{
"mcpServers": {
"my-api": {
"command": "api-mcp",
"args": ["--spec", "https://api.example.com/openapi.yaml", "--auth", "bearer", "--bearer", "env:MY_API_TOKEN"],
"env": { "MY_API_TOKEN": "..." }
}
}
}Keep secrets out of the arguments. Any value can be read from an environment variable with
the env: prefix — a secret passed directly sits in ps output and in /proc/<pid>/cmdline,
where every other process on the machine can read it:
--bearer env:MY_API_TOKEN # reads $MY_API_TOKEN
--auth-field secret=env:MY_SECRET
--header 'X-Key=env:MY_KEY'An unset variable is an error, not an empty string.
Static, when the token is fixed:
--auth bearer --bearer env:TOKEN
--auth basic --basic env:USER_AND_PASSWORD # the variable holds user:password
--auth apikey --api-key header:X-Api-Key=env:KEY # header | query | cookieDynamic, when the API trades credentials for a short-lived token — the case most tools do not cover, and the one that makes a server work for two hours and then return nothing but 401:
api-mcp --spec ./openapi.yaml \
--auth-url https://api.example.com/authenticate \
--auth-field key=env:API_KEY --auth-field secret=env:API_SECRET \
--auth-token-path data.token \
--auth-ttl 2hThe token is fetched on the first call, kept in memory and renewed before it expires.
Some APIs do not carry a token at all — they sign every call over its own content. Shopee's affiliate API and TikTok Shop's are both like this, and no amount of bearer configuration reaches them: the credential is not a value, it is a computation.
# Shopee: sha256 of appId+timestamp+body+secret, in an Authorization header
--sign sha256 \
--sign-payload '{app_id}{timestamp}{body}{secret}' \
--sign-into 'header:Authorization=SHA256 Credential={app_id}, Timestamp={timestamp}, Signature={signature}' \
--sign-app-id env:APP_ID --sign-secret env:APP_SECRET
# TikTok Shop: HMAC-SHA256 over path+sorted query+body, as a `sign` parameter
--sign hmac-sha256 \
--sign-payload '{path}{query}{body}' \
--sign-into 'query:sign={signature}' \
--sign-app-id env:APP_KEY --sign-secret env:APP_SECRET
# A scheme that signs the verb too, base64-encoded, with an ISO 8601 timestamp
--sign hmac-sha256 \
--sign-payload '{timestamp}{method}{path}{body}' \
--sign-into 'header:X-SIGN={signature}' \
--sign-encoding base64 --sign-timestamp iso8601-ms \
--header 'X-TIMESTAMP={timestamp}' \
--sign-app-id env:API_KEY --sign-secret env:API_SECRETPlaceholders: {app_id} {secret} {timestamp} {method} (the verb, uppercase) {body}
{path} {query} (sorted, k=v joined) and {signature} in --sign-into.
Two shapes vary between APIs and neither announces itself when wrong — both fail as an authentication error that says nothing about format:
| Flag | Values | Default |
|---|---|---|
--sign-encoding |
hex, base64 |
hex |
--sign-timestamp |
unix (seconds), iso8601-ms (2020-12-08T09:08:57.715Z) |
unix |
{timestamp} expands to the same instant in the payload and in --sign-into, so a scheme
that signs the timestamp and also sends it in a header stays consistent. Signing one instant and
announcing another is a signature error that looks like a wrong secret.
--header understands the same placeholders, and gets the same instant the signature used.
Some APIs sign the timestamp and also demand it in a header of its own:
--sign-payload '{timestamp}{method}{path}{query}{body}' \
--sign-into 'header:OK-ACCESS-SIGN={signature}' \
--header 'OK-ACCESS-TIMESTAMP={timestamp}' # expanded, not sent literallyThe two must agree. Filling the header from a second now would make it drift from the signed
one, and an API that checks both rejects the pair — intermittently, which is worse than never
working at all.
{uuid} in a --header becomes a fresh UUID v4 on every call. A family of APIs requires a
unique id on each request — eToro demands x-request-id on all of its operations — and a fixed
value there sends the SAME id forever, which is precisely what such a header exists to prevent:
api-mcp --spec https://api-portal.etoro.com/api-reference/openapi.json \
--header 'x-request-id={uuid}' \
--header 'x-api-key=env:ETORO_API_KEY' \
--header 'x-user-key=env:ETORO_USER_KEY'Unlike {timestamp}, it needs no signature: it is filled for any authentication, including
none.
Some APIs put the timestamp IN THE QUERY and sign the query itself. Binance is the common case:
api-mcp --spec binance.yaml \
--sign hmac-sha256 --sign-payload '{query}{body}' \
--sign-into 'query:signature={signature}' --sign-encoding hex \
--sign-timestamp unix-ms \
--query 'timestamp={timestamp}' \
--server-fills timestamp,signature \
--header 'X-MBX-APIKEY=env:BINANCE_API_KEY'Three parts, each fixing something that fails on its own:
--queryadds a fixed query parameter, expanded like the payload and set BEFORE the signature is computed — so a scheme that signs the query signs it too. Adding it afterwards would leave the server recomputing over a different string.--sign-timestamp unix-msbecause those APIs want milliseconds. With seconds they answer about a stale timestamp and never about the unit, which sends whoever debugs it to look at clocks.--server-fillsdrops those parameters from the schema the model sees. A signed API declares them as ordinary parameters — Binance declarestimestampandsignatureas REQUIRED on 302 operations — and asking the model for an HMAC it cannot compute makes the tool unusable. They are still accepted if sent; they just stop being asked for.
--include-paths / --exclude-paths decide by path, --include-methods / --exclude-methods by
method. Neither expresses "every read, plus the writes of one area" — and that is a common shape
for a connector meant to look without acting.
--exclude-ops matches against METHOD /path, in uppercase with one space, and fills that gap:
api-mcp --spec etoro.json \
--exclude-ops '^(POST|PUT|PATCH|DELETE) /api/v[0-9]+/(trading|posts|money)'That keeps every GET in the API, keeps the writes of watchlists and price alerts, and drops the
ones that place an order or publish a post. By path alone the GETs of trading would go too, and
those are half of what such a connector is for.
OpenAPI 3 allows servers at three levels — document, path, operation — and the closest one to
the operation wins. That is honoured here, because APIs do use it: EvoLink serves generation on
api.evolink.ai and file uploads on files-api.evolink.ai, in a single document. Reading only
the top-level servers sends every file call to the wrong host, which answers 403 with the right
address written in the tool's own description.
--base-url still beats all of them: whoever passes it is naming the destination on purpose,
usually a test environment or a proxy, and an override buried in the document must not divert part
of the traffic out of it.
Google does not publish OpenAPI. They publish a Discovery Document, their own format, at a predictable address — and they publish one for over three hundred services: Drive, Sheets, Calendar, Gmail, YouTube, Search Console, Analytics, Business Profile, and the rest.
api-mcp --spec 'https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest'
api-mcp --spec 'https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest'The address is https://www.googleapis.com/discovery/v1/apis/{api}/{version}/rest; a few services
serve their own, like https://generativelanguage.googleapis.com/$discovery/rest?version=v1beta.
The dialect is detected from the document, so there is nothing to declare.
Trim them. These are large surfaces — Drive is 64 methods, YouTube 83, Gmail 79 — and a connector is usually for one job:
api-mcp --spec '…/drive/v3/rest' --include-paths '^/files' --exclude-methods DELETENested types are bounded by --depth (2 by default). Google's types refer to each other
freely and some refer to themselves: expanded without a limit, a single Gemini
models.generateContent tool comes to about 60 KB of JSON Schema. At the default it is 4 KB, and
what lies past the limit is described as an object with a pointer to the API's own reference —
the model can still send it.
Two details this dialect handles that a generic reader would get wrong: {+name} is reserved
expansion, so a value like models/veo/operations/abc keeps its slashes instead of being
percent-encoded into a 404; and a repeated query parameter is sent repeated, not joined with
commas.
Two fields on a method, both read from the document itself, cover the upload that Google's own documents leave out:
"upload": {
"id": "firebasehosting.sites.versions.files.upload",
"path": "upload/sites/{siteId}/versions/{versionId}/files/{hash}",
"httpMethod": "POST",
"rootUrl": "https://upload-firebasehosting.googleapis.com/",
"mediaUpload": {"accept": ["*/*"]}
}rootUrl on a method beats the document's address for that method only — an upload usually
lives on a host of its own, and Firebase Hosting answers 404 for the same path on the main one.
mediaUpload says the body IS the file: the tool takes a single body argument, sent raw as
application/octet-stream rather than wrapped in JSON. With --blob-in on, body takes
file:<path> and the bytes never pass through the model.
Every Query and Mutation field becomes a tool. Since GraphQL requires the caller to say what
comes back, the selection is assembled automatically: the scalar fields of the return type,
descending two levels (--graphql-depth changes that). When the default does not fit, the tool
takes a _select argument with a hand-written selection.
Arguments travel as GraphQL variables, never interpolated into the query text.
The schema can be SDL or the JSON of an introspection query — useful when all you have is the endpoint.
On connect, the server passes the spec's own info.title and info.description to the client as
its instructions. This is not decoration. Clients that support tool search — the default in Claude
Code — load only tool names and these instructions when a session opens, and keep every
description and parameter schema deferred until the model goes looking. A server that says nothing
about itself is a server the model has no reason to search.
So the description in your spec is doing real work. Put the what for in its first sentence:
info:
title: cobalt
description: >-
Download video, audio and images from social platforms and video sites: youtube, tiktok,
instagram, twitter, facebook, reddit, soundcloud, vimeo and others.Claude Code truncates instructions at 2KB; anything longer is cut on a word boundary here, so
write the part that matters first. A schema with no info — GraphQL SDL, for one — simply sends
no instructions.
Some APIs answer with the file itself, base64'd into the JSON. A single generated image comes back as 1.17 MB in one field, a music clip as 993 KB — around 300,000 tokens for one call, of bytes the model can neither look at nor save.
Point --blob-dir at a directory and those fields go to disk instead:
--blob-dir ./downloadsThe response the model receives keeps its shape; only the oversized field is replaced:
{"saved_to":"downloads/1af9eb0862f4fa94.png","bytes":877657,"mime_type":"image/png"}Measured on a real response: 1,170,912 bytes in, 815 bytes out, with the PNG written whole.
The file is named after its own digest, so generating the same thing twice writes one file rather than two, and two different results never collide. The extension comes from the mime type declared next to the bytes. Strings below 8 KB are left alone, prose is never touched (spaces are not in the base64 alphabet), and a write that fails changes nothing — the model still gets its answer.
Without the flag, nothing here happens.
The same asymmetry runs the other way. To attach a 185 KB PDF to a Gmail message, the argument is the entire RFC 2822 message, base64'd — a quarter of a megabyte the model has to hold and type out. The tool accepts it; the model cannot produce it.
Point --blob-in at a directory and a path becomes the bytes:
--blob-in ~/workspace{"raw": "file:output/report.eml"}The file is read, base64'd and sent; the model never sees the payload. It works on any string
argument, at any depth, and only strings starting with file: are touched.
Reading is confined to that directory, resolved — file:../../etc/shadow and a symlink pointing
outside are both refused, and the argument passes through untouched so the API answers what it
would have answered.
The alphabet follows the dialect: Google declares its format: byte fields as base64URL (Gmail's
discovery document says so on raw), while OpenAPI's format: byte is plain base64.
The server announces this in its instructions when the flag is on, because a capability the model
is not told about does not exist: with --blob-in configured and running, a model that has never
heard of the prefix sends the bare path and the API rejects it.
A large spec becomes dozens of tools, and each one takes up the model's context once the model loads it — with tool search, that happens on demand rather than at session start:
--include-paths '^/v2/(offers|links)' # regexes, comma-separated
--exclude-paths '^/admin'
--include-methods GET,POST
--exclude-methods DELETEEvery flag the binary declares is here — a test compares this table against the real flag set, so neither side can move without the other. The ones about credentials have a section of their own above; this is the index.
| Flag | What |
|---|---|
--spec |
path, file://, http(s):// or - (stdin) |
--type |
openapi | graphql | discovery — forces the dialect |
--base-url |
beats the address declared in the spec |
--endpoint |
GraphQL: where queries go |
--header |
fixed header on every call, name=value (repeatable) |
--depth |
how deep nested types are expanded: GraphQL selections, discovery $ref chains (default 2) |
--graphql-depth |
deprecated alias for --depth |
--blob-dir |
write oversized base64 in responses here, and hand the model the path |
--blob-in |
read file:<path> arguments from this directory, base64 them, and send the bytes |
--mode |
stdio (default) | sse | http |
--addr, --path |
address and path in the network modes |
--list |
list the tools and exit |
--include-paths, --exclude-paths |
comma-separated regexes of paths |
--exclude-ops |
regexes matched against METHOD /path — the method and the path together |
--query |
fixed query parameter, expanded and set before signing (repeatable) |
--server-fills |
parameter names this server supplies — they leave the model's schema |
--include-methods, --exclude-methods |
HTTP verbs to keep or drop |
--auth |
none | bearer | basic | api-key | oauth2 — see Authentication |
--bearer, --basic, --api-key |
the credential itself; env:NAME reads it from the environment |
--auth-field |
where the API key goes: header:Name or query:name |
--auth-url, --auth-token-path |
OAuth2: where to ask for a token, and where it sits in the answer |
--sign |
request signing scheme, for APIs that want a digest rather than a token |
--sign-app-id, --sign-secret |
the pair the signature is built from |
--sign-payload, --sign-into, --sign-encoding, --sign-timestamp |
how the signature is assembled and where it is sent |
./commit.sh feat "what changed" # gate → version bump → CHANGELOG → tag
./push.sh # push the commits and the tag; CI builds and publishes
./push.sh --full # ...or build the binaries here and upload themBoth are shortcuts to scripts/. Pushing a vX.Y.Z tag starts the release workflow, which
cross-compiles and publishes; --full does the same locally, for when the workflow cannot run.
docs/architecture.md— the design and the decisions behind it.
The idea of serving a spec as MCP tools comes from swagger-mcp (MIT), by Danish J Sheikh — the one-tool-per-operation model, the filters and the three transports came from there. Thank you.
MIT.