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 tools, resources and prompts 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, resources/read, prompts/get; filter the lists │
│ 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.4,<2 |
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_RESOURCE_AUTHZ=legacy (temporary, see Resources and prompts) |
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
Pin it in your project as:
# pyproject.toml
koda-mcp-access>=1.4,<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)))
What is enforced. The middleware gates
tools/call,resources/readandprompts/getand filters their lists against the caller's grants: tools bytools, resources byresourcesor by the tools that link them as their UI, prompts byprompts(see Resources and prompts). Skills are authorized upstream in the registry, not here.
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(
# issuer: env OKTA_ISSUER; audience: env OKTA_AUDIENCE, no default
token_verifier=OktaTokenVerifier(audience=OKTA_AUDIENCE),
authorization_servers=[OKTA_ISSUER],
base_url=MCP_BASE_URL,
)
The audience has no default: without audience= or OKTA_AUDIENCE the
constructor raises ValueError. It no longer falls back to OKTA_CLIENT_ID
or api://default (KODA-32), so a token minted for another API is rejected.
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, and optionally the server-scoped
resources (URI patterns) and prompts (prompt-name patterns):
{ "tools": ["my_profile"],
"skills": ["pm_challenge"], "agents": { "roadmap-agent": { "actions": ["*"] } },
"resources": ["data://koda/*"], "prompts": ["welcome"] }
resources and prompts are optional, and a missing key is not the same as
[] (see Resources and prompts).
Behavior
| Operation | perms | Result |
|---|---|---|
tools/call |
match in tools |
execute / AuthorizationError |
tools/list |
tools |
filtered list |
resources/read |
match in resources, or the UI resource of a tool allowed by tools |
read / AuthorizationError |
resources/list, resources/templates/list |
same rule | filtered list |
prompts/get |
match in prompts |
render / AuthorizationError |
prompts/list |
prompts |
filtered list (empty without prompts) |
any other request (completion/complete, …) |
— | 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, prompts/get, prompts/list, 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 |
Skills are authorized upstream in the registry, not here.
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/read,prompts/getand every other request it is ignored.
Resources and prompts
Since KODA-32 resources and prompts are authorized like tools. Before, any caller with an identity read every resource and prompt.
A caller reads a resource when:
- a pattern in its
resourcesgrant matches the URI, or - the URI is the UI resource of a tool it may call (its
toolsgrant): the tool's_meta.ui.resourceUri, which@mcp.tool(app=AppConfig(resource_uri=...))writes (the flat_meta["ui/resourceUri"]of earlier MCP Apps drafts counts too).
resources in permissions |
Resources the caller reads and lists |
|---|---|
| missing (what koda-proxy sends today) | only the UI resources of the tools it may call |
["data://koda/*", …] |
the matching URIs, plus the UI resources of its tools |
[] |
none, the UI resources of its tools included |
Rule 2 keeps MCP Apps views working without a resources grant: whoever may
call get_brd_by_id reads ui://koda/brd_view.html, and nobody else does. To
resolve it, the middleware lists the server's tools (without the middleware
chain) once per resources/read or list. A resource no tool links and no grant
matches is denied (not allowed to read resource '<uri>', decision=deny in the
access log).
resources/templates/list shows a template when a grant or a linked UI URI
could be one of its URIs: the pattern's fixed part (before a trailing *) and
the template's (before the first {) agree, one starting with the other. It may
show a template whose URIs the caller cannot all read; each resources/read is
still checked on its own. To grant a template's URIs, use a prefix pattern
(ui://koda/report/*).
Prompts are matched by name against prompts, with the same patterns as tools.
Without prompts, or with [], every prompt is denied and prompts/list is
empty: no producer sends prompts yet.
Rollout escape hatch. KODA_RESOURCE_AUTHZ=legacy (or
GuardConfig(resource_authz="legacy")) brings back the 1.4.1 behavior for
resources and prompts: identity required, no grant checked, lists unfiltered.
The middleware logs WARNING authz: resource_authz='legacy' … at startup.
Tools are enforced either way. It is temporary: drop it once the runtime's
views and prompts work in enforce (the default).
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). It does not cover the resource URI or the prompt name yet: a capturedresources/readsignature can read another resource the same caller is granted, within the 5 minutes. - 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
Name grants (tools, prompts, skills, agent names and actions) are 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 |
resources patterns are URIs, compared literally and case-sensitively, with no
separator normalization (in a URI : and . are not interchangeable): *,
a trailing-* prefix (ui://koda/*) or exact. The URI is the one FastMCP
passes to the hook, str(AnyUrl(uri)) (lower-case scheme, percent-encoded), so
write patterns in that form.
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 |
resource_authz |
None |
enforce / legacy (see Resources and prompts); None reads env KODA_RESOURCE_AUTHZ, else enforce |
auto_enroll_group |
None |
opt-in: Okta group to add role-less callers to (e.g. "koda-builder") |
no_role_equivalents |
frozenset() |
roles that count as "no role" for auto-enrollment (e.g. {"stakeholder"}) |
okta_admin_credentials_provider |
None |
Callable[[], OktaAdminCredentials] for auto-enrollment, e.g. SecretsManagerOktaCredentials(secret_id); None reads env OKTA_MGMT_SECRET_ID (see Auto-enrollment) |
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 needs the Okta service app credentials (client id + RSA
private key), read from AWS Secrets Manager at runtime (KO-379):
# env: OKTA_ISSUER=https://<tenant>.okta.com OKTA_MGMT_SECRET_ID=<secret name or ARN>
mcp.add_middleware(KodaAccessMiddleware(GuardConfig(
auto_enroll_group="koda-builder",
)))
# the same, explicit (the argument wins over the env):
from koda_mcp_access import SecretsManagerOktaCredentials
mcp.add_middleware(KodaAccessMiddleware(GuardConfig(
auto_enroll_group="koda-builder",
okta_admin_credentials_provider=SecretsManagerOktaCredentials("usrv-mcp-koda/secret-manager"),
)))
The secret is a JSON object; other keys in it are ignored, so the service's existing secret works as is:
{"OKTA_MGMT_CLIENT_ID": "0oa…", "OKTA_MGMT_PRIVATE_KEY": "{\"kty\":\"RSA\", …}"}
OKTA_CLIENT_ID / OKTA_PRIVATE_KEY are accepted as keys too (the names in
usrv-mcp-koda/secret-manager and ai-projects/koda-platform/secrets);
OKTA_MGMT_* wins when both are there. The private key is the JWK as a JSON
string (a nested JSON object also works).
| Where | What |
|---|---|
pyproject.toml |
koda-mcp-access[aws] (boto3) |
| runtime env | OKTA_ISSUER (or OKTA_DOMAIN): Okta base URL. OKTA_MGMT_SECRET_ID: name or ARN of the secret (not sensitive) |
| runtime role | secretsmanager:GetSecretValue on that secret (plus kms:Decrypt if it uses a CMK) |
Where the credentials come from, first match wins:
GuardConfig(okta_admin_credentials_provider=...): anyCallable[[], OktaAdminCredentials].- env
OKTA_MGMT_SECRET_ID: aSecretsManagerOktaCredentialsover that secret. - Deprecated: the key in env,
OKTA_MGMT_PRIVATE_KEYorOKTA_PRIVATE_KEY(withOKTA_MGMT_CLIENT_IDorOKTA_CLIENT_ID). Still works, but logsWARNING koda-mcp-access: Okta admin private key read from env … is deprecated (KO-379)once per process and emits aDeprecationWarning. Move the key to the secret and drop it from the env.
- Read lazily: on the first admin-token request (not at import or startup), then
only when the Okta token is renewed. The secret is cached 5 minutes
(
SecretsManagerOktaCredentials(secret_id, ttl_s=300)), so a rotated key is picked up without a redeploy. A failed read is not cached. - Errors name the secret and the missing keys, never a value
(
secret '…' has no OKTA_MGMT_PRIVATE_KEY or OKTA_PRIVATE_KEY).OktaAdminCredentialsleaves the key out of itsrepr.
The service app must use
private_key_jwtauth and hold theokta.groups.managescope. A web-appclient_id+client_secretcannot manage groups.
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_*_prompt*, 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; keeps resources / prompts only when sent) |
matcher.py |
the pure pattern matchers; is_tool_allowed, is_resource_allowed, is_resource_template_allowed and is_prompt_allowed drive 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 and its cached-secret base SecretsManagerSecret (stdlib only, so other components can vendor it) |
enrollment.py |
opt-in auto-enrollment (Okta Management API); admin credentials via SecretsManagerOktaCredentials |
verifiers/okta.py |
optional standalone Okta JWT verifier (local dev only; explicit audience) |
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.4.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).
KODA-32 status
As of 2026-10-20. Resources and prompts are authorized here (see Resources and prompts); the Okta verifier has no default audience. Pending:
- Signature v2: sign the resource URI on
resources/readand the prompt name onprompts/get. Changes the canonical message, so koda-proxy (mcp-gateway-registry) and this package ship it together. KODA_SIGNATURE_MODE=requireon each runtime onceverifyshows nosignature missinglines.- usrv-koda (
role_permissions) and koda-proxy emittingresources/promptsin the subset. Until then only rule 2 (UI resources of allowed tools) applies.
Develop
uv sync --group dev
uv run pytest
uv run ruff check src/ tests/
Metadata
Release files for koda-mcp-access 1.5.0
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.5.0.tar.gz | 160.3 kB | Without provenance | Details for koda_mcp_access-1.5.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| koda_mcp_access-1.5.0-py3-none-any.whl | Python 3 | none | any | Details for koda_mcp_access-1.5.0-py3-none-any.whl |
Total release size: 201.7 kB
Release files / koda_mcp_access-1.5.0.tar.gz
| Download URL | koda_mcp_access-1.5.0.tar.gz |
|---|---|
| Size | 160.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
66b4b80df2f8cb6c694e5875a7fd05328c1be849a133a36608526e4a3cee17ad
|
|
BLAKE2b-256 checksum How to use checksums |
86285442a2a5c472829e5df472cfccdabc3408b75747da8e645012f12ef03b10
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.4 {"installer":{"name":"uv","version":"0.13.4","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.5.0-py3-none-any.whl
| Download URL | koda_mcp_access-1.5.0-py3-none-any.whl |
|---|---|
| Size | 41.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
63f3112138d28ec6303f23b3f31c38bdc7758b778fd0f26199e88396449093ba
|
|
BLAKE2b-256 checksum How to use checksums |
3337cca18ab82eacefa54046d913d9d78f95981e654e2571deff6105879f264a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.4 {"installer":{"name":"uv","version":"0.13.4","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.5.0
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.