Authentication¶
Pass an authentication policy to Registry(auth=...). HTTP and SSE endpoints
use its Principal for permissions, namespaces, and session ownership.
Custom authentication middleware is not required.
Policies and token verifiers¶
Authentication is the policy base class. Authorization implements OAuth
Bearer authentication and delegates token verification to a TokenVerifier:
flowchart TD
A["Authorization<br/>Extract the Bearer token"]
A --> V["TokenVerifier<br/>Verify the token"]
V --> P["Principal<br/>Identity and permissions"]
P --> C["Authorization<br/>Check expiry and required scopes"]
C --> E["Endpoint<br/>Select namespace and check session ownership"]
Replace the verifier to change token validation. Subclass Authentication
to change credential extraction, challenges, or metadata.
Choose an integration¶
Requirement |
Integration |
|---|---|
Fixed accounts or a user database |
|
API key, cookie, or existing identity |
|
Static tokens, JWT, or introspection |
|
GitHub browser login |
|
Another OAuth provider |
See permissions and token handling for access checks and refresh behavior.
Basic authentication¶
Use StaticBasicAuth for one or more configured accounts with individual scopes:
from aiohttp_tiny_mcp import Registry, StaticBasicAuth
auth = StaticBasicAuth(
("alice", "alice-example-password", {"reports:read"}),
("bob", "bob-example-password", {"reports:read", "reports:write"}),
realm="reports",
)
registry = Registry("reports", "1.0", auth=auth)
Each tuple holds (username, password, scopes); omit scopes for an account
without permissions. Supply at least one account with unique usernames.
required_scopes= restricts access for all accounts.
Clients send UTF-8 credentials in the Basic Authorization header. Use HTTPS; Base64 does not encrypt passwords. Basic authentication publishes no OAuth metadata.
For a user database, subclass BasicAuth and implement verify:
from secrets import compare_digest
from aiohttp.test_utils import make_mocked_request
from aiohttp_tiny_mcp import BasicAuth, Principal
class Users(BasicAuth):
async def verify(self, username: str, password: str) -> Principal | None:
# Replace this example comparison with your user store's password check.
if username == "alice" and compare_digest(password.encode(), b"example-password"):
return Principal(
subject="alice", namespace="reports", scopes=frozenset({"reports:read"})
)
return None
registry = Registry("reports", "1.0", auth=Users(realm="reports"))
request = make_mocked_request(
"GET", "/mcp", headers={"Authorization": "Basic YWxpY2U6ZXhhbXBsZS1wYXNzd29yZA=="}
)
principal = await registry.auth.authenticate(request)
assert principal is not None and principal.subject == "alice"
StaticBasicAuth rejects empty credentials. Custom verify() implementations
decide whether to accept them. Control characters are rejected; colons separate
the username from the password. Passwords may contain colons. Query credentials are not read.
Custom authentication¶
Subclass Authentication when you need control over credential extraction
and verification. Implement authenticate(request) and challenge(refusal):
from secrets import compare_digest
from aiohttp import web
from aiohttp.test_utils import make_mocked_request
from aiohttp_tiny_mcp import Authentication, Principal, Registry, Unauthorized
class ApiKey(Authentication):
def __init__(self, secret: str):
super().__init__()
self.secret = secret.encode()
async def authenticate(self, request: web.Request) -> Principal | None:
supplied = request.headers.get("X-API-Key", "").encode()
if compare_digest(supplied, self.secret):
return Principal(subject="service", namespace="reports")
return None
def challenge(self, refusal: Unauthorized) -> str:
return 'ApiKey realm="reports"'
policy = ApiKey("example-secret")
registry = Registry("reports", "1.0", auth=policy)
request = make_mocked_request("POST", "/mcp", headers={"X-API-Key": "example-secret"})
assert policy.check(await policy.authenticate(request)).subject == "service"
Return None for invalid credentials, or raise Unauthorized with a specific
error, description, and HTTP status. Unexpected exceptions are not treated as
authentication failures for another policy to bypass. A subclass can inspect
headers, query parameters, cookies, or trusted middleware state. It must verify
those values before constructing a principal. Custom policies publish no
metadata unless they override metadata_path and metadata().
Call super().__init__(required_scopes=[...]) in a custom constructor to configure
endpoint-wide permissions. The same constructor accepts bind_sessions and
namespace_from_token; keep their defaults unless another trusted layer owns
session isolation. The endpoint calls check() after authenticate(); you do
not need to call it inside the subclass. challenge() returns only the header
value, without WWW-Authenticate:. It is synchronous.
Use the same policy instance for the endpoint lifetime. Keep request-specific
identity in the returned Principal, not on the policy instance. Concurrent
requests share the instance. Inject your database or service into its constructor;
manage that dependency’s lifecycle through your aiohttp application.
Authentication protects the MCP routes. It does not protect unrelated routes or the static console; configure application middleware for those routes if needed. The HTTP policies do not authenticate stdio connections.
Multiple authentication methods¶
auth= accepts one policy or an iterable. Iterables are consumed once;
an empty iterable is rejected. Use auth=None for an unprotected endpoint.
from aiohttp_tiny_mcp import Authorization
class Tokens:
async def verify(self, token: str) -> Principal | None:
if token == "example-token":
return Principal(
subject="alice", namespace="reports", scopes=frozenset({"reports:read"})
)
return None
registry = Registry(
"reports",
"1.0",
auth=[
Users(realm="reports"),
Authorization(Tokens(), resource="https://mcp.example.com/mcp"),
],
)
assert len(registry.auth_policies) == 2
Policies are alternatives, evaluated in registration order. The first policy that returns a valid principal with its required scopes succeeds. Claims and scopes from different policies are never combined. If every policy rejects the request, the response advertises all challenges. A missing-scope refusal takes precedence over an invalid-credential refusal. Only policies that declare metadata add routes; equal documents at one path share a route, and conflicting documents cause a configuration error.
Principal and storage namespaces¶
Field |
Purpose |
|---|---|
|
Stable application account ID. |
|
Identity authority; participates in session ownership. |
|
Calling application’s ID; identifies the owner when |
|
Verified permission strings; there is no implicit role hierarchy or wildcard expansion. |
|
Application data for handlers, such as an organization ID. Claims grant no permissions automatically. |
|
Storage and notification partition, such as a verified organization ID. |
|
Expiry as Unix time in seconds, or |
Principal.namespace selects the Hub and session storage namespace. When it
is None, the library uses Principal.identity. Trusted middleware namespaces
take precedence, and namespace_from_token=False on the selected policy disables
automatic namespace selection. This setting applies to Basic and custom policies
as well as tokens. bind_sessions also comes from the selected policy.
Use the same verified namespace for accounts that must share notifications.
Session ownership still compares Principal.identity, so a shared namespace
does not grant access to another user’s session. Two authentication methods
can access the same session only when they resolve to the same identity and
storage namespace. Choose stable identities; do not use passwords or tokens
as identity or namespace values. These namespaces do not add application-level
permissions for projects or database records.
For a subject, identity is "<issuer>|<subject>"; a missing issuer becomes an
empty string. For a client without a subject, it is "<issuer>|client:<client_id>".
Matching usernames alone do not unify identities across issuers. The OAuth server
sets the issuer to its configured URL and the client ID to the registered MCP client.
Handler permissions¶
Annotate a handler parameter with Principal to receive the verified identity.
It is injected by the server and does not appear in the input schema.
required_scopes applies to every request accepted by that policy. Missing
required scopes produce HTTP 403 before dispatch.
@registry.tool(scopes=[...]) checks the verified principal before calling the
tool. A missing tool scope returns an error tool result. Resource, prompt,
and extension handlers must implement their own operation-specific checks;
they can also receive Principal through dependency injection. Application
permissions, such as access to one project or database row, remain application
logic. Client-reported names and capabilities are not verified identities.
principal.holds(wanted) returns the missing scopes, not a boolean indicating
success. An empty set means the principal holds every requested scope:
from pydantic import BaseModel
from aiohttp_tiny_mcp import CallToolResult, Principal, Registry, StaticBasicAuth
registry = Registry(
"reports",
"1.0",
auth=StaticBasicAuth(("alice", "example-password", ["reports:read"])),
)
REPORTS = {"quarterly": {"owner": "alice", "text": "Quarterly results"}}
class ReportRequest(BaseModel):
report_id: str
@registry.tool(scopes=["reports:read"])
async def read_report(args: ReportRequest, principal: Principal) -> str | CallToolResult:
report = REPORTS.get(args.report_id)
if report is None or report["owner"] != principal.subject:
return CallToolResult.failure("Report not found or access denied")
return report["text"]
alice = Principal(subject="alice", scopes=frozenset({"reports:read"}))
bob = Principal(subject="bob", scopes=frozenset({"reports:read"}))
assert not alice.holds(["reports:read"])
assert alice.holds(["reports:write"]) == frozenset({"reports:write"})
assert await read_report(ReportRequest(report_id="quarterly"), alice) == "Quarterly results"
assert await read_report(ReportRequest(report_id="quarterly"), bob) == CallToolResult.failure(
"Report not found or access denied"
)
The dispatcher checks the tool scope; the handler checks ownership against
server-side data. Calling the Python function directly bypasses dispatcher scope
checks. For resources, prompts, and extensions, explicitly check holds() before
reading or changing protected data. Scope checks do not hide tool definitions
from the catalogue.
Sending credentials¶
Configure headers on the aiohttp.ClientSession passed to Client:
from aiohttp import ClientSession, encode_basic_auth
from aiohttp_tiny_mcp import Client, Registry, StaticBasicAuth
from aiohttp_tiny_mcp.protocol.selection import AdapterSet
registry = Registry("reports", "1.0", auth=StaticBasicAuth(("alice", "example-password")))
url = await serve(registry)
headers = {"Authorization": encode_basic_auth("alice", "example-password")}
async with ClientSession(headers=headers) as http:
adapter = AdapterSet.default().by_version["2026-07-28"]
async with Client(url, adapter, session=http) as client:
await client.initialize()
For Bearer authentication, use {"Authorization": "Bearer <access-token>"}.
The client sends the configured headers on its HTTP requests. To obtain and
refresh OAuth tokens, pass an OAuth context through Client(auth=...); see
Client OAuth. Static session headers do not refresh tokens. Basic and Bearer policies
do not read credentials from URLs; use a custom policy when required by your
application.
Legacy HTTP+SSE clients must authenticate both GET /sse and every
POST /messages. A successful stream opening does not authenticate later
POSTs. Missing or invalid credentials return HTTP 401 with the configured
challenge, before creating a session or dispatching a message. A different
session owner receives HTTP 404. Older clients need a separately acquired
token if they lack OAuth discovery and login support.
Console authentication¶
Mount Console normally. Registry(auth=...) protects MCP requests while the
console HTML, JavaScript, and CSS remain public. The page first connects without
credentials. An HTTP 401 opens the authentication dialog. The console reads
WWW-Authenticate, which the endpoint builds from each policy’s challenge().
This uses HTTP authentication challenges
and works with every supported MCP revision.
Select Basic and enter a username and password, or select Bearer token and enter an existing access token. With the OAuth configuration below, use Sign in with OAuth to obtain an MCP token through browser login. Multiple configured policies appear in the server authentication message. Use Authentication to change credentials; Connect starts a new MCP session. Clear removes credentials and disconnects. Closing the dialog cancels editing without changing active credentials.
Credentials stay in page memory. The console sends them on all MCP requests, including notifications and answers to server questions. It does not put them in browser storage, URLs, or the traffic panel. Editing the endpoint or reloading the page clears them. Disconnecting retains them for reconnecting to the same endpoint. Credentialed requests do not follow redirects. Cookies and browser-managed HTTP credentials are not sent.
For a custom policy, use Custom header with Authorization or an X-*
header, such as X-API-Key, and its complete value. A challenge cannot describe
arbitrary credential fields, so the user supplies the header name. The console
does not implement Digest, cookie login, or token refresh. OAuth protected-resource
metadata remains available through
Authorization; it does not define a Basic login form. No additional discovery
endpoint is required for the console.
OAuth provider adapters¶
Provider integration has two independent callbacks:
OAuthProvider.profile(tokens, http)calls the provider and returns a verifiedIdentity.OAuthServer(identity=...)maps that identity to an applicationPrincipal.
Keep provider response parsing in the first callback and application permissions
in the second. The same mapper can serve multiple providers by looking up
(identity.provider, identity.sub).
For a provider with an authorization-code flow, PKCE S256, and a JSON profile API,
configure OAuthProvider directly. This example expects a profile object with a stable
string id and an optional display_name:
import asyncio
from aiohttp import ClientError, ClientSession
from aiohttp_tiny_mcp.oauth import Identity, OAuthProvider, UpstreamAuthError
async def company_profile(tokens: dict, http: ClientSession) -> Identity:
try:
async with http.get(
"https://identity.example.com/api/me",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
allow_redirects=False,
) as response:
if response.status != 200:
raise UpstreamAuthError("The provider refused the profile request.")
profile = await response.json()
except (ClientError, asyncio.TimeoutError, ValueError) as error:
raise UpstreamAuthError("The provider profile is unavailable.") from error
if not isinstance(profile, dict) or not isinstance(profile.get("id"), str):
raise UpstreamAuthError("The provider returned an invalid account ID.")
if not profile["id"]:
raise UpstreamAuthError("The provider returned an empty account ID.")
return Identity(
sub=profile["id"],
provider="company",
claims={"display_name": profile.get("display_name")},
)
provider = OAuthProvider(
name="Company login",
authorize_url="https://identity.example.com/oauth/authorize",
token_url="https://identity.example.com/oauth/token",
client_id="YOUR_CLIENT_ID",
client_secret="YOUR_CLIENT_SECRET",
scopes=["profile"],
profile=company_profile,
)
assert provider.profile is company_profile
Pass this object as provider=provider to OAuthServer. Configure the server,
registered MCP clients, and console as in the GitHub example below. The server
owns the HTTP session passed to profile; do not close it. Raise
UpstreamAuthError for expected provider failures. Its internal error text is
not sent to the browser. Return only verified identity data, never credentials.
The provider validates its URLs and client ID. The default exchange sends the
client ID and optional client_secret in form data. It expects a
JSON response with a Bearer access token and accepts optional expires_in and
refresh_token fields. Providers that require different token authentication
or response formats need an OAuthProvider subclass with these extension points:
Extension point |
Contract |
|---|---|
|
Build the provider redirect. Preserve state, the exact callback, and PKCE S256. |
|
Exchange the code using the provider’s required authentication and parse its response. |
|
Fetch and validate the account, then return |
|
Add provider-specific authorization parameters; reserved OAuth parameters retain the library’s values. |
These hooks adapt an authorization-code provider. The library does not currently supply OIDC discovery or ID-token validation, SAML, device flow, or a general refresh interface. An OIDC integration must validate its ID token with an OIDC implementation; decoding its payload does not verify identity. For an existing application login or another credential scheme, use custom authentication instead.
GitHub sign-in¶
Use OAuthServer with the provider from aiohttp_tiny_mcp.oauth.github to sign
in through a GitHub OAuth App. The server acts as the authorization server for
MCP. GitHub supplies the identity. The server issues a separate MCP token through
its default implementation or your token object. A GitHub token is not an MCP credential.
Create an OAuth App in GitHub and set its Authorization callback URL to
https://mcp.example.com/oauth/callback. Load the app’s client ID and secret
from your deployment configuration. These credentials belong to the server.
Never include the client secret in console HTML or JavaScript.
This example permits one GitHub account, identified by its numeric user ID:
from aiohttp_tiny_mcp import Endpoint, Principal, Registry
from aiohttp_tiny_mcp.console import Console
from aiohttp_tiny_mcp.oauth import Identity, OAuthClient, OAuthServer
from aiohttp_tiny_mcp.oauth.github import GitHub
def allow_user(identity: Identity) -> Principal | None:
if identity.provider != "github" or identity.sub != "1234567":
return None
return Principal(
subject=f"github:{identity.sub}",
scopes=frozenset({"reports:read"}),
claims=identity.claims,
)
oauth = OAuthServer(
issuer="https://mcp.example.com/oauth",
provider=GitHub(client_id="YOUR_CLIENT_ID", client_secret="YOUR_CLIENT_SECRET"),
resource="https://mcp.example.com/mcp",
clients=[
OAuthClient(
client_id="console",
client_name="Reports console",
redirect_uris=["https://mcp.example.com/console/oauth-callback"],
)
],
scopes=["reports:read"],
identity=allow_user,
)
registry = Registry("reports", "1.0", auth=oauth.resource(required_scopes=["reports:read"]))
app = Endpoint(registry).app("/mcp")
oauth.setup(app)
Console("/mcp", oauth_client_id="console").setup(app, "/console")
The two callback URLs have different roles. /oauth/callback receives the
GitHub response. /console/oauth-callback returns the MCP authorization code
to the console popup. Register other MCP clients with their own OAuthClient
IDs and exact callback URLs. Configure those clients with the same IDs.
The console reads the Bearer challenge, protected-resource metadata, and authorization-server metadata. Sign in with OAuth opens a popup that redirects straight to GitHub. The user signs in and returns to the console. The console checks the response state and issuer before exchanging the code with PKCE. The MCP token stays in page memory. Allow popups for this page.
Explicitly registered clients skip the local consent page by default. Register
only clients you trust. To require local confirmation for a client, set
OAuthClient(..., require_consent=True). This setting does not bypass GitHub’s
own consent screen. Both paths enforce PKCE, browser-bound state, and exact
callback validation.
identity can be synchronous or asynchronous. Return None to deny access,
or return a Principal with the account’s allowed MCP scopes and namespace.
The server rejects requests for scopes that the principal does not hold.
Without this callback, every successfully authenticated provider account is
allowed. GitHub identities use numeric IDs, so login renames do not change
session ownership. GitHub permissions (read:user by default) and MCP scopes
are separate configuration values.
Mount server routes at the origin root, even when MCP is in a subapplication.
For the example issuer, metadata is served at
/.well-known/oauth-authorization-server/oauth. issuer and resource are
explicit public URLs; the server never derives them from a request’s Host header.
HTTPS is required, with HTTP permitted for loopback development addresses.
Use OAuthServer.is_public(request) alongside Console.is_public(request) in
application-wide middleware. OAuth routes enforce their own checks.
The protected-resource metadata route must also remain reachable without an
MCP token, as described below.
By default, access tokens are opaque, stored by digest, and valid for one hour.
Set access_token_ttl to change this, up to one day. await oauth.revoke(token)
revokes a default token. See application token handling
for custom formats and deployments on several computers.
Current limits:
Clients must be registered explicitly. CIMD and dynamic registration are not implemented.
Only the authorization-code grant with PKCE S256 is implemented. Sign in again after an MCP token expires; MCP refresh tokens are not issued.
The console supports OAuth metadata and token endpoints on its own origin.
Default
OpaqueTokensdiscards provider credentials when issuing the MCP token. Provider tokens are not automatically injected into tools.GitHub revocation or account changes do not invalidate an issued MCP token immediately. Its local expiry or explicit revocation controls its lifetime.
The preset targets GitHub.com OAuth Apps. GitHub Enterprise and GitHub App installation flows need separate provider configuration.
GitHub supports PKCE and can return either non-expiring tokens or expiring tokens with a refresh token. The provider handles both response shapes. See GitHub’s OAuth App flow for application registration and provider settings.
Mapping accounts and roles¶
Use OAuthServer(identity=...) to translate a verified provider identity into
your application’s account. You do not need to subclass GitHub or OAuthServer.
GitHub is a provider factory. The mapper accepts one Identity and returns a
Principal or None; an async mapper can query your account database.
This example uses server-owned account and role mappings. Unknown or disabled accounts are denied. Replace the dictionary lookup with your database lookup:
from aiohttp_tiny_mcp import Principal
from aiohttp_tiny_mcp.oauth import Identity
ROLE_SCOPES = {
"reader": frozenset({"reports:read"}),
"editor": frozenset({"reports:read", "reports:write"}),
}
ACCOUNTS = {
("github", "1234567"): {
"id": "account-42",
"organization": "acme",
"role": "editor",
"enabled": True,
},
("github", "7654321"): {
"id": "account-43",
"organization": "acme",
"role": "reader",
"enabled": False,
},
}
async def map_identity(identity: Identity) -> Principal | None:
account = ACCOUNTS.get((identity.provider, identity.sub))
if account is None or not account["enabled"]:
return None
scopes = ROLE_SCOPES.get(account["role"])
if scopes is None:
return None
return Principal(
subject=account["id"],
namespace=f"organization:{account['organization']}",
scopes=scopes,
claims={"organization": account["organization"], "role": account["role"]},
)
principal = await map_identity(Identity(sub="1234567", provider="github"))
assert principal is not None and principal.subject == "account-42"
assert not principal.holds(["reports:write"])
assert principal.namespace == "organization:acme"
assert await map_identity(Identity(sub="7654321", provider="github")) is None
assert await map_identity(Identity(sub="unknown", provider="github")) is None
Pass identity=map_identity to the earlier OAuthServer configuration. Declare
the available MCP scopes with scopes=["reports:read", "reports:write"].
Use oauth.resource(required_scopes=["reports:read"]) for endpoint access, and
@registry.tool(scopes=["reports:write"]) for tools that change reports.
The permission layers have separate roles:
Configuration |
Meaning |
|---|---|
|
Permissions requested from GitHub for provider API access. |
|
MCP scope names the authorization server accepts. |
Mapper’s |
Maximum MCP permissions allowed for this account. |
Client’s OAuth |
Permissions requested for this MCP token. |
|
Permissions required to accept a request or call a tool. |
The server rejects a request if any requested scope is unavailable or exceeds
the mapped account’s permissions. It does not silently reduce the request.
The issued principal contains the requested scopes, which can be fewer than
the mapper returned. Omitting scope requests every scope configured on the server.
The console currently requests every scope advertised by the protected resource;
it has no scope selector. With the two-scope configuration above, a reader cannot
complete console login. A client that requests only reports:read can log in.
The mapper receives the verified profile, not the provider token. The GitHub
preset supplies login, name, and email claims when available; it does not
load organization membership, teams, or repository permissions. Use your account
database for application roles. For extra provider lookups, supply a custom
OAuthProvider.profile callback that returns Identity, then apply the same mapper.
Keep credentials out of claims; server principals must be JSON-serializable.
Mapping runs at login. Later account or role changes do not automatically update
the issued token’s permissions. Use a short access_token_ttl, or call
await oauth.revoke(token) when the token backend supports revocation.
EncryptedTokens does not support individual revocation.
For immediate account or record restrictions, check current application data in
handlers or an authentication policy on each request. The server sets token expiry
to the earlier of its configured lifetime and the mapper’s expires_at.
Application token handling¶
OAuthServer uses OpaqueTokens by default: revocable tokens stored by digest.
Pass OpaqueTokens(issuer=..., resource=..., store=...) to use a separate token
store. For tokens that carry encrypted provider credentials, use EncryptedTokens:
from aiohttp_tiny_mcp.oauth import EncryptedTokens, KECCAKCipher
def token_codec(saved_key: bytes) -> EncryptedTokens:
return EncryptedTokens(
KECCAKCipher(saved_key),
issuer="https://mcp.example.com/oauth",
resource="https://mcp.example.com/mcp",
)
Pass tokens=token_codec(saved_key) to OAuthServer. Generate at least 32 random
key bytes once, then load the same saved key on each instance.
KECCAKCipher uses zlib compression, a random 16-byte nonce, SHAKE-256 XOR
encryption, and HMAC-SHA256. It needs no extra package or access-token database.
Its compression_level defaults to 9; use 0 to disable compression.
Supply another AbstractCipher to change encryption. Its encode(dict) -> bytes
and decode(bytes) -> dict own serialization and integrity checks; invalid blobs
must raise ValueError. EncryptedTokens handles base64 and issuer/resource/expiry checks.
tokens.decode(token)["upstream"] retrieves the provider dictionary in server
code after those checks; invalid tokens raise ValueError. Individual revocation
is unavailable. Replacing the key invalidates all tokens issued with the old key.
Pass tokens=your_object to OAuthServer. It needs two async methods; no base
class or token model is required:
Method |
Contract |
|---|---|
|
Receive the approved MCP principal and the complete provider response as a dictionary. Return your MCP bearer token. |
|
Verify your token’s integrity, issuer, resource and expiry. Return its principal, or |
Your object can save credentials in PostgreSQL, or return an AES-encrypted,
base64-encoded payload. The library does not select your storage or encryption
format. Keep provider credentials out of Principal and tool responses.
An optional async revoke(token) enables oauth.revoke(token); without it,
that call raises NotImplementedError.
The callback stores the approved principal and provider response with the
authorization code for at most five minutes. This private flow store contains
provider credentials. issue() runs only after PKCE verification and atomic
code redemption. An abandoned login issues no token. If issuance fails, the
client must restart login; the code has already been consumed.
Honor the supplied principal’s expiry and scopes. The server uses that expiry
in the token endpoint response and also rejects expired or wrong-issuer principals.
For several computers, configure the same public issuer, resource, clients and provider credentials on each instance:
State |
What instances must share |
|---|---|
Pending login and authorization codes |
|
Opaque tokens |
The token object’s store; defaults to the server’s |
Application tokens |
Your database, or your encryption keys and verification settings for self-contained tokens. |
The default memory store serves one process. SQLite can share state between processes on one computer. OAuth records have their own TTL and are independent of MCP session closure. Self-contained tokens still need shared pending-login state for this authorization-code flow.
MCP refresh tokens are not implemented. Provider refresh and retention belong to your token object. Coordinate refresh across instances and atomically replace rotated refresh tokens; see RFC 6749, section 6. Refreshing provider tokens does not update MCP permissions. The account mapper runs at login; use current application data when permissions need revalidation.
OAuth Bearer authentication¶
Use Authorization when this MCP endpoint is an OAuth protected resource.
Your application supplies token verification through JWT validation,
introspection, or OAuthServer.resource(). Authorization itself does not
run an authorization server or register clients.
Give resource the public MCP URL, including its path. Endpoint publishes RFC 9728 protected-resource metadata below its well-known path and returns a Bearer challenge for missing, invalid, expired, or insufficient tokens. authorization_servers tells an MCP client where it may obtain a token.
from pydantic import BaseModel
from aiohttp_tiny_mcp import Registry
from aiohttp_tiny_mcp.auth import Authorization, Principal, StaticVerifier
verifier = StaticVerifier(
{
"report-token": Principal(
subject="alice",
client_id="reports",
issuer="https://login.example.com",
scopes=frozenset({"reports:read"}),
),
}
)
auth = Authorization(
verifier=verifier,
resource="https://mcp.example.com/reports/mcp",
authorization_servers=["https://login.example.com"],
scopes_supported=["reports:read"],
)
registry = Registry("reports", "1.0", auth=auth)
class Nothing(BaseModel):
pass
@registry.tool(scopes=["reports:read"])
async def report(args: Nothing, principal: Principal) -> str:
"""Return the current caller's report."""
return f"report for {principal.subject}"
principal = await auth.principal("Bearer report-token")
assert not principal.holds(["reports:read"])
assert await report(Nothing(), principal) == "report for alice"
assert auth.metadata()["resource"] == "https://mcp.example.com/reports/mcp"
Invalid tokens return 401; missing required scopes return 403. By default, the verified principal’s namespace (or identity when unset) separates sessions, subscriptions, and pending questions. This prevents callers from sharing state. Set namespace_from_token=False only when your application sets a namespace in its own trusted middleware.
bind_sessions=True is also the default: a legacy HTTP session belongs to the principal that opened it. Do not disable it unless a separate trusted layer binds session IDs to callers.
These checks also apply to the legacy SseEndpoint: both GET /sse and
every POST /messages require the Bearer header. Verification occurs before
opening a stream or dispatching a message. The stored session owner is checked
on each POST, including requests handled by another worker. Historical clients
must acquire a token separately if they lack OAuth discovery and login support.
See HTTP+SSE deployment
for mounting both transports with one metadata route.
Static tokens and custom verifiers¶
StaticVerifier(mapping) maps non-empty tokens to principals for development
and tests, without extra dependencies.
Custom verifiers implement async verify(token: str) -> Principal | None;
return None for invalid tokens. Keep token parsing inside the verifier and
credentials out of Principal.
JWT verifiers¶
Install the optional dependency:
pip install 'aiohttp-tiny-mcp[jwt]'
Verifier |
Key |
|---|---|
|
HS256 secret, at least 32 bytes |
|
PEM public key or file: RSA ≥2048 bits, EC P-256/P-384/P-521, or EdDSA |
Both require exp and validate signatures and time claims. Algorithms follow
the configured key. Set issuer= and audience= explicitly: empty defaults
disable those checks, and Authorization.resource does not configure them.
Audience lists are supported. JWKS fetching and discovery are not included.
Core imports and claims mapping work without [jwt].
import time
import jwt
from aiohttp_tiny_mcp import Authorization
from aiohttp_tiny_mcp.auth.jwt import HMACJWTVerifier
secret = "replace-this-example-secret-with-random-bytes"
verifier = HMACJWTVerifier(
secret,
issuer="https://login.example.com",
audience="https://mcp.example.com/mcp",
)
auth = Authorization(verifier, resource="https://mcp.example.com/mcp")
token = jwt.encode(
{
"sub": "alice",
"iss": "https://login.example.com",
"aud": "https://mcp.example.com/mcp",
"scope": "reports:read",
"exp": time.time() + 300,
},
secret,
algorithm="HS256",
)
principal = await auth.principal(f"Bearer {token}")
assert principal.subject == "alice"
assert principal.scopes == frozenset({"reports:read"})
Map verified claims to a principal¶
Both JWT verifiers accept a synchronous principal(claims) -> Principal
callback, called after verification. Raise ValueError to reject the account.
The default principal_from_claims maps:
Claim |
Principal field |
Default |
|---|---|---|
|
|
|
|
|
|
|
|
Empty string |
|
|
Empty frozenset |
|
|
|
Scopes accept a space-separated string or a list; scope takes precedence
over scp, including when empty. Claims are copied to Principal.claims;
malformed identity, scope, and expiry values are rejected.
The mapper does not verify tokens. Empty claims produce Principal();
separate session owners need a stable subject or client ID. Namespace defaults
to None. Override it through a custom mapper:
from collections.abc import Mapping
from dataclasses import replace
from typing import Any
from aiohttp_tiny_mcp import Principal, principal_from_claims
def tenant_principal(claims: Mapping[str, Any]) -> Principal:
principal = principal_from_claims(claims)
if claims.get("org") != "example-team":
raise ValueError("organization is not allowed")
return replace(principal, namespace="example-team")
verifier = HMACJWTVerifier(
secret,
issuer="https://login.example.com",
audience="https://mcp.example.com/mcp",
principal=tenant_principal,
)
assert await verifier.verify(token) is None # This token has no allowed organization.
assert principal_from_claims({}) == Principal()
Use namespace="" for shared storage. Session ownership still uses the
issuer-qualified subject or client ID, independently of namespace.
Where the metadata has to be served¶
The library logs a warning when add_subapp() adds a prefix to a .well-known
metadata route. The warning includes the required path and the prefixed path.
It applies to protected-resource metadata from both HTTP transports and to
authorization-server metadata from OAuthServer. It also works with routes()
and app.add_routes(). The warning does not move routes or prevent startup.
An MCP path such as /reports/mcp on the root application is valid and does not
trigger this warning. Reverse-proxy path rewriting is outside this check; ensure
the proxy exposes the documented public metadata URLs.
Endpoint.routes includes the metadata route along with the endpoint, and Endpoint.metadata_routes returns it alone. The path comes from resource, not from where you mounted anything, and RFC 8615 places a well-known URI directly under the authority. Give resource the endpoint’s public URL, so the two agree.
SseEndpoint.routes() and metadata_routes() follow the same rule. When both
transports share one protected resource, pass metadata=False to one endpoint
to avoid registering the metadata route twice.
assert auth.metadata_path == "/.well-known/oauth-protected-resource/reports/mcp"
assert auth.metadata_url == "https://mcp.example.com" + auth.metadata_path
A prefix in the path you pass to routes or setup is enough, and keeps the metadata at the root. Mounting the endpoint inside add_subapp does not: aiohttp prefixes every route a subapplication holds, including this one, and a client never looks there. Pass metadata=False to routes and give metadata_routes to the application that owns the root:
from aiohttp import web
from aiohttp_tiny_mcp import Endpoint, SseEndpoint
endpoint = Endpoint(registry)
section = web.Application()
section.add_routes(endpoint.routes("/mcp", metadata=False))
root = web.Application()
root.add_subapp("/reports", section)
root.add_routes(endpoint.metadata_routes())
assert [resource.canonical for resource in root.router.resources()] == [
"/reports",
auth.metadata_path,
]
When both HTTP transports share a protected resource, publish its metadata once:
both = web.Application()
Endpoint(registry).setup(both, "/mcp")
SseEndpoint(registry).setup(both, "/sse", "/messages", metadata=False)
metadata_routes() returns no routes for Basic policies or other policies
that declare no metadata. General mounting rules are in
Transports.
Optional application middleware¶
To manage authentication entirely in your application, omit Registry.auth
and use aiohttp middleware. Your code then owns permissions, identity injection,
and namespace/session isolation. See request context.
Registry(auth=...) leaves console assets public. For application middleware,
wrap its authentication checks to keep the console open:
from aiohttp import web
from aiohttp_tiny_mcp.console import Console
def allow_console(authenticate):
@web.middleware
async def middleware(request, handler):
if Console.is_public(request):
return await handler(request)
return await authenticate(request, handler)
return middleware
Install allow_console(your_auth_middleware) in web.Application(middlewares=[...]).
The exemption covers mounted console assets for GET and HEAD, including prefixed
routes. MCP requests still pass through your authentication checks.