Skip to content

Repository files navigation

api-mcp

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.yaml

It 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.

Why it exists

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.

Install

go install github.com/rosaldo/api-mcp@latest

Or download a binary for your platform from the releases — Linux, macOS and Windows, amd64 and arm64, with SHA256SUMS to verify them.

Usage

# 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 :8080

In 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": "..." }
    }
  }
}

Authentication

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 | cookie

Dynamic, 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 2h

The token is fetched on the first call, kept in memory and renewed before it expires.

Per-request signatures

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_SECRET

Placeholders: {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 literally

The 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.

A unique id per request

{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.

An API that signs the query

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:

  • --query adds 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-ms because 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-fills drops those parameters from the schema the model sees. A signed API declares them as ordinary parameters — Binance declares timestamp and signature as 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.

Filtering by the method AND the path

--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.

An API on more than one host

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 APIs

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 DELETE

Nested 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.

Uploading a file

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.

GraphQL

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.

What the server says about itself

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.

Media that would not fit

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 ./downloads

The 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.

Sending a file without carrying it

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.

Trimming the surface

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 DELETE

All flags

Every 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

Releasing

./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 them

Both 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.

Documentation

Credits

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.

License

MIT.

About

Point it at an API's specification — OpenAPI 3.x, Swagger 2.0 or GraphQL — and it becomes an MCP server. One tool per operation, no code to write, and no third party holding your credentials.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages