Skip to content

MCPcast a Real API — Recipes

promptise mcpcast turns an OpenAPI 3.x or Swagger 2 document into a curated, safe, agent-ready MCP server. This page is the proof that it survives contact with the specs people actually have: an 8 MB payments API, a 12.9 MB REST API with 1,225 operations, legacy Swagger 2.0 documents with formData bodies and no servers block, and the /openapi.json your own FastAPI app already serves.

Every command, count and message below was produced by running the tool. Where a real spec makes it do something surprising, the recipe says so and shows the fix. Console and YAML excerpts are trimmed with …; counts, names and messages are verbatim.

How these numbers were measured

Promptise 1.1.1, Python 3.12.4, Apple M3, macOS 26.2, cold spec on disk. Pipeline is load_spec → extract_operations → build_plan → write_project → import server.py timed in-process; CLI is the wall clock of the whole promptise mcpcast … command, of which ~2.6 s is Python interpreter start plus imports (promptise --help alone takes 2.60 s on this machine). All runs used --no-curate, so they are deterministic and offline — re-run them and you get the same tool counts.

petstore.swagger.io and petstore3.swagger.io were unreachable from the measuring machine at the time, so the Petstore rows use local copies of those documents — the v2 one straight from the swagger-api repository — rather than the live hosts. The Netlify document is the Swagger 2.0 one published on apis.guru. Every spec is the vendor's own; none was edited before measuring, except where a recipe explicitly narrows one with jq.

Results across real specs

API Spec Operations Profile Tools Approval-gated Not exposed Pipeline CLI
GitHub v3 REST OpenAPI 3.0.3, 12.9 MB 1,225 full 1,185 562 40 2.84 s 4.26 s
GitHub v3 REST OpenAPI 3.0.3, 12.9 MB 1,225 read-only 623 0 602 1.39 s —
Stripe (2026-08-26.dahlia) OpenAPI 3.0.0, 8.0 MB 594 full 587 326 7 2.71 s 4.62 s
Stripe OpenAPI 3.0.0, 8.0 MB 594 read-only 261 0 333 0.94 s —
Twilio Api 1.0.0 OpenAPI 3.0.1, 1.9 MB 197 full 197 94 0 0.62 s 3.00 s
Netlify 2.15.0 Swagger 2.0, 0.13 MB 120 standard 101 47 19 0.13 s —
Swagger Petstore v2 Swagger 2.0, 23 KB 20 standard 16 9 4 0.05 s —
Swagger Petstore v3 OpenAPI 3.0.4, 17 KB 19 standard 15 7 4 0.03 s —
Notes API (FastAPI 0.141) OpenAPI 3.1.0, 6 KB 5 standard 4 1 1 0.03 s —

Why operations disappear, across all of the above:

Reason recorded in plan.dropped Where it happened
write operation excluded by profile 'read-only' 362 GitHub, 158 Stripe, 56 Twilio
destructive operation excluded by profile 'read-only' 193 GitHub, 52 Stripe, 34 Twilio
financial operation excluded by profile 'read-only' 116 Stripe, 7 GitHub, 4 Twilio
deprecated in spec 38 GitHub, 6 Stripe, 1 Petstore v2
unsupported by mcpcast: unsupported request body media type … multipart/form-data: Stripe PostFiles · text/plain, text/x-markdown: GitHub markdown_render_raw · application/octet-stream: GitHub repos_upload_release_asset, Petstore v3 uploadFile

Two things to read out of that table. First, the profile is the lever, not the spec: the same GitHub document is a 623-tool read-only surface or a 1,185-tool everything surface depending on one flag. Second, nothing vanishes silently — every one of those 602 GitHub omissions is a line in mcpcast.plan.yaml with a reason you can grep.


Recipe: Stripe

The Stripe spec is the hard case on purpose: 594 operations, 8 MB of JSON, form-encoded bodies everywhere, and money words in half the path names.

What the classifier does with it

promptise mcpcast stripe.json --no-curate --profile read-only -o stripe-mcp
Parsed 594 operations from stripe.json; profile=read-only auth=passthrough
╭───────────────────────── mcpcast ─────────────────────────╮
│ stripe → stripe-mcp/                                     │
│   tools: 261  (0 require human approval)                 │
│   not exposed: 333 operations (with reasons in the plan) │
│   files: stripe_mcp/ (36 modules), tests/ (2), server.py, README.md,           │
│   pyproject.toml, Dockerfile, .env.example, .gitignore                          │
╰──────────────────────────────────────────────────────────────────────────────────╯

