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.
@@ -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
+ ]