PromptisePromptise
Docs
GitHub
Promptise - AI Framework LogoPromptise

The foundation layer for agentic intelligence. Build, secure and operate autonomous AI systems with Promptise Foundry.

pip install promptise

[01] Foundry

  • MCPcast
  • The Promptise Agent
  • Reasoning Engine
  • MCP
  • Agent Runtime
  • Prompt Engineering
  • Execution Engine
  • Agent Identity

[02] Resources

  • Documentation
  • GitHub
  • Guides
  • Learning Paths
  • Questions

[03] Company

  • About
  • Terms of Service
  • Privacy Policy
  • Cookie Policy
  • Subprocessors

© 2026 Promptise by Manser Ventures. All rights reserved.

Open source · Python

← Learning paths> Agentic Engineering with Promptise Foundry
Lesson 20 / 71
28%

FastAPI MCP Server: Turn Your FastAPI App into MCP Tools

Turn your FastAPI app into an MCP server for Claude and AI agents: fix auth, names and descriptions, gate writes, and test it with a real agent.

Intermediate22 minModel Context Protocol (MCP)FastAPIOpenAPI and REST APIsHuman-in-the-Loop
All 71 lessons↓
  1. ✓What Is an AI Agent? Definition, How It Works, Examples
  2. ✓How to Build an AI Agent in Python (2026 Guide)
  3. ✓What Is an Agent Harness? Parts and How to Build One
  4. ✓System Prompt Best Practices for AI Agents in Python
  5. ✓Best LLM for AI Agents: Test and Switch Models in Python
  6. ✓AI Agent YAML: Define and Run an Agent From a Config File
  7. ✓Streaming LLM Responses with FastAPI: Tokens and Tool Calls
  8. ✓Claude AI Agent with MCP: Use Anthropic Models in Promptise
  9. ✓Gemini AI Agent with MCP: Use Google Models in Promptise
  10. ✓Azure OpenAI Agent with MCP: Use Deployments in Promptise
  11. ✓OpenRouter AI Agent with MCP: Many Models in Promptise
  12. ✓Ollama AI Agent with MCP: Run Local Models in Promptise
  13. ✓LLM Semantic Cache and Model Fallback in Python
  14. ✓What Is MCP (Model Context Protocol)? A Developer's Guide
  15. ✓What Is Tool Calling? LLM Function Calling Explained
  16. ✓MCP Tools vs Resources vs Prompts (With Python Examples)
  17. ✓How to Connect MCP Servers to Your AI Agent in Python
  18. ✓Build a Production MCP Server in Python (Step by Step)
  19. ✓MCP Transports: stdio vs Streamable HTTP vs SSE (Python)
  20. 20FastAPI MCP Server: Turn Your FastAPI App into MCP Tools
  21. 21OpenAPI to MCP: Turn Any REST API into an MCP Server
  22. 22MCP Authentication in Python: API Keys, JWT and OAuth
  23. 23Build an Authenticated MCP Server for Your AI Agent (Python)
  24. 24MCP Security Best Practices: A Checklist for MCP Servers
  25. 25Testing MCP Servers in Python: pytest Without a Live Server
  26. 26Too Many MCP Tools? LLM Tool Selection, Measured
  27. 27MCP Server Caching, Timeouts and Circuit Breakers in Python
  28. 28MCP vs CLI for AI Agents: Tokens, Security, When to Use Each
  29. 29AI Agent Audit Logging: Tamper-Evident Logs in Python
  30. 30AI Agent Human in the Loop: Approve Tool Calls in Python
  31. 31AI Agent Prompt Injection: How to Defend Against It
  32. 32Build a Multi-Agent System in Python (and When Not To)
  33. 33Context Engineering for AI Agents: Context Window Management
  34. 34How to Deploy an AI Agent with Docker (2026 Guide)
  35. 35LLM Tool Rate Limiting: Per-Client Limits for MCP Servers
  36. 36MCP Long-Running Tasks: Background Jobs in Python
  37. 37MCP Server Monitoring: Prometheus, OpenTelemetry and Logs
  38. 38Python AI Agent Frameworks Compared: Which to Choose (2026)
  39. 39Self-Improving AI Agent: Learn From Tool Failures in Python
  40. 40AI Agent Identity: Authenticate Agents to MCP Servers
  41. 41Code-Action Agents in Python: One Program vs 37 Tool Calls
  42. 42Multi-Tenant AI Agents: Keep Each Customer's Data Apart
  43. 43What Is Agentic RAG? How It Differs from Classic RAG
  44. 44AI Agent Memory in Python: Short-Term and Long-Term
  45. 45Agentic RAG in Python: Give Your AI Agent a Search Tool
  46. 46AI Agent Reasoning Patterns in Python: 9 Patterns Tested
  47. 47Plan-and-Execute Agents in Python: Beyond the ReAct Loop
  48. 48Self-Modifying AI Agent in Python: Open Mode & Limits
  49. 49CrewAI Alternatives: 7 Options and How to Migrate (2026)
  50. 50Migrate from AutoGen: Agent Framework, Promptise and More
  51. 51What Is an LLM Guardrail? Types, Examples and Limits
  52. 52AI Agent Guardrails in Python: Block Injection, Redact PII
  53. 53AI Agent Code Execution Sandbox: Run LLM Code Safely
  54. 54AI Agent Production Checklist: What to Check Before Launch
  55. 55Approval Fatigue: Auto-Approve Safe AI Agent Actions
  56. 56What Is Human in the Loop AI? Approvals for AI Agents
  57. 57AI Agent Notifications: Webhook and Slack Alerts in Python
  58. 58AI Agent Observability: Trace Tool Calls, Tokens and Cost
  59. 59AI Agent Stuck in a Loop? How to Detect and Stop It
  60. 60How to Run, Debug and Deploy an AI Agent in Python
  61. 61AI Agent Cost Tracking: Tokens and Cost per Run and User
  62. 62How to Evaluate AI Agents: Evals, LLM Judges and pass^k
  63. 63AI Agent Cost Control: Budgets That Stop Runaway Spend
  64. 64How to Test LLM Prompts and AI Agents in Python
  65. 65AI Agent Evals in CI: Run Them on Every Pull Request
  66. 66Autonomous AI Agent Runtime: Budgets, Crash Recovery, API
  67. 67Event-Driven AI Agents: Webhook, Cron and File Triggers
  68. 68Durable AI Agents: Crash Recovery and Safe Resume
  69. 69Deploy AI Agents in Production: Long-Running Python Agents
  70. 70What Is an AI Agent Framework? When You Need One
  71. 71LangGraph Alternatives: 7 Options and How to Migrate (2026)

