fdkey 0.0.1.dev0__tar.gz → 0.1.0__tar.gz
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.
- fdkey-0.1.0/ARCHITECTURE.md +234 -0
- fdkey-0.1.0/LICENSE +21 -0
- fdkey-0.1.0/PKG-INFO +189 -0
- fdkey-0.1.0/README.md +154 -0
- fdkey-0.1.0/pyproject.toml +53 -0
- fdkey-0.1.0/src/fdkey/__init__.py +20 -0
- fdkey-0.1.0/src/fdkey/guard.py +57 -0
- fdkey-0.1.0/src/fdkey/jwt_verify.py +69 -0
- fdkey-0.1.0/src/fdkey/middleware.py +590 -0
- fdkey-0.1.0/src/fdkey/session_store.py +115 -0
- fdkey-0.1.0/src/fdkey/types.py +96 -0
- fdkey-0.1.0/src/fdkey/vps_client.py +114 -0
- fdkey-0.1.0/src/fdkey/well_known.py +49 -0
- fdkey-0.1.0/tests/test_guard.py +60 -0
- fdkey-0.1.0/tests/test_jwt_verify.py +121 -0
- fdkey-0.1.0/tests/test_middleware.py +152 -0
- fdkey-0.1.0/tests/test_session_correlation.py +190 -0
- fdkey-0.1.0/tests/test_session_store.py +72 -0
- fdkey-0.1.0/tests/test_thread_safety.py +93 -0
- fdkey-0.1.0/tests/test_version_sync.py +45 -0
- fdkey-0.0.1.dev0/PKG-INFO +0 -27
- fdkey-0.0.1.dev0/README.md +0 -11
- fdkey-0.0.1.dev0/pyproject.toml +0 -23
- fdkey-0.0.1.dev0/src/fdkey/__init__.py +0 -1
- {fdkey-0.0.1.dev0 → fdkey-0.1.0}/.gitignore +0 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# `fdkey` (Python SDK) — Architecture Reference
|
|
2
|
+
|
|
3
|
+
> **Purpose.** Python port of the TypeScript SDK at `@fdkey/mcp`. Same wire format, same JWT, same per-session policy semantics. Wraps `mcp.server.fastmcp.FastMCP` to inject the two FDKEY tools and gate `protect`-listed tool calls behind a verification challenge.
|
|
4
|
+
>
|
|
5
|
+
> **Last verified against:** `src/fdkey/` as of 2026-05-09 (initial 0.1.0 release; one shared `httpx.AsyncClient` per VPSClient/WellKnownClient for connection-pool reuse).
|
|
6
|
+
>
|
|
7
|
+
> **Companion docs.**
|
|
8
|
+
> - [../typescript/ARCHITECTURE.md](../typescript/ARCHITECTURE.md) — `@fdkey/mcp`, the language-of-record SDK whose wire shape this port mirrors.
|
|
9
|
+
> - [../../../vps/ARCHITECTURE.md](../../../vps/ARCHITECTURE.md) — VPS scoring server. The Python SDK speaks the exact same `/v1/challenge` and `/v1/submit` endpoints as the TS SDK.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## § 1 — Top-level
|
|
14
|
+
|
|
15
|
+
`fdkey` (PyPI) is the Python integrator surface for FDKEY. It mirrors the TypeScript SDK's behavior:
|
|
16
|
+
|
|
17
|
+
- Injects `fdkey_get_challenge` + `fdkey_submit_challenge` MCP tools.
|
|
18
|
+
- Wraps `FastMCP.add_tool` / `FastMCP.tool` so any tool listed in `protect` returns `fdkey_verification_required` until the connecting agent has solved a challenge.
|
|
19
|
+
- Talks to `https://api.fdkey.com` over HTTPS via `httpx.AsyncClient`.
|
|
20
|
+
- Verifies Ed25519 JWTs offline using `PyJWT[crypto]` against a 1-hour cache of public keys from `/.well-known/fdkey.json`.
|
|
21
|
+
|
|
22
|
+
**Per-HTTP-session isolation (the multi-tenant case).** Each HTTP-Streamable client connection has its own `mcp.server.lowlevel.server.ServerSession` instance. The mcp package threads it through a `request_ctx` `ContextVar` for the duration of every tool-call dispatch. `_resolve_session_id()` reads that contextvar and looks the session up in `_SessionKeyTracker` — a module-level `WeakKeyDictionary[ServerSession, str]` that maps each live session to a fresh UUID assigned on first sight. The UUID is `f"mcp-{uuid4().hex}"`. Two HTTP clients hitting one shared `FastMCP` server get isolated `SessionState` entries (verified by `tests/test_session_correlation.py::test_two_clients_have_isolated_verification_state`).
|
|
23
|
+
|
|
24
|
+
**Why not just `id(session)`?** Python's `id()` is the memory address. After a `ServerSession` is garbage-collected, that address is free for reuse by the allocator. A long-lived `SessionStore` entry (TTL is 1h) keyed on the old address could be resurrected for a new, unrelated session — a cross-tenant verification leak. The UUID is monotonically unique per process, and `weakref.finalize(session, ...)` evicts the corresponding `SessionStore` entry the moment the session is gc'd, so the id-reuse window closes immediately rather than waiting for the TTL. Verified by `tests/test_session_correlation.py::test_session_state_evicted_when_session_is_gc_collected`.
|
|
25
|
+
|
|
26
|
+
**Stdio transport** falls through to the literal `"stdio"` key (one session for life of the process — correct).
|
|
27
|
+
|
|
28
|
+
**What this SDK does NOT do** that the TypeScript one does:
|
|
29
|
+
- **MCP `initialize` handshake metadata capture.** FastMCP's hook surface for the low-level Server's `initialize` request and `oninitialized` callback differs by mcp package version. Capturing `clientInfo`, `protocol_version`, etc. is intentionally deferred — the wire schema accepts a missing `agent` block. Add when the FastMCP API stabilizes.
|
|
30
|
+
|
|
31
|
+
### Layer model
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
with_fdkey(server, api_key=..., protect={...})
|
|
35
|
+
│
|
|
36
|
+
├─ Build _FdkeyState bundle (httpx clients, JWT verifier, session map)
|
|
37
|
+
│ and attach it to the server via setattr(server, "_fdkey_state", state)
|
|
38
|
+
├─ Register fdkey_get_challenge and fdkey_submit_challenge via
|
|
39
|
+
│ server.add_tool(...) (or server.tool() if add_tool unavailable)
|
|
40
|
+
├─ Monkey-patch server.add_tool / server.tool to intercept future
|
|
41
|
+
│ registrations matching `protect`
|
|
42
|
+
└─ Return the same server instance (mutated)
|
|
43
|
+
|
|
44
|
+
Per-call (gated tool):
|
|
45
|
+
handler invocation
|
|
46
|
+
├─ Read sid from the _active_session_id ContextVar (default: 'stdio')
|
|
47
|
+
├─ Look up SessionState in state.sessions
|
|
48
|
+
├─ guard.can_call(policy, name, session)? → run original or return
|
|
49
|
+
│ "fdkey_verification_required" string
|
|
50
|
+
└─ On success: guard.consume_policy(policy, session)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## § 2 — Directory map
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
mcp-integration/sdks/python/
|
|
59
|
+
├─ src/fdkey/
|
|
60
|
+
│ ├─ __init__.py — Public re-exports: with_fdkey, get_fdkey_context,
|
|
61
|
+
│ │ FdkeyConfig, FdkeyContext, Policy. __version__.
|
|
62
|
+
│ ├─ middleware.py — with_fdkey() entry point. Wraps FastMCP, registers
|
|
63
|
+
│ │ the two FDKEY tools, monkey-patches add_tool/tool.
|
|
64
|
+
│ │ SDK_VERSION constant lives here.
|
|
65
|
+
│ ├─ guard.py — Pure-function policy logic. can_call, mark_verified,
|
|
66
|
+
│ │ consume_policy. Mirrors the TS guard byte-for-byte.
|
|
67
|
+
│ ├─ session_store.py — Bounded `SessionStore` keyed by MCP session id.
|
|
68
|
+
│ │ TTL eviction (1h idle) + LRU hard cap (10k).
|
|
69
|
+
│ │ Sweep-on-access; no background tasks. Mirrors
|
|
70
|
+
│ │ the TS SDK's session-store.ts byte-for-byte.
|
|
71
|
+
│ ├─ types.py — Public types: FdkeyConfig, FdkeyContext (with
|
|
72
|
+
│ │ first-class score/tier), Policy union, SessionState.
|
|
73
|
+
│ ├─ vps_client.py — VpsClient + ChallengeResponse + SubmitResponse.
|
|
74
|
+
│ │ ONE shared httpx.AsyncClient per instance — keeps
|
|
75
|
+
│ │ the keepalive pool warm across requests.
|
|
76
|
+
│ ├─ jwt_verify.py — JwtVerifier + extract_score + extract_tier.
|
|
77
|
+
│ │ Uses PyJWT[crypto] for Ed25519 with 30s leeway.
|
|
78
|
+
│ └─ well_known.py — WellKnownClient. {kid → public_key} cache, 1h TTL,
|
|
79
|
+
│ refreshes on unknown kid (mid-rotation handling).
|
|
80
|
+
│ Shared httpx client.
|
|
81
|
+
├─ tests/
|
|
82
|
+
│ ├─ test_guard.py — Pure-function tests. Mirrors guard.test in TS.
|
|
83
|
+
│ └─ test_jwt_verify.py — Verify a known-good JWT, reject unknown kid.
|
|
84
|
+
│ Uses respx to mock httpx.
|
|
85
|
+
├─ pyproject.toml — name=fdkey, version=0.1.0. Build: hatchling.
|
|
86
|
+
│ Deps: mcp, httpx, pyjwt[crypto], cryptography.
|
|
87
|
+
│ Dev deps: pytest, pytest-asyncio, respx.
|
|
88
|
+
├─ LICENSE — MIT.
|
|
89
|
+
├─ README.md — Install + with_fdkey example + policy reference.
|
|
90
|
+
└─ ARCHITECTURE.md — This file.
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## § 3 — Per-file detail
|
|
96
|
+
|
|
97
|
+
### `src/fdkey/middleware.py`
|
|
98
|
+
|
|
99
|
+
**Public exports.**
|
|
100
|
+
- `with_fdkey(server, *, api_key, protect=None, difficulty="medium", on_fail="block", on_vps_error="allow", inline_challenge=False, vps_url=None, tags=None)` — the wrapper.
|
|
101
|
+
- `get_fdkey_context(server, extra_or_session_id) -> Optional[FdkeyContext]` — read verified state, capability `score`, `tier`, decoded JWT claims for the current session.
|
|
102
|
+
|
|
103
|
+
**Internals.**
|
|
104
|
+
- `_FDKEY_ATTR = "_fdkey_state"` — sentinel attribute on the FastMCP server holding the per-server `_FdkeyState` bundle.
|
|
105
|
+
- `_active_session_id: ContextVar[Optional[str]]` — the seam for HTTP-Streamable session correlation when FastMCP's API exposes it. Today: `None` → falls through to `'stdio'` for everyone.
|
|
106
|
+
- `_register_fdkey_tools(server, state)` — best-effort registration: tries `server.add_tool` first, falls back to `server.tool` decorator. Errors loudly if neither exists.
|
|
107
|
+
- `_wrap_tool_registrar(server, state)` — monkey-patches BOTH `add_tool` and `tool` on the server. Future tools registered with names in `state.protect` get wrapped via `_wrap_handler`.
|
|
108
|
+
- `_wrap_handler(fn, tool_name, policy, state)` — async wrapper that consults `guard.can_call` before delegating. On guard miss, returns the literal `"fdkey_verification_required..."` string (FastMCP auto-wraps strings into TextContent).
|
|
109
|
+
|
|
110
|
+
**SDK_VERSION** is hand-maintained at `'0.1.0'` and forwarded to the VPS as `integrator.sdk_version`. Keep in sync with `pyproject.toml [project] version` and `__init__.py __version__`.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
### `src/fdkey/guard.py`
|
|
115
|
+
|
|
116
|
+
Pure functions over `SessionState`. No I/O. Mirrors `guard.ts` and `guard.rs` byte-for-byte (same state machine: `verified`, `fresh_verification_available`, `verified_at` epoch ms).
|
|
117
|
+
|
|
118
|
+
`now_ms()` reads `time.time()` once per call.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
### `src/fdkey/types.py`
|
|
123
|
+
|
|
124
|
+
Dataclasses for type-safe surface.
|
|
125
|
+
|
|
126
|
+
- `FdkeyContext` — surfaced via `get_fdkey_context()`. **`score: Optional[float]` and `tier: Optional[str]` are first-class fields** (extracted from `claims` for ergonomics; the wire reserves the float for graduated capability scoring).
|
|
127
|
+
- `FdkeyConfig` — full config dataclass (mirrors TS `FdkeyConfig`).
|
|
128
|
+
- `Policy` — `Union[OncePerSessionPolicy, EachCallPolicy, EveryMinutesPolicy]` plus `normalise_policy(...)` which accepts string shorthand or dict-form input.
|
|
129
|
+
- `SessionState` — per-session mutable bag; same shape as the TS `SessionState`.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
### `src/fdkey/vps_client.py`
|
|
134
|
+
|
|
135
|
+
**Critical:** `VpsClient.__init__` constructs ONE `httpx.AsyncClient(timeout=10s)` and reuses it across `_post()` calls. Constructing one per call (which the initial implementation did) defeats httpx's connection pooling and forces a new TCP+TLS handshake every challenge. `aclose()` is exposed for explicit shutdown but Python's GC handles the common case.
|
|
136
|
+
|
|
137
|
+
`fetch_challenge(meta)` posts to `/v1/challenge`. Body matches the TS SDK exactly:
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"difficulty": "...", "client_type": "mcp",
|
|
141
|
+
"agent": { ... }, // only included if at least one field populated
|
|
142
|
+
"integrator": { ... },
|
|
143
|
+
"tags": { ... }
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`submit_answers(challenge_id, answers)` posts to `/v1/submit`.
|
|
148
|
+
|
|
149
|
+
`VpsHttpError` raised on non-2xx; carries `status` + parsed body so the middleware can branch on `error == "challenge_expired"` etc.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
### `src/fdkey/jwt_verify.py`
|
|
154
|
+
|
|
155
|
+
`JwtVerifier.verify(token)` flow:
|
|
156
|
+
1. `pyjwt.get_unverified_header(token)` → `kid`.
|
|
157
|
+
2. `WellKnownClient.get_key(kid)` → `cryptography` public-key object.
|
|
158
|
+
3. `pyjwt.decode(..., algorithms=["EdDSA"], leeway=30, options={"verify_aud": False})`.
|
|
159
|
+
4. Returns the claims dict, or `None` on any failure.
|
|
160
|
+
|
|
161
|
+
**Why `verify_aud=False`:** the SDK doesn't know its own `vps_users.id` at verify time. The VPS already binds aud to the api_key that requested the challenge — defense in depth. Same choice as the TS and Rust ports. Caveat: a JWT issued for one integrator's id could in principle be replayed against another FDKEY-protected service within the JWT lifetime (~5 min default).
|
|
162
|
+
|
|
163
|
+
`extract_score(claims)` and `extract_tier(claims)` — defensive accessors with explicit type checks (handle missing field, wrong type). Used by `get_fdkey_context()` to populate the first-class fields.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
### `src/fdkey/well_known.py`
|
|
168
|
+
|
|
169
|
+
Shared `httpx.AsyncClient(timeout=5s)`. Cache: `dict[str, object]` (kid → cryptography public-key). TTL: 1 hour. On unknown kid (mid-rotation), refreshes once before returning `None`. Thread-safety: not currently locked — concurrent first-use calls may both trigger a refresh, but the second simply overwrites with identical data. Acceptable for a single-process MCP server.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## § 4 — Configuration reference
|
|
174
|
+
|
|
175
|
+
| Field | Default | Purpose |
|
|
176
|
+
|---|---|---|
|
|
177
|
+
| `api_key` (required) | — | Bearer token. Must match a `vps_users.key_sha256` row on the target VPS. |
|
|
178
|
+
| `protect` | `{}` | `{tool_name: {"policy": "each_call" \| "once_per_session" \| {"type": "every_minutes", "minutes": N}}}` |
|
|
179
|
+
| `difficulty` | `"medium"` | `"easy" \| "medium" \| "hard"` — forwarded to the VPS. |
|
|
180
|
+
| `on_fail` | `"block"` | `"block" \| "allow"` — what to do when the agent fails the puzzle. |
|
|
181
|
+
| `on_vps_error` | `"allow"` | `"block" \| "allow"` — what to do when the VPS is unreachable. Default `"allow"` (fail-open) so an FDKEY outage doesn't brick integrator workflows. |
|
|
182
|
+
| `inline_challenge` | `False` | Embed puzzle JSON in the blocked-tool error so the agent can submit without a separate `fdkey_get_challenge` round-trip. |
|
|
183
|
+
| `vps_url` | `https://api.fdkey.com` | Override for self-hosted FDKEY. |
|
|
184
|
+
| `tags` | `None` | Free-form non-PII labels forwarded to FDKEY for analytics. |
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## § 5 — Cross-cutting concerns
|
|
189
|
+
|
|
190
|
+
### Threading model
|
|
191
|
+
|
|
192
|
+
The SDK is **safe under multi-threaded access**, even though the conventional `mcp` Python deployment is asyncio-only. Two integrator-side patterns motivate this:
|
|
193
|
+
|
|
194
|
+
1. Integrators wrapping the SDK in a thread-pool web framework (Flask, Quart with `run_sync`).
|
|
195
|
+
2. `asyncio.to_thread()` used adjacent to FDKEY context (e.g. blocking auth checks).
|
|
196
|
+
|
|
197
|
+
In both cases, two threads can race through the same `SessionStore.get(sid)` or `_SessionKeyTracker.key_for(session)` and — without locks — produce two `SessionState` instances for one connection, with one of them silently discarded by the dict overwrite.
|
|
198
|
+
|
|
199
|
+
Mechanism:
|
|
200
|
+
- `SessionStore` holds a `threading.RLock` around all mutators (`get`, `peek`, `delete`, `size`). Reentrant because the gc-finalizer on a `ServerSession` may call `delete()` synchronously mid-`get()` under aggressive gc tuning.
|
|
201
|
+
- `_SessionKeyTracker.key_for` holds a `threading.Lock` over the entire get-or-create critical section, including the `WeakKeyDictionary` insert and `weakref.finalize` registration.
|
|
202
|
+
|
|
203
|
+
Lock contention is essentially nil in single-threaded asyncio (~100 ns per acquire), so the cost is invisible against any real workload. Verified by `tests/test_thread_safety.py`.
|
|
204
|
+
|
|
205
|
+
### Test coverage
|
|
206
|
+
|
|
207
|
+
- `test_guard.py` — five tests covering all three policy variants + freshness consumption + uninitialized-session behavior.
|
|
208
|
+
- `test_jwt_verify.py` — four tests: JWT round-trip via respx-mocked well-known + `extract_score`/`extract_tier` defensive checks.
|
|
209
|
+
- `test_session_store.py` — five tests: TTL eviction, LRU cap, peek-doesn't-touch-LRU, `delete()` correctness, size tracking.
|
|
210
|
+
- `test_session_correlation.py` — five tests: stable per-session keys, gc-eviction safety (the id-reuse window closure), two-client isolation end-to-end.
|
|
211
|
+
- `test_session_correlation` autouse fixture isolates the `request_ctx` ContextVar between tests.
|
|
212
|
+
- `test_thread_safety.py` — three thread-stress tests: 32 threads × 500 calls on shared sid (single SessionState identity preserved), 1000 distinct sids (all entries survive), and concurrent get/peek/delete (no exceptions, no dict-mid-iteration crashes).
|
|
213
|
+
- `test_middleware.py` — six tests covering tool wrapping, gating, EachCall ticket consumption, OncePerSession persistence, unprotected tools passing through.
|
|
214
|
+
- `test_version_sync.py` — version constants kept in sync across `pyproject.toml`, `__init__.py`, and `middleware.py`.
|
|
215
|
+
|
|
216
|
+
### Wire-format synchronization
|
|
217
|
+
|
|
218
|
+
This is the rule: every byte FDKEY's VPS sees from this SDK matches what the TS SDK sends. If a TS SDK release changes the challenge body, this SDK gets the same change in the same release — coordinated via [`../typescript/ARCHITECTURE.md` § 6](../typescript/ARCHITECTURE.md#-6--public-api-surface).
|
|
219
|
+
|
|
220
|
+
### MCP SDK version compatibility
|
|
221
|
+
|
|
222
|
+
The `mcp` package's API is still evolving. `_register_fdkey_tools` defensively probes for both `server.add_tool(...)` and `server.tool(...)` — works against either path FastMCP might expose. `_read_server_info` falls through several private/public attribute names. If a future `mcp` release adds a stable hook for `clientInfo` capture, that's where to add the metadata-forwarding code path.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## § 6 — Maintenance protocol
|
|
227
|
+
|
|
228
|
+
> **Rule:** when you change `src/fdkey/**` or `pyproject.toml`'s public surface, update this file.
|
|
229
|
+
|
|
230
|
+
Common changes:
|
|
231
|
+
- New field on `FdkeyContext`? → `types.py` + matching field in TS `FdkeyContext` and Rust `FdkeyContext`. README "Reading verification context" section.
|
|
232
|
+
- New policy variant? → `types.py` + `guard.py` (exhaustive isinstance match). Same change in TS + Rust.
|
|
233
|
+
- Bumped version? → `pyproject.toml [project] version`, `src/fdkey/__init__.py __version__`, AND `src/fdkey/middleware.py SDK_VERSION` (all three must match).
|
|
234
|
+
- `mcp` package introduces `set_request_handler` / `oninitialized` parity with TS? → wire it in `with_fdkey` to capture `clientInfo` + `protocol_version`. Update `_meta_for` to populate the `agent` block.
|
fdkey-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FDKEY
|
|
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.
|
fdkey-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fdkey
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: FDKEY verification middleware for MCP servers — gate AI-agent access behind LLM-only puzzles.
|
|
5
|
+
Project-URL: Homepage, https://fdkey.com
|
|
6
|
+
Project-URL: Dashboard, https://app.fdkey.com
|
|
7
|
+
Project-URL: Repository, https://github.com/fdkey/sdks
|
|
8
|
+
Project-URL: Issues, https://github.com/fdkey/sdks/issues
|
|
9
|
+
Author: FDKEY
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai-agent,anti-bot,captcha,fdkey,mcp,middleware,model-context-protocol,verification
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Classifier: Topic :: Security
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: cryptography>=42.0.0
|
|
26
|
+
Requires-Dist: httpx>=0.27.0
|
|
27
|
+
Requires-Dist: mcp>=1.0.0
|
|
28
|
+
Requires-Dist: pyjwt[crypto]>=2.8.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: respx>=0.20; extra == 'dev'
|
|
33
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# fdkey
|
|
37
|
+
|
|
38
|
+
> **FDKEY verification middleware for MCP servers (Python).** Gate AI-agent
|
|
39
|
+
> access to your tools behind LLM-only puzzles. Drop-in for any
|
|
40
|
+
> [Model Context Protocol](https://modelcontextprotocol.io) server built
|
|
41
|
+
> on the official Python SDK's `FastMCP`.
|
|
42
|
+
|
|
43
|
+
## What it does
|
|
44
|
+
|
|
45
|
+
- Injects two MCP tools into your server: `fdkey_get_challenge` and
|
|
46
|
+
`fdkey_submit_challenge`.
|
|
47
|
+
- Wraps the tools you want to protect — they return
|
|
48
|
+
`fdkey_verification_required` until the connecting agent has solved a
|
|
49
|
+
challenge.
|
|
50
|
+
- Talks to `https://api.fdkey.com` for challenge issuance and scoring.
|
|
51
|
+
- Verifies the Ed25519 JWT response **offline** using the public key
|
|
52
|
+
from `https://api.fdkey.com/.well-known/fdkey.json`.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install fdkey
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
You also need the official MCP Python SDK:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install mcp
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Get an API key at [app.fdkey.com](https://app.fdkey.com).
|
|
67
|
+
|
|
68
|
+
## Usage
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import os
|
|
72
|
+
from mcp.server.fastmcp import FastMCP
|
|
73
|
+
from fdkey import with_fdkey
|
|
74
|
+
|
|
75
|
+
server = FastMCP("my-server")
|
|
76
|
+
|
|
77
|
+
with_fdkey(
|
|
78
|
+
server,
|
|
79
|
+
api_key=os.environ["FDKEY_API_KEY"],
|
|
80
|
+
protect={
|
|
81
|
+
"sensitive_action": {"policy": "each_call"},
|
|
82
|
+
"register": {"policy": "once_per_session"},
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
@server.tool()
|
|
87
|
+
def sensitive_action() -> str:
|
|
88
|
+
# Reaches here only after the agent has solved a challenge.
|
|
89
|
+
return "verified"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Policies
|
|
93
|
+
|
|
94
|
+
Per-tool gating policy — passed as `{"policy": ...}` in the `protect` map:
|
|
95
|
+
|
|
96
|
+
- `"each_call"` — verification required for every invocation. Use for
|
|
97
|
+
irreversible actions (payments, deletes).
|
|
98
|
+
- `"once_per_session"` — verification required once per connection. Use
|
|
99
|
+
for account creation, signup-style flows.
|
|
100
|
+
- `{"type": "every_minutes", "minutes": N}` — verification good for N
|
|
101
|
+
minutes after the puzzle was solved. Middle ground when "every call"
|
|
102
|
+
is too aggressive but "once forever" is too loose. The timer does NOT
|
|
103
|
+
extend on calls — it expires `minutes` after the solve, regardless
|
|
104
|
+
of activity.
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
protect={
|
|
108
|
+
"delete_account": {"policy": "each_call"},
|
|
109
|
+
"register": {"policy": "once_per_session"},
|
|
110
|
+
"refresh_dashboard": {"policy": {"type": "every_minutes", "minutes": 15}},
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Configuration reference
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
with_fdkey(
|
|
118
|
+
server,
|
|
119
|
+
api_key="fdk_...", # required
|
|
120
|
+
protect={...}, # tool name -> {"policy": ...}
|
|
121
|
+
vps_url="https://api.fdkey.com", # override for self-hosted
|
|
122
|
+
difficulty="medium", # easy | medium | hard
|
|
123
|
+
on_fail="block", # block | allow (puzzle failed)
|
|
124
|
+
on_vps_error="allow", # block | allow — see below
|
|
125
|
+
inline_challenge=False, # embed puzzle in blocked-tool error
|
|
126
|
+
tags={"env": "prod"}, # forwarded to FDKEY for analytics
|
|
127
|
+
)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Failure-mode defaults
|
|
131
|
+
|
|
132
|
+
`on_vps_error="allow"` is the default — if the FDKEY scoring service is
|
|
133
|
+
unreachable, the protected tool falls through to your handler instead of
|
|
134
|
+
blocking. We chose this so an FDKEY outage doesn't brick your workflow
|
|
135
|
+
(e.g. if we shut down or DNS can't resolve `api.fdkey.com`). FDKEY is
|
|
136
|
+
verification, not gating — your service should still serve traffic when
|
|
137
|
+
ours is down. Set `on_vps_error="block"` if you'd rather drop traffic
|
|
138
|
+
than admit unverified callers during an outage.
|
|
139
|
+
|
|
140
|
+
## Reading verification context
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from fdkey import get_fdkey_context
|
|
144
|
+
|
|
145
|
+
@server.tool()
|
|
146
|
+
def whoami(ctx) -> str:
|
|
147
|
+
fdkey = get_fdkey_context(server, ctx)
|
|
148
|
+
if fdkey and fdkey.verified:
|
|
149
|
+
# `score` and `tier` are first-class fields on the context.
|
|
150
|
+
# `score` is a 0..1 float — today effectively binary
|
|
151
|
+
# (1.0 passed / 0.0 failed) but reserved for future capability
|
|
152
|
+
# scoring without an API change. `tier` is the VPS-issued
|
|
153
|
+
# capability bucket label.
|
|
154
|
+
return f"verified (score={fdkey.score}, tier={fdkey.tier})"
|
|
155
|
+
return "not verified"
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## What FDKEY sees
|
|
159
|
+
|
|
160
|
+
- The MCP `clientInfo` your agent reports (when forwarded by your server).
|
|
161
|
+
- Challenge IDs, scores, timestamps.
|
|
162
|
+
- Your integrator-supplied `tags`.
|
|
163
|
+
|
|
164
|
+
## Security notes
|
|
165
|
+
|
|
166
|
+
- **JWT `aud` is not validated by the SDK.** The audience claim binds the
|
|
167
|
+
JWT to the integrator's `vps_users.id`, which the SDK doesn't know at
|
|
168
|
+
verify time. The VPS already binds `aud` to the API key that requested
|
|
169
|
+
the challenge — defense in depth — but in principle, a JWT issued for
|
|
170
|
+
one FDKEY-protected service could be replayed against a different one
|
|
171
|
+
within the JWT lifetime (~5 min default). Keep the JWT lifetime short
|
|
172
|
+
on the VPS side if your threat model includes cross-integrator replay.
|
|
173
|
+
|
|
174
|
+
## What FDKEY does NOT see
|
|
175
|
+
|
|
176
|
+
- Your prompts.
|
|
177
|
+
- Tool inputs or outputs.
|
|
178
|
+
- Your end users' identities or PII.
|
|
179
|
+
|
|
180
|
+
## Links
|
|
181
|
+
|
|
182
|
+
- Marketing + docs: <https://fdkey.com>
|
|
183
|
+
- Dashboard (sign up + manage keys): <https://app.fdkey.com>
|
|
184
|
+
- Source: <https://github.com/fdkey/sdks>
|
|
185
|
+
- Issues: <https://github.com/fdkey/sdks/issues>
|
|
186
|
+
|
|
187
|
+
## License
|
|
188
|
+
|
|
189
|
+
MIT — see [LICENSE](./LICENSE).
|
fdkey-0.1.0/README.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# fdkey
|
|
2
|
+
|
|
3
|
+
> **FDKEY verification middleware for MCP servers (Python).** Gate AI-agent
|
|
4
|
+
> access to your tools behind LLM-only puzzles. Drop-in for any
|
|
5
|
+
> [Model Context Protocol](https://modelcontextprotocol.io) server built
|
|
6
|
+
> on the official Python SDK's `FastMCP`.
|
|
7
|
+
|
|
8
|
+
## What it does
|
|
9
|
+
|
|
10
|
+
- Injects two MCP tools into your server: `fdkey_get_challenge` and
|
|
11
|
+
`fdkey_submit_challenge`.
|
|
12
|
+
- Wraps the tools you want to protect — they return
|
|
13
|
+
`fdkey_verification_required` until the connecting agent has solved a
|
|
14
|
+
challenge.
|
|
15
|
+
- Talks to `https://api.fdkey.com` for challenge issuance and scoring.
|
|
16
|
+
- Verifies the Ed25519 JWT response **offline** using the public key
|
|
17
|
+
from `https://api.fdkey.com/.well-known/fdkey.json`.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install fdkey
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
You also need the official MCP Python SDK:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install mcp
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Get an API key at [app.fdkey.com](https://app.fdkey.com).
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import os
|
|
37
|
+
from mcp.server.fastmcp import FastMCP
|
|
38
|
+
from fdkey import with_fdkey
|
|
39
|
+
|
|
40
|
+
server = FastMCP("my-server")
|
|
41
|
+
|
|
42
|
+
with_fdkey(
|
|
43
|
+
server,
|
|
44
|
+
api_key=os.environ["FDKEY_API_KEY"],
|
|
45
|
+
protect={
|
|
46
|
+
"sensitive_action": {"policy": "each_call"},
|
|
47
|
+
"register": {"policy": "once_per_session"},
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@server.tool()
|
|
52
|
+
def sensitive_action() -> str:
|
|
53
|
+
# Reaches here only after the agent has solved a challenge.
|
|
54
|
+
return "verified"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Policies
|
|
58
|
+
|
|
59
|
+
Per-tool gating policy — passed as `{"policy": ...}` in the `protect` map:
|
|
60
|
+
|
|
61
|
+
- `"each_call"` — verification required for every invocation. Use for
|
|
62
|
+
irreversible actions (payments, deletes).
|
|
63
|
+
- `"once_per_session"` — verification required once per connection. Use
|
|
64
|
+
for account creation, signup-style flows.
|
|
65
|
+
- `{"type": "every_minutes", "minutes": N}` — verification good for N
|
|
66
|
+
minutes after the puzzle was solved. Middle ground when "every call"
|
|
67
|
+
is too aggressive but "once forever" is too loose. The timer does NOT
|
|
68
|
+
extend on calls — it expires `minutes` after the solve, regardless
|
|
69
|
+
of activity.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
protect={
|
|
73
|
+
"delete_account": {"policy": "each_call"},
|
|
74
|
+
"register": {"policy": "once_per_session"},
|
|
75
|
+
"refresh_dashboard": {"policy": {"type": "every_minutes", "minutes": 15}},
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Configuration reference
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
with_fdkey(
|
|
83
|
+
server,
|
|
84
|
+
api_key="fdk_...", # required
|
|
85
|
+
protect={...}, # tool name -> {"policy": ...}
|
|
86
|
+
vps_url="https://api.fdkey.com", # override for self-hosted
|
|
87
|
+
difficulty="medium", # easy | medium | hard
|
|
88
|
+
on_fail="block", # block | allow (puzzle failed)
|
|
89
|
+
on_vps_error="allow", # block | allow — see below
|
|
90
|
+
inline_challenge=False, # embed puzzle in blocked-tool error
|
|
91
|
+
tags={"env": "prod"}, # forwarded to FDKEY for analytics
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Failure-mode defaults
|
|
96
|
+
|
|
97
|
+
`on_vps_error="allow"` is the default — if the FDKEY scoring service is
|
|
98
|
+
unreachable, the protected tool falls through to your handler instead of
|
|
99
|
+
blocking. We chose this so an FDKEY outage doesn't brick your workflow
|
|
100
|
+
(e.g. if we shut down or DNS can't resolve `api.fdkey.com`). FDKEY is
|
|
101
|
+
verification, not gating — your service should still serve traffic when
|
|
102
|
+
ours is down. Set `on_vps_error="block"` if you'd rather drop traffic
|
|
103
|
+
than admit unverified callers during an outage.
|
|
104
|
+
|
|
105
|
+
## Reading verification context
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from fdkey import get_fdkey_context
|
|
109
|
+
|
|
110
|
+
@server.tool()
|
|
111
|
+
def whoami(ctx) -> str:
|
|
112
|
+
fdkey = get_fdkey_context(server, ctx)
|
|
113
|
+
if fdkey and fdkey.verified:
|
|
114
|
+
# `score` and `tier` are first-class fields on the context.
|
|
115
|
+
# `score` is a 0..1 float — today effectively binary
|
|
116
|
+
# (1.0 passed / 0.0 failed) but reserved for future capability
|
|
117
|
+
# scoring without an API change. `tier` is the VPS-issued
|
|
118
|
+
# capability bucket label.
|
|
119
|
+
return f"verified (score={fdkey.score}, tier={fdkey.tier})"
|
|
120
|
+
return "not verified"
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## What FDKEY sees
|
|
124
|
+
|
|
125
|
+
- The MCP `clientInfo` your agent reports (when forwarded by your server).
|
|
126
|
+
- Challenge IDs, scores, timestamps.
|
|
127
|
+
- Your integrator-supplied `tags`.
|
|
128
|
+
|
|
129
|
+
## Security notes
|
|
130
|
+
|
|
131
|
+
- **JWT `aud` is not validated by the SDK.** The audience claim binds the
|
|
132
|
+
JWT to the integrator's `vps_users.id`, which the SDK doesn't know at
|
|
133
|
+
verify time. The VPS already binds `aud` to the API key that requested
|
|
134
|
+
the challenge — defense in depth — but in principle, a JWT issued for
|
|
135
|
+
one FDKEY-protected service could be replayed against a different one
|
|
136
|
+
within the JWT lifetime (~5 min default). Keep the JWT lifetime short
|
|
137
|
+
on the VPS side if your threat model includes cross-integrator replay.
|
|
138
|
+
|
|
139
|
+
## What FDKEY does NOT see
|
|
140
|
+
|
|
141
|
+
- Your prompts.
|
|
142
|
+
- Tool inputs or outputs.
|
|
143
|
+
- Your end users' identities or PII.
|
|
144
|
+
|
|
145
|
+
## Links
|
|
146
|
+
|
|
147
|
+
- Marketing + docs: <https://fdkey.com>
|
|
148
|
+
- Dashboard (sign up + manage keys): <https://app.fdkey.com>
|
|
149
|
+
- Source: <https://github.com/fdkey/sdks>
|
|
150
|
+
- Issues: <https://github.com/fdkey/sdks/issues>
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
MIT — see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fdkey"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "FDKEY verification middleware for MCP servers — gate AI-agent access behind LLM-only puzzles."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
authors = [{name = "FDKEY"}]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
keywords = ["mcp", "model-context-protocol", "ai-agent", "verification", "captcha", "fdkey", "anti-bot", "middleware"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
25
|
+
"Topic :: Security",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"mcp>=1.0.0",
|
|
29
|
+
"httpx>=0.27.0",
|
|
30
|
+
"pyjwt[crypto]>=2.8.0",
|
|
31
|
+
"cryptography>=42.0.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=8.0",
|
|
37
|
+
"pytest-asyncio>=0.23",
|
|
38
|
+
"respx>=0.20",
|
|
39
|
+
"ruff>=0.6",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://fdkey.com"
|
|
44
|
+
Dashboard = "https://app.fdkey.com"
|
|
45
|
+
Repository = "https://github.com/fdkey/sdks"
|
|
46
|
+
Issues = "https://github.com/fdkey/sdks/issues"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["src/fdkey"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
asyncio_mode = "auto"
|
|
53
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""FDKEY — verification middleware for MCP servers.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
|
|
5
|
+
from fdkey import with_fdkey, get_fdkey_context, FdkeyContext
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .middleware import get_fdkey_context, with_fdkey
|
|
9
|
+
from .types import FdkeyConfig, FdkeyContext, Policy
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"with_fdkey",
|
|
15
|
+
"get_fdkey_context",
|
|
16
|
+
"FdkeyContext",
|
|
17
|
+
"FdkeyConfig",
|
|
18
|
+
"Policy",
|
|
19
|
+
"__version__",
|
|
20
|
+
]
|