The risk classification of all 594 operations:

Class Count What it means for you
read 261 generated under every profile, never gated
write 163 needs --profile standard, always approval-gated
financial 116 needs --profile full
destructive 54 needs --profile full

--profile standard on Stripe gives you 419 tools — 261 reads plus 158 gated writes — and still refuses every one of the 116 financial and 54 destructive operations (175 omissions in total): PostCharges, PostRefunds, PostPaymentIntentsIntentCapture, DeleteSubscriptionsSubscriptionExposedId. That is the point of the default: you have to type --profile full before an agent can move money, and even then every such tool carries requires_approval=True.

Why you want --max-tools and curation

261 read tools is not an agent-ready surface; it is a token bill. But the whole Stripe spec is too big to hand to a model:

$ promptise mcpcast stripe.json --profile standard -o stripe-mcp
Parsed 594 operations from stripe.json; profile=standard auth=passthrough
Curating with openai:gpt-5-mini (budget 25 tools)…
Error: the curation prompt for 593 operations is 925,263 characters (limit 600,000); narrow the spec first (curate one tag or path prefix at a time, or use --no-curate and edit the plan)

That is the curation prompt limit (MAX_PROMPT_CHARS, 600,000 characters) doing its job: the catalogue is re-sent in full on every retry, so a 900k-character prompt would be a very expensive way to fail. Narrow the spec to the surface you actually want an agent to have — one product area at a time:

jq '.paths |= with_entries(select(.key | startswith("/v1/subscriptions")))' \
   stripe.json > stripe-subs.json

promptise mcpcast stripe-subs.json \
  --profile full --max-tools 6 --auth env-token --name stripe -o stripe-mcp

Nine operations went in. Six intent tools came out:

Parsed 9 operations from stripe-subs.json; profile=full auth=env-token
Curating with openai:gpt-5-mini (budget 6 tools)…
╭──────────────────────── mcpcast ────────────────────────╮
│ stripe → stripe-mcp/                                   │
│   tools: 6  (5 require human approval)                 │
│   not exposed: 0 operations (with reasons in the plan) │
╰────────────────────────────────────────────────────────╯
Tool Risk Approval Upstream operations
find_subscriptions read — GET /v1/subscriptions/{id}, GET /v1/subscriptions/search, GET /v1/subscriptions
create_subscription financial required POST /v1/subscriptions
update_subscription financial required POST /v1/subscriptions/{id}
cancel_subscription destructive required DELETE /v1/subscriptions/{id}
remove_subscription_discount destructive required DELETE /v1/subscriptions/{id}/discount
change_subscription_billing financial required POST /v1/subscriptions/{id}/migrate, POST /v1/subscriptions/{id}/resume

Three reads collapsed into one find_subscriptions whose description tells the model how to pick a route, and the risk classes came through untouched — curation may escalate a class, never relax it, so no amount of model creativity turns that DELETE into a read.

What the plan looks like

The generated cancel_subscription carries its hidden-parameter defaults in the description, so whoever approves the call knows what will actually be sent:

    # -- cancel_subscription (destructive, requires approval) --------------------
    @server.tool(
        name='cancel_subscription',
        description='Cancel a subscription immediately. …\n\n'
                    '  - subscription_exposed_id (string, required): The subscription ID to cancel (sub_...).\n'
                    '  - invoice_now (boolean): …; default False\n\n'
                    'Always sends: expand=[]\n\n'
                    'Example: {"subscription_exposed_id": "sub_1K0eX2AbCdEfGh", …}',
        tags=['cancel', 'subscriptions'],
        destructive_hint=True,
        requires_approval=True,
    )

What to edit

  • The --max-tools budget, then regenerate. A budget without curation is blunt: --no-curate --profile read-only --max-tools 40 keeps reads first and then document order, which on Stripe means all 40 tools come from /v1/account* (get_account, get_accounts_account_capabilities, get_accounts_account_persons_person, …) and nothing from /v1/customers*.
  • The tool names. find_subscriptions is good; get_quotes_quote_pdf is not. Rename it in mcpcast.plan.yaml and run promptise mcpcast mcpcast.plan.yaml — the plan file is left untouched, including your comments, and the package, server.py, tests/ and README.md are regenerated next to it.

