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

← Guides> AI Engineering

MCP Security Best Practices: A Python Checklist

15 MCP security best practices built into one Python MCP server, then tested with 15 attacks: 11 got through the naive server, none the hardened one.

Level
Intermediate
Reading time
15 min
Published
Oct 15, 2026
By
Promptise Team
  • MCP
  • MCP Security
  • MCP Servers
  • Authentication
  • AI Agent Security
  • Python
  • Promptise Foundry

MCP security best practices come down to one rule: treat every tool call as a request from an untrusted client, because it is one. Authenticate each call, bind tokens to this server, give each tool the narrowest scope, validate every argument, put a human in front of irreversible actions, rate limit per client, keep secrets out of code and tool output, and keep a tamper-evident log. This checklist has 15 points, each mapped to the Promptise Foundry feature that implements it. To prove the list works, we ran 15 attacks against two copies of one MCP server: the naive copy let 11 of them through, and the hardened copy stopped 14 cleanly. The fifteenth was refused in a way that is still a problem, a bug we cover below. Everything ran on Promptise Foundry 1.3.3 from PyPI, with no model calls, and every output is pasted from that run.

This page is the hub for MCP server security. Each point links to the guide that covers it in depth instead of repeating it.

[01]

The MCP security checklist

The official MCP Security Best Practices list the attacks: confused deputy, token passthrough, SSRF, session hijacking, local server compromise and over-broad scopes. The checklist below turns them, and the everyday risks of a server that an AI agent calls, into controls you can check off.

#

Control

Stops

In Promptise

Deep dive

1

Authenticate every call

Anonymous callers

MCPServer(require_auth=True) + AuthMiddleware

MCP authentication

2

Accept only tokens issued for this server, and keep them short-lived

Token passthrough, replay across servers

JWTAuth(audience=...), exp checked on every call

MCP authentication

3

Give each tool the narrowest scope or role

Privilege escalation

guards=[HasScope(...)], roles=[...]

Agent identity

4

Only list the tools a caller may use

Reconnaissance, wasted agent turns

MCPServer(hide_unauthorized_tools=True)

This guide

5

Validate every argument with tight schemas

Injection-shaped input, absurd values

Text
Annotated[..., Field(pattern=, max_length=, le=)]

Production MCP server

6

Require human approval for irreversible tools

An agent, or a hijacked one, moving money

requires_approval=True + ApprovalGateMiddleware

Human-in-the-loop approval

7

Rate limit per client, after authentication

Runaway agents, floods

RateLimitMiddleware

Rate limiting per client

8

Cap concurrency and time per call

Resource exhaustion

ConcurrencyLimiter, TimeoutMiddleware

Caching and circuit breakers

9

Return clean errors that leak nothing

Recon through stack traces

ToolError; unhandled exceptions become INTERNAL_ERROR

This guide

10

Keep secrets out of code, config and tool output

Credential leaks

Env vars + a redaction middleware with CredentialDetector

Guardrails

11

Keep a tamper-evident audit log, and verify it

Silent misuse, edited evidence

AuditMiddleware + promptise audit verify

Audit logging

12

Lock down the transport

DNS rebinding, exposure on the network

Loopback bind by default, allowed_hosts / allowed_origins

MCP transports

13

Treat tool results as untrusted on the agent side

Prompt injection through data

PromptiseSecurityScanner(scan_tool_results=True)

Prompt injection in agents

14

Review and pin the third-party servers your agents use

Tool poisoning, rug pulls

Not built in: see Honest limits

Too many MCP tools

15

Test the security behaviour in CI

Regressions

TestClient tests + an attack script like the one below

Testing MCP servers

Points 1 to 12 live on the server, and the rest of this guide implements them on one server and attacks it. Points 13 and 14 are about the agent that calls the server; point 15 is how you keep the other 14 from quietly breaking.


[02]

What you need

  • Python 3.10 or newer, and Promptise Foundry 1.3.3 or newer.

  • No model and no API key: the attacks call the server directly, as any MCP client could.

  • Two free local ports, here 9750 and 9751.

>_Terminal
pip install "promptise>=1.3.3" pyjwt httpx

Both extras are already pulled in by Promptise's dependencies; naming them keeps the attack script's imports explicit.


[03]

Secure an MCP server in Python, step by step

Step 01

Start from the naive server

