FastAPI already describes your API in an OpenAPI document, so you're most of the way to an MCP server that Claude and other AI agents can use. What's left is deciding what an agent may do, and writing your routes so a model can tell them apart. This guide takes a small orders API built with FastAPI, turns it into a FastAPI MCP server with MCPcast, the OpenAPI-to-MCP generator in Promptise Foundry, and fixes what a typical day-one app gets wrong. Each fix is proven with the plan MCPcast writes, before and after. At the end, a real agent uses the generated server against the running app. Every command was run against Promptise Foundry 1.2.1, FastAPI 0.143.0 and uvicorn 0.54.0, and the output is what it printed.
How do you turn a FastAPI app into an MCP server?
Start your app, then point MCPcast at the OpenAPI document FastAPI serves at /openapi.json:
pip install promptise
promptise mcpcast http://127.0.0.1:8370/openapi.json --no-curate --profile standard --auth env-tokenParsed 4 operations from http://127.0.0.1:8370/openapi.json; profile=standard
auth=env-token
╭────────────────────────────────── mcpcast ───────────────────────────────────╮
│ orders → orders-mcp/ │
│ tools: 3 (1 require human approval) │
│ not exposed: 1 operations (with reasons in the plan) │
│ files: orders_mcp/ (8 modules), tests/ (2), mcpcast.plan.yaml, server.py, │
│ README.md, pyproject.toml, Dockerfile, .env.example, .gitignore │
╰──────────────────────────────────────────────────────────────────────────────╯MCPcast reads the spec, sorts every operation into read, write, destructive or financial, keeps what the safety profile allows, and writes a standalone Python project: the MCP server, its tests, a plan file that records every decision, and setup for Claude Desktop, Claude Code and Cursor. Nothing is mounted inside your app. The generated server is a separate process that calls your API over HTTP, with your API key. How much of your API makes it through, and how well a model uses it, depends on how the app is written. That's what the rest of this guide is about.
[02]
How a FastAPI app becomes MCP tools
Rendering diagram…
An agent never reads your code. It sees tool names, descriptions and parameters, and MCPcast builds all of them from what FastAPI puts in the spec:
In your FastAPI app | In /openapi.json | What MCPcast makes of it |
|---|---|---|
The function name, or operation_id= | operationId | The tool name |
summary= and the docstring | summary, description | The tool description |
Field(description=...), Query(...), Path(...) | Parameter and property descriptions | Each parameter's description |
| examples | The worked example in the tool description |
| tags | Tool tags, and the module the tool is generated in |
include_in_schema=False | Absent | Nothing: MCPcast never sees the route |
APIKeyHeader, HTTPBearer and friends | securitySchemes | Where the server sends your credential |
A Header() parameter | A header parameter | Never sent, so a required one drops the operation |
The HTTP method and the words in the operation id, path and summary decide each operation's risk: GET is a read, DELETE is destructive, and most POST calls are writes. OpenAPI to MCP: Turn Any REST API into an MCP Server explains the rules and the three safety profiles.
[03]
What you need
Python 3.10 or newer. This guide ran on 3.12.
pip install promptise fastapi uvicorn.An OpenAI API key in OPENAI_API_KEY, for the agent and the evaluation at the end. Generating the server with --no-curate needs no key and no network beyond your app.
[04]
Make your FastAPI app an MCP server, step by step
Start with an ordinary FastAPI app
Here's an orders API the way many of us write it on day one: pydantic models, an API key checked in a dependency, and no thought yet for agents.
import os
from fastapi import Depends, FastAPI, Header, HTTPException
from pydantic import BaseModel
API_KEY = os.environ.get("ORDERS_API_KEY", "dev-key")
app = FastAPI(title="Orders API", version="1.0.0")
def check_key(x_api_key: str = Header()):
if x_api_key != API_KEY:
raise HTTPException(status_code=401, detail="invalid API key")
class Item(BaseModel):
sku: str
quantity: int
class OrderIn(BaseModel):
customer: str
items: list[Item]
class Order(OrderIn):
id: int
status: str
ORDERS = {
1001: Order(id=1001, customer="acme", items=[Item(sku="MUG-01", quantity=2)], status="shipped"),
1002: Order(id=1002, customer="globex", items=[Item(sku="TEE-M", quantity=1)], status="pending"),
1003: Order(id=1003, customer="acme", items=[Item(sku="CAP-02", quantity=3)], status="pending"),
}
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/orders", dependencies=[Depends(check_key)])
def list_orders(status: str | None = None, limit: int = 20) -> list[Order]:
found = [o for o in ORDERS.values() if status is None or o.status == status]
return found[:limit]
@app.get("/orders/{order_id}", dependencies=[Depends(check_key)])
def get_order(order_id: int) -> Order:
if order_id not in ORDERS:
raise HTTPException(status_code=404, detail="order not found")
return ORDERS[order_id]
@app.post("/orders", status_code=201, dependencies=[Depends(check_key)])
def create_order(order: OrderIn) -> Order:
new = Order(id=max(ORDERS) + 1, status="pending", **order.model_dump())
ORDERS[new.id] = new
return new
@app.delete("/orders/{order_id}", status_code=204, dependencies=[Depends(check_key)])
def delete_order(order_id: int):
if ORDERS.pop(order_id, None) is None:
raise HTTPException(status_code=404, detail="order not found")Run it, and check that the spec is there:
uvicorn app:app --port 8370 --reload
curl -s http://127.0.0.1:8370/openapi.json | head -c 200{"openapi":"3.1.0","info":{"title":"Orders API","version":"1.0.0"},"paths":{"/health":{"get":{"summary":"Health","operationId":"health_health_get","responses":{"200":{"description":"Successful Respons--reload restarts the app every time you save, which you'll want in the next steps.
Point MCPcast at /openapi.json
Generate a first server. --no-curate keeps every decision in MCPcast's own rules, offline, so you can repeat this run and get the same result:
promptise mcpcast http://127.0.0.1:8370/openapi.json --no-curateParsed 5 operations from http://127.0.0.1:8370/openapi.json; profile=read-only
auth=passthrough
╭────────────────────────────────── mcpcast ───────────────────────────────────╮
│ orders → orders-mcp/ │
│ tools: 1 (0 require human approval) │
│ not exposed: 4 operations (with reasons in the plan) │
│ files: orders_mcp/ (8 modules), tests/ (2), mcpcast.plan.yaml, server.py, │
│ README.md, pyproject.toml, Dockerfile, .env.example, .gitignore │
╰──────────────────────────────────────────────────────────────────────────────╯One tool out of five operations, and it's the health check. Open orders-mcp/mcpcast.plan.yaml to see why:
tools:
- name: health_health_get
description: Health
risk: read
routes:
- operation_id: health_health_get
method: GET
path: /health
dropped:
- operation_id: list_orders_orders_get
reason: 'unsupported by mcpcast: required header parameter ''x-api-key'' cannot be sent'
- operation_id: create_order_orders_post
reason: 'unsupported by mcpcast: required header parameter ''x-api-key'' cannot be sent'
- operation_id: get_order_orders_order_id_get
reason: 'unsupported by mcpcast: required header parameter ''x-api-key'' cannot be sent'
- operation_id: delete_order_orders_order_id_delete
reason: 'unsupported by mcpcast: required header parameter ''x-api-key'' cannot be sent'x_api_key: str = Header() reads like authentication to you, but in the spec it's just a required header parameter, the same as any other. The generated server doesn't send header parameters, and the only credential it sends is the one your spec's security scheme describes. An operation that needs any other header could never succeed, so MCPcast drops it with the reason, instead of generating a tool that fails on every call. Notice the write and the delete were dropped for the header too, before the default read-only profile even got a say.
Declare your API key as a security scheme
FastAPI's APIKeyHeader checks the same header, and also tells the spec that it's a credential. It's a small change to the dependency:
from fastapi import Depends, FastAPI, HTTPException, Security
from fastapi.security import APIKeyHeader
api_key_header = APIKeyHeader(name="X-API-Key")
def check_key(key: str = Security(api_key_header)):
if key != API_KEY:
raise HTTPException(status_code=401, detail="invalid API key")Save, and run the same command again:
Parsed 5 operations from http://127.0.0.1:8370/openapi.json; profile=read-only
auth=passthrough
Error: the spec authenticates with a credential in header 'X-API-Key', which --auth passthrough cannot relay (it forwards only the caller's Authorization header) — generate with --auth env-token (one upstream credential from MCPCAST_UPSTREAM_TOKEN, presented there) or --auth api-key (one per tenant)Progress. MCPcast now knows where the key goes, and it's telling you the default auth mode can't put it there. The auth mode decides where the generated server gets the credential it sends to your app:
passthrough (the default) forwards each caller's own Authorization header. It's for shared HTTP deployments where every user brings a token, and it can't fill an X-API-Key header.
env-token sends one credential from the MCPCAST_UPSTREAM_TOKEN environment variable, in the header your spec names. This is the one for your own agent, Claude Desktop or Claude Code.
api-key gives each customer their own key to the MCP server, with your API credential per customer kept on the server.
Use env-token. --force lets MCPcast overwrite the project from step 2:
promptise mcpcast http://127.0.0.1:8370/openapi.json --no-curate --auth env-token --forceParsed 5 operations from http://127.0.0.1:8370/openapi.json; profile=read-only
auth=env-token
╭────────────────────────────────── mcpcast ───────────────────────────────────╮
│ orders → orders-mcp/ │
│ tools: 3 (0 require human approval) │
│ not exposed: 2 operations (with reasons in the plan) │
│ files: orders_mcp/ (9 modules), tests/ (2), mcpcast.plan.yaml, server.py, │
│ README.md │
╰──────────────────────────────────────────────────────────────────────────────╯Three tools now. Here's the plan before and after, trimmed with …:
@@ -5,9 +5,10 @@
api:
name: orders
base_url: http://127.0.0.1:8370
- auth: passthrough
+ auth: env-token
description: Orders API
spec_source: http://127.0.0.1:8370/openapi.json
+ credential_name: X-API-Key
…
+- name: list_orders_orders_get
+ description: List Orders
…
+- name: get_order_orders_order_id_get
+ description: Get Order
…
dropped:
-- operation_id: list_orders_orders_get
- reason: 'unsupported by mcpcast: required header parameter ''x-api-key'' cannot be sent'
- operation_id: create_order_orders_post
- reason: 'unsupported by mcpcast: required header parameter ''x-api-key'' cannot be sent'
-- operation_id: get_order_orders_order_id_get
- reason: 'unsupported by mcpcast: required header parameter ''x-api-key'' cannot be sent'
+ reason: write operation excluded by profile 'read-only'
- operation_id: delete_order_orders_order_id_delete
- reason: 'unsupported by mcpcast: required header parameter ''x-api-key'' cannot be sent'
+ reason: destructive operation excluded by profile 'read-only'The two reads are tools. The write and the delete are now out for the right reason: the safety profile, which you'll change in step 6.
Every FastAPI auth style ends up in one of three places. Planning a one-route app with each style in MCPcast 1.2.1 gave this:
Your FastAPI auth | What MCPcast does |
|---|---|
A Header() parameter | Drops the operation: the header can't be sent |
APIKeyHeader | Sends the key in that header, with --auth env-token or api-key |
APIKeyQuery | Sends the key as that query parameter, with --auth env-token or api-key |
APIKeyCookie | Drops the operation: the server can't present a cookie |
HTTPBearer, HTTPBasic, OAuth2PasswordBearer | Sends the Authorization header, with any auth mode |
Give every operation a real name
The tool names are still list_orders_orders_get and get_order_orders_order_id_get. FastAPI builds each operation id from the function name, the path and the method, and MCPcast uses the operation id as the tool name. A model picking between names like that is guessing. Fix it once, on the app:
app = FastAPI(
title="Orders API",
version="1.0.0",
# Name each operation after its function: list_orders, not list_orders_orders_get.
generate_unique_id_function=lambda route: route.name,
)Your function names must then be unique across the app. Setting operation_id="..." on a single route works too. Run the same command again, and the plan changes like this:
tools:
-- name: health_health_get
+- name: health
description: Health
risk: read
routes:
- - operation_id: health_health_get
+ - operation_id: health
method: GET
path: /health
-- name: list_orders_orders_get
+- name: list_orders
description: List Orders
risk: read
routes:
- - operation_id: list_orders_orders_get
+ - operation_id: list_orders
…
-- name: get_order_orders_order_id_get
+- name: get_order
description: Get Order
…
dropped:
-- operation_id: create_order_orders_post
+- operation_id: create_order
reason: write operation excluded by profile 'read-only'
-- operation_id: delete_order_orders_order_id_delete
+- operation_id: delete_order
reason: destructive operation excluded by profile 'read-only'Fix names in your app rather than in the plan. You can rename tools in mcpcast.plan.yaml, but the next run from the spec brings the derived names back.
Describe operations and fields for the model
List Orders and Get Order are what FastAPI makes of the function names. The model reads nothing else when it picks a tool, so tell it what each operation does and when to use it. Here are the routes with a summary, a docstring, a tag, and a description and example on every parameter:
Status = Literal["pending", "shipped"]
OrderId = Annotated[int, Path(description="The order number, for example 1001.", examples=[1001])]
class Item(BaseModel):
sku: str = Field(description="Product code, for example MUG-01.", examples=["MUG-01"])
quantity: int = Field(ge=1, le=100, description="How many units to order.", examples=[2])
class OrderIn(BaseModel):
customer: str = Field(description="Customer account name, for example acme.", examples=["acme"])
items: list[Item] = Field(min_length=1, description="The products and quantities to order.")
@app.get("/health", include_in_schema=False)
def health():
return {"status": "ok"}
@app.get("/orders", tags=["orders"], summary="List orders", dependencies=[Depends(check_key)])
def list_orders(
status: Annotated[Status | None, Query(description="Only orders with this status: pending or shipped.")] = None,
limit: Annotated[int, Query(ge=1, le=100, description="Maximum number of orders to return.")] = 20,
) -> list[Order]:
"""Newest orders first. Use get_order when you already know the order number."""include_in_schema=False takes the health check out of the spec. A load balancer needs it; an agent has no task that does. The complete file is at the end of this section. Run the same command again:
-- name: health
- description: Health
- risk: read
- routes:
- - operation_id: health
- method: GET
- path: /health
- name: list_orders
- description: List Orders
+ description: List orders. Newest orders first. Use get_order when you already know the order number.
risk: read
…
params:
status:
+ description: 'Only orders with this status: pending or shipped.'
…
limit:
+ description: Maximum number of orders to return.
…
+ tags:
+ - orders
- name: get_order
- description: Get Order
+ description: Get one order. Returns the customer, items and status of a single order.
…
order_id:
+ description: The order number, for example 1001.
…
example:
- order_id: 1
+ order_id: 1001
+ tags:
+ - ordersThe summary and docstring became one description. Every parameter has its own description now. The example for get_order uses an order that exists, instead of 1, which doesn't. The orders tag is kept on each tool and names the module it's generated in, orders_mcp/tools/orders.py.
That's what the agent will see, written into the generated server:
@server.tool(
name="list_orders",
description=(
"List orders. Newest orders first. Use get_order when you already know the order "
"number.\n"
"\n"
"Parameters:\n"
" - status (string): Only orders with this status: pending or shipped.\n"
" - limit (integer): Maximum number of orders to return."
),
tags=["orders"],
read_only_hint=True,
idempotent_hint=True,
open_world_hint=True,
)Allow writes, behind approval
Agents that only read are useful, but the sales team also wants to place orders. The standard profile generates writes and puts every one of them behind human approval. --review -y prints the plan as a table before writing it:
promptise mcpcast http://127.0.0.1:8370/openapi.json --no-curate --profile standard --auth env-token --review -y --forceParsed 4 operations from http://127.0.0.1:8370/openapi.json; profile=standard auth=env-token
Tools (3) — profile standard
┏━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Tool ┃ Risk ┃ Approval ┃ Operations ┃ Params ┃ Description ┃
┡━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━┩
│ list_orders │ read │ — │ GET /orders │ status, limit │ List orders. Newest │
…
│ create_order │ write │ required │ POST /orders │ customer, items │ Create an order. │
…
│ get_order │ read │ — │ GET │ order_id │ Get one order. │
…
└──────────────┴───────┴──────────┴──────────────────────┴─────────────────┴───────────────────────┘
Not exposed (1)
┏━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Operation ┃ Reason ┃
┡━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ delete_order │ destructive operation excluded by profile 'standard' │
└──────────────┴──────────────────────────────────────────────────────┘
╭─────────────────────────────────────── mcpcast ───────────────────────────────────────╮
│ orders → orders-mcp/ │
│ tools: 3 (1 require human approval) │
│ not exposed: 1 operations (with reasons in the plan) │
│ files: orders_mcp/ (8 modules), tests/ (2), mcpcast.plan.yaml, server.py, README.md │
╰───────────────────────────────────────────────────────────────────────────────────────╯create_order is a write with requires_approval: true in the plan. Its example came straight from your Field examples:
example:
customer: acme
items:
- sku: MUG-01
quantity: 2
requires_approval: truedelete_order stays out: standard never generates destructive operations. With --profile full it becomes a fourth tool, delete_order, risk destructive, also behind approval. Leave deletes to people unless you have a reason.
Run the generated tests
The project is an ordinary Python package with its own tests. They run against a fake of your API, so they never touch real orders:
cd orders-mcp
pip install -e ".[dev]"
pytest -q..... [100%]
5 passed in 0.02sThe five tests check that every tool is listed, that each one calls the right method and route, and that a denied create_order never reaches your API.
Use it from an agent and from Claude
Here's a Promptise agent connected to the generated server, the same pattern as in How to Connect MCP Servers to Your AI Agent in Python. MCPCAST_UPSTREAM_TOKEN holds your API key. Because your spec names the X-API-Key header, the value is the raw key, with no Bearer in front:
import asyncio
import sys
from promptise import build_agent
from promptise.config import StdioServerSpec
async def main():
agent = await build_agent(
model="openai:gpt-5-mini",
servers={
"orders": StdioServerSpec(
command=sys.executable,
args=["orders-mcp/server.py"],
env={"MCPCAST_UPSTREAM_TOKEN": "dev-key"},
),
},
instructions=(
"You help the sales team with customer orders. Use your tools. "
"If a tool call is denied, say so plainly and do not retry it."
),
trace_tools=True,
)
try:
for question in [
"Which orders are still pending, and what is in them?",
"Create an order for globex: 3 of MUG-01.",
]:
result = await agent.ainvoke({"messages": [{"role": "user", "content": question}]})
print(">>>", result["messages"][-1].content)
finally:
await agent.shutdown()
asyncio.run(main())Run it with your app still up:
Elicitation request failed (McpError: Elicitation not supported) — treated as declined
→ Invoking tool: list_orders with {'status': 'pending', 'limit': None}
✔ Tool result from list_orders: [{"customer": "acme", "items": [{"sku": "CAP-02", "quantity": 3}], "id": 1003, "status": "pending"}, {"customer": "globex", "items": [{"sku": "TEE-M", "quantity": 1}], "id": 1002, "status": "pending"}]
>>> There are two pending orders:
- Order 1003 — customer: acme
- CAP-02 × 3
- Order 1002 — customer: globex
- TEE-M × 1
…
→ Invoking tool: create_order with {'customer': 'globex', 'items': [{'sku': 'MUG-01', 'quantity': 3}]}
✔ Tool result from create_order: {
"error": {
"code": "APPROVAL_DENIED",
"message": "Approval denied for tool 'create_order': client declined or returned an invalid elicitation response",
…
>>> I couldn’t create the order — the system denied the create_order request (approval denied). …The read went straight to your live app, with the status filter the description advertised. The write was stopped before it reached your app, and the order list afterwards still has the same three orders. The first line is the generated server's own log message, and it says why: approval in this server uses MCP elicitation, where the server asks the person behind the client to confirm the exact call. Promptise's own MCP client can't answer elicitation yet, so there's nobody to ask, and the server fails closed. OpenAPI to MCP: Turn Any REST API into an MCP Server shows the prompt a person sees in a client that can answer, and the pending approval mode for agents that run without a person at the client.
To see the approved path, give the generated server an approver of your own. build_server() in the generated package takes an approval_handler, and TestClient runs the whole server in memory, gate included:
import asyncio
import os
from promptise.approval import ApprovalRequest
from promptise.mcp.server import TestClient
from promptise.mcpcast import load_generated_server
os.environ["MCPCAST_UPSTREAM_TOKEN"] = "dev-key"
def reviewer(request: ApprovalRequest) -> bool:
"""Stands in for a person: show the exact call, then say yes."""
print("approve?", request.tool_name, request.arguments)
return True
async def main():
generated = load_generated_server("orders-mcp/server.py")
client = TestClient(generated.build_server(approval_handler=reviewer))
(reply,) = await client.call_tool(
"create_order", {"customer": "globex", "items": [{"sku": "MUG-01", "quantity": 3}]}
)
print(reply.text)
asyncio.run(main())approve? create_order {'customer': 'globex', 'items': [{'sku': 'MUG-01', 'quantity': 3}]}
{"customer": "globex", "items": [{"sku": "MUG-01", "quantity": 3}], "id": 1004, "status": "pending"}The reviewer saw the exact call, said yes, and order 1004 now exists in your app, whose log shows the one POST /orders of this whole guide.
For Claude, the generated orders-mcp/README.md has the setup filled in for your server. For Claude Desktop, add this to claude_desktop_config.json, with the absolute path to server.py and your key:
{
"mcpServers": {
"orders": { "command": "python", "args": ["/absolute/path/to/server.py"], "env": {"MCPCAST_UPSTREAM_TOKEN": "<your API key>"} }
}
}For Claude Code, it's one command:
claude mcp add orders -e MCPCAST_UPSTREAM_TOKEN="<your API key>" -- python /absolute/path/to/server.pyUse a python that has promptise installed. The key goes in the client's config, because a desktop app started from the dock doesn't see variables you export in a terminal.
The finished app
Here's app.py after all four fixes:
import os
from typing import Annotated, Literal
from fastapi import Depends, FastAPI, HTTPException, Path, Query, Security
from fastapi.security import APIKeyHeader
from pydantic import BaseModel, Field
API_KEY = os.environ.get("ORDERS_API_KEY", "dev-key")
app = FastAPI(
title="Orders API",
version="1.0.0",
# Name each operation after its function: list_orders, not list_orders_orders_get.
generate_unique_id_function=lambda route: route.name,
)
api_key_header = APIKeyHeader(name="X-API-Key")
def check_key(key: str = Security(api_key_header)):
if key != API_KEY:
raise HTTPException(status_code=401, detail="invalid API key")
Status = Literal["pending", "shipped"]
OrderId = Annotated[int, Path(description="The order number, for example 1001.", examples=[1001])]
class Item(BaseModel):
sku: str = Field(description="Product code, for example MUG-01.", examples=["MUG-01"])
quantity: int = Field(ge=1, le=100, description="How many units to order.", examples=[2])
class OrderIn(BaseModel):
customer: str = Field(description="Customer account name, for example acme.", examples=["acme"])
items: list[Item] = Field(min_length=1, description="The products and quantities to order.")
class Order(OrderIn):
id: int
status: Status
ORDERS = {
1001: Order(id=1001, customer="acme", items=[Item(sku="MUG-01", quantity=2)], status="shipped"),
1002: Order(id=1002, customer="globex", items=[Item(sku="TEE-M", quantity=1)], status="pending"),
1003: Order(id=1003, customer="acme", items=[Item(sku="CAP-02", quantity=3)], status="pending"),
}
@app.get("/health", include_in_schema=False)
def health():
return {"status": "ok"}
@app.get("/orders", tags=["orders"], summary="List orders", dependencies=[Depends(check_key)])
def list_orders(
status: Annotated[Status | None, Query(description="Only orders with this status: pending or shipped.")] = None,
limit: Annotated[int, Query(ge=1, le=100, description="Maximum number of orders to return.")] = 20,
) -> list[Order]:
"""Newest orders first. Use get_order when you already know the order number."""
found = [o for o in ORDERS.values() if status is None or o.status == status]
return sorted(found, key=lambda o: o.id, reverse=True)[:limit]
@app.get("/orders/{order_id}", tags=["orders"], summary="Get one order", dependencies=[Depends(check_key)])
def get_order(order_id: OrderId) -> Order:
"""Returns the customer, items and status of a single order."""
if order_id not in ORDERS:
raise HTTPException(status_code=404, detail="order not found")
return ORDERS[order_id]
@app.post("/orders", status_code=201, tags=["orders"], summary="Create an order", dependencies=[Depends(check_key)])
def create_order(order: OrderIn) -> Order:
"""Places a new order in status pending. Check the SKUs with the customer first."""
new = Order(id=max(ORDERS) + 1, status="pending", **order.model_dump())
ORDERS[new.id] = new
return new
@app.delete("/orders/{order_id}", status_code=204, tags=["orders"], summary="Delete an order", dependencies=[Depends(check_key)])
def delete_order(order_id: OrderId):
"""Removes an order for good. There is no undo."""
if ORDERS.pop(order_id, None) is None:
raise HTTPException(status_code=404, detail="order not found")[05]
Measure how well agents use it
Good names and descriptions are a judgement call until you measure them. --eval has a model write realistic tasks for your tools, then a real agent tries each one against the generated server. Reads go to your live app; writes go to mocks built from the spec, so nothing changes. Run it on the plan:
export OPENAI_API_KEY="sk-..."
export MCPCAST_UPSTREAM_TOKEN=dev-key
promptise mcpcast orders-mcp/mcpcast.plan.yaml --eval --eval-tasks 8Regenerating from plan orders-mcp/mcpcast.plan.yaml (3 tools)
…
Evaluating with openai:gpt-5-mini (8 tasks)…
Agent Readiness: A (7/8 tasks succeeded)
✗ `get_order` called the API with identifiers it does not recognise — the
example in the plan teaches both the task writer and the agent, so replace those
example values with ones that exist
• `list_orders` has no example — agents lean on examples heavilyThe report in orders-mcp/eval/report.md gives a score of 0.93, the right tool chosen first in 100% of tasks, and no parameter errors. Your app's log shows what the two get_order complaints were:
INFO: 127.0.0.1:63553 - "GET /orders/1 HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63620 - "GET /orders/1001 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63620 - "GET /orders/2045 HTTP/1.1" 404 Not FoundThe failed task asked for order 2045, a number the task writer made up, and the agent called the right tool for it. The lookup of order 1 came after a mocked create_order, whose made-up reply the agent tried to check against the live app. Neither is a tool design problem, and the plan's example for get_order is already 1001. The other suggestion is fair: list_orders has no required parameters, so it gets no generated example, and you can add one in mcpcast.plan.yaml. Scores move between runs, too: an earlier run of the same evaluation on the same plan scored 8 of 8. If you use --eval as a release check, set a floor rather than expecting a number.
[06]
Honest limits
Required header and cookie parameters drop the operation. That includes tenant headers like X-Tenant. Optional ones are left off the tool. Move credentials into a security scheme, and anything else into the path, query or body.
Allowed values and limits don't reach the tool schema in 1.2.1, as step 5 showed. Spell them out in descriptions.
A Promptise agent can't approve its own writes yet. Its MCP client doesn't answer elicitation, so gated tools are denied. Use a client that does, or --auth api-key with pending approvals, covered in Human approval.
An explicit null can't be sent. A None argument is left out of the request, so a PATCH that clears a field by sending null won't work through a generated tool.
The base URL is baked in. The plan records where your app was when you generated it, here http://127.0.0.1:8370. Set MCPCAST_BASE_URL to point the same server at staging or production.
An env-token server is for one person. Over HTTP it binds loopback only, since anyone reaching it would act with your key. For a shared deployment, regenerate with --auth api-key or passthrough.
The full list is in the limits section of the MCPcast reference.
[07]
Frequently asked questions
How is MCPcast different from fastapi-mcp?
fastapi-mcp is an open-source library from Tadata that exposes your FastAPI endpoints as MCP tools, and can mount the MCP server directly inside your app. MCPcast takes a different route: it works from the OpenAPI document, so it isn't tied to FastAPI, and it generates a separate, editable server project. Before writing any code it classifies every operation's risk, applies a safety profile, gates writes behind human approval in the server, and records every decision with its reason in a plan file you review. --eval then measures how well a real agent uses the result.
Can Claude Desktop or Claude Code use my FastAPI MCP server?
Yes. The generated server speaks MCP over stdio, which is what both clients start, and the generated README has the exact config for Claude Desktop, Claude Code and Cursor. Put your API key in the client's config as MCPCAST_UPSTREAM_TOKEN, as shown in step 8.
How does authentication work for a FastAPI MCP server?
There are two hops. Your MCP client talks to the generated server, and the generated server talks to your FastAPI app with the credential your security scheme describes. The key lives in the server's environment, never in tool arguments, so the model never handles it. For a server many customers share, --auth api-key gives each one their own key to the MCP server.
Do I have to change my FastAPI app?
Not to start, but you'll get a much better server if you do. A security scheme instead of a Header() parameter, real operation ids, and descriptions on routes and fields are the changes this guide made, and none of them changes what your endpoints do. Two things do change at the edges: a request without a key now gets 401 Not authenticated instead of a 422 validation error, and new operation ids rename the methods in any client generated from your spec.
Is there a guided setup?
Yes. Run promptise mcpcast with no arguments for an interactive setup in your terminal. It can detect a FastAPI app running on a usual local port such as 8000, shows every tool for review, and writes nothing until you confirm. See Guided setup.
[08]
Where to go next
Make your Python API MCP-ready: the docs walkthrough for FastAPI, Django, Flask and Litestar, including a CI check that keeps the plan in sync with your app.
MCPcast reference: every option, the plan format, the auth modes and the readiness score.
Approval Gates: elicitation, pending approvals and custom handlers.
MCPcast recipes: what happens on large real-world APIs.
OpenAPI to MCP: Turn Any REST API into an MCP Server: curation with a model, plan edits and the approval flow in depth.
How to Connect MCP Servers to Your AI Agent in Python: use your new server from your own agent.