Gotcha — PostFiles is dropped; the other files.stripe.com route is not

POST /v1/files takes a multipart/form-data body, and the generated client speaks JSON and application/x-www-form-urlencoded only, so it is recorded as reason: 'unsupported by mcpcast: unsupported request body media type multipart/form-data' rather than emitted as a tool that could never work. File upload stays a hand-written tool: put it on a server of your own and mount() the generated one next to it.

Stripe declares an operation-level servers block on two routes, and the second one — GET /v1/quotes/{quote}/pdf — is a read, so it survives even under read-only and the plan records the override per route rather than rewriting the API base:

- operation_id: GetQuotesQuotePdf
  method: GET
  path: /v1/quotes/{quote}/pdf
  base_url: https://files.stripe.com
Passing --base-url forces every route to your value, this override included — useful against a mock, wrong against production.

Gotcha — a risk call you should override

POST /v1/apple_pay/domains is classified financial because the classifier sees pay; registering a domain moves no money. Classification is deliberately blunt and always errs upward, so the fix is yours: move the operation out of dropped into a tool with risk: write and regenerate. The plan schema enforces only that the risk you write is legal for the profile — it will not let you mark it read and dodge the approval gate under standard.


Recipe: GitHub

1,225 operations in one 12.9 MB document. The whole pipeline runs in 2.84 s and emits a 2.7 MB server.py that imports in 1.22 s, so size is not the problem. Tool selection is.

promptise mcpcast github.json --no-curate --profile full -o github-mcp
Parsed 1225 operations from github.json; profile=full auth=passthrough
╭──────────────────────── mcpcast ─────────────────────────╮
│ github → github-mcp/                                    │
│   tools: 1185  (562 require human approval)             │
│   not exposed: 40 operations (with reasons in the plan) │
╰─────────────────────────────────────────────────────────╯

Do not ship that. No model picks correctly from 1,185 near-duplicate tools, and the tool list alone would dominate the context window. Two things fix it, and only one of them is --max-tools.

The budget is not curation

promptise mcpcast github.json --no-curate --profile read-only --max-tools 40 -o github-mcp

You get 40 tools and 1,185 dropped, each with:

  reason: over tool budget (max_tools=40); raise --max-tools or let curation
    pick the most useful tools

But the budget keeps reads first and then document order, so the 40 you get are meta_root, security_advisories_list_global_advisories, agent_tasks_list_tasks_for_repo… — an arbitrary slice, not a product. The budget is a safety valve, not a design.

Narrow, then curate

Cut the spec down to the surface an agent should have. GitHub's spec exceeds the curation prompt limit by a factor of two and a half — "the curation prompt for 1223 operations is 1,546,141 characters (limit 600,000)" — so this step is mandatory, not optional:

jq '.paths |= with_entries(select(.key | startswith("/repos/{owner}/{repo}/issues")))' \
   github.json > github-issues.json

promptise mcpcast github-issues.json --no-curate --profile standard --review \
  --auth env-token --name github -o github-mcp

48 operations → 37 tools (18 approval-gated), 11 destructive ones refused by the standard profile. --review prints the kept/dropped tables and waits for a yes before writing anything.

37 is still too many for one product surface, so hand the same slice to a model:

promptise mcpcast github-issues.json --profile standard --max-tools 12 \
  --auth env-token --name github -o github-mcp
Parsed 48 operations from github-issues.json; profile=standard auth=env-token
Curating with openai:gpt-5-mini (budget 12 tools)…
╭──────────────────────── mcpcast ─────────────────────────╮
│ github → github-mcp/                                    │
│   tools: 9  (6 require human approval)                  │
│   not exposed: 36 operations (with reasons in the plan) │
╰─────────────────────────────────────────────────────────╯

find_issues, get_issue, create_issue, update_issue, find_comments (three routes behind one tool), create_issue_comment, update_comment, pin_comment, add_reaction (two) — nine tools where the deterministic plan had 37. Note what the model did not merge: update_issue, update_comment and pin_comment all take the same identifiers, and a tool cannot tell such routes apart, so they stay separate. The 36 omissions carry the model's own reasoning, which is exactly the part a human should audit (these reasons are from a --model openai:gpt-5 run of the same slice — wording varies by model and by run):