This is the server most tutorials end with. The tools are correct, and nothing else is there. It has an order lookup, a search, a refund and a status tool that returns its own configuration:

Pythonnaive_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
"""The 'it works on my machine' MCP server: correct tools, no defences."""
from promptise.mcp.server import MCPServer

ORDERS = {"A-1001": {"customer": "acme", "total": 129.00, "status": "paid"}}
CONFIG = {"warehouse_db": "postgres://app:S3cr3t-pw@db.internal:5432/orders"}

server = MCPServer("orders-naive")


@server.tool()
async def get_order(order_id: str) -> dict:
    """Get an order by ID."""
    if order_id not in ORDERS:
        raise LookupError(f"order {order_id} not found via {CONFIG['warehouse_db']}")
    return {"order_id": order_id, **ORDERS[order_id]}


@server.tool()
async def search_orders(query: str) -> list[dict]:
    """Search orders by customer name."""
    return [{"order_id": k, **v} for k, v in ORDERS.items() if query.lower() in v["customer"]]


@server.tool()
async def refund_order(order_id: str, amount: float) -> dict:
    """Refund an order."""
    return {"order_id": order_id, "refunded": amount}


@server.tool()
async def integration_status() -> dict:
    """Show which back-end systems this server talks to."""
    return {"ok": True, "config": CONFIG}


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

Apply the checklist

The hardened server has the same four tools. The numbers in brackets are the checklist points:

Pythonsecure_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
92
93
94
"""The same four tools, with the checklist applied. Secrets come from the
environment; the server refuses to start without them."""
import os
from typing import Annotated

from promptise.guardrails import CredentialDetector, PromptiseSecurityScanner
from promptise.mcp.server import (
    ApprovalGateMiddleware,
    AuditMiddleware,
    AuthMiddleware,
    ConcurrencyLimiter,
    HasScope,
    JWTAuth,
    MCPServer,
    PendingApprover,
    RateLimitMiddleware,
    RequestContext,
    TimeoutMiddleware,
    ToolError,
)
from pydantic import Field

# [10] Secrets come from the environment: a missing one is a KeyError at start-up.
JWT_SECRET = os.environ["ORDERS_JWT_SECRET"]
DB_URL = os.environ["ORDERS_DB_URL"]
os.environ["PROMPTISE_AUDIT_SECRET"]  # the audit key; AuditMiddleware reads it itself

ORDERS = {"A-1001": {"customer": "acme", "total": 129.00, "status": "paid"}}

# [5] A tight schema for every argument.
OrderId = Annotated[str, Field(pattern=r"^[A-Z]-\d{4}$", description="An order ID like A-1001.")]
Query = Annotated[str, Field(min_length=2, max_length=100)]
Amount = Annotated[float, Field(gt=0, le=500)]


class RedactSecrets:
    """Middleware: scrub credentials from every string a tool returns."""

    def __init__(self) -> None:
        self.scanner = PromptiseSecurityScanner(detectors=[CredentialDetector()])

    async def clean(self, value):
        if isinstance(value, str):
            return await self.scanner.check_output(value)
        if isinstance(value, dict):
            return {k: await self.clean(v) for k, v in value.items()}
        if isinstance(value, list):
            return [await self.clean(v) for v in value]
        return value

    async def __call__(self, ctx: RequestContext, call_next):
        return await self.clean(await call_next(ctx))


server = MCPServer("orders", require_auth=True,                  # [1] every call authenticates
                   hide_unauthorized_tools=True)               # [4] list only what a caller may use
server.add_middleware(AuthMiddleware(JWTAuth(secret=JWT_SECRET, audience="orders-mcp")))  # [2]
server.add_middleware(AuditMiddleware(log_path="audit.jsonl", include_args=True))         # [11]
server.add_middleware(RateLimitMiddleware(rate_per_minute=60, burst=10))                  # [7]
server.add_middleware(ConcurrencyLimiter(max_concurrent=20))                               # [8]
server.add_middleware(TimeoutMiddleware(default_timeout=10.0))                             # [8]
server.add_middleware(RedactSecrets())                                                    # [10]
approver = PendingApprover(server, approver_role="approver")
server.add_middleware(ApprovalGateMiddleware(approver, timeout=3))                        # [6]