Lessons in this path

  1. ✓What Is an AI Agent? Definition, How It Works, Examples
  2. ✓How to Build an AI Agent in Python (2026 Guide)
  3. ✓What Is an Agent Harness? Parts and How to Build One
  4. ✓System Prompt Best Practices for AI Agents in Python
  5. ✓Best LLM for AI Agents: Test and Switch Models in Python
  6. ✓AI Agent YAML: Define and Run an Agent From a Config File
  7. ✓Streaming LLM Responses with FastAPI: Tokens and Tool Calls
  8. ✓Claude AI Agent with MCP: Use Anthropic Models in Promptise
  9. ✓Gemini AI Agent with MCP: Use Google Models in Promptise
  10. ✓Azure OpenAI Agent with MCP: Use Deployments in Promptise
  11. ✓OpenRouter AI Agent with MCP: Many Models in Promptise
  12. ✓Ollama AI Agent with MCP: Run Local Models in Promptise
  13. ✓LLM Semantic Cache and Model Fallback in Python
  14. ✓What Is MCP (Model Context Protocol)? A Developer's Guide
  15. ✓What Is Tool Calling? LLM Function Calling Explained
  16. ✓MCP Tools vs Resources vs Prompts (With Python Examples)
  17. ✓How to Connect MCP Servers to Your AI Agent in Python
  18. ✓Build a Production MCP Server in Python (Step by Step)
  19. ✓MCP Transports: stdio vs Streamable HTTP vs SSE (Python)
  20. 20FastAPI MCP Server: Turn Your FastAPI App into MCP Tools
  21. 21OpenAPI to MCP: Turn Any REST API into an MCP Server
  22. 22MCP Authentication in Python: API Keys, JWT and OAuth
  23. 23Build an Authenticated MCP Server for Your AI Agent (Python)
  24. 24MCP Security Best Practices: A Checklist for MCP Servers
  25. 25Testing MCP Servers in Python: pytest Without a Live Server
  26. 26Too Many MCP Tools? LLM Tool Selection, Measured
  27. 27MCP Server Caching, Timeouts and Circuit Breakers in Python
  28. 28MCP vs CLI for AI Agents: Tokens, Security, When to Use Each
  29. 29AI Agent Audit Logging: Tamper-Evident Logs in Python
  30. 30AI Agent Human in the Loop: Approve Tool Calls in Python
  31. 31AI Agent Prompt Injection: How to Defend Against It
  32. 32Build a Multi-Agent System in Python (and When Not To)
  33. 33Context Engineering for AI Agents: Context Window Management
  34. 34How to Deploy an AI Agent with Docker (2026 Guide)
  35. 35LLM Tool Rate Limiting: Per-Client Limits for MCP Servers
  36. 36MCP Long-Running Tasks: Background Jobs in Python
  37. 37MCP Server Monitoring: Prometheus, OpenTelemetry and Logs
  38. 38Python AI Agent Frameworks Compared: Which to Choose (2026)
  39. 39Self-Improving AI Agent: Learn From Tool Failures in Python
  40. 40AI Agent Identity: Authenticate Agents to MCP Servers
  41. 41Code-Action Agents in Python: One Program vs 37 Tool Calls
  42. 42Multi-Tenant AI Agents: Keep Each Customer's Data Apart
  43. 43What Is Agentic RAG? How It Differs from Classic RAG
  44. 44AI Agent Memory in Python: Short-Term and Long-Term
  45. 45Agentic RAG in Python: Give Your AI Agent a Search Tool
  46. 46AI Agent Reasoning Patterns in Python: 9 Patterns Tested
  47. 47Plan-and-Execute Agents in Python: Beyond the ReAct Loop
  48. 48Self-Modifying AI Agent in Python: Open Mode & Limits
  49. 49CrewAI Alternatives: 7 Options and How to Migrate (2026)
  50. 50Migrate from AutoGen: Agent Framework, Promptise and More
  51. 51What Is an LLM Guardrail? Types, Examples and Limits
  52. 52AI Agent Guardrails in Python: Block Injection, Redact PII
  53. 53AI Agent Code Execution Sandbox: Run LLM Code Safely
  54. 54AI Agent Production Checklist: What to Check Before Launch
  55. 55Approval Fatigue: Auto-Approve Safe AI Agent Actions
  56. 56What Is Human in the Loop AI? Approvals for AI Agents
  57. 57AI Agent Notifications: Webhook and Slack Alerts in Python
  58. 58AI Agent Observability: Trace Tool Calls, Tokens and Cost
  59. 59AI Agent Stuck in a Loop? How to Detect and Stop It
  60. 60How to Run, Debug and Deploy an AI Agent in Python
  61. 61AI Agent Cost Tracking: Tokens and Cost per Run and User
  62. 62How to Evaluate AI Agents: Evals, LLM Judges and pass^k
  63. 63AI Agent Cost Control: Budgets That Stop Runaway Spend
  64. 64How to Test LLM Prompts and AI Agents in Python
  65. 65AI Agent Evals in CI: Run Them on Every Pull Request
  66. 66Autonomous AI Agent Runtime: Budgets, Crash Recovery, API
  67. 67Event-Driven AI Agents: Webhook, Cron and File Triggers
  68. 68Durable AI Agents: Crash Recovery and Safe Resume
  69. 69Deploy AI Agents in Production: Long-Running Python Agents
  70. 70What Is an AI Agent Framework? When You Need One
  71. 71LangGraph Alternatives: 7 Options and How to Migrate (2026)