dropped:
- operation_id: issues_pin_comment
  reason: Niche moderation action; most agents don't need to pin comments.
- operation_id: issues_check_user_can_be_assigned_to_issue
  reason: Validation helper; an agent can attempt to add assignees and handle 4xx responses.
- operation_id: issues_add_blocked_by_dependency
  reason: Dependency management is an advanced workflow outside common agent tasks.

Gotcha — a CRUD-dense write surface can still be rejected

A tool dispatches to the first of its operations whose required parameters were all supplied, so operations it merges must be tellable apart. issues_update, issues_lock, issues_add_labels and issues_set_labels all take exactly owner, repo and issue_number: merged into one tool, only the first could ever be reached. The curation prompt states this rule and the post-condition enforces it, so a proposal that breaks it is sent back with the reason — and if three attempts cannot fix it, the run stops rather than shipping a tool with a dead route:

Error: curation failed after 3 attempt(s): curation proposal rejected:
- tool 'manage_issues': operation 'issues_add_labels' can never be selected because
  'issues_remove_all_labels' (listed earlier) needs a subset of its parameters;
  list the more specific operation first
Re-run with --no-curate for a deterministic one-tool-per-operation plan.
Reads collapse cleanly because their required parameters differ (an id, a query, nothing); identical-signature writes do not, and a good proposal leaves them separate — which is what the run above did. If you do hit the wall, raise --max-tools, narrow the slice, try a stronger --model, or use --no-curate and merge by hand.

Gotcha — the one per-route host is also the one dropped upload

GitHub declares exactly one operation-level servers block, on POST /repos/{owner}/{repo}/releases/{release_id}/assets → https://uploads.github.com. That same operation takes an application/octet-stream body, so it is dropped as unsupported by mcpcast: unsupported request body media type application/octet-stream. Uploading a release asset stays a hand-written tool.

Gotcha — escalation depends on what the spec actually declares

Two GitHub operations escalate on the path rule, because a segment is admin (or its plural):

GET  /repos/{owner}/{repo}/branches/{branch}/protection/enforce_admins   read  → write
POST /repos/{owner}/{repo}/branches/{branch}/protection/enforce_admins   write → destructive
So that GET disappears from a read-only server — correct, since it reads an admin-enforcement setting. The other 31 escalations are all deprecated: true. Zero come from OAuth scopes, because GitHub's OpenAPI document declares no security blocks at all: scope escalation only fires on specs that actually carry security: [{oauth: [admin:org]}]. Check the risks in the plan against your own expectations rather than assuming a scope rule protected you.


Recipe: a legacy Swagger 2.0 API

Swagger 2.0 documents are still everywhere, and they differ from OpenAPI 3 in exactly the places that matter here: no servers block, a whole-body in: body parameter, in: formData fields, and schemes that may well say http.

The canonical Swagger Petstore v2 (20 operations) shows all four:

promptise mcpcast petstore-v2.json --no-curate --profile standard \
  --base-url https://petstore.swagger.io/v2 -o petstore2-mcp
Parsed 20 operations from petstore-v2.json; profile=standard auth=passthrough
╭──────────────────────── mcpcast ────────────────────────╮
│ petstore → petstore2-mcp/                              │
│   tools: 16  (9 require human approval)                │
│   not exposed: 4 operations (with reasons in the plan) │
╰────────────────────────────────────────────────────────╯
dropped:
- operation_id: findPetsByTags
  reason: deprecated in spec
- operation_id: deletePet          # …and deleteOrder, deleteUser
  reason: destructive operation excluded by profile 'standard'

What the plan looks like

Swagger 2's whole-body in: body parameter is flattened into named parameters — add_pet exposes name, status, photoUrls, category, tags, not an opaque body blob — and in: formData fields become body parameters with body_encoding: form:

- name: update_pet_with_form
  description: Updates a pet in the store with form data
  risk: write
  routes:
  - operation_id: updatePetWithForm
    method: POST
    path: /pet/{petId}
    params:
      petId:
        location: path
        required: true
      name:
        location: body
      status:
        location: body
    body_encoding: form
  params:
    petId:
      json_schema:
        type: integer
      required: true
    name:
      description: Updated name of the pet
    status:
      description: Updated status of the pet
  example:
    petId: 1
  requires_approval: true

