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.
- agent_comms_mcp-0.1.0.dist-info/METADATA +221 -0
- agent_comms_mcp-0.1.0.dist-info/RECORD +26 -0
- agent_comms_mcp-0.1.0.dist-info/WHEEL +5 -0
- agent_comms_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- agent_comms_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_comms_mcp-0.1.0.dist-info/top_level.txt +13 -0
- auth.py +343 -0
- db.py +70 -0
- exceptions.py +107 -0
- identity.py +102 -0
- main.py +305 -0
- migrations/env.py +100 -0
- migrations/versions/15ef34885e30_add_conversations_owner_snapshot.py +65 -0
- migrations/versions/18f2d7735523_rate_limit_indexes_and_display_name_.py +124 -0
- migrations/versions/6d2a8e63e469_add_tasks_table.py +127 -0
- migrations/versions/da3e1646c44d_drop_tasks_table.py +194 -0
- migrations/versions/e1db7c2e6b70_backfill_accepted_types_for_existing_.py +92 -0
- migrations/versions/ef8394b37c8d_initial_schema.py +190 -0
- models.py +232 -0
- observability.py +245 -0
- providers/__init__.py +6 -0
- providers/comms.py +755 -0
- schemas.py +544 -0
- scopes.py +170 -0
- service.py +2264 -0
- state_machine.py +143 -0
|
@@ -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,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.
|
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
|
+
)
|