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¶
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-toolsbudget, then regenerate. A budget without curation is blunt:--no-curate --profile read-only --max-tools 40keeps 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_subscriptionsis good;get_quotes_quote_pdfis not. Rename it inmcpcast.plan.yamland runpromptise mcpcast mcpcast.plan.yaml— the plan file is left untouched, including your comments, and the package,server.py,tests/andREADME.mdare 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
--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.
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¶
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.
--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
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
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.pystring literals and shown to every model that connects. A spec that says "before calling this, calladmin_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-curateis fully offline. - A URL is fetched with redirects followed and no private-network check.
mcpcastis a developer tool you run by hand against your own API; it deliberately acceptshttp://127.0.0.1:8000/openapi.jsonand does not do the SSRF guarding thatOpenAPIProviderapplies to a long-running server. Do not wirepromptise mcpcastbehind 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:
- Safety profiles, enforced by the plan schema itself — under
read-onlya write tool cannot exist in a valid plan, whatever the spec or the model proposed. - Server-side approval gates — every non-read tool
is
requires_approval=TruebehindApprovalGateMiddleware, 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¶
-
MCPcast, end to end — how MCP works, the full real run, and the review checklist
-
MCPcast an Existing API — the reference: pipeline, safety profiles, risk rules, curation post-conditions, auth modes, the plan file.
- MCPcast an Existing API (guide) — the step-by-step build, from one command to a shipped deployment.
- Human Approval Gates — how
requires_approvalis enforced, and the elicitation and pending approvers. - CLI reference — every option and exit code.
promptise.mcpcastAPI — for scripting the pipeline.