File uploads are recognised in both dialects and left out. Swagger 2 spells one as a formData parameter of type: file (OpenAPI 3 spells the same thing as a multipart/form-data request body, which is how Stripe's PostFiles is dropped), and the generated server cannot send either:

dropped:
- operation_id: uploadFile
  reason: 'unsupported by mcpcast: unsupported request body media type multipart/form-data'

What to edit

Gotcha — schemes: [http] becomes your base URL

With no --base-url, the base is assembled from schemes[0], host and basePath, which for this document is http://petstore.swagger.io/v2 — plain HTTP, because that is what the spec lists first. Always pass --base-url https://… for a Swagger 2 document, or set MCPCAST_BASE_URL at run time: the generated server reads it — BASE_URL = os.environ.get("MCPCAST_BASE_URL", '…').rstrip("/").

Gotcha — username twice

PUT /user/{username} takes username in the path and username in the body. Path wins the plain name; the body one is exposed as body_username with wire_name: username and is still sent under its real name. Rename it to something an agent understands (new_username) in the plan before you ship.

A larger real example behaves the same way: the Netlify 2.15.0 Swagger 2.0 document (120 operations, host: api.netlify.com, basePath: /api/v1) needs no --base-url at all — host + basePath + schemes: [https] resolve to https://api.netlify.com/api/v1 — and yields 101 tools with 47 gated under standard, 18 destructive operations refused, plus one classified financial that is not: PUT /dns_zones/{zone_id}/transfer matched the money word transfer. Override it in the plan.


Recipe: your own FastAPI (or Django) app

This is the recipe most people actually need. FastAPI already serves the spec at /openapi.json, so there is nothing to export.

# app.py — a real, if small, FastAPI service
app = FastAPI(title="Notes API", version="1.0.0")

@app.get("/notes", operation_id="list_notes", summary="List notes")
async def list_notes(tag: str | None = None) -> list[dict]:
    """Return every note, optionally filtered by tag."""

@app.post("/notes/search", operation_id="search_notes", summary="Search notes")
async def search_notes(query: Query) -> list[dict]:
    """Full-text search over notes."""

# …plus get_note (GET), create_note (POST) and delete_note (DELETE)

Run it, then point mcpcast at the URL:

uvicorn app:app --port 8000 &
promptise mcpcast http://127.0.0.1:8000/openapi.json \
  --no-curate --profile standard --auth env-token --review -o notes-mcp
Parsed 5 operations from http://127.0.0.1:8000/openapi.json; profile=standard auth=env-token
                                Tools (4) — profile standard
┏━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
┃ Tool         ┃ Risk  ┃ Approval ┃ Operations       ┃ Params           ┃ Description      ┃
┡━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
│ list_notes   │ read  │ —        │ GET /notes       │ tag              │ List notes. …    │
│ create_note  │ write │ required │ POST /notes      │ title, body, tag │ Create a note. … │
│ get_note     │ read  │ —        │ GET /notes/{no…} │ note_id          │ Get a note. …    │
│ search_notes │ read  │ —        │ POST /notes/sea… │ text             │ Search notes. …  │
└──────────────┴───────┴──────────┴──────────────────┴──────────────────┴──────────────────┘

A second table, Not exposed (1), lists delete_note — destructive operation excluded by profile 'standard' — and then --review waits for a yes. Three things happened there that are worth naming.

The base URL came from the spec URL. FastAPI emits no servers block, so mcpcast resolved the API base against where it fetched the document: base_url: http://127.0.0.1:8000. The same holds for a relative servers: [{url: /api/v1}] (Django REST framework's generators and FastAPI behind a root_path both emit one) — fetched over HTTP it resolves, read from a file it does not.

POST /notes/search is a read. The leading-verb rule saw search at the front of the operation id with no mutating verb anywhere, so search_notes is risk: read and ungated while POST /notes is a gated write. HTTP method alone would have got this wrong.

--auth env-token is the right mode for a personal server. Claude Desktop, Claude Code and Cursor launch a server over stdio and cannot send headers, so the default passthrough mode would fail every call with UPSTREAM_AUTH_MISSING; env-token presents the one credential in MCPCAST_UPSTREAM_TOKEN upstream on every call.

Let the model design the surface

Five operations is under the default 25-tool budget, so curation is free to merge rather than cut:

promptise mcpcast http://127.0.0.1:8000/openapi.json \
  --profile standard --auth env-token -o notes-mcp
Curating with openai:gpt-5-mini (budget 25 tools)…
│ notes → notes-mcp/                                     │
│   tools: 2  (1 require human approval)                 │

Four tools became two, because three of them were one intent:

- name: find_notes
  description: Read notes. Use this to fetch a single note by id, perform a full-text
    search, or list notes (optionally filtered by tag). …
  risk: read
  routes:
  - {operation_id: get_note,     method: GET,  path: /notes/{note_id}}
  - {operation_id: search_notes, method: POST, path: /notes/search}
  - {operation_id: list_notes,   method: GET,  path: /notes}
  params:
    note_id:
      description: ID of the note to fetch. When provided, the tool returns that single
        note and ignores text/tag. (required for the get_note operation; …)
    text: {description: Full-text substring to search for. …}
    tag:  {description: Optional one-word tag to filter list results …}

A real build_agent() over the real stdio transport picks the right route every time — the FastAPI access log (right) says which:

Q: List every note, just the titles.     GET  /notes         200 OK
A: Ship the docs, Buy milk

Q: Search the notes for 'milk' and       POST /notes/search  200 OK
   tell me the note id.
A: The note ID is n2. (Title: "Buy milk")

Score it

export MCPCAST_EVAL_AUTHORIZATION='Bearer <token>'   # so live reads are authenticated
promptise mcpcast http://127.0.0.1:8000/openapi.json \
  --no-curate --profile standard --auth env-token --eval --eval-tasks 6 -o notes-mcp
Evaluating with openai:gpt-5-mini (6 tasks)…
Agent Readiness: A  (5/6 tasks succeeded)
  • `list_notes` has no example — agents lean on examples heavily
Report: notes-mcp/eval/report.md  Tasks: notes-mcp/eval/tasks.yaml

eval/report.md records score 0.90, correct tool selected first 100%, parameter error rate 0%, no tool unused and none uncovered. The one failure is instructive: the generated task said "Open the note with id 123", get_note is a read, and reads run against the live API — so the agent called the right tool and the API correctly answered 404. Writes and deletes never touch anything real; they hit spec-derived mocks behind an auto-approver. The split is by risk class, not HTTP method.

Gotcha — set operation_id on every FastAPI route

Without it, FastAPI derives operation ids from the function name, path and method, and those become your tool names verbatim:

list_notes_notes_get   create_note_notes_post   get_note_notes_note_id_get
search_notes_notes_search_post
Either pass operation_id= per route (as above), set generate_unique_id_function on the app, or rename the tools in mcpcast.plan.yaml and regenerate. This is the single highest-value edit for a FastAPI app — tool names are most of what the model has to choose from.

Django, Flask, Express, Rails

Anything that can produce an OpenAPI document works the same way: drf-spectacular (/api/schema/), flask-smorest, swagger-jsdoc, rswag. Save it to a file or point mcpcast at the URL. If the generator emits a relative servers entry and you are working from a file, pass --base-url.


When the spec fights back

Every message below is what the tool actually prints. All of them exit 1 (MCPcastError) except the option errors, which exit 2.

A relative servers URL, read from a file.

Error: could not build plan: 1 validation error for ApiPlan
base_url
  Value error, base_url must start with http:// or https:// (got '/api/v3'); the spec
  declares a relative server URL '/api/v3'; pass --base-url https://<api-host>/api/v3

Fix: --base-url https://petstore3.swagger.io/api/v3 — or fetch the spec over HTTP instead of reading the file, in which case the relative URL resolves against the spec URL automatically.

A server variable with no default — Error: server URL 'https://{region}.api.example.com/v1' has a variable with no default; pass --base-url with the resolved host. OpenAPI requires a default on every server variable; when one is missing there is nothing to guess, so the run stops.

No servers block at all. Same validator, (got ''), same fix — except for Swagger 2, where host + basePath + schemes[0] are used instead, and for a spec fetched over HTTP, where the spec URL's origin is used.

A webhooks-only OpenAPI 3.1 document — Error: spec declares only webhooks (calls the API sends to you); there are no operations an agent can call. Webhooks are calls your API makes to someone; there is nothing for an agent to invoke. If you want an agent to receive them, that is a WebhookTrigger on an agent process, not an MCP tool.

An unsupported request-body media type. Not an error — a recorded drop: reason: 'unsupported by mcpcast: unsupported request body media type text/plain, text/x-markdown' (GitHub's markdown_render_raw). The generated client sends JSON or application/x-www-form-urlencoded; anything else is dropped rather than emitted as a tool that could never succeed. If the operation matters, write it by hand and mount it alongside.

Name collisions. A path parameter and a body field with the same name: path keeps the plain name, the later one is exposed as <location>_<name> and still sent under its wire name — username / body_username, name / body_name (five GitHub operations do this). Rename the exposed one in the plan if it is confusing.

Operations with no operationId. Legal, and common in hand-written specs. The id is synthesised from method and path and sanitised: GET /items → get_items, POST /items → post_items, duplicates getting _2, _3 suffixes. Python keywords, invalid identifiers and the names the generated module reserves (approvals_list, approvals_decide, server, upstream, str, …) are renamed or rejected rather than emitted into broken code. Set real operationIds if you can — they are your tool names.

Deprecated operations. Always dropped, under every profile, with reason: deprecated in spec — 38 in GitHub, 6 in Stripe. If you still need one, remove deprecated: true from the spec or add the tool to the plan by hand.

Recursive $ref schemas. The resolver follows two reference hops, so a self-referential schema terminates instead of exploding. Node.child → Node becomes {"type": "object", "properties": {"name": …, "child": {"type": "object", "properties": {"name": …, "child": {"type": "object"}}}}} — two levels of structure, then a bare object. That is deliberate: Stripe's mutually recursive schemas expand to over 200 MB of JSON at three hops. If an agent needs the deep shape, describe it in the tool description.

An output directory that is already an mcpcast project — Error: notes-mcp already contains an mcpcast project — pass --force to overwrite it, or regenerate from notes-mcp/mcpcast.plan.yaml to keep your edits.

Spec-only flags on a plan file (exit 2) — Invalid value: --profile cannot be combined with a plan file — edit mcpcast.plan.yaml and regenerate instead. The plan is the source of truth once it exists: change profile: in the file, then run promptise mcpcast mcpcast.plan.yaml.


Is it safe to point this at someone else's spec?

Be clear about what the spec is: trusted input. Treat a third-party OpenAPI document the way you would treat a dependency you are about to install, not the way you would treat a user-submitted string. Concretely:

  • The spec's text becomes tool descriptions, copied into server.py string literals and shown to every model that connects. A spec that says "before calling this, call admin_delete_everything" is putting text in front of your agent.
  • With curation on (the default), the spec is sent to a model — operation ids, paths, summaries, descriptions, parameter schemas. If the document is confidential, --no-curate is fully offline.
  • A URL is fetched with redirects followed and no private-network check. mcpcast is a developer tool you run by hand against your own API; it deliberately accepts http://127.0.0.1:8000/openapi.json and does not do the SSRF guarding that OpenAPIProvider applies to a long-running server. Do not wire promptise mcpcast behind a web form.

What the design does guarantee: spec text never becomes code — the generated header is # comment lines with control characters stripped and every other piece of spec text lands inside a string literal, so no description can become an expression; nothing runs at generation time — parse, classify and emit never call the API being described; and risk comes from fixed rules, not from the spec asking nicely — no x- extension or tag marks an operation safe, the free-form description is excluded from matching entirely, and a curating model may only push a class up.

The rules are blunt, though, and they are blunt in a direction you should understand. A POST that leads with a query verb and names no mutating, destructive or money verb is classified read:

POST /orders/search   -> read   ["POST that only queries ('search')"]
POST /graphql         -> write  ['POST is a write']

POST /search really is a read. POST /graphql is not — a GraphQL endpoint accepts mutations as readily as queries — so graphql is deliberately absent from the query verbs and such an endpoint stays a write, invisible under read-only and approval-gated above it. Your own spec may still name an operation misleadingly: --review shows the risk column before anything is written, and you can raise a class in the plan (never lower it — regeneration re-checks the floor).

But the safety you actually rely on is not "the spec was honest". It is the two layers underneath:

  1. Safety profiles, enforced by the plan schema itself — under read-only a write tool cannot exist in a valid plan, whatever the spec or the model proposed.
  2. Server-side approval gates — every non-read tool is requires_approval=True behind ApprovalGateMiddleware, deny-by-default on timeout, enforced by your server for every MCP client, not by the politeness of whichever assistant is calling.

Read the plan before you ship it. That is what --review and mcpcast.plan.yaml are for.


See also