@server.tool(read_only_hint=True, guards=[HasScope("orders:read")])                       # [3]
async def get_order(order_id: OrderId) -> dict:
    """Get an order by ID."""
    if order_id not in ORDERS:
        raise ToolError(f"No order {order_id}.")                                          # [9]
    return {"order_id": order_id, **ORDERS[order_id]}


@server.tool(read_only_hint=True, guards=[HasScope("orders:read")])
async def search_orders(query: Query) -> list[dict]:
    """Search orders by customer name."""
    return [{"order_id": k, **v} for k, v in ORDERS.items() if query.lower() in v["customer"]]


@server.tool(destructive_hint=True, guards=[HasScope("refunds:write")], requires_approval=True)
async def refund_order(order_id: OrderId, amount: Amount) -> dict:
    """Refund an order. Needs the refunds:write scope and a human approval."""
    return {"order_id": order_id, "refunded": amount}


@server.tool(read_only_hint=True, roles=["ops"])
async def integration_status() -> dict:
    """Show which back-end systems this server talks to."""
    return {"ok": True, "config": {"warehouse_db": DB_URL}}


if __name__ == "__main__":
    server.run(transport="http", host="127.0.0.1", port=9751)                            # [12]

What each piece does:

  • Secrets come from the environment [10]. The JWT key, the audit key and the database URL are read with os.environ[...], so a missing secret stops the server at start-up instead of running with a default. AuditMiddleware also refuses to write a signed log file without a key you keep.

  • Every call authenticates, with a token issued for this server [1, 2]. require_auth=True makes every tool authenticate. JWTAuth(audience="orders-mcp") rejects tokens minted for any other service, even ones signed with the same key: that's the MCP spec's rule against token passthrough, enforced. Expiry is checked on every call.

  • Each tool has one scope or role [3, 4]. Reads need orders:read, refunds need refunds:write, and integration_status needs the ops role. hide_unauthorized_tools=True filters tools/list per caller with the same guards.

  • Arguments have tight schemas [5]. An order ID must match ^[A-Z]-\d{4}$, a search is 2 to 100 characters, and a refund is more than 0 and at most 500.

  • Middleware order matters [6, 7, 8, 11]. Authentication runs first, so the audit log records who called and the rate limiter keeps one bucket per client. The approval gate runs last and checks the tool's guards before asking anyone, so a caller without the scope never reaches a reviewer.

  • Refunds wait for a person [6]. requires_approval=True parks the call until someone with the approver role decides through the auto-registered approvals_list and approvals_decide tools, and nobody can approve their own call. With no decision in 3 seconds the call is denied: the gate fails closed. Use minutes in production.

  • Tool output is scrubbed [10]. RedactSecrets runs every string a tool returns through CredentialDetector, which ships 96 credential patterns. It works on the result object, not its JSON text, for a reason covered in Honest limits.

  • Errors say only what the caller needs [9]. ToolError("No order Z-9999.") becomes a structured error with a code. Anything else a tool raises becomes INTERNAL_ERROR with a generic message.

  • Loopback bind [12]. run(host="127.0.0.1") is reachable from this machine only, and Promptise turns on Host and Origin validation for loopback binds by default.

The secrets for this local run come from a small helper that generates them once. In production they come from your secret manager:

>_Terminalenv.sh
# Demo secrets for a local run, generated once into demo-secrets.env.
# In production these come from your secret manager, never from the repo.
[ -f demo-secrets.env ] || python3 - > demo-secrets.env <<'PY'
import secrets
print(f"export ORDERS_JWT_SECRET={secrets.token_urlsafe(32)}")
print("export ORDERS_DB_URL=postgres://app:S3cr3t-pw@db.internal:5432/orders")
print(f"export PROMPTISE_AUDIT_SECRET={secrets.token_urlsafe(32)}")
PY
source demo-secrets.env
Step 03

Write the attack script

The script makes the calls a careless or hostile client would. Each attack is one tool call with one token, and the script prints whether the tool ran. Tokens are minted with PyJWT, so the script can also forge them:

Pythonattack.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
92
93
"""Attack an MCP server the way a careless client or a hostile one would.

python attack.py http://127.0.0.1:9750/mcp      # naive
python attack.py http://127.0.0.1:9751/mcp      # hardened
"""
import asyncio
import os
import sys
import time

import httpx
import jwt
from promptise.mcp.client import MCPClient

URL = sys.argv[1]
SECRET = os.environ["ORDERS_JWT_SECRET"]


