This release is a pre-release and may not be stable for production use.
koda-mcp-access
Reusable FastMCP access-control middleware for KODA-ecosystem MCP servers, plus identity helpers for tools. It is a pure Policy Enforcement Point (PEP): it reads identity + an already-resolved permission subset from proxy-injected headers and allows / denies / filters MCP tool calls by pattern matching.
It does not know about roles, servers, Okta, or the permissions model —
that intelligence lives upstream (usrv-koda = data, koda-proxy = subset
resolution). Any MCP behind a proxy that honors the header contract can use it
unchanged.
How it fits
client → registry/nginx → koda-proxy → AgentCore runtime → your MCP + KodaAccessMiddleware
(resolves & (this package:
injects X-Koda-* ) reads & enforces)
- Decision (who are you, what may you do) happens upstream in the
koda-proxy: it calls usrv-koda, resolves the per-server subset, and injects
it as base64-JSON in the
permissionsheader. - Enforcement (allow / deny / filter each call) happens here. This package never validates the JWT, never queries a DB, never knows roles.
- Trust: with signed identity on, the runtime only reads headers koda-proxy signed for it.
Components (C3)
What runs where, and which step each component owns:
MCP client (Claude, Cursor, …)
│ POST /mcp-koda/mcp Authorization: Bearer <Okta JWT>
▼
┌─ mcp-gateway-registry ───────────────────────────────────────────────────────┐
│ nginx │
│ ├─ auth_server /validate JWT valid? groups │
│ └─ X-Koda-Server-Name = mcp-koda (server path; the client cannot │
│ X-Koda-Runtime-Arn = proxy_pass_url set these, nginx overwrites them) │
│ │ │
│ ▼ │
│ koda-proxy │
│ 1 verify the bearer (auth_server) │
│ 2 (server, ARN) is a registered pair ──────────► registry /api/servers │
│ 3 subset = permissions.mcp["mcp-koda"] ────────► usrv-koda profile API │
│ 4 build Koda-* headers, sign with k_mcp-koda ─► Secrets Manager │
│ .../mcp-identity │
│ 5 SigV4 InvokeAgentRuntime(ARN) │
└─────────────────────┬────────────────────────────────────────────────────────┘
▼
┌─ AgentCore runtime (one per MCP) ────────────────────────────────────────────┐
│ AgentCore forwards only the allowlisted ...-Custom-Koda-* headers │
│ │ │
│ ▼ │
│ KodaAccessMiddleware (this package) │
│ 6 signature: audience, method, tool, ts ──► Secrets Manager │
│ .../mcp-identity (same one), │
│ derives k_mcp-koda │
│ 7 HeaderContract → Identity + MCPPermissions │
│ 8 matcher: gate tools/call, filter tools/list │
│ 9 access log line + audit_hook │
│ │ │
│ ▼ │
│ your tools ── get_current_identity() / get_current_access_token() │
└──────────────────────────────────────────────────────────────────────────────┘
Steps 1–5 decide, steps 6–9 enforce. Step 6 runs with signature_mode
verify (the default once KODA_SERVER_NAME is set) or require; with off
(no server name) the runtime trusts the headers as they arrive, like 1.2.0 did.
Example: Ana calls mcp-koda
Ana has role qa, which in usrv-koda grants:
permissions: { mcp: { "mcp-koda": { tools: ["greeting", "jira_*"] } }, skills: [...], agents: {...} }
For tools/call greeting, koda-proxy sends this to the mcp-koda runtime (prefix
X-Amzn-Bedrock-AgentCore-Runtime-Custom-Koda- omitted):
Sub: ana@kushki
Roles: qa
Permissions: base64({"tools": ["greeting", "jira_*"], "skills": [...], "agents": {...}})
Client-Id: 0oa1koda
Signed-At: 1760400000
Key-Id: 2026-10
Signature: v1=9f2c… # HMAC(k_mcp-koda, v1 · ts · mcp-koda · tools/call · greeting · headers)
What the middleware does with each request (runtime in require):
| Request | Result | access line |
|---|---|---|
tools/list |
greeting, jira_create, jira_get; my_profile is hidden |
target=* decision=allow |
tools/call greeting |
runs; inside, get_current_identity().sub == "ana@kushki" |
target=greeting decision=allow |
tools/call my_profile |
AuthorizationError: not in tools |
target=my_profile decision=deny |
Someone with InvokeAgentRuntime sends Permissions={"tools":["*"]}, no signature |
denied before any header is read | sub=- decision=deny reason=missing-signature |
Ana's greeting signature replayed on tools/call my_profile |
denied: the signature covers the tool name | reason=bad-signature |
A signature made for another runtime (infra-mcp) |
denied: different key and audience | reason=bad-signature |
Registry scan: tools/list + Discovery: true, no bearer (koda-proxy signs it too) |
full catalogue | bypass=discovery |
tools/call + Discovery: true sent straight to the runtime |
denied for the missing signature; with off/verify the flag is ignored (WARNING authz: discovery flag ignored) and it is denied for the missing identity |
reason=missing-signature / reason=missing-identity |
Wiring a runtime
from fastmcp import FastMCP
from koda_mcp_access import GuardConfig, KodaAccessMiddleware, get_current_identity
mcp = FastMCP("mcp-koda")
# Reads KODA_SERVER_NAME → verify, keyring from ai-projects/koda-platform/mcp-identity
mcp.add_middleware(KodaAccessMiddleware(GuardConfig()))
@mcp.tool()
async def greeting() -> str:
return f"Hola, {get_current_identity().email}"
| Where | What |
|---|---|
pyproject.toml |
koda-mcp-access>=1.3,<2 (rc: koda-mcp-access==1.3.0rc1) |
AgentCore runtime allowlisted_headers |
add ...-Custom-Koda-Signed-At, -Key-Id, -Signature, -Client-Id |
| runtime role | secretsmanager:GetSecretValue on ai-projects/koda-platform/mcp-identity in the runtime's region (plus kms:Decrypt if it uses a CMK) |
| runtime env | KODA_SERVER_NAME (the registry path, lowercase, no /: mcp-koda-dev for /mcp-koda-dev) |
| runtime env, optional | KODA_SIGNATURE_MODE (off / verify / require, default verify with a server name), KODA_IDENTITY_SIGNING_SECRET_ID (default ai-projects/koda-platform/mcp-identity) |
KODA_SERVER_NAME is the audience and the derive_for of the key: it must be the
name koda-proxy sends as X-Koda-Server-Name, or every signature fails
(reason=bad-signature). The secret is read by name, so each runtime gets the
replica in its own region. At startup the middleware loads the keyring once and
logs authz: signature_mode='verify' audience=mcp-koda-dev keyring=ai-projects/koda-platform/mcp-identity kid=…,
or the error if the role cannot read it.
The signing secret is one for the whole platform, in each region where a reader runs (koda-proxy and every runtime; Secrets Manager replication keeps the copies equal):
{"kid": "2026-10", "key": "<32 random bytes, base64>"}
Rollout: set KODA_SERVER_NAME → verify (an invalid signature is denied, a
missing one only logs WARNING authz: signature missing on <method>) → 24–48 h
with no signature missing lines → KODA_SIGNATURE_MODE=require.
KODA_SIGNATURE_MODE=off turns it off.
Install
uv add koda-mcp-access
# or with the optional standalone Okta verifier (local-dev):
uv add "koda-mcp-access[okta]"
# or: pip install koda-mcp-access
# release candidate (pip skips pre-releases unless pinned):
uv add "koda-mcp-access==1.3.0rc1"
Pin it in your project as:
# pyproject.toml
koda-mcp-access>=1.3,<2
Use
from fastmcp import FastMCP
from koda_mcp_access import KodaAccessMiddleware
mcp = FastMCP("My MCP")
mcp.add_middleware(KodaAccessMiddleware()) # defaults: AgentCore prefix, fail-closed
With an audit hook for observability (every allow/deny decision):
from koda_mcp_access import GuardConfig, KodaAccessMiddleware
def audit(d):
logger.info("access action=%s target=%s decision=%s roles=%s",
d.action, d.target, d.decision, d.identity.roles if d.identity else [])
mcp.add_middleware(KodaAccessMiddleware(GuardConfig(audit_hook=audit)))
Only tools are enforced. The middleware filters
tools/listand gatestools/callagainst the caller'stoolsgrant. Skills are authorized upstream in the registry, not here. Prompts are deprecated and not part of the permission model. Resources pass through (see Behavior).
Read identity from inside a tool:
from koda_mcp_access import get_current_identity
@mcp.tool()
async def whoami() -> dict:
ident = get_current_identity()
return {"sub": ident.sub, "roles": ident.roles, "tenant": ident.tenant_id}
Tools that call a backend on the caller's behalf can get the raw JWT as a
FastMCP AccessToken (or a compact user dict):
from koda_mcp_access import get_current_access_token, get_current_user
@mcp.tool()
async def list_my_initiatives() -> list:
token = get_current_access_token() # None if unauthenticated
return await backend.get("/initiatives", bearer=token.token)
Standard: every KODA-ecosystem MCP authorizes with a single
add_middleware(KodaAccessMiddleware())and no localsrc/auth/. If a tool needs identity, import it from this package. Seedocs/MCP_ACCESS_STANDARD.md.
Standalone local-dev (validate the Okta JWT itself, requires [okta] extra):
from koda_mcp_access.verifiers.okta import OktaTokenVerifier
from fastmcp.server.auth import RemoteAuthProvider
if IS_LOCAL:
mcp.auth = RemoteAuthProvider(
token_verifier=OktaTokenVerifier(),
authorization_servers=[OKTA_ISSUER],
base_url=MCP_BASE_URL,
)
Header contract
The upstream proxy injects, under a configurable prefix (default
x-amzn-bedrock-agentcore-runtime-custom-koda-):
| Header suffix | Meaning |
|---|---|
sub |
caller subject (required; absent → no identity) |
email |
caller email (defaults to sub) |
roles |
space-separated roles (resolved upstream) |
auth-groups |
space-separated Okta groups |
tenant-id |
tenant |
authorization |
raw Okta JWT (for tools calling backends) |
permissions |
base64(JSON) — the already-resolved subset for this MCP |
discovery |
"true" → registry catalogue scan: list operations skip the filter; ignored everywhere else |
client-id |
OAuth client the caller used (Okta cid); shows up as client= in the access log |
signed-at, key-id, signature |
koda-proxy's signature over the headers above (see Signed identity context) |
permissions decodes to a flat object — the server-scoped tools plus the
global catalogs skills / agents:
{ "tools": ["my_profile"],
"skills": ["pm_challenge"], "agents": { "roadmap-agent": { "actions": ["*"] } } }
Behavior
| Operation | perms | Result |
|---|---|---|
tools/call |
match in tools |
execute / AuthorizationError |
tools/list |
tools |
filtered list |
resources/read, resources/list, resources/templates/list |
— | passthrough (not role-gated), still fail-closed without identity |
any other request (prompts/*, …) |
— | passthrough with identity, fail-closed without it |
initialize |
— | passthrough |
tools/list, resources/list, resources/templates/list |
discovery | passthrough (full catalogue, no identity bound) |
tools/call, resources/read, any other request |
discovery | flag ignored (WARNING), normal identity + permissions check |
| any | stdio | passthrough (no filter) |
| any | header missing/invalid + not stdio | AuthorizationError (fail-closed) |
any but initialize / ping |
signature invalid (verify/require) or missing (require) |
AuthorizationError, before any header is trusted |
Only tools are role-gated. Resources pass through for any authenticated caller (the permission model has no resource dimension); the fail-closed check still applies (no identity → denied). Skills are authorized upstream in the registry. Prompts are deprecated and not part of the permission model: they have no hook of their own and fall under the generic
on_requestcheck.
Discovery only lists. The discovery header lets the registry scan the catalogue. It never lets a request execute a tool or read content: on
tools/call,resources/readand every other request it is ignored.
Access log
Every operation logs one line on the koda_mcp_access.middleware logger: who
came in and what they used. Allows go to INFO; denials and fail-open calls go
to WARNING.
INFO access sub=ana@kushki roles=qa tenant=acme client=0oa1koda action=tools/call target=greeting decision=allow
WARNING access sub=ana@kushki roles=qa tenant=acme client=0oa1koda action=tools/call target=my_profile decision=deny
WARNING access sub=- roles=- tenant=- client=- action=tools/call target=greeting decision=deny reason=missing-identity
WARNING access sub=- roles=- tenant=- client=- action=tools/call target=greeting decision=deny reason=bad-signature
INFO access sub=- roles=- tenant=- client=- action=tools/list target=* decision=allow bypass=discovery
actionis the MCP method;targetis the tool name, resource URI or prompt name (*for lists).bypass(discovery,transport,fail_open) andreason(missing-identity,missing-permissions, or a signature reason:missing-signature,bad-signature,stale-signature,bad-timestamp,unknown-key,keyring-unavailable) appear only when they apply.- Signature denials log
sub=-: unsigned headers are not trusted, not even for the log. - It never logs the bearer, the grants or the tool arguments. Whitespace and
control characters in values are replaced with
_so a caller cannot forge log lines. audit_hookreceives the same decisions (withaction= MCP method) if you need to ship them somewhere else. Turn the line off withGuardConfig(access_log=False).
Signed identity context
Without it, anyone allowed to call the runtime directly (InvokeAgentRuntime)
can send made-up Koda-* headers. With it, koda-proxy signs them and the
runtime only trusts what it signed. The signature proves who sent the headers
and that nobody changed them; it does not encrypt them.
# env: KODA_SERVER_NAME=mcp-koda → verify with the shared secret
mcp.add_middleware(KodaAccessMiddleware(GuardConfig()))
# the same, explicit (arguments win over the env):
from koda_mcp_access import SecretsManagerKeyring
mcp.add_middleware(KodaAccessMiddleware(GuardConfig(
signature_mode="require", # off | verify | require
signature_audience="mcp-koda", # this runtime's server name
signing_key_provider=SecretsManagerKeyring(
"ai-projects/koda-platform/mcp-identity", derive_for="mcp-koda"
),
)))
signature_mode |
Missing signature | Invalid signature |
|---|---|---|
off (no server name, or KODA_SIGNATURE_MODE=off) |
ignored | ignored |
verify (default with a server name) |
allowed, WARNING authz: signature missing on <method> |
denied |
require |
denied | denied |
- Checked on every request but
initialize/ping(and stdio), discovery included, before anyKoda-*header is read. - Each runtime verifies with its own key, derived from the shared secret
(
derive_for), so a signature made for one runtime does not verify on another. The signature also covers the audience (server name), the MCP method, the tool name ontools/calland a timestamp (max_skew_s, default 300). - Whoever reads the secret can sign for any runtime. Grant
GetSecretValueonly to koda-proxy and the runtimes' roles. SecretsManagerKeyring(the default provider) caches the secret 5 minutes and accepts the current and the previouskid, so keys rotate without a redeploy. Rotate in two steps: new key inpreviousfirst; after 5 minutes, swap it intokey(old one inprevious), so no runtime sees akidit does not know yet. The secret is{"kid", "key", "previous": {"kid", "key"}},keyin base64.requirecannot be combined withfail_mode="open"(ValueErrorat startup).- AgentCore only forwards allowlisted headers: add
...-Custom-Koda-Signed-At,...-Key-Id,...-Signatureand...-Client-Idto the runtime, orrequiredenies everything.
The contract (canonical message, key derivation, test vector) is in
docs/MCP_ACCESS_STANDARD.md.
Pattern matching
Every grant list is matched the same way (: and . are interchangeable
separators). An empty / missing list always denies (fail-closed):
| Pattern | Matches |
|---|---|
* |
anything |
jira_* |
prefix (jira_create, jira_get, …) |
my_profile |
exact |
agents is a map {name: {actions: [...]}}; is_agent_action_allowed matches
the agent key (exact / * / prefix), then the action against that entry's list.
Configuration (GuardConfig)
| Field | Default | Purpose |
|---|---|---|
header_prefix |
AgentCore custom prefix | header namespace to read |
fail_mode |
"closed" |
closed denies when identity/perms absent; open passes through (logs a WARNING at startup and on every unfiltered tools/call) |
bypass_transports |
{"stdio"} |
transports that skip filtering (local dev) |
discovery_suffix |
"discovery" |
header suffix that marks a catalogue scan (list operations only) |
audit_hook |
None |
Callable[[AccessDecision], None] for every decision |
access_log |
True |
one access log line per operation (see Access log) |
signature_mode |
None |
off / verify / require (see Signed identity context); None reads env KODA_SIGNATURE_MODE, else verify with an audience and off without |
signature_audience |
None |
this runtime's server name; None reads env KODA_SERVER_NAME; required unless off |
signing_key_provider |
None |
Callable[[], SigningKeyring]; None builds SecretsManagerKeyring(<KODA_IDENTITY_SIGNING_SECRET_ID or ai-projects/koda-platform/mcp-identity>, derive_for=<audience>) unless off |
max_skew_s |
300 |
max age / clock skew of a signature, in seconds |
auto_enroll_group |
None |
opt-in: Okta group to add role-less callers to (e.g. "koda-builder") |
Auto-enrollment (opt-in)
When auto_enroll_group is set, a caller with no role (empty roles) is added
to that Okta group. This is the one path where the package calls the Okta
Management API — it requires the [okta] extra and admin credentials via env:
mcp.add_middleware(KodaAccessMiddleware(GuardConfig(
auto_enroll_group="koda-builder",
)))
| Env var | Purpose |
|---|---|
OKTA_ISSUER / OKTA_DOMAIN |
Okta base URL |
OKTA_CLIENT_ID (or OKTA_MGMT_CLIENT_ID) |
client id for the private_key_jwt grant — the ecosystem's Okta service app (same one the usrv-koda authorizer uses) |
OKTA_PRIVATE_KEY (or OKTA_MGMT_PRIVATE_KEY) |
RSA private key (JWK JSON), scopes okta.groups.read okta.groups.manage |
The service app must use
private_key_jwtauth and hold theokta.groups.managescope. A web-appclient_id+client_secretcannot manage groups.OKTA_MGMT_*overridesOKTA_*when both are set.
The write (PUT /api/v1/groups/{gid}/users/{uid}) is idempotent; enrollment is
best-effort (failures are logged, never raised) and eventual — the new
membership takes effect on the caller's next session, once the upstream
authorizer/proxy re-resolves roles. Leaving auto_enroll_group=None (the default)
keeps the package a pure PEP with zero Okta writes.
Note: only callers with no role at all (empty roles header) are enrolled; anyone carrying any role is left untouched.
Internals
One file per concern — all role/server-agnostic:
| Module | Responsibility |
|---|---|
middleware.py |
the Middleware hooks (on_call_tool, on_list_tools, on_*_resource*, on_request for everything else); binds identity per request, enforces, resets |
headers.py |
HeaderContract — builds header names from the prefix, reads them, base64-decodes permissions |
identity.py |
Identity dataclass + identity_from_headers |
permissions.py |
MCPPermissions shape + parse_permissions (tolerant of malformed input; ignores legacy prompts) |
matcher.py |
the pure pattern matchers; is_tool_allowed drives enforcement, is_skill_allowed / is_agent_action_allowed are exported helpers for consumers |
context.py |
contextvar holding the current Identity for the request |
config.py |
GuardConfig, AccessDecision |
signing.py |
signed identity context: canonical message, key derivation, SecretsManagerKeyring (stdlib only, so other components can vendor it) |
verifiers/okta.py |
optional standalone Okta JWT verifier (local dev only) |
Request flow inside on_call_tool: bypass if stdio → verify the signature
(unless off) → resolve identity + perms from headers (the discovery flag is
ignored here) → deny if tool not granted →
call_next → reset identity (always).
KODA-30 / AR-01 status
As of 2026-10-14. The live checklist is in the KODA-30 plan
(planes/koda-mcp-access/PLAN_KODA-30_DISCOVERY_BYPASS.md).
| Covered | Repo | Branch |
|---|---|---|
Discovery only skips the filter on list operations; prompts/* and unknown methods fail closed (Phase 1) |
koda-mcp-access | main (PR #3) |
Signed identity context, verified per runtime, verify by default when KODA_SERVER_NAME is set (shared secret by default); tool name in the signature, per-runtime keys, no require with fail_mode="open", client= in the access log |
koda-mcp-access | fix/koda-30-identity-signature |
koda-proxy signs the Koda-* headers with the destination runtime's key |
mcp-gateway-registry | feature/koda-30-identity-signature |
| H1: koda-proxy checks the (server, runtime ARN) pair; only admins set a server's Koda runtime; one server per ARN | mcp-gateway-registry | feature/koda-30-identity-signature |
| H2: only admins publish an AgentCore URL; one agent per runtime, in the registry and in koda-proxy | mcp-gateway-registry | feature/koda-30-identity-signature |
Pending:
- Publish
1.3.0and move the consumers to it: usrv-mcp-koda, usrv-koda-infra-mcp, usrv-mcp-koda-builder (and itssqs_consumer), qmetry-mcp. - The shared signing secret (
ai-projects/koda-platform/mcp-identity) in each region. - Each runtime: allowlisted headers,
GetSecretValueon that secret, thenverify→require. - Least privilege on
bedrock-agentcore:InvokeAgentRuntime(Phase 3). - Sign the agent path too (koda-agent-runtime trusts
Koda-*headers the same way). - H3 (auto-enrollment on unverified identity), H4 (tags and Trusted Publishing), H5–H7 (low).
Develop
uv sync --group dev
uv run pytest
uv run ruff check src/ tests/
Metadata
Release files for koda-mcp-access 1.3.0rc1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| koda_mcp_access-1.3.0rc1.tar.gz | 122.2 kB | Without provenance | Details for koda_mcp_access-1.3.0rc1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| koda_mcp_access-1.3.0rc1-py3-none-any.whl | Python 3 | none | any | Details for koda_mcp_access-1.3.0rc1-py3-none-any.whl |
Total release size: 155.7 kB
Release files / koda_mcp_access-1.3.0rc1.tar.gz
| Download URL | koda_mcp_access-1.3.0rc1.tar.gz |
|---|---|
| Size | 122.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e18357c009e9d47db817096c43d278ee475bb697d531099db600b3212f4fc139
|
|
BLAKE2b-256 checksum How to use checksums |
b246a2c8461c0376d7d8f53099509d2ad9b146e9a82b4c52422b2a0bb77a1274
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / koda_mcp_access-1.3.0rc1-py3-none-any.whl
| Download URL | koda_mcp_access-1.3.0rc1-py3-none-any.whl |
|---|---|
| Size | 33.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d6ad91a24c6fd0df98dc8f23941614be492a4593b054ab809021449fe6da75c1
|
|
BLAKE2b-256 checksum How to use checksums |
bf578b8fddcbe17f59a7b2018bb4a6ba489e1cba7a59d32881826f3bddaf8409
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for koda-mcp-access 1.3.0rc1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.