MCP authentication over HTTP comes down to one header on every request: an API key, a JWT signed with a shared secret, or an OAuth access token from your identity provider that the server checks against the provider's published keys. This guide secures one billing MCP server all three ways with Promptise Foundry, against a real local OAuth authorization server for the third. It shows what each kind of failure looks like on the wire, including why a refusal is either a 401 or a tool error and never a 403, and how an agent decides whose token to send. That last choice turned out to matter most: in ten runs with gpt-5-mini, a support user without finance rights asked the agent for a credit note. With her own token forwarded, all five requests were refused. With the agent's credential, all five went through. Every snippet ran against Promptise Foundry 1.3.3 installed from PyPI, and every output below is pasted from those runs.
How do you add authentication to an MCP server?
Pick a provider, wrap it in AuthMiddleware, and build the server with require_auth=True. From then on every request needs a valid credential, and every tool sees the verified caller on ctx.client:
# Trust access tokens from this issuer, minted for this server's audience, and nothing else.
tokens = JwksAuth.from_discovery(issuer="http://127.0.0.1:9431", audience="api://billing")
server = MCPServer("billing", require_auth=True)
server.add_middleware(AuthMiddleware(tokens))That's the production version: OAuth access tokens from an identity provider, verified against its public keys. Swap the provider for APIKeyAuth or JWTAuth and nothing else changes; the tools, roles and scope checks stay exactly as they are. On the client side, Promptise's MCPClient takes api_key=, bearer_token=, or a function that returns a fresh token; an agent's HTTPServerSpec takes the first two, plus an AgentIdentity for tokens that expire.
[02]
API keys vs JWT vs OAuth: which MCP authentication to use
Method | Server side | Client side | Use it for | Watch out for |
|---|---|---|---|---|
API key | | api_key=, sent as x-api-key | A few internal callers you control, quick starts | Long-lived shared secrets, no expiry, manual rotation, no scopes |
JWT with a shared secret | JWTAuth(secret=..., audience=...) | bearer_token= | Your own token service; development with the built-in token endpoint | Every server that verifies tokens holds the secret that signs them |
OAuth client credentials | JwksAuth.from_discovery(issuer=..., audience=...) | A token function or an AgentIdentity | Machine-to-machine in production, with Entra ID, Okta, Auth0 or Keycloak | Clients have to fetch and refresh tokens |
The signed-in user's token | JwksAuth, same as above | CallerContext(bearer_token=...), forwarded by the agent | Per-user permissions through an agent | The token must be issued for this server's audience |
If your agent runs on Azure, AWS or Google Cloud, it may not need a client secret at all: a workload identity mints the tokens for it. AI Agent Identity: Authenticate Agents to MCP Servers covers that path, and this guide links to it rather than repeating it.
[03]
How MCP authentication works in Promptise
A request passes two checks, and they fail in different ways:
Rendering diagram…
Authentication happens at the HTTP layer. With require_auth=True, the server checks the credential on every HTTP request before any MCP message is read. A missing or invalid one gets 401 Unauthorized, and the client never sees the tool list.
Authorization happens per tool. roles=["finance"] and guards=[HasScope("ledger:export")] are checked when a tool is called. A refusal is a tool result marked isError: true with the code ACCESS_DENIED, inside an ordinary 200 response. An authorization refusal is never an HTTP 403.
Identity is read per request. AuthMiddleware verifies the credential again for each call and builds ctx.client: client ID, roles, scopes, issuer, audience, expiry.
The MCP specification makes authorization optional. When an HTTP server does implement it, the specification's authorization section describes an OAuth 2.1 flow with discovery, WWW-Authenticate headers and 403 scope challenges. Promptise 1.3.3 implements the resource-server half of that, validating bearer tokens and their audience, but not the discovery half. Honest limits, below, says exactly what's missing.
[04]
What you need
Python 3.10 or newer.
Promptise Foundry 1.3.3 or newer. PyJWT and cryptography, used to verify and sign tokens, are installed with it.
An OpenAI API key, only for the agent section. Everything else runs offline.
pip install "promptise>=1.3.3"
export OPENAI_API_KEY="sk-..."[05]
Secure an MCP server, step by step
The server is a billing API for a shop's agents, with four tools. whoami shows the identity the server verified, issue_credit is for the finance role only, and export_ledger needs the ledger:export scope:
from promptise.mcp.server import HasScope, MCPServer, RequestContext, ToolError
# A stand-in for your billing system. Amounts are in EUR.
INVOICES = {
"INV-7": {"customer": "acme", "amount": 1200.0, "credited": 0.0},
"INV-8": {"customer": "globex", "amount": 310.0, "credited": 0.0},
}
def add_billing_tools(server: MCPServer) -> None:
"""The same four tools, whichever way the server authenticates."""
@server.tool(read_only_hint=True)
async def whoami(ctx: RequestContext) -> dict:
"""Show the identity this server verified for the current call."""
client = ctx.client
return {
"client_id": client.client_id,
"roles": sorted(client.roles),
"scopes": sorted(client.scopes),
}
@server.tool(read_only_hint=True)
async def list_invoices() -> list[dict]:
"""List invoices with their amount and what has been credited."""
return [{"invoice_id": iid, **inv} for iid, inv in INVOICES.items()]
@server.tool(roles=["finance"], destructive_hint=True)
async def issue_credit(invoice_id: str, amount: float, reason: str) -> dict:
"""Credit part of an invoice back to the customer. Finance only."""
invoice = INVOICES.get(invoice_id)
if invoice is None:
raise ToolError(f"There is no invoice {invoice_id}.")
invoice["credited"] += amount
return {"invoice_id": invoice_id, "credited": invoice["credited"], "reason": reason}
@server.tool(read_only_hint=True, guards=[HasScope("ledger:export")])
async def export_ledger(month: str) -> dict:
"""Export one month's ledger as CSV. Needs the ledger:export scope."""
rows = [f"{iid},{inv['amount']},{inv['credited']}" for iid, inv in INVOICES.items()]
return {"month": month, "csv": "\n".join(["invoice_id,amount,credited", *rows])}The tools never look at a token. They declare who may call them, and ctx.client tells whoami who did. That's what lets the three servers below share this one file.
To try each tool with a given credential, this helper connects with Promptise's MCPClient and prints one line per call:
import json
from promptise.mcp.client import MCPClient
CALLS = [
("whoami", {}),
("issue_credit", {"invoice_id": "INV-7", "amount": 50, "reason": "late delivery"}),
("export_ledger", {"month": "2026-09"}),
]
def summary(result) -> str:
"""One line per call: the result, or the error code and message."""
text = result.content[0].text
if result.isError:
error = json.loads(text)["error"]
return f"{error['code']}: {error['message']}"
return " ".join(text.split())[:100]
async def probe(url: str, label: str, **credentials) -> None:
"""Connect with the given credentials and try each tool once."""
print(f"== {label}")
async with MCPClient(url=url, **credentials) as client:
for name, args in CALLS:
print(f"{name:<14} {summary(await client.call_tool(name, args))}")Start with API keys
API keys are the simplest MCP authentication: a shared secret per client, sent in the x-api-key header. The rich key format attaches a client ID and roles to each key:
import os
from billing_tools import add_billing_tools
from promptise.mcp.server import APIKeyAuth, AuthMiddleware, MCPServer
# Each key names a client and its roles. Keep the keys themselves in a secret store.
keys = APIKeyAuth(
keys={
os.environ["BILLING_AGENT_KEY"]: {"client_id": "billing-agent", "roles": ["finance"]},
os.environ["REPORTING_KEY"]: {"client_id": "reporting-job", "roles": ["reporting"]},
}
)
server = MCPServer("billing", require_auth=True)
server.add_middleware(AuthMiddleware(keys))
add_billing_tools(server)
if __name__ == "__main__":
server.run(transport="http", host="127.0.0.1", port=9432)import asyncio
import os
from probe import probe
URL = "http://127.0.0.1:9432/mcp"
async def main():
await probe(URL, "billing-agent (API key)", api_key=os.environ["BILLING_AGENT_KEY"])
await probe(URL, "reporting-job (API key)", api_key=os.environ["REPORTING_KEY"])
asyncio.run(main())export BILLING_AGENT_KEY="sk-billing-agent-local-only" REPORTING_KEY="sk-reporting-local-only"
python server_apikey.py # in one terminal
python client_apikey.py # in another== billing-agent (API key)
whoami {"client_id": "billing-agent", "roles": ["finance"], "scopes": []}
issue_credit {"invoice_id": "INV-7", "credited": 50.0, "reason": "late delivery"}
export_ledger ACCESS_DENIED: Requires any of scopes [ledger:export], but client has [(none)]
== reporting-job (API key)
whoami {"client_id": "reporting-job", "roles": ["reporting"], "scopes": []}
issue_credit ACCESS_DENIED: Requires any of roles [finance], but client has [reporting]
export_ledger ACCESS_DENIED: Requires any of scopes [ledger:export], but client has [(none)]The roles came from the key, so issue_credit let the billing agent in and kept the reporting job out, with a message that says why. export_ledger refused both: an API key carries no scopes, so a scope guard denies every API-key caller. Use roles with API keys, and scopes with tokens.
Switch to JWTs from a token endpoint
A JWT carries its own claims and an expiry, so a stolen one stops working on its own. JWTAuth verifies tokens signed with a shared secret (HS256). For development, Promptise can also issue them: enable_token_endpoint() adds an /auth/token endpoint where registered clients trade a client ID and secret for a token, and claims adds a scope to each client's tokens:
import os
from billing_tools import add_billing_tools
from promptise.mcp.server import AuthMiddleware, JWTAuth, MCPServer
jwt_auth = JWTAuth(secret=os.environ["BILLING_JWT_SECRET"], audience="api://billing")
server = MCPServer("billing", require_auth=True)
server.add_middleware(AuthMiddleware(jwt_auth))
# A built-in OAuth-style token endpoint at /auth/token, for development.
server.enable_token_endpoint(
jwt_auth=jwt_auth,
clients={
"billing-agent": {
"secret": os.environ["BILLING_AGENT_SECRET"],
"roles": ["finance"],
"claims": {"scope": "invoices:read ledger:export"},
"expires_in": 900,
},
"reporting-job": {
"secret": os.environ["REPORTING_SECRET"],
"roles": ["reporting"],
"claims": {"scope": "invoices:read"},
"expires_in": 900,
},
},
)
add_billing_tools(server)
if __name__ == "__main__":
server.run(transport="http", host="127.0.0.1", port=9433)On the client, MCPClient.fetch_token asks the endpoint for a token with a standard OAuth 2.0 client credentials request, and bearer_token= sends it as Authorization: Bearer:
import asyncio
import os
import jwt
from promptise.mcp.client import MCPClient
from probe import probe
SERVER = "http://127.0.0.1:9433"
async def main():
token = await MCPClient.fetch_token(
f"{SERVER}/auth/token",
client_id="billing-agent",
client_secret=os.environ["BILLING_AGENT_SECRET"],
)
claims = jwt.decode(token, options={"verify_signature": False}) # for display only
print("claims:", {k: claims[k] for k in ("sub", "aud", "roles", "scope")})
await probe(f"{SERVER}/mcp", "billing-agent (JWT)", bearer_token=token)
asyncio.run(main())export BILLING_JWT_SECRET="local-only-jwt-secret-0f3a9c"
export BILLING_AGENT_SECRET="agent-secret-local-only" REPORTING_SECRET="reporting-secret-local-only"
python server_jwt.py # in one terminal
python client_jwt.py # in anotherclaims: {'sub': 'billing-agent', 'aud': 'api://billing', 'roles': ['finance'], 'scope': 'invoices:read ledger:export'}
== billing-agent (JWT)
whoami {"client_id": "billing-agent", "roles": ["finance"], "scopes": ["invoices:read", "ledger:export"]}
issue_credit {"invoice_id": "INV-7", "credited": 50.0, "reason": "late delivery"}
export_ledger {"month": "2026-09", "csv": "invoice_id,amount,credited\nINV-7,1200.0,50.0\nINV-8,310.0,0.0"}Same tools, same rules, and now export_ledger works for a client whose token has the ledger:export scope. The token's aud is api://billing because JWTAuth was given that audience, and the server refuses tokens minted for any other.
Since Promptise 1.3.3 the endpoint speaks OAuth 2.0 as RFC 6749 describes it: it takes a form-encoded request, with the client secret in the body or in HTTP Basic auth, and it still accepts JSON. This script calls it the standard way, refreshes, reuses a refresh token, and leaves out grant_type:
"""The built-in token endpoint, called the way RFC 6749 describes."""
import os
import httpx
URL = "http://127.0.0.1:9433/auth/token"
CLIENT = ("reporting-job", os.environ["REPORTING_SECRET"])
# Form-encoded, with the client secret in HTTP Basic auth.
grant = httpx.post(URL, data={"grant_type": "client_credentials"}, auth=CLIENT)
print("client_credentials:", grant.status_code, sorted(grant.json()))
print(" Content-Type:", grant.headers["content-type"], " Cache-Control:", grant.headers.get("cache-control"))
refresh = {"grant_type": "refresh_token", "refresh_token": grant.json()["refresh_token"]}
print("refresh:", httpx.post(URL, data=refresh).status_code)
again = httpx.post(URL, data=refresh).json()
print("same refresh token again:", again["error"], "-", again["error_description"])
no_grant = httpx.post(URL, data={"scope": "invoices:read"}, auth=CLIENT).json()
print("form without grant_type:", no_grant["error"], "-", no_grant["error_description"])client_credentials: 200 ['access_token', 'expires_in', 'refresh_token', 'token_type']
Content-Type: application/json Cache-Control: no-store
refresh: 200
same refresh token again: invalid_grant - Invalid or expired refresh token.
form without grant_type: invalid_request - 'grant_type' is required.The response carries Cache-Control: no-store, refresh tokens rotate on every use, and errors come with the standard error and error_description. fetch_token returns only the access token, so to refresh, post grant_type=refresh_token yourself. Keep this endpoint for development. Its refresh tokens live in the server's memory, and every server that verifies its tokens needs the signing secret.
Use OAuth client credentials and JWKS in production
In production, an identity provider issues the tokens and signs them with a private key. Servers verify them with the provider's public keys, published as a JWKS document, so no server holds a secret that can mint tokens. Machine clients such as agents and batch jobs use the OAuth 2.0 client credentials grant: they post their client ID and secret to the provider's token endpoint and get a short-lived access token.
This script plays the identity provider. It does the three jobs Entra ID, Okta, Auth0 or Keycloak would do for a machine client: it checks client credentials, signs RS256 access tokens with the requested scopes and audience, and publishes its discovery document and keys:
"""A tiny OAuth 2.0 authorization server for local development. Never run it in production.
It does what Entra ID, Okta, Auth0 or Keycloak do for machine clients: checks a
client's ID and secret (the client_credentials grant), signs a short-lived RS256
access token, and publishes its discovery document and public keys.
"""
import base64
import hmac
import json
import time
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
ISSUER = "http://127.0.0.1:9431"
DEFAULT_AUDIENCE = "api://billing"
TOKEN_TTL = 600 # seconds
# Registered machine clients: their secret, app roles and the scopes they may request.
CLIENTS = {
"billing-agent": {
"secret": "agent-secret-for-local-dev",
"roles": ["finance"],
"scopes": ["invoices:read", "ledger:export"],
},
"reporting-job": {
"secret": "reporting-secret-for-local-dev",
"roles": ["reporting"],
"scopes": ["invoices:read"],
},
}
# People who sign in to your app. A real IdP issues their tokens after a login.
USERS = {"dana": {"roles": ["support"], "scopes": ["invoices:read"]}}
KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
KID = uuid.uuid4().hex[:8]
JWK = {
**json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(KEY.public_key())),
"kid": KID,
"use": "sig",
"alg": "RS256",
}
def sign(subject: str, audience: str, roles: list[str], scopes: list[str]) -> str:
now = int(time.time())
claims = {
"iss": ISSUER,
"sub": subject,
"aud": audience,
"iat": now,
"exp": now + TOKEN_TTL,
"jti": uuid.uuid4().hex,
"roles": roles,
"scope": " ".join(scopes),
}
return jwt.encode(claims, KEY, algorithm="RS256", headers={"kid": KID})
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/.well-known/openid-configuration":
self.reply(200, {
"issuer": ISSUER,
"token_endpoint": f"{ISSUER}/oauth/token",
"jwks_uri": f"{ISSUER}/jwks.json",
"grant_types_supported": ["client_credentials"],
})
elif self.path == "/jwks.json":
self.reply(200, {"keys": [JWK]})
elif self.path.startswith("/dev/login?user="):
# Stands in for your app's sign-in (the authorization code flow).
user = self.path.split("=", 1)[1]
if user not in USERS:
return self.reply(404, {"error": "unknown user"})
u = USERS[user]
self.reply(200, {"access_token": sign(user, DEFAULT_AUDIENCE, u["roles"], u["scopes"])})
else:
self.reply(404, {"error": "not_found"})
def do_POST(self):
if self.path != "/oauth/token":
return self.reply(404, {"error": "not_found"})
content_type = self.headers.get("content-type", "").split(";")[0]
if content_type != "application/x-www-form-urlencoded":
hint = "Use application/x-www-form-urlencoded (RFC 6749)."
return self.reply(400, {"error": "invalid_request", "error_description": hint})
body = self.rfile.read(int(self.headers["content-length"])).decode()
form = {key: values[0] for key, values in parse_qs(body).items()}
client_id, secret = form.get("client_id"), form.get("client_secret")
basic = self.headers.get("authorization", "")
if basic.startswith("Basic "):
client_id, secret = base64.b64decode(basic[6:]).decode().split(":", 1)
client = CLIENTS.get(client_id or "")
if client is None or not hmac.compare_digest(client["secret"], secret or ""):
return self.reply(401, {"error": "invalid_client"})
if form.get("grant_type") != "client_credentials":
return self.reply(400, {"error": "unsupported_grant_type"})
asked = form.get("scope", "").split() or client["scopes"]
if not set(asked) <= set(client["scopes"]):
return self.reply(400, {"error": "invalid_scope"})
audience = form.get("resource") or DEFAULT_AUDIENCE # RFC 8707 resource indicator
token = sign(client_id, audience, client["roles"], asked)
print(f"[oauth] {client_id}: token for {audience}, scope '{' '.join(asked)}'", flush=True)
grant = {"access_token": token, "token_type": "Bearer", "expires_in": TOKEN_TTL}
self.reply(200, grant | {"scope": " ".join(asked)})
def reply(self, status, body):
data = json.dumps(body).encode()
self.send_response(status)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, *args):
pass
if __name__ == "__main__":
print(f"[oauth] issuer {ISSUER}, key {KID}", flush=True)
ThreadingHTTPServer(("127.0.0.1", 9431), Handler).serve_forever()The server needs one change, the provider. JwksAuth.from_discovery reads the issuer's /.well-known/openid-configuration, refuses it if it names a different issuer, fetches the keys from its jwks_uri, and checks each token's signature, issuer, audience and expiry:
from billing_tools import add_billing_tools
from promptise.mcp.server import AuthMiddleware, JwksAuth, MCPServer
# Trust access tokens from this issuer, minted for this server's audience, and nothing else.
tokens = JwksAuth.from_discovery(issuer="http://127.0.0.1:9431", audience="api://billing")
server = MCPServer("billing", require_auth=True)
server.add_middleware(AuthMiddleware(tokens))
add_billing_tools(server)
if __name__ == "__main__":
server.run(transport="http", host="127.0.0.1", port=9434)The client side is a standard client credentials request: form-encoded, with the client secret in HTTP Basic auth and an RFC 8707 resource naming the server. ClientCredentialsToken caches the token and hands MCPClient a fresh one before it expires, through bearer_token_provider:
import time
import httpx
TOKEN_URL = "http://127.0.0.1:9431/oauth/token"
def client_credentials(
client_id: str, secret: str, *, scope: str = "", resource: str = "api://billing"
) -> dict:
"""The OAuth 2.0 client_credentials grant: form-encoded, client secret in HTTP Basic auth."""
form = {"grant_type": "client_credentials", "resource": resource}
if scope:
form["scope"] = scope
response = httpx.post(TOKEN_URL, data=form, auth=(client_id, secret))
response.raise_for_status()
return response.json()
class ClientCredentialsToken:
"""Hands MCPClient a cached access token, and fetches a new one before it expires."""
def __init__(self, client_id: str, secret: str, *, scope: str = "", margin: float = 60) -> None:
self._args = (client_id, secret)
self._scope = scope
self._margin = margin
self._token, self._expires_at = None, 0.0
def __call__(self, force_refresh: bool = False) -> str:
if force_refresh or self._token is None or time.time() > self._expires_at - self._margin:
grant = client_credentials(*self._args, scope=self._scope)
self._token, self._expires_at = grant["access_token"], time.time() + grant["expires_in"]
return self._tokenimport asyncio
from client_credentials import ClientCredentialsToken
from probe import probe
URL = "http://127.0.0.1:9434/mcp"
async def main():
agent = ClientCredentialsToken("billing-agent", "agent-secret-for-local-dev")
reporting = ClientCredentialsToken("reporting-job", "reporting-secret-for-local-dev")
await probe(URL, "billing-agent (OAuth)", bearer_token_provider=agent)
await probe(URL, "reporting-job (OAuth)", bearer_token_provider=reporting)
asyncio.run(main())python local_oauth.py # terminal 1: the identity provider on port 9431
python server_oauth.py # terminal 2: the MCP server on port 9434
python client_oauth.py # terminal 3== billing-agent (OAuth)
whoami {"client_id": "billing-agent", "roles": ["finance"], "scopes": ["invoices:read", "ledger:export"]}
issue_credit {"invoice_id": "INV-7", "credited": 50.0, "reason": "late delivery"}
export_ledger {"month": "2026-09", "csv": "invoice_id,amount,credited\nINV-7,1200.0,50.0\nINV-8,310.0,0.0"}
== reporting-job (OAuth)
whoami {"client_id": "reporting-job", "roles": ["reporting"], "scopes": ["invoices:read"]}
issue_credit ACCESS_DENIED: Requires any of roles [finance], but client has [reporting]
export_ledger ACCESS_DENIED: Requires any of scopes [ledger:export], but client has [invoices:read]The scopes are now the identity provider's decision: the reporting job may only ask for invoices:read, so export_ledger refuses it and says which scope was missing. The provider's log shows one token per client, even though MCPClient asked for one on every HTTP request; ClientCredentialsToken reused the cached one:
[oauth] issuer http://127.0.0.1:9431, key aef46aac
[oauth] billing-agent: token for api://billing, scope 'invoices:read ledger:export'
[oauth] reporting-job: token for api://billing, scope 'invoices:read'MCPClient.fetch_token from Step 2 works against this provider too. Since Promptise 1.3.3 it sends the same standard form-encoded request, with the client secret in the body rather than in Basic auth:
import asyncio
import jwt
from promptise.mcp.client import MCPClient
async def main():
token = await MCPClient.fetch_token(
"http://127.0.0.1:9431/oauth/token",
client_id="billing-agent",
client_secret="agent-secret-for-local-dev",
)
claims = jwt.decode(token, options={"verify_signature": False}) # for display only
print({k: claims[k] for k in ("iss", "sub", "aud", "roles", "scope")})
asyncio.run(main()){'iss': 'http://127.0.0.1:9431', 'sub': 'billing-agent', 'aud': 'api://billing', 'roles': ['finance'], 'scope': 'invoices:read ledger:export'}It returns only the access token, though, without its expiry, and it can't ask for a scope or a resource. For tokens you cache and renew, or an identity provider that needs a resource indicator, keep the few lines of HTTP above, or use your provider's SDK.
Check what clients see when access fails
A check you haven't seen fail is a check you're trusting on faith. This script sends raw HTTP to the OAuth server, with every kind of bad credential, and then makes one allowed and one forbidden tool call by hand:
"""What each kind of failure looks like on the wire, as raw HTTP."""
import json
import httpx
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from client_credentials import client_credentials
MCP = "http://127.0.0.1:9434/mcp"
HEADERS = {"accept": "application/json, text/event-stream", "content-type": "application/json"}
VERSION = "2025-06-18"
INITIALIZE = {
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": VERSION, "capabilities": {}, "clientInfo": {"name": "probe", "version": "1"}},
}
def bearer(token):
return {"authorization": f"Bearer {token}"}
def show(label, response):
challenge = response.headers.get("www-authenticate", "(none)")
print(f"{label:<26} HTTP {response.status_code} WWW-Authenticate: {challenge} {response.text[:58]}")
def forged():
"""Claims to be billing-agent, but is signed with an attacker's key."""
kid = httpx.get("http://127.0.0.1:9431/jwks.json").json()["keys"][0]["kid"] # key ids are public
claims = {"iss": "http://127.0.0.1:9431", "sub": "billing-agent", "aud": "api://billing", "exp": 4102444800}
attacker_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
return jwt.encode(claims, attacker_key, "RS256", headers={"kid": kid})
def call_tool(token, name, arguments):
"""An MCP session by hand: initialize, initialized, then one tools/call."""
headers = HEADERS | bearer(token)
opened = httpx.post(MCP, json=INITIALIZE, headers=headers)
headers |= {"mcp-session-id": opened.headers["mcp-session-id"], "mcp-protocol-version": VERSION}
httpx.post(MCP, json={"jsonrpc": "2.0", "method": "notifications/initialized"}, headers=headers)
call = {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": name, "arguments": arguments}}
response = httpx.post(MCP, json=call, headers=headers)
data = [line[5:] for line in response.text.splitlines() if line.startswith("data:")]
result = json.loads(data[-1])["result"]
code = json.loads(result["content"][0]["text"])["error"]["code"] if result["isError"] else "-"
print(f"{'tools/call ' + name:<26} HTTP {response.status_code} isError: {result['isError']} error: {code}")
secret = "reporting-secret-for-local-dev"
reporting = client_credentials("reporting-job", secret)["access_token"]
payroll = client_credentials("reporting-job", secret, resource="api://payroll")["access_token"]
show("no credential", httpx.post(MCP, json=INITIALIZE, headers=HEADERS))
show("malformed token", httpx.post(MCP, json=INITIALIZE, headers=HEADERS | bearer("not-a-jwt")))
show("forged signature", httpx.post(MCP, json=INITIALIZE, headers=HEADERS | bearer(forged())))
show("token for api://payroll", httpx.post(MCP, json=INITIALIZE, headers=HEADERS | bearer(payroll)))
show("resource metadata (RFC 9728)", httpx.get("http://127.0.0.1:9434/.well-known/oauth-protected-resource"))
call_tool(reporting, "list_invoices", {})
call_tool(reporting, "issue_credit", {"invoice_id": "INV-7", "amount": 50, "reason": "late delivery"})no credential HTTP 401 WWW-Authenticate: (none) {"error": "Authentication required", "message": "This serv
malformed token HTTP 401 WWW-Authenticate: (none) {"error": "Invalid authentication token", "message": "The
forged signature HTTP 401 WWW-Authenticate: (none) {"error": "Invalid authentication token", "message": "The
token for api://payroll HTTP 401 WWW-Authenticate: (none) {"error": "Invalid authentication token", "message": "The
resource metadata (RFC 9728) HTTP 401 WWW-Authenticate: (none) {"error": "Authentication required", "message": "This serv
tools/call list_invoices HTTP 200 isError: False error: -
tools/call issue_credit HTTP 200 isError: True error: ACCESS_DENIEDRead it as two rules. A credential the server can't verify, for whatever reason, gets a 401 before any MCP message is read; that includes a perfectly valid token minted for another server, api://payroll. A credential the server can verify but that lacks a role or scope gets a tool error inside a 200, marked isError: true with the code ACCESS_DENIED. An agent reads that envelope and can tell the user why; a client that only checks HTTP status codes will see success.
Two more things in that output. No 401 carries a WWW-Authenticate header, and the body is the same whatever the reason was, so a client can't tell an expired token from one for the wrong audience. The server log has only the access line. To find out why a token was refused, run it through the server in-process with TestClient, which reports what the verifier said:
"""A 401 doesn't say why. Run the same token through the server in-process to find out."""
import asyncio
import json
from client_credentials import client_credentials
from promptise.mcp.server import TestClient
from server_oauth import server
secret = "reporting-secret-for-local-dev"
token = client_credentials("reporting-job", secret, resource="api://payroll")["access_token"]
async def main():
client = TestClient(server, meta={"authorization": f"Bearer {token}"})
result = await client.call_tool("whoami", {})
print(json.loads(result[0].text)["error"]["message"])
asyncio.run(main())Invalid JWT: Audience doesn't match[06]
Roles, scopes and guards
roles= and guards= decide what a verified caller may do. Promptise reads roles from the token's roles claim, or from an API key's config, and scopes from the space-separated scope claim. Then:
`roles=["finance"]` adds a HasRole guard: the caller needs at least one of the roles. It also turns on authentication for that tool, on its own.
`guards=[HasScope("ledger:export")]` needs at least one of the scopes. HasAllRoles and HasAllScopes need all of them, RequireClientId names specific clients, and HasTenant names tenants.
A built-in guard turns on authentication too. Since Promptise 1.3.3, HasScope, HasRole, RequireAuth and the other built-in identity guards imply auth=True, as roles= does. A guard you write yourself doesn't, so give its tool auth=True or the server require_auth=True.
Denials explain themselves. Requires any of scopes [ledger:export], but client has [invoices:read] tells an agent, and you, exactly what was missing.
Roles suit coarse job functions that your directory already has, like finance or support. Scopes suit what a client was granted for this token, and they are the only lever when an identity provider issues least-privilege tokens per client. Authentication & Security lists every guard and how to write your own.
[07]
Connect an agent: its own token, or the user's
An agent is an MCP client, so the same three credentials work on HTTPServerSpec. This script builds an agent against each server and calls whoami directly, without a model call, to show what each server verified:
"""The same agent, three ways to authenticate. Calls whoami directly: no model call."""
import asyncio
import os
from billing_identity import identity
from promptise import build_agent
from promptise.config import HTTPServerSpec
from promptise.mcp.client import MCPClient
async def whoami(spec: HTTPServerSpec, **agent_options) -> str:
agent = await build_agent(model="openai:gpt-5-mini", servers={"billing": spec}, **agent_options)
try:
tool = next(tool for tool in agent.tools if tool.name == "whoami")
return await tool.ainvoke({})
finally:
await agent.shutdown()
async def main():
api_key = HTTPServerSpec(url="http://127.0.0.1:9432/mcp", api_key=os.environ["BILLING_AGENT_KEY"])
print("api_key= ", await whoami(api_key))
print("api_key= + identity", await whoami(api_key, identity=identity)) # the key still wins
token = await MCPClient.fetch_token(
"http://127.0.0.1:9433/auth/token",
client_id="billing-agent",
client_secret=os.environ["BILLING_AGENT_SECRET"],
)
print("bearer_token= ", await whoami(HTTPServerSpec(url="http://127.0.0.1:9433/mcp", bearer_token=token)))
oauth = HTTPServerSpec(url="http://127.0.0.1:9434/mcp", audience="api://billing")
print("identity= ", await whoami(oauth, identity=identity))
asyncio.run(main())api_key= {"client_id": "billing-agent", "roles": ["finance"], "scopes": []}
api_key= + identity {"client_id": "billing-agent", "roles": ["finance"], "scopes": []}
bearer_token= {"client_id": "billing-agent", "roles": ["finance"], "scopes": ["invoices:read", "ledger:export"]}
identity= {"client_id": "billing-agent", "roles": ["finance"], "scopes": ["invoices:read", "ledger:export"]}The second line is the same agent with an AgentIdentity, talking to the API-key server. An explicit credential on the server spec, api_key=, bearer_token= or an Authorization or x-api-key header, wins over the identity, so the server saw the key and one agent can use API-key servers and token servers side by side. (Before Promptise 1.3.3, the identity's token was sent next to the key and the server answered 401.)
api_key= and bearer_token= are fixed values; a fixed token stops working when it expires. For the OAuth server, the agent has an AgentIdentity whose credential is a client credentials request, so Promptise asks it for a token scoped to the server's audience on every request and gets a fresh one before the old one expires:
import httpx
from client_credentials import client_credentials
from promptise import CallerContext
from promptise.identity import AgentIdentity, CallableTokenProvider
def agent_token(audience: str | None) -> str:
"""The agent's own credential: a client_credentials token for the server's audience."""
grant = client_credentials("billing-agent", "agent-secret-for-local-dev", resource=audience or "api://billing")
return grant["access_token"]
identity = AgentIdentity(
"billing-agent",
credential=CallableTokenProvider(token_fn=agent_token, provider_label="client-credentials"),
)
def signed_in_user(name: str) -> CallerContext:
"""Stands in for your app's login: the user's own access token from the same IdP."""
login = httpx.get("http://127.0.0.1:9431/dev/login", params={"user": name}).json()
return CallerContext(user_id=f"{name}@shop.example", bearer_token=login["access_token"])Now the question that matters for authorization. When an agent acts for a signed-in user, whose permissions should the server check? Promptise's default is the user's. When a request's CallerContext carries a bearer_token, the agent sends that token, not its own, on every tool call to a server that forwards caller tokens, which is every server without an API key unless you say otherwise. To see what that buys you, Dana from support, whose token has the support role, asks the agent for a credit note. The agent's own credential has the finance role. A small LangChain callback counts the model calls and tokens:
from langchain_core.callbacks import BaseCallbackHandler
class Usage(BaseCallbackHandler):
"""Count model calls and tokens for one run."""
def __init__(self):
self.calls = self.input_tokens = self.output_tokens = 0
def on_llm_end(self, response, **kwargs):
self.calls += 1
usage = response.generations[0][0].message.usage_metadata or {}
self.input_tokens += usage.get("input_tokens", 0)
self.output_tokens += usage.get("output_tokens", 0)
def __str__(self):
tokens = f"{self.input_tokens:,} input + {self.output_tokens:,} output tokens"
return f"{self.calls} model calls, {tokens}"import asyncio
import sys
from billing_identity import identity, signed_in_user
from promptise import build_agent
from promptise.config import HTTPServerSpec
from usage import Usage
REQUEST = "Customer acme had a late delivery. Issue a 50 EUR credit on invoice INV-7."
async def main(forward: bool) -> None:
agent = await build_agent(
model="openai:gpt-5-mini",
servers={
"billing": HTTPServerSpec(
url="http://127.0.0.1:9434/mcp",
audience="api://billing",
forward_caller_token=forward,
),
},
identity=identity,
instructions="You are a billing assistant. Use the billing tools. If a tool refuses, say why.",
trace_tools=True,
)
usage = Usage()
try:
result = await agent.ainvoke(
{"messages": [{"role": "user", "content": REQUEST}]},
caller=signed_in_user("dana"),
config={"callbacks": [usage]},
)
print(">>>", result["messages"][-1].content)
print("usage:", usage)
finally:
await agent.shutdown()
if __name__ == "__main__":
asyncio.run(main(forward="--no-forward" not in sys.argv))python billing_agent.py # forwards Dana's token, the default here
python billing_agent.py --no-forward # sends the agent's own tokenWith the default, the server checks Dana:
→ Invoking tool: list_invoices with {}
✔ Tool result from list_invoices: [{"invoice_id": "INV-7", "customer": "acme", "amount": 1200.0, "credited": 0.0}, {"invoice_id": "INV-8", "customer": "globex", "amount": 310.0, "credited": 0.0}]
→ Invoking tool: issue_credit with {'invoice_id': 'INV-7', 'amount': 50.0, 'reason': 'Late delivery credit'}
✖ issue_credit error: ACCESS_DENIED: Requires any of roles [finance], but client has [support]
→ Invoking tool: whoami with {}
✔ Tool result from whoami: {"client_id": "dana", "roles": ["support"], "scopes": ["invoices:read"]}
>>> I attempted to apply a 50 EUR credit to invoice INV-7 (customer: acme), but the action was blocked.
…
usage: 4 model calls, 1,904 input + 1,284 output tokensWith --no-forward, the server checks the agent:
→ Invoking tool: issue_credit with {'invoice_id': 'INV-7', 'amount': 50.0, 'reason': 'Late delivery - 50 EUR customer goodwill credit'}
✔ Tool result from issue_credit: {"invoice_id": "INV-7", "credited": 50.0, "reason": "Late delivery - 50 EUR customer goodwill credit"}
>>> Done — a 50 EUR credit has been issued on invoice INV-7 for "Late delivery - 50 EUR customer goodwill credit".
usage: 2 model calls, 760 input + 136 output tokensEach setting ran five times against a fresh server, with the code above:
Setting | Credit issued | Model calls (mean) | Tokens in / out (mean) |
|---|---|---|---|
forward_caller_token=True (the default for this server) | 0 of 5 | 3.8 | 1,768 / 1,172 |
forward_caller_token=False | 5 of 5 | 2.2 | 862 / 153 |
The outcome didn't vary at all: refused five times out of five with Dana's token, issued five times out of five with the agent's. The refused runs cost more, because the model looked for another way, checked whoami and explained the refusal. With the agent's credential, Dana got a credit she isn't allowed to issue, every time, and nothing in the agent's code was wrong. That's the confused deputy problem: a privileged intermediary doing on someone's behalf what they couldn't do themselves. Forward the user's token to servers that make per-user decisions and trust the same identity provider. Turn forwarding off for servers outside your trust boundary, which should never receive your users' tokens. The MCP specification forbids a server from passing a token it received on to other services, so don't build a server that forwards one either. Multi-Tenant AI Agents: Keep Each Customer's Data Apart goes further, with tenants, per-user sessions and conversation ownership.
Which servers get the user's token
forward_caller_token is unset by default, and HTTPServerSpec.forwards_caller_token shows what that resolves to: forward the caller's token, unless the server has an API key. An API key is the server's own explicit credential, so it wins, and the user's token isn't sent there at all. This script asks the same agent, acting for Dana, who each server thinks it is:
"""Which credential a server gets when the agent acts for a signed-in user."""
import asyncio
import os
from billing_identity import identity, signed_in_user
from promptise import build_agent
from promptise.config import HTTPServerSpec
API_KEY_SERVER = "http://127.0.0.1:9432/mcp"
SPECS = {
"OAuth server, default": HTTPServerSpec(url="http://127.0.0.1:9434/mcp", audience="api://billing"),
"API-key server, default": HTTPServerSpec(url=API_KEY_SERVER, api_key=os.environ["BILLING_AGENT_KEY"]),
"API-key server, forward=True": HTTPServerSpec(
url=API_KEY_SERVER, api_key=os.environ["BILLING_AGENT_KEY"], forward_caller_token=True
),
}
async def main():
for label, spec in SPECS.items():
agent = await build_agent(
model="openai:gpt-5-mini",
servers={"billing": spec},
identity=identity,
instructions="Call the whoami tool once, then reply in one short sentence.",
)
try:
result = await agent.ainvoke(
{"messages": [{"role": "user", "content": "Who does the billing server think I am?"}]},
caller=signed_in_user("dana"),
)
seen = [m.content for m in result["messages"] if m.type == "tool"]
print(f"{label:<29} forwards_caller_token={spec.forwards_caller_token!s:<5} whoami -> {seen[-1][:110]}")
finally:
await agent.shutdown()
asyncio.run(main())OAuth server, default forwards_caller_token=True whoami -> {"client_id": "dana", "roles": ["support"], "scopes": ["invoices:read"]}
API-key server, default forwards_caller_token=False whoami -> {"client_id": "billing-agent", "roles": ["finance"], "scopes": []}
API-key server, forward=True forwards_caller_token=True whoami -> Error: MCPClientError: Failed to call MCP tool 'whoami': Server 'billing' rejected the connection: 401 UnauthoThe OAuth server checked Dana, and the API-key server checked the agent's key. The third line sets forward_caller_token=True on the API-key server: the agent then sends both Dana's bearer token and the key, a Promptise server checks the bearer first, and an API-key server can't verify a JWT, so the call fails with 401. Set True only for a server that verifies the same tokens as your users' identity provider, and False for any server that should never see them.
[08]
Honest limits
These are true of Promptise Foundry 1.3.3:
No OAuth discovery. The server doesn't publish OAuth 2.0 Protected Resource Metadata (RFC 9728) or send WWW-Authenticate on a 401, which the MCP specification requires of servers that implement its authorization flow. Behind require_auth=True, a request for /.well-known/oauth-protected-resource itself gets 401. Clients that discover an authorization server on their own, through the specification's interactive flow, won't find yours: give them a token or key out of band.
No HTTP 403 for missing permissions. A missing role or scope is a tool error, ACCESS_DENIED with isError: true, in a 200 response. There is no insufficient_scope challenge for a client to step up from.
A 401 doesn't say why. The response body and the server log are the same for an expired token, a bad signature and a wrong audience. Run the token through TestClient to see the reason.
A server gets one credential that counts. With forward_caller_token=True on an API-key server, both the user's bearer token and the key are sent, the bearer is checked first, and the call fails with 401. Leave it unset for API-key servers.
The built-in token endpoint is for development. It keeps refresh tokens in memory, and its tokens are HS256, signed with the same secret every verifying server holds. MCPClient.fetch_token speaks standard RFC 6749 to it and to identity providers that accept the client secret in the body, but it returns only the access token: no refresh token, no expiry, and no way to ask for a scope or a resource.
`HTTPServerSpec` has no token function. bearer_token= is fixed for the agent's lifetime. For tokens that expire, use an AgentIdentity as shown, or bearer_token_provider= on MCPClient when you use the client directly.
API keys carry no scopes. HasScope denies every API-key caller. Use roles, or switch to tokens.
`JWTAuth` is HS256 only. For RS256 or ES256 tokens, use JwksAuth, or AsymmetricJWTAuth with a fixed public key. JwksAuth requires an audience and tolerates 60 seconds of clock skew by default.
The identity provider here is a stand-in. Entra ID, Okta, Auth0 and Keycloak weren't run for this guide. Their claim names differ: check where yours puts roles and scopes, and that its tokens carry your server's audience.
[09]
Frequently asked questions
Does MCP require OAuth?
No. The MCP specification makes authorization optional. When an HTTP server implements it, the specification describes an OAuth 2.1 flow; stdio servers should take credentials from their environment instead. For a server that only your own agents and services call, API keys or bearer tokens from your existing identity provider are enough, and that's what Promptise supports.
What's the difference between 401 and 403 for an MCP server?
A 401 Unauthorized means the server couldn't verify who you are: no credential, a malformed or forged one, an expired token, or one issued for a different server. A 403 Forbidden means it knows who you are, but you aren't allowed. Promptise answers the first with HTTP 401 and the second with a tool error, ACCESS_DENIED, so check isError and the error code on tool results, not only the HTTP status.
How do I pass an API key or bearer token to an MCP server in Python?
With Promptise's client, MCPClient(url=..., api_key=...) sends x-api-key, and MCPClient(url=..., bearer_token=...) sends Authorization: Bearer. For tokens that expire, pass bearer_token_provider=, a function that returns the current token. An agent takes the same api_key= and bearer_token= on HTTPServerSpec, or an AgentIdentity for tokens it renews itself.
Should an AI agent use its own credentials or the user's token?
Use the user's token when the server should apply that user's permissions, which is the safe default for anything acting on a person's behalf. Use the agent's own credential for work that isn't anyone's request, like a nightly job, and for servers that shouldn't see your users' tokens. In this guide's test, the agent's credential let a support user issue finance credits in 5 of 5 runs; her own token stopped it in 5 of 5.
Can MCP clients find my identity provider automatically?
Not with Promptise 1.3.3. Automatic discovery needs Protected Resource Metadata and a WWW-Authenticate header on 401, and the server sends neither. Configure the client with a token or API key, or put a gateway that implements the specification's authorization flow in front of the server.
[10]
Where to go next
Authentication & Security: every provider and guard, ClientContext, and the token endpoint's options.
Deployment: the transport-level auth gate, Host and Origin checks, CORS and running behind a proxy.
Client Guide: bearer_token, api_key, bearer_token_provider and calling tools as a specific user.
Server configuration: every field of HTTPServerSpec, including audience and forward_caller_token.
Agent Identity overview: AgentIdentity and its credential providers.
AI Agent Identity: Authenticate Agents to MCP Servers: workload identity on Entra ID, AWS, Google Cloud and SPIFFE, and how tokens are cached and refreshed.
Build a Production MCP Server in Python (Step by Step): rate limits, timeouts and an audit log around the same kind of server.