Skip to main content

mcp-scribe

Point it at an OpenAPI schema. Get an MCP server.

Every operation in the spec becomes a tool a model can call, with the JSON Schema, the credentials, the retries, the rate limiting, and the response shaping already handled.

Install

pip install mcp-scribe

Or from source, as a global CLI:

git clone https://github.com/kyegomez/mcp-scribe && cd mcp-scribe
uv tool install --editable ".[http]"

Deploy

One command. Spec in, server up.

mcp-scribe deploy https://api.swarms.world/openapi.json --port 8000
╭────────────────────────────────────────────────────────────────────────────────────────────────╮
│    ▄  █  ▄     mcp-scribe                                                                      │
│   ▄███████▄    Swarms API 1.0.0  ·  23 tools                                                   │
│  ██▄█████▄██   https://api.swarms.world                                                        │
│  ▀ █▄   ▄█ ▀                                                                                   │
├────────────────────────────────────────────────────────────────────────────────────────────────┤
│  ▸ mcp       http://localhost:8000/mcp                                                         │
│  ▸ health    http://localhost:8000/health                                                      │
│  ▸ auth      caller-supplied (x-api-key), required                                             │
├────────────────────────────────────────────────────────────────────────────────────────────────┤
│  connect a client                                                                              │
│  claude mcp add --transport http swarms http://localhost:8000/mcp --header "x-api-key: <key>"  │
╰────────────────────────────────────────────────────────────────────────────────────────────────╯

deploy is serve with the defaults a shared server wants: HTTP transport, stateless sessions, a 0.0.0.0 bind, a /health probe, and caller credential passthrough switched on automatically when you supply no key of your own. Everything is overridable — --port, --host, --path, --rate-limit, --timeout, --read-only, --include-tag, --stateful.

Hold the credential server-side instead, and passthrough turns itself off:

mcp-scribe deploy https://api.swarms.world/openapi.json \
    --port 8000 --env-file .env --api-key-env SWARMS_API_KEY --api-key-name x-api-key

Install into a client

For a local, personal server, skip the ports entirely:

mcp-scribe install --spec https://api.swarms.world/openapi.json \
    --api-key sk-... --api-key-name x-api-key
Building the server…
  Swarms API 1.0.0 — 23 tool(s) from 25 operation(s)
✓ registered with Claude Code (scope: user)

Server name: swarms_api
Credential parameters hidden from the model: x-api-key
Restart your MCP client to pick up the new server.

It builds the server first and only writes config if that succeeds, so you never register something that fails on first launch. Client configs are merged and backed up, never rewritten. --client takes auto (default), claude-code, claude-desktop, cursor, project (a local .mcp.json), all, or print. --dry-run shows the JSON without touching anything.

Keep the key out of the config

MCP clients launch servers with a bare environment — they inherit no shell variables and expand nothing — so credentials normally get pasted into the client's JSON. Point at an env file instead and the server reads it at startup:

echo 'SWARMS_API_KEY=sk-...' >> .env    # already gitignored

mcp-scribe install --spec https://api.swarms.world/openapi.json \
    --env-file .env --api-key-env SWARMS_API_KEY --api-key-name x-api-key \
    --name swarms

The registered config now holds a path and no secret:

{
  "command": "/abs/path/to/mcp-scribe",
  "args": ["serve", "--spec", "https://api.swarms.world/openapi.json",
           "--env-file", "/abs/path/.env",
           "--api-key-env", "SWARMS_API_KEY", "--api-key-name", "x-api-key"]
}

--env-file and --api-key-env work on serve, deploy, inspect, and call too. Real environment variables win over the file, so an exported value still overrides.

Look before you wire

mcp-scribe inspect --spec https://api.swarms.world/openapi.json
Swarms API 1.0.0
  source:   https://api.swarms.world/openapi.json
  base URL: https://api.swarms.world
  tools:    23 of 25 operations

  check_swarm_types_v1_swarms_available_get   GET  /v1/swarms/available     1 arg(s), 1 required
  run_swarm_v1_swarm_completions_post         POST /v1/swarm/completions   22 arg(s), 1 required
  run_agent_v1_agent_completions_post         POST /v1/agent/completions    7 arg(s), 2 required

That required argument is the API key — this spec declares x-api-key as a required header parameter on every operation, so without a credential the model would have to invent one. Supply it and the parameter disappears:

mcp-scribe inspect --spec https://api.swarms.world/openapi.json \
    --env-file .env --api-key-env SWARMS_API_KEY --api-key-name x-api-key
  check_swarm_types_v1_swarms_available_get   GET  /v1/swarms/available     0 arg(s)
  run_swarm_v1_swarm_completions_post         POST /v1/swarm/completions   21 arg(s)
  run_agent_v1_agent_completions_post         POST /v1/agent/completions    6 arg(s), 1 required

If a credential still shows up in that listing, it isn't wired up.

Debug from the terminal

call runs the exact code path the server uses:

mcp-scribe call run_agent_v1_agent_completions_post \
    --spec https://api.swarms.world/openapi.json \
    --env-file .env --api-key-env SWARMS_API_KEY --api-key-name x-api-key \
    --args '{"task": "What is 2+2?",
             "agent_config": {"agent_name": "calc", "model_name": "gpt-4o-mini"}}' \
    --dry-run
{
  "method": "POST",
  "url": "https://api.swarms.world/v1/agent/completions",
  "query": [],
  "headers": {
    "Accept": "application/json, */*;q=0.1",
    "x-api-key": "<redacted>"
  },
  "body": {
    "agent_config": {"agent_name": "calc", "model_name": "gpt-4o-mini"},
    "task": "What is 2+2?"
  }
}

Drop --dry-run to actually send it.

From Python

import asyncio
from mcp_scribe import Settings, build_server

settings = Settings.model_validate({
    "spec": {"url": "https://api.swarms.world/openapi.json"},
    "auth": [{"type": "api_key", "name": "x-api-key", "api_key": "sk-..."}],
})

async def main():
    app = await build_server(settings)
    try:
        await app.run_stdio()
    finally:
        await app.aclose()

asyncio.run(main())

Serving over HTTP instead, with a health probe:

import asyncio, uvicorn
from mcp_scribe import Settings, build_server

settings = Settings.model_validate({
    "spec": {"url": "https://api.swarms.world/openapi.json"},
    "transport": {"kind": "http", "host": "0.0.0.0", "port": 8000, "stateless": True},
    "passthrough": {"enabled": True, "required": True},
})

async def main():
    app = await build_server(settings)
    config = uvicorn.Config(app.http_app(health=True), host="0.0.0.0", port=8000)
    try:
        await uvicorn.Server(config).serve()
    finally:
        await app.aclose()

asyncio.run(main())

Inspecting the generated tools without starting anything:

import asyncio
from mcp_scribe import Settings, load_toolset

async def main():
    settings = Settings.model_validate({
        "spec": {"url": "https://api.swarms.world/openapi.json"},
        "filters": {"include_tags": ["Agents"]},
    })
    toolset, spec = await load_toolset(settings)
    for tool in toolset.tools:
        required = tool.input_schema.get("required", [])
        print(f"{tool.name:60} {tool.signature:40} {required}")

asyncio.run(main())

Calling one tool directly, no MCP client in the loop:

import asyncio
from mcp_scribe import Settings, load_toolset
from mcp_scribe.runtime.executor import HTTPExecutor

async def main():
    settings = Settings.model_validate({
        "spec": {"url": "https://api.swarms.world/openapi.json"},
        "auth": [{"type": "api_key", "name": "x-api-key", "api_key": "sk-..."}],
    })
    toolset, _ = await load_toolset(settings)
    async with HTTPExecutor(settings, toolset.base_url) as executor:
        output = await executor.call(toolset.get("list_models_v1_models_get"), {})
        print(output.is_error, output.content[0].text[:200])

asyncio.run(main())

Examples: swarms_api.py · swarms_api.yaml · petstore_readonly.py

How it works

spec url  ->  fetch  ->  normalize  ->  lower  ->  tools  ->  serve
                 |           |            |          |          |
              json/yaml   swagger 2.0   internal   JSON      stdio or
              $refs       -> openapi 3   IR        Schema    streamable http
              caching     dialect fixes            2020-12

fetch — url, file, or stdin. JSON or YAML. External $ref documents are collected in one pass and loaded concurrently, so resolution afterwards is synchronous.

normalize — Swagger 2.0 is converted up to OpenAPI 3. nullable: true becomes a type union, boolean exclusiveMinimum becomes a number, allOf of plain objects is flattened. Component schemas land in $defs and are referenced, so recursive models stay finite.