← Path overview

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.3.1, FastAPI 0.143.0 and uvicorn 0.54.0, with gpt-5-mini for the agent and the evaluation, and the output is what it printed.

[01]

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:

>_Terminal
pip install promptise
promptise mcpcast http://127.0.0.1:8370/openapi.json --no-curate --profile standard --auth env-token
Output
Parsed 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

Text
examples=[...]

examples

The worked example in the tool description

Text
tags=[...]

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>=1.3.1" 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

Step 01

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.

Pythonapp.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
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:

>_Terminal
uvicorn app:app --port 8370 --reload
curl -s http://127.0.0.1:8370/openapi.json | head -c 200
Output
{"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.

Step 02

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:

>_Terminal
promptise mcpcast http://127.0.0.1:8370/openapi.json --no-curate
Output
Parsed 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:

YAMLmcpcast.plan.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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.

Step 03

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:

Pythonapp.py
1
2
3
4
5
6
7
8
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:

Output
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:

>_Terminal
promptise mcpcast http://127.0.0.1:8370/openapi.json --no-curate --auth env-token --force
Output
Parsed 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 …:

Output
@@ -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.3.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

Step 04

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:

Pythonapp.py
1
2
3
4
5
6
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:

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

Warning

If you generate client SDKs from the same spec, changing operation ids renames their methods too. Do it before anyone depends on the old names, or set operation_id= only on the routes you expose to agents.

Step 05

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:

Pythonapp.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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:

Output
-- 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:
+  - orders

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

Pythonorders_mcp/tools/orders.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    @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.; one of: "
            "pending, shipped\n"
            "  - limit (integer): Maximum number of orders to return.; default 20"
        ),
        tags=["orders"],
        read_only_hint=True,
        idempotent_hint=True,
        open_world_hint=True,
    )
    @advertise(PARAM_SCHEMAS["list_orders"])