def token(scopes="", roles=(), aud="orders-mcp", secret=SECRET, ttl=300, sub="support-bot"):
    now = int(time.time())
    claims = {"sub": sub, "aud": aud, "scope": scopes, "roles": list(roles),
              "iat": now, "exp": now + ttl}
    return jwt.encode(claims, secret, algorithm="HS256")


SUPPORT = token("orders:read")
REFUNDER = token("orders:read refunds:write", sub="refund-agent")
OPS = token("orders:read", roles=["ops"], sub="ops-agent")


async def call(tool, args, bearer=None):
    """Return (outcome, text): 'ok' when the tool ran, else the refusal."""
    try:
        async with MCPClient(url=URL, bearer_token=bearer) as client:
            result = await client.call_tool(tool, args)
    except Exception as exc:
        return "refused", f"{type(exc).__name__}: {exc}"
    text = " ".join(" ".join(getattr(c, "text", "").split()) for c in result.content)
    return ("refused" if result.isError else "ok"), text


async def flood(n=50):
    async with MCPClient(url=URL, bearer_token=SUPPORT) as client:
        results = await asyncio.gather(*(client.call_tool("get_order", {"order_id": "A-1001"})
                                         for _ in range(n)))
    return sum(not r.isError for r in results), n


async def rebinding(bearer=None):
    """A web page whose DNS name was rebound to 127.0.0.1 sends its own Host header."""
    body = {"jsonrpc": "2.0", "id": 1, "method": "initialize",
            "params": {"protocolVersion": "2025-06-18", "capabilities": {},
                       "clientInfo": {"name": "evil-page", "version": "1"}}}
    async with httpx.AsyncClient() as http:
        r = await http.post(URL, json=body, headers={
            "Host": "attacker.example", "Origin": "http://attacker.example",
            "Accept": "application/json, text/event-stream",
            **({"Authorization": f"Bearer {bearer}"} if bearer else {})})
    return r.status_code


ATTACKS = [
    ("No credentials", "get_order", {"order_id": "A-1001"}, None),
    ("Forged token (wrong key)", "get_order", {"order_id": "A-1001"},
     token("orders:read", secret="a-guessed-secret-of-32-bytes-len")),
    ("Token minted for another server", "get_order", {"order_id": "A-1001"},
     token("orders:read", aud="billing-mcp")),
    ("Expired token", "get_order", {"order_id": "A-1001"}, token("orders:read", ttl=-60)),
    ("Read-only agent issues a refund", "refund_order", {"order_id": "A-1001", "amount": 129}, SUPPORT),
    ("Refund with scope, no human approval", "refund_order", {"order_id": "A-1001", "amount": 129}, REFUNDER),
    ("Refund larger than any order", "refund_order", {"order_id": "A-1001", "amount": 1e9}, REFUNDER),
    ("Path-traversal-shaped ID", "get_order", {"order_id": "../../etc/passwd"}, SUPPORT),
    ("1 MB search string", "search_orders", {"query": "x" * 1_000_000}, SUPPORT),
    ("Error message leaks internals", "get_order", {"order_id": "Z-9999"}, SUPPORT),
    ("Tool output leaks a DB password", "integration_status", {}, OPS),
    ("Support agent reads ops config", "integration_status", {}, SUPPORT),
]


async def main():
    for name, tool, args, bearer in ATTACKS:
        started = time.perf_counter()
        outcome, text = await call(tool, args, bearer)
        leaked = "S3cr3t-pw" in text
        print(f"{name:<38} {outcome:<8} {time.perf_counter() - started:4.1f}s  "
              f"{'LEAKED SECRET  ' if leaked else ''}{len(text):>7,} chars  {text[:70]}")
    served, n = await flood()
    print(f"{'Flood: 50 parallel calls, one client':<38} {served}/{n} served")
    print(f"{'DNS rebinding, no token':<38} HTTP {await rebinding()}")
    print(f"{'DNS rebinding, valid token':<38} HTTP {await rebinding(SUPPORT)}")


asyncio.run(main())
Step 04

Attack both servers

run_demo.sh starts both servers, runs the attacks and a tool listing against each, stops the servers by PID, then checks the audit log in a scratch copy:

>_Terminalrun_demo.sh
#!/bin/zsh
# Start both servers, attack them, stop them by PID, then verify and tamper with the audit log.
HERE=${0:A:h} && cd $HERE && source env.sh && rm -f audit.jsonl && mkdir -p out
python naive_server.py > out/naive_server.log 2>&1 & NAIVE=$!
python secure_server.py > out/secure_server.log 2>&1 & SECURE=$!
for i in {1..30}; do
  curl -sf -o /dev/null http://127.0.0.1:9750/health && curl -sf -o /dev/null http://127.0.0.1:9751/health && break
  sleep 1
done
python attack.py http://127.0.0.1:9750/mcp > out/attack_naive.txt 2>&1
python attack.py http://127.0.0.1:9751/mcp > out/attack_secure.txt 2>&1
python list_tools.py http://127.0.0.1:9750/mcp > out/list_naive.txt 2>&1
python list_tools.py http://127.0.0.1:9751/mcp > out/list_secure.txt 2>&1
kill $NAIVE $SECURE

# Verify the log, tamper with a copy, verify again
WORK=$(mktemp -d) && cp audit.jsonl tamper.py demo-secrets.env $WORK/ && cd $WORK
{ echo '$ promptise audit verify audit.jsonl'; promptise audit verify audit.jsonl; echo "exit=$?"
  echo '$ python tamper.py'; python tamper.py
  echo '$ promptise audit verify audit.jsonl'; promptise audit verify audit.jsonl; echo "exit=$?"
} > $HERE/out/audit_verify.txt 2>&1

Against the naive server:

Output
No credentials                         ok        0.0s       76 chars  {"order_id": "A-1001", "customer": "acme", "total": 129.0, "status": "
Forged token (wrong key)               ok        0.0s       76 chars  {"order_id": "A-1001", "customer": "acme", "total": 129.0, "status": "
Token minted for another server        ok        0.0s       76 chars  {"order_id": "A-1001", "customer": "acme", "total": 129.0, "status": "
Expired token                          ok        0.0s       76 chars  {"order_id": "A-1001", "customer": "acme", "total": 129.0, "status": "
Read-only agent issues a refund        ok        0.0s       41 chars  {"order_id": "A-1001", "refunded": 129.0}
Refund with scope, no human approval   ok        0.0s       41 chars  {"order_id": "A-1001", "refunded": 129.0}
Refund larger than any order           ok        0.0s       48 chars  {"order_id": "A-1001", "refunded": 1000000000.0}
Path-traversal-shaped ID               refused   0.0s       99 chars  {"error": {"code": "INTERNAL_ERROR", "message": "An internal error occ
1 MB search string                     ok        0.0s        2 chars  []
Error message leaks internals          refused   0.0s       99 chars  {"error": {"code": "INTERNAL_ERROR", "message": "An internal error occ
Tool output leaks a DB password        ok        0.0s  LEAKED SECRET       92 chars  {"ok": true, "config": {"warehouse_db": "postgres://app:S3cr3t-pw@db.i
Support agent reads ops config         ok        0.0s  LEAKED SECRET       92 chars  {"ok": true, "config": {"warehouse_db": "postgres://app:S3cr3t-pw@db.i
Flood: 50 parallel calls, one client   50/50 served
DNS rebinding, no token                HTTP 421
DNS rebinding, valid token             HTTP 421

Against the hardened server:

Output
No credentials                         refused   0.0s      156 chars  MCPConnectionRejectedError: Server at http://127.0.0.1:9751/mcp reject
Forged token (wrong key)               refused   0.0s      156 chars  MCPConnectionRejectedError: Server at http://127.0.0.1:9751/mcp reject
Token minted for another server        refused   0.0s      156 chars  MCPConnectionRejectedError: Server at http://127.0.0.1:9751/mcp reject
Expired token                          refused   0.0s      156 chars  MCPConnectionRejectedError: Server at http://127.0.0.1:9751/mcp reject
Read-only agent issues a refund        refused   0.0s      203 chars  { "error": { "code": "ACCESS_DENIED", "message": "Requires any of scop
Refund with scope, no human approval   refused   3.0s      228 chars  { "error": { "code": "APPROVAL_DENIED", "message": "Approval for tool 
Refund larger than any order           refused   0.0s       71 chars  Input validation error: 1000000000.0 is greater than the maximum of 50
Path-traversal-shaped ID               refused   0.0s       74 chars  Input validation error: '../../etc/passwd' does not match '^[A-Z]-\\d{
1 MB search string                     refused   0.0s  1,000,038 chars  Input validation error: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Error message leaks internals          refused   0.0s       88 chars  { "error": { "code": "TOOL_ERROR", "message": "No order Z-9999.", "ret
Tool output leaks a DB password        ok        0.0s       65 chars  {"ok": true, "config": {"warehouse_db": "[POSTGRES_CONNECTION]"}}
Support agent reads ops config         refused   0.0s      192 chars  { "error": { "code": "ACCESS_DENIED", "message": "Requires any of role
Flood: 50 parallel calls, one client   8/50 served
DNS rebinding, no token                HTTP 401
DNS rebinding, valid token             HTTP 421

[04]

Attack results: naive vs hardened MCP server

Attack

Naive server

Hardened server

Control

No credentials

Served

Refused (HTTP 401)

1

Forged token, wrong key

Served

Refused (401)

1

Token minted for another server

Served

Refused (401)

2

Expired token

Served

Refused (401)

2

Read-only agent issues a refund

Refund ran

ACCESS_DENIED, names the missing scope

3

Refund with the scope, no human approval

Refund ran

APPROVAL_DENIED after the 3 s timeout

6

Refund of 1,000,000,000

Refund ran

Validation error

5

Path-traversal-shaped order ID

Failed with a generic error

Validation error

5

1 MB search string

Served

Refused, but the error is 1 MB long (bug)

5

Unknown order ID

Generic INTERNAL_ERROR, no leak

Clean TOOL_ERROR, no leak

9

Tool returns a database URL

Password leaked

Text
[POSTGRES_CONNECTION]

10

Support agent reads ops config

Password leaked

ACCESS_DENIED

3

50 parallel calls from one client

50 of 50 served

8 of 50 served

7

DNS rebinding, no token

HTTP 421

HTTP 401

12, 1

DNS rebinding, valid token

HTTP 421

HTTP 421

12

Counting the rows where the attacker got what they wanted: 11 of 15 against the naive server. The hardened server stopped 14 cleanly. The fifteenth, the 1 MB search, is refused, but the refusal itself is a problem. Four things in this table are worth a closer look.

The naive server isn't as naive as it looks. Two protections are on by default. An exception from a tool becomes INTERNAL_ERROR: An internal error occurred., so the naive get_order, which put the database URL in its exception message, leaked nothing that way. And a loopback bind validates the Host header, so the DNS-rebinding request got 421 Invalid Host header from both servers. The naive server leaked its password through a normal tool result, which no default can catch.

The flood shows a per-client bucket at work. The limiter allows a burst of 10, refilling at one per second. Earlier calls in the script had already used part of the support-bot bucket, so 8 of the 50 parallel calls got through and the other 42 got RATE_LIMIT_EXCEEDED with a retry hint. Another client would have had its own 10. The rate-limiting guide load-tests this properly and shows how an agent should back off.

DNS rebinding hits authentication first. On the hardened server a rebinding request without a token is refused with 401 before the Host check, and with a stolen valid token the Host check still refuses it with 421. Either way the browser page gets nothing.

The 1 MB search is refused, and the refusal is 1,000,038 characters long. Over HTTP, the MCP Python SDK validates arguments against the tool's JSON schema before Promptise sees the call, and its error message quotes the whole rejected value. That reply lands in the calling agent's context. The same call through Promptise's TestClient returns a 357-character VALIDATION_ERROR. Calls rejected this way also skip the middleware, so they aren't rate limited or audited. We've reported it with a repro, and Honest limits says what to do until it's fixed.


[05]

Least privilege for MCP tools: who sees what

hide_unauthorized_tools=True changes what tools/list returns. It evaluates each tool's guards against the caller's token, so an agent is never shown a tool it can't call. list_tools.py asks as three different agents:

Pythonlist_tools.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
"""What does each caller see when it asks for the tool list?"""
import asyncio
import os
import sys
import time

import jwt
from promptise.mcp.client import MCPClient

URL = sys.argv[1]


def token(scopes, roles=(), sub="agent"):
    now = int(time.time())
    claims = {"sub": sub, "aud": "orders-mcp", "scope": scopes, "roles": list(roles),
              "iat": now, "exp": now + 300}
    return jwt.encode(claims, os.environ["ORDERS_JWT_SECRET"], algorithm="HS256")


async def main():
    callers = [("support-bot", token("orders:read")),
               ("refund-agent", token("orders:read refunds:write")),
               ("ops-agent", token("orders:read", roles=["ops"]))]
    for who, bearer in callers:
        async with MCPClient(url=URL, bearer_token=bearer) as client:
            names = sorted(t.name for t in await client.list_tools())
        print(f"{who:<12} sees {names}")


asyncio.run(main())

Naive server:

Output
support-bot  sees ['get_order', 'integration_status', 'refund_order', 'search_orders']
refund-agent sees ['get_order', 'integration_status', 'refund_order', 'search_orders']
ops-agent    sees ['get_order', 'integration_status', 'refund_order', 'search_orders']

Hardened server:

Output
support-bot  sees ['get_order', 'search_orders']
refund-agent sees ['get_order', 'refund_order', 'search_orders']
ops-agent    sees ['get_order', 'integration_status', 'search_orders']

Hiding is not the security boundary: a hidden tool is still refused by its guards if someone calls it by name. It's least privilege for discovery. Tool names and descriptions can be sensitive themselves, and an agent shown a tool it can't use will waste turns trying it. Hiding also took the two approval tools out of every list here, because none of these agents holds the approver role.


[06]

MCP audit logging: prove who did what

Every call that reached the middleware is in audit.jsonl: the caller's verified identity, the tool, the arguments and the outcome, each entry HMAC-signed and chained to the one before. Verify it, change one recorded refund amount, then verify again:

Output
$ promptise audit verify audit.jsonl
OK 55 entries: intact.
last_hash 8538394296aa18178d32ac4d08ee7c8fa5375234134ac8e73864e769f6143b5b (seq 
54)
exit=0
$ python tamper.py
edited entry seq=0: amount -> 1.0
$ promptise audit verify audit.jsonl
TAMPERED audit.jsonl:1: modified: the entry was changed after it was signed (its
signature no longer matches, the next entry still links to it)
FAILED 55 entries: 1 problem; the first is at audit.jsonl:1.
exit=1

The edit is caught at the exact line, and the command exits 1, so a CI job or a cron check can alert on it. The audit logging guide tampers with a log six ways, anchors the end of the chain, and ships closed files to storage nobody can rewrite.

Count the entries: 55, while the script made more calls than that. The four token attacks were refused with HTTP 401 before any tool ran, and the three schema-invalid calls were rejected by the SDK before the middleware. Neither kind is in the audit log, so keep your HTTP access logs too.


[07]

Secure the MCP transport

A server bound to 127.0.0.1 can't be reached from the network, but it can be reached from a web page in your browser through DNS rebinding. Promptise checks Host and Origin on loopback binds by default. On any other bind it can't guess your public hostname, so validation is off until you name it. build_transport_security is the internal function run() uses to pick the policy, so asking it directly shows the rule:

Pythontransport_policy.py
1
2
3
4
5
6
"""Which Host/Origin policy does run() pick for a bind address?"""
from promptise.mcp.server._transport import build_transport_security as policy

print("127.0.0.1 ->", "on" if policy("127.0.0.1") else "off")
print("0.0.0.0 ->", "on" if policy("0.0.0.0") else "off")
print("0.0.0.0 + allowed_hosts ->", policy("0.0.0.0", allowed_hosts=["mcp.example.com"]).allowed_hosts)
Output
127.0.0.1 -> on
0.0.0.0 -> off
0.0.0.0 + allowed_hosts -> ['mcp.example.com']

On for 127.0.0.1, off for 0.0.0.0 (the server logs a warning at start-up), and exactly your list once you pass allowed_hosts. In a container, pass host="0.0.0.0", allowed_hosts=["mcp.example.com"], and terminate TLS at your proxy. Deployment has the full set of bind, CORS and proxy options. For a server that only one local client uses, prefer stdio: no port at all, which is also what the MCP spec recommends for local servers.


[08]

Honest limits

  • Schema-invalid calls over HTTP bypass the pipeline. As the attack table shows, the MCP SDK rejects them before Promptise's middleware runs: the error quotes the full input (1 MB in, 1 MB out), and the call is neither rate limited nor audited. Until it's fixed, cap request body size at your reverse proxy (for example nginx client_max_body_size), and keep string limits small so honest mistakes stay small. Reported with a repro.

  • `CredentialDetector` breaks JSON when it redacts a connection string. Its database-URL patterns run to the next whitespace, so on JSON text they swallow the closing quote and brace: {"db": "postgres://..."} becomes {"db": "[POSTGRES_CONNECTION] with the rest cut. That's why RedactSecrets scans the string values of the result, not its serialised JSON. The same applies to an agent's scan_tool_results on JSON tool output. Reported.

  • A stolen token works until it expires. JWTs are bearer tokens and Promptise keeps no list of used token IDs (jti), so a captured token replays freely within its lifetime on the server it was issued for. The audience check stops it everywhere else. Keep lifetimes in minutes, not days.

  • No standard OAuth discovery yet. Promptise validates JWTs, API keys and JWKS-signed tokens and ships a client-credentials token endpoint for development. It doesn't publish the RFC 9728 protected-resource metadata that the MCP authorization spec uses to point clients at an authorization server, so clients that expect that discovery flow can't sign in on their own. The authentication guide covers what works today.

  • Nothing pins third-party servers' tools. Point 14 is on you: Promptise scans tool results, but it doesn't hash or diff the tool names and descriptions a third-party server sends, so a server that changes a description to inject instructions won't be flagged. Review what you connect, and put approval and sandboxing in front of what you can't review.

  • Rate limits and approvals live in one process. The token buckets and the pending-approval store are in memory, so several replicas each keep their own, and a restart drops pending approvals, which are then denied. The rate-limiting guide shows how to share limits through your own limiter.

  • Refusals before the middleware aren't audited. 401s at the HTTP layer and schema rejections leave no audit entry. Keep HTTP access logs alongside the audit log.

  • One run, one machine. The attacks ran once on macOS against servers on loopback. They show which controls engage, not how they behave under real load.


[09]

Frequently asked questions

What are the biggest MCP security risks?

For servers: callers that aren't authenticated, tokens accepted from other services (token passthrough), tools with broader access than the caller needs, irreversible actions with no human check, and secrets leaking through tool output. For agents that use MCP: prompt injection through tool results and malicious or changed third-party servers. The official MCP security best practices also cover the confused-deputy problem for OAuth proxies, SSRF during OAuth discovery, and session hijacking.

Does an MCP server need authentication if it runs locally?

If it uses stdio, the client process that starts it is the only one that can talk to it, so no. If it listens on HTTP, yes: any process on the machine, and any web page through DNS rebinding, can reach a local port. Host validation stops the browser case, and a token stops the rest.

Should an MCP server pass the user's token through to downstream APIs?

No. The MCP authorization spec forbids token passthrough: a server must reject tokens that weren't issued for it, and use its own credentials, or a token exchanged for the downstream API, when it calls onward. With Promptise, JWTAuth(audience=...) enforces the first half; the second half is how you write your tools.

How do I audit what an AI agent did through MCP?

Log every tool call on the server, signed and chained, with the caller's verified identity, and verify the chain regularly. Server-side logging covers every client, including ones you don't control. Agent-side traces are still useful for the model's reasoning; the observability guide covers that half.

Is input validation enough to stop prompt injection?

No. Validation stops malformed arguments reaching your code. Prompt injection arrives in the content tools return, a web page or a ticket, and targets the model reading it. That's the agent's job: scan tool results and gate risky actions. See Prompt Injection in AI Agents.


[10]

Go deeper

Every point in the checklist has its own guide:

  • MCP Authentication in Python: API Keys, JWT and OAuth: points 1 to 3, with API keys, JWTs and OAuth client credentials on one server.

  • LLM Tool Rate Limiting: Per-Client Limits for MCP Servers: point 7, load tested.

  • AI Agent Audit Logging: Tamper-Evident Logs in Python: point 11, with six tamper tests.

  • Testing MCP Servers in Python: point 15, turning this attack list into pytest tests.

  • AI Agent Human-in-the-Loop Approval and AI Agent Approval Fatigue: point 6, from the agent's side.

  • AI Agent Identity: Authenticate Agents to MCP Servers: which token an agent should present.

  • Multi-Tenant AI Agents: keeping customers apart from the token to the tool.

  • Build a Production MCP Server in Python: the server features this checklist builds on.

  • Authentication & Security and Approval Gates in the Promptise docs.

Learning paths

Want more structure? Paths put guides in order, like a short course.

See the paths →

Keep going.

Browse every guide by topic and level, or follow a learning path that puts them in order.

All guidesLearning paths