lower — every operation becomes an Operation: parameters with their style/explode rules resolved, one chosen request media type, path-level parameters inherited. Nothing downstream ever touches a raw OpenAPI dict again.

tools — one tool per operation. Two decisions matter here:

  • Credential parameters are hidden. Specs routinely declare the API key as a required header parameter (FastAPI does this by default). Configure the credential and the parameter vanishes from the schema — the runtime injects it. The model is never asked to produce a secret it does not have.
  • Simple bodies are flattened. {"task": "..."} beats {"body": {"task": "..."}} for tool-calling accuracy. Recursive, huge, non-object, or colliding bodies stay nested.

serve — a tool call becomes one HTTP request on a warm connection. Around it: full-jitter exponential backoff honoring Retry-After, a per-host circuit breaker, a token bucket, and a response renderer that returns structured JSON when the API returns JSON and truncates with a hint when it returns 40MB.

What it handles

OpenAPI 3.0 / 3.1, Swagger 2.0 converted up front
$ref, external and recursive prefetched, emitted as $defs
style / explode the full matrix — deepObject, pipeDelimited, matrix, label
bodies json, form, multipart with binary fields, text, octet-stream
auth api key (header/query/cookie), bearer, basic, oauth2 client credentials, static headers
secrets .env files, MCP_SCRIBE_* env vars, ${VAR} in config, never in tool schemas
retries idempotent by default, Retry-After, total wall-clock budget
failure per-host circuit breaker, token bucket, concurrency ceiling
transports stdio, streamable http, /health probe
multi-tenant per-caller credential passthrough with allowlisting

Sharing one server between callers

A stdio server is a personal adapter — one process per user, launched by that user, so the key it holds is the caller's. A shared HTTP server is different: one held key would bill every caller to the operator's account. Passthrough lets each caller carry their own credential.

mcp-scribe deploy $SPEC --port 8000        # passthrough is the default here
Alice ──POST /mcp  x-api-key: sk-alice──▶ server ──x-api-key: sk-alice──▶ upstream API
Bob   ──POST /mcp  x-api-key: sk-bob────▶ server ──x-api-key: sk-bob────▶ upstream API

Clients already speak it:

claude mcp add --transport http swarms https://your-host/mcp --header "x-api-key: sk-..."

Credentials travel as an argument through a single call and are never stored on the shared executor, so concurrent callers cannot cross. They are redacted from logs and from --dry-run output. Give the server its own key and it falls back to that when a caller sends none, so the same binary serves both models.

Only allowlisted headers are forwarded, and transport headers (Cookie, Host, Mcp-Session-Id, …) never are. authorization is not forwarded by default: when the MCP server is itself behind OAuth, that header carries the token minted for this server, and relaying it would hand a third-party API a credential meant for you. Opt in with --passthrough-header authorization when the upstream genuinely expects it.

For a public multi-tenant service, the MCP authorization spec (OAuth 2.1) is the real answer; passthrough is the pragmatic one.

Trimming the surface

Two hundred tools is worse than twenty. Filters compose, and work on every command:

mcp-scribe deploy $SPEC \
    --include-tag Agents --include-tag Swarms \
    --exclude-path '^/internal' \
    --read-only

Ship it

mcp-scribe generate --spec $SPEC -o ./my-server --freeze
my-server/
├── server.py          thin entrypoint
├── config.yaml        every setting, ${VAR} placeholders for secrets
├── openapi.json       vendored by --freeze, so startup needs no network
├── Dockerfile         non-root, slim base
├── requirements.txt
├── mcp.json           paste into a client
├── .env.example
└── README.md          the tool table for this API
cd my-server && docker build -t my-server . && docker run --rm -i --env-file .env my-server

Or containerize the CLI directly:

docker run -p 8000:8000 mcp-scribe \
    deploy https://api.swarms.world/openapi.json --host 0.0.0.0 --port 8000

Configuration

Defaults < config file < environment < flags. Every value in config.py is reachable from all three.

spec:
  url: https://api.swarms.world/openapi.json
  cache_ttl: 3600          # survive a cold start without the network
  refresh_interval: 0      # >0 re-fetches and hot-swaps the toolset

auth:
  - type: api_key
    name: x-api-key
    api_key: ${SWARMS_API_KEY}   # read from the environment at startup