@advertise puts each parameter's JSON Schema from the plan into the tool's input schema, so the allowed values and limits reach the agent too. This is the list_orders schema the agent receives:

Output
{'status': {'anyOf': [{'enum': ['pending', 'shipped'], 'type': 'string'}, {'type': 'null'}], 'description': 'Only orders with this status: pending or shipped.', 'title': 'Status'}, 'limit': {'default': 20, 'description': 'Maximum number of orders to return.', 'maximum': 100, 'minimum': 1, 'title': 'Limit', 'type': 'integer'}}

The Literal values became an enum, and ge=1, le=100 became minimum and maximum. Your FastAPI app still enforces them on every call; the schema tells the model about them before it calls. A Promptise agent passes them on to its model since Promptise 1.3.1.

Step 06

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:

>_Terminal
promptise mcpcast http://127.0.0.1:8370/openapi.json --no-curate --profile standard --auth env-token --review -y --force
Output
Parsed 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:

YAMLmcpcast.plan.yaml
1
2
3
4
5
6
  example:
    customer: acme
    items:
    - sku: MUG-01
      quantity: 2
  requires_approval: true

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

Step 07

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:

>_Terminal
cd orders-mcp
pip install -e ".[dev]"
pytest -q
Output
......                                                                   [100%]
6 passed in 0.05s

The six tests check that every tool is listed, that every parameter advertises its schema from the plan, that each tool calls the right method and route, and that a denied create_order never reaches your API.

Step 08

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:

Pythonagent.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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:

Output
→ Invoking tool: list_orders with {'status': 'pending', 'limit': 20}
✔ 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) — 3 × CAP-02
- Order 1002 (customer: globex) — 1 × TEE-M
→ Invoking tool: create_order with {'customer': 'globex', 'items': [{'sku': 'MUG-01', 'quantity': 3}]}
Elicitation request failed (McpError: Elicitation not supported) — treated as declined
✖ create_order error: APPROVAL_DENIED: Approval denied for tool 'create_order': client declined or returned an invalid elicitation response
>>> I’m sorry — I couldn’t create the order. The create_order tool call was denied (approval denied), so I don’t have permission to place the order right now.

I can help in one of these ways — tell me which you want:
- Retry creating the order once tool permission is available.
- I can prepare the exact order payload for you to paste/run yourself:
  {"customer":"globex","items":[{"sku":"MUG-01","quantity":3}]}
- Review/check SKUs or list current orders instead.

Please confirm how you’d like to proceed.

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 line right after the create_order call is the generated server's own log message, and it says why: approval in this server uses MCP elicitation, where the server asks the client to confirm the exact call. This agent was built without an approval handler, so it tells the server it can't answer, and the server fails closed. OpenAPI to MCP: Turn Any REST API into an MCP Server shows a handler that sees the server's question, and the pending approval mode for agents that run without a person at the client.

Tip

Tell the agent what to do with a denied call, but don't count on the instructions alone. Even with the second sentence in instructions, one of two runs of this agent answered the denial by asking the user to reply "Confirm" in chat, which can't approve anything. The gate in the server is what keeps the write out; the instructions only shape the reply.

To see the approved path, give the agent a reviewer. With approval=, build_agent sends the server's confirmation request to your handler and answers with its decision. This version of agent.py approves the write after printing what the server asks:

Pythonagent_approve.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import asyncio
import sys

from promptise import build_agent
from promptise.approval import ApprovalDecision, ApprovalRequest
from promptise.config import StdioServerSpec


