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 18 / 71
25%

Build a Production MCP Server in Python (Step by Step)

Build an MCP server in Python ready for real agents: typed tools, clear errors, API keys with roles, rate limits, a tamper-evident audit log and tests.

Intermediate18 minModel Context Protocol (MCP)Authentication and Access ControlEvals and Testing
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. 18Build a Production MCP Server in Python (Step by Step)
  19. 19MCP 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. 18Build a Production MCP Server in Python (Step by Step)
  19. 19MCP 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

A first MCP server takes ten lines. A server you'd let real agents use takes a bit more thought: it has to tell the model exactly what each tool expects, refuse bad input, know who is calling, keep some callers out of some tools, survive a flood of requests, and leave a record of what happened. This guide builds a small help desk server in Python with Promptise Foundry and adds each of those, one step at a time. At the end you'll have a test suite, a real agent using the server, and an audit log you can prove nobody edited. Everything here was run against Promptise Foundry 1.3.1, with gpt-5-mini as the agent's model.

[01]

How do you build an MCP server in Python?

Install Promptise Foundry, create an MCPServer, and turn functions into tools with a decorator:

Pythonfirst_server.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from promptise.mcp.server import MCPServer

server = MCPServer("helpdesk")

TICKETS = {
    "T-101": {"subject": "Login fails after a password reset", "status": "open"},
    "T-102": {"subject": "Invoice shows the wrong VAT number", "status": "open"},
}


@server.tool()
async def search_tickets(query: str) -> list[dict]:
    """Find support tickets whose subject contains the query."""
    return [
        {"ticket_id": tid, **t}
        for tid, t in TICKETS.items()
        if query.lower() in t["subject"].lower()
    ]


if __name__ == "__main__":
    server.run()

That's a working MCP server. The function name becomes the tool name, the type hints become its input schema, and the docstring becomes the description the model reads. server.run() speaks stdio, so a desktop client or an agent can start it directly. The rest of this guide is about what you add before other people's agents depend on it.


[02]

What makes an MCP server production-ready?

Here's the request path you'll end up with. Every call passes through the same checks, in order, before your code runs:

Rendering diagram…

Need

What you use

The model understands each input

Annotated types with Field descriptions and limits

Clear failures

ToolError

Related tools grouped and named

MCPRouter

Knowing who is calling

AuthMiddleware with APIKeyAuth or JWTAuth

Some tools for some callers

roles= or guards= on the tool

Protection from floods and hangs

RateLimitMiddleware, TimeoutMiddleware

A record you can trust

AuditMiddleware

Confidence before you ship

TestClient


[03]

Build the help desk server, step by step

You need Python 3.10 or newer and Promptise Foundry 1.3.1 or newer (pip install "promptise>=1.3.1"). The complete server file is at the end of this section; each step shows the part it adds.

Step 01

Describe every input for the model

The model only sees your tool's name, description and input schema, so put your knowledge there. Annotated with a pydantic Field adds a description and limits to a parameter. A docstring's Args: section works too:

Pythonhelpdesk_server.py
1
2
3
4
5
6
7
8
9
10
11
12
13
TicketId = Annotated[str, Field(description='A ticket ID, for example "T-101".', pattern=r"^T-\d+$")]


@tickets.tool(read_only_hint=True)
async def search(
    query: Annotated[str, Field(description="Words to look for in the ticket subject.", min_length=2)],
    status: Literal["open", "closed"] | None = None,
) -> list[dict]:
    """Find support tickets whose subject contains the query.

    Args:
        status: Only return tickets with this status. Leave it out to search all tickets.
    """

This is the input schema the model receives for search, generated from those few lines:

Output
{'properties': {'query': {'description': 'Words to look for in the ticket subject.', 'minLength': 2, 'title': 'Query', 'type': 'string'}, 'status': {'anyOf': [{'enum': ['open', 'closed'], 'type': 'string'}, {'type': 'null'}], 'default': None, 'description': 'Only return tickets with this status. Leave it out to search all tickets.', 'title': 'Status'}}, 'required': ['query'], 'type': 'object'}

The limits aren't just documentation. Promptise checks them before your function runs, so a malformed ticket ID never reaches your code:

Output
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid input: ticket_id: String should match pattern '^T-\\d+$'",
    "retryable": true,
    "suggestion": "Check the parameter types and constraints in the tool description.",
    ...
Note

Parameter descriptions from Field and from Args: need Promptise Foundry 1.2.1 or newer. Earlier versions dropped them from the schema.

Step 02

Fail with a message the model can use

When something legitimately can't be done, raise ToolError with a sentence the model can pass on. It arrives as a structured error, not a crash:

Pythonhelpdesk_server.py
1
2
3
4
5
6
7
@tickets.tool(read_only_hint=True)
async def get(ticket_id: TicketId) -> dict:
    """Get one ticket with its subject, status and customer."""
    ticket = TICKETS.get(ticket_id)
    if ticket is None:
        raise ToolError(f"There is no ticket {ticket_id}.")
    return {"ticket_id": ticket_id, **ticket}
Output
{
  "error": {
    "code": "TOOL_ERROR",
    "message": "There is no ticket T-999.",
    "retryable": false
  }
}
Step 03

Group related tools with a router

As a server grows, routers keep it organised. A router's prefix becomes part of each tool name, so search, get and close on the tickets router reach the model as tickets_search, tickets_get and tickets_close:

Pythonhelpdesk_server.py
tickets = MCPRouter(prefix="tickets")

# ... tools defined with @tickets.tool() ...

server.include_router(tickets)
Step 04

Know who is calling, and limit what they can do

Turn on authentication for the whole server with require_auth=True, and give each caller an API key. The rich key format attaches roles to a key:

Pythonhelpdesk_server.py
1
2
3
4
5
6
7
8
9
10
11
12
server = MCPServer("helpdesk", version="1.0.0", require_auth=True)

server.add_middleware(
    AuthMiddleware(
        APIKeyAuth(
            keys={
                os.environ["HELPDESK_AGENT_KEY"]: {"client_id": "support-bot", "roles": ["agent"]},
                os.environ["HELPDESK_LEAD_KEY"]: {"client_id": "team-lead", "roles": ["agent", "lead"]},
            }
        )
    )
)

Then decide, per tool, who may call it. Closing a ticket is for team leads only:

Pythonhelpdesk_server.py
1
2
3
4
5
6
@tickets.tool(roles=["lead"], destructive_hint=True)
async def close(
    ticket_id: TicketId,
    resolution: Annotated[str, Field(description="One sentence the customer will see.", min_length=10)],
) -> dict:
    """Close a ticket with a resolution. Only team leads can close tickets."""

When the support bot tries anyway, the answer says exactly why:

Output
{
  "error": {
    "code": "ACCESS_DENIED",
    "message": "Requires any of roles [lead], but client has [agent]",
    "retryable": false,
    "details": {
      "guard": "HasRole",
      "tool": "tickets_close"
    }
  }
}

read_only_hint and destructive_hint are standard MCP tool annotations. They tell clients what kind of tool this is, so a client can, for example, ask the user before running a destructive one. If you're using JSON Web Tokens from an identity provider instead of API keys, swap APIKeyAuth for JWTAuth. Authentication & Security covers that, plus scope-based guards.

Step 05

Add rate limits, timeouts and an audit log

Middleware wraps every tool call. It runs in the order you add it, so the first one wraps all the others:

Pythonhelpdesk_server.py
server.add_middleware(LoggingMiddleware())
server.add_middleware(AuthMiddleware(...))  # from step 4
server.add_middleware(RateLimitMiddleware(rate_per_minute=120, per_tool=True))
server.add_middleware(TimeoutMiddleware(default_timeout=10))
server.add_middleware(AuditMiddleware("audit.jsonl", hmac_secret=os.environ["HELPDESK_AUDIT_SECRET"]))
  • RateLimitMiddleware caps how often tools can be called, here 120 calls a minute per tool, so one runaway agent can't flood your backend.

  • TimeoutMiddleware stops any call that takes longer than ten seconds and returns a TIMEOUT error the agent can retry.

  • AuditMiddleware writes one line per call: which tool, which client, which roles, and whether it worked.

Here are the three audit lines from a real run, shortened:

Output
{"timestamp": 1791711456.96081, "tool": "tickets_search", "client_id": "support-bot", "request_id": "c22ca232bdac", "status": "ok", …
{"timestamp": 1791711458.249373, "tool": "tickets_close", "client_id": "support-bot", "request_id": "17ba82af8fbb", "status": "error", …
{"timestamp": 1791711460.048096, "tool": "tickets_get", "client_id": "support-bot", "request_id": "2e96b9bab17f", "status": "ok", …

Each line also carries its position in the chain (seq) and the signature of the line before it, signed with your secret. That makes the log tamper-evident: change a field, delete a line or reorder two, and the chain breaks. A restarted server continues the chain in the same file. Promptise ships a checker; point it at the variable that holds your secret:

>_Terminal
promptise audit verify audit.jsonl --key-env HELPDESK_AUDIT_SECRET

On the untouched log from the agent run below:

Output
OK 3 entries: intact.
last_hash 76faa13af8c0ffcb7139d23ed9903b856c15bdc8d29c420fef213d04f0f7ccab (seq 2)

After changing a single status from ok to error on the first line, and on a copy with the second line deleted:

Output
TAMPERED edited.jsonl:1: modified: the entry was changed after it was signed (its signature no longer matches, the next entry still links to it)
FAILED 3 entries: 1 problem; the first is at edited.jsonl:1.
TAMPERED deleted.jsonl:2: deleted: 1 entry deleted between line 1 and this line (seq 0 -> 2)
FAILED 2 entries: 1 problem; the first is at deleted.jsonl:2.

The command exits with 1 when the log was changed, so it fits a scheduled job or a CI step. In Python, verify_audit_log("audit.jsonl", secret) from promptise.mcp.server returns the same report, with ok, entries and each problem.

Warning

Keep hmac_secret somewhere safe; nobody can verify the log without it. If you write a log file without hmac_secret or the PROMPTISE_AUDIT_SECRET environment variable, AuditMiddleware raises ValueError when the server starts, instead of signing with a random key that can't be checked later.

Calls rejected before the audit middleware, such as those without a valid key or with invalid input, don't reach the log. Move AuditMiddleware earlier in the chain if you want those recorded too.

Step 06

Test it without starting anything

TestClient runs the full pipeline in memory: validation, authentication, middleware, role checks and your code. Pass request metadata to act as a particular caller:

Pythontest_helpdesk.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
import os

os.environ.setdefault("HELPDESK_AGENT_KEY", "test-agent-key")
os.environ.setdefault("HELPDESK_LEAD_KEY", "test-lead-key")
os.environ.setdefault("HELPDESK_AUDIT_SECRET", "test-audit-secret")

from promptise.mcp.server import TestClient  # noqa: E402

from helpdesk_server import server  # noqa: E402

AGENT = TestClient(server, meta={"x-api-key": "test-agent-key"})
LEAD = TestClient(server, meta={"x-api-key": "test-lead-key"})


async def test_tools_are_listed_with_descriptions():
    tools = {t.name: t for t in await AGENT.list_tools()}
    assert set(tools) == {"tickets_search", "tickets_get", "tickets_close"}
    status = tools["tickets_search"].inputSchema["properties"]["status"]
    assert "Only return tickets with this status" in status["description"]


async def test_search_finds_open_login_tickets():
    result = await AGENT.call_tool("tickets_search", {"query": "login", "status": "open"})
    assert "T-101" in result[0].text and "T-103" not in result[0].text


async def test_an_agent_cannot_close_tickets():
    result = await AGENT.call_tool("tickets_close", {"ticket_id": "T-102", "resolution": "Corrected the VAT number."})
    assert "ACCESS_DENIED" in result[0].text


async def test_a_lead_can_close_tickets():
    result = await LEAD.call_tool("tickets_close", {"ticket_id": "T-102", "resolution": "Corrected the VAT number."})
    assert '"status": "closed"' in result[0].text


async def test_bad_input_is_rejected_before_the_handler_runs():
    result = await AGENT.call_tool("tickets_get", {"ticket_id": "101"})
    assert "VALIDATION_ERROR" in result[0].text


async def test_calls_without_a_key_are_refused():
    result = await TestClient(server).call_tool("tickets_get", {"ticket_id": "T-101"})
    assert "AUTHENTICATION" in result[0].text.upper()

Install the test tools and run them. The tests are async, so tell pytest-asyncio to run them automatically:

>_Terminal
pip install pytest pytest-asyncio
pytest -o asyncio_mode=auto
Output
…
test_helpdesk.py ......                                                  [100%]

============================== 6 passed in 1.26s ===============================

These tests caught a real mistake while this guide was being written: the first version of a ticket said "log in", and a search for "login" quietly found nothing.

The complete server

Pythonhelpdesk_server.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
87
88
89
90
91
import os
from typing import Annotated, Literal

from pydantic import Field

from promptise.mcp.server import (
    APIKeyAuth,
    AuditMiddleware,
    AuthMiddleware,
    LoggingMiddleware,
    MCPRouter,
    MCPServer,
    RateLimitMiddleware,
    TimeoutMiddleware,
    ToolError,
)

server = MCPServer("helpdesk", version="1.0.0", require_auth=True)

# Middleware runs in the order it's added: the first one wraps all the others.
server.add_middleware(LoggingMiddleware())
server.add_middleware(
    AuthMiddleware(
        APIKeyAuth(
            keys={
                os.environ["HELPDESK_AGENT_KEY"]: {"client_id": "support-bot", "roles": ["agent"]},
                os.environ["HELPDESK_LEAD_KEY"]: {"client_id": "team-lead", "roles": ["agent", "lead"]},
            }
        )
    )
)
server.add_middleware(RateLimitMiddleware(rate_per_minute=120, per_tool=True))
server.add_middleware(TimeoutMiddleware(default_timeout=10))
server.add_middleware(AuditMiddleware("audit.jsonl", hmac_secret=os.environ["HELPDESK_AUDIT_SECRET"]))

# A stand-in for your ticketing system.
TICKETS = {
    "T-101": {"subject": "Login fails after a password reset", "status": "open", "customer": "acme"},
    "T-102": {"subject": "Invoice shows the wrong VAT number", "status": "open", "customer": "globex"},
    "T-103": {"subject": "Login page is slow", "status": "closed", "customer": "acme"},
}

TicketId = Annotated[str, Field(description='A ticket ID, for example "T-101".', pattern=r"^T-\d+$")]

tickets = MCPRouter(prefix="tickets")


@tickets.tool(read_only_hint=True)
async def search(
    query: Annotated[str, Field(description="Words to look for in the ticket subject.", min_length=2)],
    status: Literal["open", "closed"] | None = None,
) -> list[dict]:
    """Find support tickets whose subject contains the query.

    Args:
        status: Only return tickets with this status. Leave it out to search all tickets.
    """
    words = query.lower()
    return [
        {"ticket_id": tid, **t}
        for tid, t in TICKETS.items()
        if words in t["subject"].lower() and (status is None or t["status"] == status)
    ]


@tickets.tool(read_only_hint=True)
async def get(ticket_id: TicketId) -> dict:
    """Get one ticket with its subject, status and customer."""
    ticket = TICKETS.get(ticket_id)
    if ticket is None:
        raise ToolError(f"There is no ticket {ticket_id}.")
    return {"ticket_id": ticket_id, **ticket}


@tickets.tool(roles=["lead"], destructive_hint=True)
async def close(
    ticket_id: TicketId,
    resolution: Annotated[str, Field(description="One sentence the customer will see.", min_length=10)],
) -> dict:
    """Close a ticket with a resolution. Only team leads can close tickets."""
    ticket = TICKETS.get(ticket_id)
    if ticket is None:
        raise ToolError(f"There is no ticket {ticket_id}.")
    ticket.update(status="closed", resolution=resolution)
    return {"ticket_id": ticket_id, **ticket}


server.include_router(tickets)

if __name__ == "__main__":
    server.run(transport="http", host="127.0.0.1", port=8080)

[04]

Put a real agent in front of it

Start the server with the three secrets set, and it listens at http://127.0.0.1:8080/mcp. Then connect an agent with the support bot's key. This is the same pattern as in How to Connect MCP Servers to Your AI Agent in Python:

Pythonsupport_agent.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
import asyncio
import os

from promptise import build_agent
from promptise.config import HTTPServerSpec


async def main():
    agent = await build_agent(
        model="openai:gpt-5-mini",
        servers={
            "helpdesk": HTTPServerSpec(
                url="http://127.0.0.1:8080/mcp",
                api_key=os.environ["HELPDESK_AGENT_KEY"],
            ),
        },
        instructions="You are a support assistant. Use the help desk tools.",
        trace_tools=True,
    )
    try:
        result = await agent.ainvoke(
            {"messages": [{"role": "user", "content": "Find the open login ticket and close it as fixed."}]}
        )
        print(">>>", result["messages"][-1].content)
    finally:
        await agent.shutdown()


asyncio.run(main())
Output
→ Invoking tool: tickets_search with {'query': 'login', 'status': 'open'}
✔ Tool result from tickets_search: [{"ticket_id": "T-101", "subject": "Login fails after a password reset", "status": "open", "customer": "acme"}]
→ Invoking tool: tickets_close with {'ticket_id': 'T-101', 'resolution': 'We fixed the login-after-password-reset issue; please try logging in again and let us know if you still see problems.'}
✖ tickets_close error: ACCESS_DENIED: Requires any of roles [lead], but client has [agent]
→ Invoking tool: tickets_get with {'ticket_id': 'T-101'}
✔ Tool result from tickets_get: {"ticket_id": "T-101", "subject": "Login fails after a password reset", "status": "open", "customer": "acme"}
>>> I found the open login ticket (T-101) but was unable to close it because my account lacks the required "lead" role. …

Read it from the top and the rules from this guide show up. The agent found the ticket and went straight to closing it, with a full sentence as the resolution. It never tried a one-word answer, because the model saw that resolution needs at least 10 characters: since Promptise 1.3.1, an agent's model sees each parameter's limits, like minLength and pattern, not just its type. The server still enforces them for every other client. Then the role check stopped the agent, because the support bot isn't a lead. The refusal shows up as a failed call (✖); the agent looked the ticket up and told the user why it couldn't close it. Nothing in the agent's code mentions any of these rules; they live in the server, where they hold no matter which agent calls.


[05]

Before you deploy

  • Keep secrets out of code. API keys and the audit secret come from environment variables or a secret manager.

  • Put HTTPS in front. Run the server behind a reverse proxy or load balancer that terminates TLS. server.run() binds 127.0.0.1 by default, so only the same machine can reach it. When the proxy runs on another host or the server runs in a container, pass host="0.0.0.0" and allowed_hosts with the hostname your proxy forwards.

  • Use your identity provider in production. API keys suit service-to-service calls. For people and per-user access, use JWTAuth, and see Multi-Tenancy if several customers share one server.

  • Ask a human before risky actions. Mark a tool with requires_approval=True and connect an approver, and each call waits for a person's sign-off. Approval Gates shows how to set it up.


[06]

Frequently asked questions

Do I need async functions for tools?

The examples use async def, which suits tools that call databases or other APIs without blocking. Keep slow, blocking work out of your tools or move it to a background job, so one call doesn't hold up the others.

stdio or HTTP?

Use stdio while you build and for tools that run on the same machine as the client. Use HTTP for anything shared or deployed. Authentication and per-caller roles only make sense over HTTP, where each request carries its own key or token.

How do I see what my server exposes?

Connect any MCP client and list its tools, or call list_tools() on a TestClient as the tests above do. Each tool's description and input schema are exactly what the model will read.

Can I use JWTs instead of API keys?

Yes. JWTAuth validates tokens, puts their roles and scopes on the request, and works with the same roles= and guards. Authentication & Security has the details.


[07]

Where to go next

  • Server fundamentals: tools, resources, prompts and dependency injection.

  • Routers & Middleware: every built-in middleware and how to write your own.

  • Testing: more on TestClient.

  • Production Features and Deployment: caching, health, queues and running in production.

  • OpenAPI to MCP: Turn Any REST API into an MCP Server: generate a server like this from an existing API.

← PreviousHow to Connect MCP Servers to Your AI Agent in PythonNext lesson →MCP Transports: stdio vs Streamable HTTP vs SSE (Python)