agent-comms-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-comms-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP service for permissioned, structured agent-to-agent communications
5
+ Author-email: Redesign Health <tech@redesignhealth.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/redesignhealth/agent-comms-mcp
8
+ Project-URL: Repository, https://github.com/redesignhealth/agent-comms-mcp
9
+ Project-URL: Issues, https://github.com/redesignhealth/agent-comms-mcp/issues
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: alembic==1.18.5
14
+ Requires-Dist: asyncpg==0.31.0
15
+ Requires-Dist: fastmcp==3.4.2
16
+ Requires-Dist: greenlet==3.5.4
17
+ Requires-Dist: httpx==0.28.1
18
+ Requires-Dist: py-key-value-aio[filetree]==0.4.5
19
+ Requires-Dist: pydantic==2.12.5
20
+ Requires-Dist: sqlalchemy[asyncio]==2.0.51
21
+ Requires-Dist: structlog==25.5.0
22
+ Dynamic: license-file
23
+
24
+ # agent-comms-mcp
25
+
26
+ MCP service for **permissioned, structured agent-to-agent communications**.
27
+ First use case: a user's main agent delegates to a dedicated EA agent, which
28
+ communicates with other people's EA agents to negotiate availability (including
29
+ judgment, not just calendar overlap). Communications are scoped and structured
30
+ — no free text initially. See [`docs/DESIGN.md`](docs/DESIGN.md) for the full
31
+ spec (data model, permission model, message schemas). EA agent logic lives
32
+ elsewhere — this repo is only the comms layer.
33
+
34
+ ## Layout
35
+
36
+ ```
37
+ main.py # FastMCP server, observability + scope-enforcement middleware
38
+ auth.py # Okta OIDCProxy (humans) + agent-jwt JWTVerifier (agents) via MultiAuth
39
+ scopes.py # TOOL_SCOPES catalog + fail-closed scope helpers
40
+ identity.py # Issuer-gated JWT identity resolution (anti-impersonation guards)
41
+ observability.py # structlog JSON events (tool_call, scope_denial, auth_flow, ...)
42
+ providers/comms.py # Comms provider sub-server — the MCP tools (see below)
43
+ models.py # SQLAlchemy 2.x async ORM models (agents, conversations,
44
+ # participants, messages, audit_log — DESIGN.md §5)
45
+ db.py # Async engine/session factory (DATABASE_URL, fail-fast)
46
+ schemas.py # Pydantic message-payload schemas (all registered message types)
47
+ state_machine.py # Conversation/participant state transitions (DESIGN.md §4, §6)
48
+ service.py # Domain/service layer: membership rules, uniform denials, audit
49
+ exceptions.py # Service-layer exception shapes (mapped to ToolError in providers/comms.py)
50
+ migrations/ # Alembic migrations (async env.py); run `alembic upgrade head`
51
+ tests/ # pytest suite (composition, scope fail-closed, domain logic, schema)
52
+ ```
53
+
54
+ ## Domain layer
55
+
56
+ The comms board is five Postgres tables — `agents`, `conversations`,
57
+ `participants`, `messages`, `audit_log` — with `messages` and
58
+ `audit_log` append-only. An agent self-provisions via `comms_register`,
59
+ then either starts a conversation (adding named targets as `invited`) or
60
+ gets invited into one. A target only gains message-history read/write
61
+ access after calling `comms_accept` (`invited → active`); declining
62
+ (`comms_decline_invite`) is terminal and grants nothing. Task coordination
63
+ uses task message types (`task_assign`, `task_report`, `task_complete`,
64
+ `task_decline`, `task_cancel`) within ordinary conversations — task state
65
+ lives on `conversations.state`, not a separate table. Conversation types
66
+ (`open`, `internal`, `asymmetric`) gate admission by ownership; message
67
+ types gate boundary crossing via the `boundary_safe` flag — see
68
+ [`docs/DESIGN.md`](docs/DESIGN.md) §4–§9 for full details.
69
+
70
+ ## MCP tool surface
71
+
72
+ All tools below are mounted under the `comms` namespace (e.g. `whoami` in
73
+ `providers/comms.py` is exposed as `comms_whoami`) and enrolled in the
74
+ fail-closed `scopes.TOOL_SCOPES` registry. Source of truth:
75
+ `providers/comms.py`.
76
+
77
+ | Tool | Scope | Purpose |
78
+ |---|---|---|
79
+ | `comms_whoami` | `comms:read` | Return the caller's identity, issuer, caller type, and scopes |
80
+ | `comms_register` | `comms:write` | Idempotently self-provision (or re-bind) the caller's board `Agent` row |
81
+ | `comms_list_agents` | `comms:read` | Paginated board directory |
82
+ | `comms_start_conversation` | `comms:write` | Open a conversation with N target agents and post the seq-1 message |
83
+ | `comms_post_message` | `comms:write` | Post a typed, schema-validated message to an active conversation |
84
+ | `comms_get_conversation` | `comms:read` | Combined read: conversation + participants + messages since a seq; advances the caller's read cursor |
85
+ | `comms_inbox` | `comms:read` | Active conversations with unread messages, plus pending invites |
86
+ | `comms_list_conversations` | `comms:read` | Paginated list, filterable by role/type/state; newest-first |
87
+ | `comms_accept` | `comms:write` | Flip the caller's participant status `invited → active`, granting history read + posting rights |
88
+ | `comms_decline_invite` | `comms:write` | Decline a pending invite — terminal, no access is ever granted |
89
+ | `comms_invite` | `comms:write` | Invite another board agent into an active conversation (as `invited`) |
90
+ | `comms_leave` | `comms:write` | Leave a conversation the caller is currently `active` in |
91
+
92
+ ## Auth model
93
+
94
+ Both humans and machines POST to the same `/mcp` endpoint; FastMCP
95
+ `MultiAuth` routes them (`/health` is unauthenticated):
96
+
97
+ - **Humans** (Claude Code / Claude Desktop / browser): Okta OIDC via FastMCP
98
+ `OIDCProxy`. Identity claims (email) are available to tools via
99
+ `get_access_token().claims`. Interactive callers bypass per-tool scope
100
+ checks.
101
+ - **Agents / services**: HS256 Bearer JWT with `iss="agent-jwt"`, `sub`, and
102
+ `scopes` claims, verified by a `JWTVerifier` keyed to `AGENT_JWT_SECRET`.
103
+ Every tool call is then gated by the `TOOL_SCOPES` catalog in `scopes.py`
104
+ — **fail-closed**: a tool without a registry entry rejects every agent
105
+ call, denial messages are uniform (anti-enumeration), and each denial emits
106
+ a structured `scope_denial` log event.
107
+
108
+ When adding a tool, enroll its mounted name (`comms_<tool>`) in
109
+ `TOOL_SCOPES` in the same PR — `tests/test_main.py` fails otherwise.
110
+
111
+ ## Local development
112
+
113
+ Requires [uv](https://docs.astral.sh/uv/).
114
+
115
+ ```bash
116
+ uv sync # install deps from uv.lock
117
+
118
+ # Start Postgres, apply migrations, then run the tests (see "Database /
119
+ # migrations" below for why the port is 55432, not 5432)
120
+ docker compose up -d postgres
121
+ export DATABASE_URL=postgresql://postgres:postgres@localhost:55432/agent_comms
122
+ uv run alembic upgrade head
123
+ uv run pytest # tests
124
+ uv run ruff check . && uv run ruff format --check .
125
+ uv run mypy . # strict type check
126
+
127
+ # Run the server (needs real Okta + secret config)
128
+ cp .env.example .env # fill in values; .env is gitignored
129
+ uv run python main.py # http://127.0.0.1:8080/mcp
130
+
131
+ # Or the full stack (server + Postgres) in Docker
132
+ docker compose up --build
133
+ ```
134
+
135
+ Tests never touch the network: the Okta OIDC discovery call is patched out
136
+ in every test module that imports `main` (see `tests/test_main.py`'s
137
+ `_OIDC_PATCH`), so `uv run pytest` needs no real Okta tenant, issuer
138
+ reachability, or credentials — only a reachable Postgres for the
139
+ real-database tests (below), which skip cleanly if it's absent.
140
+
141
+ ### Database / migrations
142
+
143
+ Postgres is provisioned by `docker-compose.yml`, mapped to **host port
144
+ 55432** (container-internal port stays the standard 5432). This dev
145
+ machine — and, per earlier build stages, others too — already runs a
146
+ native Postgres bound to the default host port 5432, which silently
147
+ collides with `docker-compose.yml`'s old `5432:5432` mapping (you'd connect
148
+ to the wrong database with no error). Moving the compose Postgres's
149
+ *host-side* port to 55432 sidesteps this permanently; nothing about the
150
+ container's internal networking changes, so the `agent-comms-mcp`
151
+ service's own `DATABASE_URL` (which reaches `postgres` by service name on
152
+ the internal port 5432) is unaffected.
153
+
154
+ After starting Postgres, apply migrations before running the service or
155
+ the real-database tests:
156
+
157
+ ```bash
158
+ docker compose up -d postgres # start Postgres only (host port 55432)
159
+ export DATABASE_URL=postgresql://postgres:postgres@localhost:55432/agent_comms
160
+ uv run alembic upgrade head # create/upgrade the 5-table schema
161
+ ```
162
+
163
+ If you still hit a conflict (e.g. something else is bound to 55432), check
164
+ with `lsof -i :55432` and either free the port or change the host-side
165
+ number in `docker-compose.yml`'s `ports:` mapping for the `postgres`
166
+ service (updating `DATABASE_URL` to match) — a single fixed alternate port
167
+ is enough here, so there's no compose-override or env-var indirection.
168
+
169
+ To generate a new migration after changing `models.py`:
170
+
171
+ ```bash
172
+ uv run alembic revision --autogenerate -m "<description>"
173
+ ```
174
+
175
+ `tests/test_db_models.py` (and the other real-database test modules) run
176
+ against this same real Postgres instance (no mocking, per the RH standard)
177
+ and skip gracefully with a clear reason if they can't connect.
178
+
179
+ Configuration is env-driven and **fail-fast**: the service refuses to start
180
+ if any required variable (`OKTA_ISSUER_URL`, `OKTA_CLIENT_ID`,
181
+ `OKTA_CLIENT_SECRET`, `MCP_JWT_SECRET`, `AGENT_JWT_SECRET`, `DATABASE_URL`)
182
+ is missing or empty. See `.env.example` for the full list. No secrets are committed
183
+ anywhere in this repo.
184
+
185
+ ## Observability
186
+
187
+ Structured JSON logs via `structlog` to stdout. Events follow the schema in
188
+ `observability.py` (`tool_call`, `user_active`, `auth_flow`, `auth_rejected`,
189
+ `scope_denial`). Message content and attacker-controlled claim values are
190
+ never logged.
191
+
192
+ ## Deployment
193
+
194
+ The service is a standard Python HTTP process backed by PostgreSQL. The
195
+ included `Dockerfile` and `docker-compose.yml` cover local and self-hosted
196
+ deployments.
197
+
198
+ **Quick start (Docker Compose):**
199
+
200
+ ```bash
201
+ cp .env.example .env # fill in real values
202
+ docker compose up --build
203
+ ```
204
+
205
+ **Required environment variables** (see `.env.example`):
206
+
207
+ | Variable | Purpose |
208
+ |---|---|
209
+ | `OKTA_ISSUER_URL` | Okta OIDC issuer URL for interactive callers |
210
+ | `OKTA_CLIENT_ID` | Okta app client ID |
211
+ | `OKTA_CLIENT_SECRET` | Okta app client secret |
212
+ | `MCP_JWT_SECRET` | Signing secret for FastMCP's internal OAuth JWTs |
213
+ | `AGENT_JWT_SECRET` | Shared HS256 secret for agent JWT verification |
214
+ | `DATABASE_URL` | PostgreSQL connection string |
215
+
216
+ `entrypoint.sh` runs `alembic upgrade head` automatically on every container
217
+ start, so migrations apply before the server accepts traffic.
218
+
219
+ ## License
220
+
221
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,26 @@
1
+ auth.py,sha256=hgA0BwQlgbNj4sEM6oXibS0QhtdslG9Ncxpw5X7Whto,15620
2
+ db.py,sha256=OoVCXNc2CcitURxncXs3k14IpsMoXwYXAI2akKYyr3Q,2452
3
+ exceptions.py,sha256=yKxfSkWfGwaUn5IU0lUzOVIGnw8fU5xlq4nZEswylqM,5063
4
+ identity.py,sha256=wcyzDCQIhOoGBXuqboCzzegKMLxmHuED76yTIzpgm20,3803
5
+ main.py,sha256=QxdQihFrZ81JS6b45aSjfx93wcUrnORhbw-MR3q2e68,12047
6
+ models.py,sha256=Uagazi4HXxuN3Hn85goeeTQxNf4oUjbmdZhtSF05yTc,9722
7
+ observability.py,sha256=wHAW1epj83NwBJN4n-1onD4qpd2HzAdAn5Qp5ailEBA,9250
8
+ schemas.py,sha256=neU165FEWvXkkeWIYMuaLKs6ULwb1G75X_LPXpishZE,22218
9
+ scopes.py,sha256=hYjWdI2q4xOi1BtL1Vgf4xt55aceYePyChvoJPTs4nc,6964
10
+ service.py,sha256=Ev4Bb8GIORfG8P1UldTk_TnkyKW7qu8RbHpVjZA_UkI,90360
11
+ state_machine.py,sha256=pLMAOUcI7Z0YOC4J7yq3wqNLqD2n2b8bKs-eSbsG-js,6388
12
+ agent_comms_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=GaHnR5bjXQbO7TqD7vtfQkwoR7xlKYtOHW9ryBtkMkk,1072
13
+ migrations/env.py,sha256=AWZUJ5uaj77kHEk4gAw3iN8PuP61FmxrOPXe_PV5cII,3783
14
+ migrations/versions/15ef34885e30_add_conversations_owner_snapshot.py,sha256=yGGUIYD3-WqcgV2Rlgl-60gcoshZzBI8QxPUv7b1uBs,2682
15
+ migrations/versions/18f2d7735523_rate_limit_indexes_and_display_name_.py,sha256=AbcUz4AOwxxIVgUub2c46tO2li2-a1kT7GplSSiACIE,5575
16
+ migrations/versions/6d2a8e63e469_add_tasks_table.py,sha256=SfbBSBYhBkdk2IMQqIx--G20lpjvocQWZsBat47NQqE,5873
17
+ migrations/versions/da3e1646c44d_drop_tasks_table.py,sha256=hENB-aEAjAytoR3zl8XhLASRjeIqTAxeECHpOxEgR1A,8141
18
+ migrations/versions/e1db7c2e6b70_backfill_accepted_types_for_existing_.py,sha256=URRmhDNrI9uyQ1zbN35IJFc81rUP0sUdam7zzf1r4KU,4548
19
+ migrations/versions/ef8394b37c8d_initial_schema.py,sha256=BnYqb3MjDGHpXNbL-dcspuarH7SJVCWCAi53l29fisk,7136
20
+ providers/__init__.py,sha256=QTIQr_6KA51kiKpI5rjms596Ba93TcNEjB-mlcs_8eo,286
21
+ providers/comms.py,sha256=RKdre3dSGAFW6Cl6_w-jep79-wK0aG9gGsdnrJ1zKds,31430
22
+ agent_comms_mcp-0.1.0.dist-info/METADATA,sha256=dDSyPhB78UwW7vWu6YE5Nqr9ikk5PoAeAKa3JFePHaY,10523
23
+ agent_comms_mcp-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
24
+ agent_comms_mcp-0.1.0.dist-info/entry_points.txt,sha256=hFM0cDbPbq9MndmdpuIVOKKvZ-BIwrqotMVO9XLdv3M,46
25
+ agent_comms_mcp-0.1.0.dist-info/top_level.txt,sha256=8d1AS-g_iAfdLckXqiIJKwExMLKze4DkRQTRfceZlx8,112
26
+ agent_comms_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agent-comms-mcp = main:_cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Redesign Health
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,13 @@
1
+ auth
2
+ db
3
+ exceptions
4
+ identity
5
+ main
6
+ migrations
7
+ models
8
+ observability
9
+ providers
10
+ schemas
11
+ scopes
12
+ service
13
+ state_machine
auth.py ADDED
@@ -0,0 +1,343 @@
1
+ """Okta OIDC + agent-jwt JWT authentication for agent-comms-mcp.
2
+
3
+ Auth paths
4
+ ----------
5
+ Interactive users (Claude Code, Claude Desktop, browser):
6
+ Okta OIDC via FastMCP ``OIDCProxy``. Uses ``verify_id_token=True`` so
7
+ FastMCP validates the Okta id_token (which carries identity claims like
8
+ ``email`` and ``preferred_username``) and makes those claims available
9
+ to tools via ``get_access_token().claims``.
10
+
11
+ Programmatic callers (agents, services, CI jobs):
12
+ agent-jwt HS256 Bearer token. FastMCP ``MultiAuth`` composes the Okta
13
+ OIDC provider with a ``JWTVerifier`` keyed to ``AGENT_JWT_SECRET``.
14
+ Both humans and machines POST to the same ``/mcp`` endpoint.
15
+
16
+ Health check (``/health``):
17
+ Unauthenticated — handled by FastMCP before auth middleware runs.
18
+
19
+ Refresh-token rotation grace (``_ROTATION_*``): FastMCP rotates refresh
20
+ tokens on every use (one-time use). A second concurrent connection
21
+ presenting the just-consumed old token would otherwise fail hard and force
22
+ a full Okta re-auth. We record a short-lived old-token-hash -> new-token
23
+ mapping in the same persistent store on every rotation; a subsequent "miss"
24
+ checks this mapping first and transparently follows it before declaring a
25
+ real miss.
26
+
27
+ The hop counter is deliberately NOT a parameter of the public
28
+ ``load_refresh_token`` override (see that method's own docstring) to close
29
+ a cap-reset weakness in a more permissive design.
30
+
31
+ Emitting ``refresh_token_miss`` / ``refresh_token_hop_cap_exceeded`` on
32
+ paths that previously logged nothing means a broad ``$.event = "auth_flow"``
33
+ log filter now also counts failed refresh lookups as auth activity — add
34
+ dedicated metric filters as needed (see observability.py's docstring).
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import base64
40
+ import hashlib
41
+ import json
42
+ import logging
43
+ import os
44
+ from pathlib import Path
45
+ from typing import Any
46
+
47
+ from fastmcp.server.auth import MultiAuth
48
+ from fastmcp.server.auth.oidc_proxy import OIDCProxy
49
+ from fastmcp.server.auth.providers.jwt import JWTVerifier
50
+ from key_value.aio.protocols import AsyncKeyValue
51
+ from key_value.aio.stores.filetree import (
52
+ FileTreeStore,
53
+ FileTreeV1CollectionSanitizationStrategy,
54
+ FileTreeV1KeySanitizationStrategy,
55
+ )
56
+ from mcp.server.auth.provider import AuthorizationCode, RefreshToken
57
+ from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
58
+
59
+ from identity import AGENT_JWT_ISSUER
60
+ from observability import log_auth_flow
61
+
62
+ logger = logging.getLogger(__name__)
63
+
64
+ # Claims to extract from the Okta ID token into the FastMCP JWT.
65
+ _UPSTREAM_CLAIM_KEYS = ["sub", "email", "preferred_username", "name"]
66
+
67
+ _DEFAULT_TOKEN_STORAGE_PATH = "/data/fastmcp-tokens"
68
+
69
+ _ROTATION_GRACE_SECONDS = 5 * 60
70
+ _ROTATION_MAX_HOPS = 3
71
+ _ROTATION_COLLECTION = "mcp-refresh-token-rotations"
72
+
73
+
74
+ def _hash_token(token: str) -> str:
75
+ return hashlib.sha256(token.encode()).hexdigest()
76
+
77
+
78
+ def require_env(name: str) -> str:
79
+ """Return a required environment variable, failing fast if missing/empty.
80
+
81
+ No silent defaults for secrets (RH security standard + the
82
+ secret-loading-fallback-consistency failure mode): a missing required
83
+ value must crash at startup, not surface later as a broken auth path.
84
+ """
85
+ value = os.environ.get(name)
86
+ if not value:
87
+ raise RuntimeError(
88
+ f"Required environment variable {name} is not set. "
89
+ "See .env.example for the full list of required configuration."
90
+ )
91
+ return value
92
+
93
+
94
+ class OktaOIDCProxy(OIDCProxy):
95
+ """OIDC proxy configured for Okta SSO.
96
+
97
+ ``_extract_upstream_claims`` embeds the Okta id_token identity claims in
98
+ the FastMCP JWT as a fallback for code paths that read from there; with
99
+ ``verify_id_token=True`` the claims are also available directly. Emits
100
+ structured ``auth_flow`` observability events.
101
+ """
102
+
103
+ def __init__(self, *, client_storage: AsyncKeyValue, **kwargs: Any) -> None:
104
+ self._rotation_store = client_storage
105
+ super().__init__(client_storage=client_storage, **kwargs)
106
+
107
+ async def _extract_upstream_claims(self, idp_tokens: dict[str, Any]) -> dict[str, Any] | None:
108
+ """Decode the Okta ID token and extract identity claims.
109
+
110
+ SECURITY (call-ordering dependency): this decodes the id_token
111
+ payload WITHOUT verifying its signature. That is safe ONLY because
112
+ the parent ``OIDCProxy`` has already verified the token earlier in
113
+ the OAuth exchange (this hook runs downstream of that verification,
114
+ not before it) — if that call ordering ever changes, this becomes a
115
+ forgeable trust boundary (an attacker-controlled payload would be
116
+ trusted as identity claims).
117
+
118
+ Defense-in-depth only (does not replace the ordering dependency
119
+ above): reject ``alg: none`` tokens outright rather than extracting
120
+ claims from them. This protects a misconfigured dev/test
121
+ environment where the parent-class verification hasn't actually
122
+ run yet — it is not a substitute for that verification.
123
+ """
124
+ id_token = idp_tokens.get("id_token")
125
+ if not id_token:
126
+ return None
127
+ try:
128
+ header_b64 = id_token.split(".")[0]
129
+ header_b64 += "=" * (-len(header_b64) % 4) # base64 padding
130
+ header = json.loads(base64.urlsafe_b64decode(header_b64))
131
+ if not isinstance(header, dict):
132
+ logger.error("Rejecting Okta id_token with non-object header")
133
+ return None
134
+ if str(header.get("alg", "")).lower() == "none":
135
+ logger.error("Rejecting Okta id_token with alg=none")
136
+ return None
137
+ # Decode the JWT payload without verification — the upstream
138
+ # provider already validated the token during the auth flow.
139
+ payload_b64 = id_token.split(".")[1]
140
+ payload_b64 += "=" * (-len(payload_b64) % 4) # base64 padding
141
+ payload = json.loads(base64.urlsafe_b64decode(payload_b64))
142
+ if not isinstance(payload, dict):
143
+ logger.error("Rejecting Okta id_token with non-object payload")
144
+ return None
145
+ claims = {k: payload[k] for k in _UPSTREAM_CLAIM_KEYS if k in payload}
146
+ return claims or None
147
+ except (IndexError, json.JSONDecodeError, ValueError):
148
+ logger.error("Failed to decode Okta id_token for upstream claims")
149
+ return None
150
+
151
+ async def exchange_authorization_code(
152
+ self,
153
+ client: OAuthClientInformationFull,
154
+ authorization_code: AuthorizationCode,
155
+ ) -> OAuthToken:
156
+ """Exchange authorization code and emit a ``new_auth`` event."""
157
+ result = await super().exchange_authorization_code(client, authorization_code)
158
+ log_auth_flow("new_auth")
159
+ return result
160
+
161
+ async def load_refresh_token(
162
+ self,
163
+ client: OAuthClientInformationFull,
164
+ refresh_token: str,
165
+ ) -> RefreshToken | None:
166
+ """Look up a refresh token, following a rotation-grace redirect on miss.
167
+
168
+ FastMCP rotates refresh tokens on every use (one-time use). A second
169
+ concurrent connection presenting the just-consumed old token would
170
+ otherwise fail hard here and force a full Okta re-auth. Before
171
+ declaring a real miss, check whether this token was rotated recently
172
+ and, if so, transparently follow it to its successor (capped at
173
+ ``_ROTATION_MAX_HOPS`` to bound a pathological chain).
174
+
175
+ No hop counter in this signature on purpose: it's the base class's
176
+ override point (FastMCP calls it positionally), so a public
177
+ ``_hops`` parameter would let any caller reset the cap. Hop-tracking
178
+ lives in the name-mangled ``__follow_rotation_grace`` instead.
179
+ """
180
+ result = await super().load_refresh_token(client, refresh_token)
181
+ if result is not None:
182
+ return result
183
+ return await self.__follow_rotation_grace(client, refresh_token, hops=0)
184
+
185
+ async def __follow_rotation_grace(
186
+ self,
187
+ client: OAuthClientInformationFull,
188
+ refresh_token: str,
189
+ *,
190
+ hops: int,
191
+ ) -> RefreshToken | None:
192
+ """Recursive hop-following helper for ``load_refresh_token``.
193
+
194
+ Double-underscore (name-mangled to ``_OktaOIDCProxy__follow_rotation_grace``)
195
+ rather than single, mainly to avoid an accidental same-name collision
196
+ in a subclass -- NOT an access-control guarantee. Name mangling is
197
+ trivially bypassable (``self._OktaOIDCProxy__follow_rotation_grace(...,
198
+ hops=0)`` from a subclass, or simply overriding ``load_refresh_token``
199
+ itself), so it does not by itself defend against a malicious
200
+ subclass. The actual hardening that closed the original weakness is
201
+ that ``hops`` is not a parameter of the PUBLIC ``load_refresh_token``
202
+ override point FastMCP calls -- an ordinary caller (not a subclass
203
+ author deliberately reaching for the mangled name) has no way to
204
+ supply it at all.
205
+ """
206
+ entry = await self._rotation_store.get(
207
+ collection=_ROTATION_COLLECTION, key=_hash_token(refresh_token)
208
+ )
209
+ successor = entry.get("new_token") if entry is not None else None
210
+ if successor and hops < _ROTATION_MAX_HOPS:
211
+ logger.info("Refresh token reuse detected: redirecting to rotated successor")
212
+ log_auth_flow("refresh_token_grace_redirect")
213
+ result = await super().load_refresh_token(client, successor)
214
+ if result is not None:
215
+ return result
216
+ return await self.__follow_rotation_grace(client, successor, hops=hops + 1)
217
+ if successor:
218
+ # A dedicated auth_type, not folded into refresh_token_miss:
219
+ # the chain kept going past the cap, which is a structurally
220
+ # different condition from "no rotation entry at all" and
221
+ # needs its own CloudWatch signal for on-call triage. hops is
222
+ # not sensitive; the token/its hash is deliberately NOT
223
+ # included here (observability.py's never-log-tokens policy).
224
+ logger.warning("Refresh token rotation-grace hop cap exceeded", extra={"hops": hops})
225
+ log_auth_flow("refresh_token_hop_cap_exceeded")
226
+ return None
227
+ log_auth_flow("refresh_token_miss")
228
+ return None
229
+
230
+ async def exchange_refresh_token(
231
+ self,
232
+ client: OAuthClientInformationFull,
233
+ refresh_token: RefreshToken,
234
+ scopes: list[str],
235
+ ) -> OAuthToken:
236
+ """Exchange refresh token, emit a ``token_refresh`` event, and record
237
+ the old->new rotation mapping for the grace window."""
238
+ old_token = refresh_token.token
239
+ result = await super().exchange_refresh_token(client, refresh_token, scopes)
240
+ log_auth_flow("token_refresh")
241
+ if result.refresh_token:
242
+ # SECURITY: the stored value is the live successor refresh
243
+ # token in plaintext, not a hash -- the hashed KEY only
244
+ # prevents reverse-lookup of the just-consumed OLD token, it
245
+ # does not protect the new one. Accepted posture: this collection
246
+ # lives in the same EFS-backed, task-role-scoped store as the primary
247
+ # token store, which already holds live refresh tokens
248
+ # unencrypted (see build_okta_provider's client_storage). Anyone
249
+ # who can read one collection can read the other.
250
+ await self._rotation_store.put(
251
+ collection=_ROTATION_COLLECTION,
252
+ key=_hash_token(old_token),
253
+ value={"new_token": result.refresh_token},
254
+ ttl=_ROTATION_GRACE_SECONDS,
255
+ )
256
+ return result
257
+
258
+
259
+ def _build_client_storage(storage_path: str) -> FileTreeStore:
260
+ """Build a FileTreeStore with standard V1 sanitization for CIMD-safe keys.
261
+
262
+ The stock ``FileTreeV1`` strategies hash any key or collection name
263
+ containing filesystem-unsafe characters, so URL-style client IDs are
264
+ stored safely on the persistent volume. The directory is created if
265
+ missing (the sanitization strategies require an existing path).
266
+ """
267
+ storage_root = Path(storage_path)
268
+ storage_root.mkdir(parents=True, exist_ok=True)
269
+ return FileTreeStore(
270
+ data_directory=storage_path,
271
+ key_sanitization_strategy=FileTreeV1KeySanitizationStrategy(storage_root),
272
+ collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy(storage_root),
273
+ )
274
+
275
+
276
+ def build_okta_provider() -> OktaOIDCProxy:
277
+ """Build an OktaOIDCProxy from environment variables.
278
+
279
+ Required environment variables (fail-fast if missing):
280
+ OKTA_ISSUER_URL: Okta authorization server issuer URL
281
+ OKTA_CLIENT_ID: Okta application client ID
282
+ OKTA_CLIENT_SECRET: Okta application client secret
283
+ MCP_JWT_SECRET: Stable JWT signing secret (provisioned by Terraform,
284
+ injected via SSM)
285
+
286
+ Optional environment variables (non-secret, documented defaults):
287
+ BASE_URL: Public URL of this MCP server
288
+ (default: http://localhost:8080, for local dev)
289
+ MCP_TOKEN_STORAGE_PATH: Directory for persistent OAuth token storage
290
+ (default: /data/fastmcp-tokens, EFS-backed on ECS)
291
+ """
292
+ issuer_url = require_env("OKTA_ISSUER_URL")
293
+ config_url = f"{issuer_url.rstrip('/')}/.well-known/openid-configuration"
294
+ storage_path = os.environ.get("MCP_TOKEN_STORAGE_PATH", _DEFAULT_TOKEN_STORAGE_PATH)
295
+
296
+ return OktaOIDCProxy(
297
+ config_url=config_url,
298
+ client_id=require_env("OKTA_CLIENT_ID"),
299
+ client_secret=require_env("OKTA_CLIENT_SECRET"),
300
+ base_url=os.environ.get("BASE_URL", "http://localhost:8080"),
301
+ # offline_access is required for Okta to issue a refresh_token;
302
+ # without it clients must do a full re-auth every ~1 hour.
303
+ required_scopes=["openid", "email", "profile", "offline_access"],
304
+ jwt_signing_key=require_env("MCP_JWT_SECRET"),
305
+ client_storage=_build_client_storage(storage_path),
306
+ verify_id_token=True,
307
+ # FastMCP's CIMD redirect_uri validation rejects Claude Code's
308
+ # dynamic-port loopback callbacks.
309
+ enable_cimd=False,
310
+ )
311
+
312
+
313
+ def build_auth_provider() -> MultiAuth:
314
+ """Compose Okta OIDC + agent-jwt JWT verification via FastMCP MultiAuth.
315
+
316
+ MultiAuth tries the Okta OIDCProxy first (interactive users), then the
317
+ JWTVerifier (programmatic agent-jwt tokens). The two paths are fully
318
+ independent.
319
+
320
+ Required environment variables:
321
+ AGENT_JWT_SECRET: Shared HS256 secret for verifying agent-jwt tokens.
322
+ (Plus everything required by ``build_okta_provider``.)
323
+ """
324
+ return MultiAuth(
325
+ server=build_okta_provider(),
326
+ verifiers=[
327
+ JWTVerifier(
328
+ public_key=require_env("AGENT_JWT_SECRET"),
329
+ algorithm="HS256",
330
+ issuer=AGENT_JWT_ISSUER,
331
+ ),
332
+ ],
333
+ # CRITICAL: do NOT inherit the Okta provider's
334
+ # required_scopes (["openid", "email", "profile", "offline_access"]).
335
+ # MultiAuth's bearer middleware enforces required_scopes against
336
+ # EVERY verified token — including agent-jwt M2M JWTs, which never
337
+ # carry `openid`. Inheriting them silently rejects all agent-jwt
338
+ # tokens with `insufficient_scope` even after a valid signature.
339
+ # Authorization for agent-jwt callers is handled per-tool by
340
+ # ScopeEnforcementMiddleware, so the provider-level gate must be
341
+ # empty.
342
+ required_scopes=[],
343
+ )