passthrough:
  enabled: false           # forward each caller's own credentials instead
  headers: [x-api-key]
  required: false

http:
  timeout: { read: 300.0, connect: 10.0 }
  http2: true

retry:
  max_attempts: 3
  retry_non_idempotent: false    # never re-send a billable POST
  total_budget: 90.0

rate_limit:
  requests_per_second: 5
  max_concurrency: 8

filters:
  include_tags: [Agents, Swarms]
  read_only: false

schema:
  body_mode: auto          # flatten simple bodies, nest complex ones
  inline_refs: false       # true for clients that cannot follow $ref

response:
  max_bytes: 120000
mcp-scribe deploy --config swarms.yaml --port 8000

Every scalar also has an env var: MCP_SCRIBE_SPEC, MCP_SCRIBE_API_KEY, MCP_SCRIBE_PORT, MCP_SCRIBE_RATE_LIMIT_RPS, MCP_SCRIBE_INCLUDE_TAGS, MCP_SCRIBE_PASSTHROUGH, and so on. MCP_SCRIBE_HEADER_X_TENANT_ID=acme becomes an X-Tenant-Id: acme header on every upstream request.

Notes

  • Tool output is truncated to response.max_bytes with a note telling the model how to narrow the request. Context windows are a resource.
  • outputSchema is opt-in (--output-schema). Clients must reject responses that do not validate against it, and real APIs drift from their specs. Structured content is returned either way.
  • Arguments get light coercion — "5" for an integer, a JSON string for an object — and then the API is the authority. Imperfect specs should not block calls the API would have accepted.
  • With spec.refresh_interval set, the toolset is re-fetched and hot-swapped in place. Clients that cache tool listings pick it up on their next tools/list.
  • Banners go to stderr and disable colour when the output is not a terminal, when NO_COLOR is set, or when TERM=dumb. stdout carries JSON-RPC framing on stdio and stays clean.

Development

poetry install --all-extras --with lint,test
poetry run pytest -q                 # 201 tests
poetry run ruff check src tests && poetry run ruff format --check src tests
poetry run mypy src/mcp_scribe
poetry build

Todo

  • OAuth2 authorization code flow with local callback
  • Credential validation at install time, before writing client config
  • Response streaming for text/event-stream endpoints
  • AsyncAPI and gRPC reflection as additional front ends
  • Prompt generation from operation examples

Citations

@misc{mcpscribe2026,
    title   = {mcp-scribe: production-grade MCP servers from OpenAPI schemas},
    author  = {Gomez, Kye},
    year    = {2026},
    url     = {https://github.com/kyegomez/mcp-scribe}
}
@misc{mcp2024,
    title   = {Model Context Protocol},
    author  = {Anthropic},
    year    = {2024},
    url     = {https://modelcontextprotocol.io}
}

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

mcp_scribe-0.1.0.tar.gz (84.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mcp_scribe-0.1.0-py3-none-any.whl (94.1 kB view details)

Uploaded Python 3

File details

Details for the file mcp_scribe-0.1.0.tar.gz.

File metadata

  • Download URL: mcp_scribe-0.1.0.tar.gz
  • Upload date:
  • Size: 84.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/2.1.3 CPython/3.12.3 Darwin/24.6.0

File hashes

Hashes for mcp_scribe-0.1.0.tar.gz
Algorithm Hash digest
SHA256 77db8347cd809911df73825d6fb6255bacc11ea96d5a9abc2f2fb3b45652a939
MD5 a8ece1e6319ad6fddefab7ae935b3bc1
BLAKE2b-256 7ac0dc63814b470938cb82aba106fe06e23a793d7d656ec617705274380deb08

See more details on using hashes here.

File details

Details for the file mcp_scribe-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: mcp_scribe-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 94.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/2.1.3 CPython/3.12.3 Darwin/24.6.0

File hashes

Hashes for mcp_scribe-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 1cb3702021ee40701db2138f2e9c461366c04861190283037d4bf4f76b00f504
MD5 6f44e3d311484aaabc69992861e8d75d
BLAKE2b-256 de6e2698e5225f126cc35ded17f840fee7f1f9dc944c992e9a3c57937b522fe6

See more details on using hashes here.

Release history Release notifications | RSS feed

0.2.0

2 files

This release

0.1.0 This release

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page