async def reviewer(request: ApprovalRequest) -> ApprovalDecision:
    """Stands in for a person: show what the server asks, then say yes."""
    print(request.context_summary)
    return ApprovalDecision(approved=True)


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."
        ),
        approval=reviewer,
        trace_tools=True,
    )
    try:
        result = await agent.ainvoke(
            {"messages": [{"role": "user", "content": "Create an order for globex: 3 of MUG-01."}]}
        )
        print(">>>", result["messages"][-1].content)
    finally:
        await agent.shutdown()


asyncio.run(main())
Output
→ Invoking tool: create_order with {'customer': 'globex', 'items': [{'sku': 'MUG-01', 'quantity': 3}]}
Server 'orders' asks: Approval required: call tool 'create_order' with arguments {'customer': 'globex', 'items': [{'sku': 'MUG-01', 'quantity': 3}]}. Approve?
✔ Tool result from create_order: {"customer": "globex", "items": [{"sku": "MUG-01", "quantity": 3}], "id": 1004, "status": "pending"}
>>> Order created — #1004
Customer: globex
Items:
- MUG-01 × 3
Status: pending

Would you like me to do anything else with this order?

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. In production the handler asks a person, for example through a webhook or a queue: Server-side approval gates lists the handlers approval= accepts. For tests, build_server(approval_handler=...) in the generated package takes a reviewer function too, and TestClient runs the whole server in memory, gate included.

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:

JSONclaude_desktop_config.json
{
  "mcpServers": {
    "orders": { "command": "python", "args": ["/absolute/path/to/server.py"], "env": {"MCPCAST_UPSTREAM_TOKEN": "<your API key>"} }
  }
}

For Claude Code, it's one command:

>_Terminal
claude mcp add orders -e MCPCAST_UPSTREAM_TOKEN="<your API key>" -- python /absolute/path/to/server.py

Use 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:

Pythonapp.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
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:

>_Terminal
export OPENAI_API_KEY="sk-..."
export MCPCAST_UPSTREAM_TOKEN=dev-key
promptise mcpcast orders-mcp/mcpcast.plan.yaml --eval --eval-tasks 8
Output
Regenerating from plan orders-mcp/mcpcast.plan.yaml (3 tools)
…
Evaluating with openai:gpt-5-mini (8 tasks)…
Agent Readiness: A  (8/8 tasks succeeded)
  • `list_orders` has no example — agents lean on examples heavily
…

The report in orders-mcp/eval/report.md gives a score of 1.00, the right tool chosen first in all 8 tasks, and no parameter errors. Your app's log shows every read the agent made:

Output
INFO:     127.0.0.1:50406 - "GET /orders?status=pending&limit=2 HTTP/1.1" 200 OK
INFO:     127.0.0.1:50406 - "GET /orders HTTP/1.1" 200 OK
INFO:     127.0.0.1:50450 - "GET /orders/1001 HTTP/1.1" 200 OK
INFO:     127.0.0.1:50450 - "GET /orders/1001 HTTP/1.1" 200 OK
INFO:     127.0.0.1:50450 - "GET /orders/1001 HTTP/1.1" 200 OK

The get_order tasks all asked for order 1001, the number in the plan's example, so nothing came back not found. The three create_order tasks went to mocks, which is why the log has no POST. The one 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 this evaluation, on an older Promptise version, scored 7 of 8, because the task writer made up an order number that doesn't exist. If you use --eval as a release check, set a floor rather than expecting a number.

When the API doesn't recognise an identifier, the report says whether it came from the plan's example, from the agent guessing, or from the task or a mocked reply, and only the first two ask you to change the plan. It has done so since Promptise Foundry 1.3.0.


[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 are advertised, not enforced, by the generated server. The tool schema shows them to the agent; the generated handler checks only base types, and your app checks the rest.

  • A Promptise agent needs an approval handler for writes. Without approval= on build_agent, the agent tells the server it can't confirm calls, so gated tools are denied. Pass a handler as in step 8, or use --auth api-key with pending approvals, covered in Human approval.

  • Only required, nullable body fields can be sent as null. A required JSON body property your spec marks as nullable accepts None and is sent as null. An optional parameter's None is left out of the request, so a PATCH that clears an optional 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.

← PreviousMCP Transports: stdio vs Streamable HTTP vs SSE (Python)Next lesson →OpenAPI to MCP: Turn Any REST API into an MCP Server