kade-agent-sdk 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,15 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .env
5
+ .env.*
6
+ !.env.example
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ dist/
10
+ *.egg-info/
11
+ .DS_Store
12
+
13
+ # Local runtime data (results backend, load-test output)
14
+ .data/
15
+ .design/
@@ -0,0 +1,93 @@
1
+ # PolyForm Shield License 1.0.0
2
+
3
+ <https://polyformproject.org/licenses/shield/1.0.0>
4
+
5
+ ## Acceptance
6
+
7
+ In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
8
+
9
+ ## Copyright License
10
+
11
+ The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
12
+
13
+ ## Distribution License
14
+
15
+ The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
16
+
17
+ ## Notices
18
+
19
+ You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
20
+
21
+ > Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
22
+
23
+ ## Changes and New Works License
24
+
25
+ The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
26
+
27
+ ## Patent License
28
+
29
+ The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
30
+
31
+ ## Noncompete
32
+
33
+ Any purpose is a permitted purpose, except for providing any product that competes with the software or any product the licensor or any of its affiliates provides using the software.
34
+
35
+ ## Competition
36
+
37
+ Goods and services compete even when they provide functionality through different kinds of interfaces or for different technical platforms. Applications can compete with services, libraries with plugins, frameworks with development tools, and so on, even if they're written in different programming languages or for different computer architectures. Goods and services compete even when provided free of charge. If you market a product as a practical substitute for the software or another product, it definitely competes.
38
+
39
+ ## New Products
40
+
41
+ If you are using the software to provide a product that does not compete, but the licensor or any of its affiliates brings your product into competition by providing a new version of the software or another product using the software, you may continue using versions of the software available under these terms beforehand to provide your competing product, but not any later versions.
42
+
43
+ ## Discontinued Products
44
+
45
+ You may begin using the software to compete with a product or service that the licensor or any of its affiliates has stopped providing, unless the licensor includes a plain-text line beginning with `Licensor Line of Business:` with the software that mentions that line of business. For example:
46
+
47
+ > Licensor Line of Business: YoyodyneCMS Content Management System (http://example.com/cms)
48
+
49
+ ## Sales of Business
50
+
51
+ If the licensor or any of its affiliates sells a line of business developing the software or using the software to provide a product, the buyer can also enforce [Noncompete](#noncompete) for that product.
52
+
53
+ ## Fair Use
54
+
55
+ You may have "fair use" rights for the software under the law. These terms do not limit them.
56
+
57
+ ## No Other Rights
58
+
59
+ These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
60
+
61
+ ## Patent Defense
62
+
63
+ If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
64
+
65
+ ## Violations
66
+
67
+ The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
68
+
69
+ ## No Liability
70
+
71
+ ***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
72
+
73
+ ## Definitions
74
+
75
+ The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
76
+
77
+ A **product** can be a good or service, or a combination of them.
78
+
79
+ **You** refers to the individual or entity agreeing to these terms.
80
+
81
+ **Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all its affiliates.
82
+
83
+ **Affiliates** means the other organizations than an organization has control over, is under the control of, or is under common control with.
84
+
85
+ **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
86
+
87
+ **Your licenses** are all the licenses granted to you for the software under these terms.
88
+
89
+ **Use** means anything you do with the software requiring one of your licenses.
90
+
91
+ Required Notice: Copyright (c) 2026 TaskmasterGPT (https://taskmastergpt.nl)
92
+
93
+ Licensor Line of Business: a marketplace that lists, bills and pays out AI agents and MCP servers sold per use, and the tools and SDKs for building agents for it.
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.5
2
+ Name: kade-agent-sdk
3
+ Version: 0.1.0
4
+ Summary: Creator SDK for Kade: verify gateway requests and report billable events
5
+ Project-URL: Homepage, https://kade.taskmastergpt.nl/creators
6
+ Project-URL: Documentation, https://kade.taskmastergpt.nl/docs/creator
7
+ License-Expression: LicenseRef-PolyForm-Shield-1.0.0
8
+ License-File: LICENSE
9
+ Keywords: agents,fastmcp,kade,marketplace,mcp
10
+ Classifier: Framework :: AsyncIO
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Requires-Python: <3.15,>=3.12
15
+ Requires-Dist: httpx>=0.28
16
+ Requires-Dist: pyjwt[crypto]>=2.10
17
+ Provides-Extra: fastmcp
18
+ Requires-Dist: fastmcp>=4.1; extra == 'fastmcp'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # kade-agent-sdk
22
+
23
+ Creator SDK for [Kade](https://kade.taskmastergpt.nl): verify that requests come from the Kade
24
+ gateway, report billable events and answer data-subject (export/erase) requests.
25
+
26
+ ```bash
27
+ uv add "kade-agent-sdk[fastmcp]" # import name: marketplace_sdk
28
+ ```
29
+
30
+ ```python
31
+ from fastmcp import FastMCP
32
+ from marketplace_sdk import GatewayVerifier, gateway_claims, report_event
33
+ from marketplace_sdk.fastmcp import MarketplaceMiddleware, add_data_subject_tools
34
+
35
+ mcp = FastMCP("my-agent")
36
+ verifier = GatewayVerifier(
37
+ jwks_url="https://kade.taskmastergpt.nl/.well-known/jwks.json",
38
+ issuer="https://mcp.kade.taskmastergpt.nl",
39
+ agent_id="agt_…", # from your creator console
40
+ )
41
+ mcp.add_middleware(MarketplaceMiddleware(verifier))
42
+
43
+ @mcp.tool
44
+ async def lookup(query: str) -> dict:
45
+ report_event("record_found", 3) # billed at your event price, capped per call
46
+ return {"run_id": gateway_claims().run_id, "query": query}
47
+
48
+ # Required to publish. Stateless agents:
49
+ add_data_subject_tools(mcp, stores_no_user_data=True)
50
+ # ...or, if you keep data per user (subject = pseudonymous `sub` from the gateway token):
51
+ # add_data_subject_tools(mcp, erase=lambda s: db.delete(s), export=lambda s: db.records(s))
52
+
53
+ app = mcp.http_app(path="/mcp") # serve with uvicorn on HTTPS
54
+ ```
55
+
56
+ - `GatewayVerifier` checks EdDSA signature (JWKS cached 1 h, `kid` rotation), `iss`, `aud =
57
+ agent:<agent_id>`, `exp` and `scope` (`call`, `register`, `verify`, `erase`, `export`).
58
+ - `MarketplaceMiddleware` verifies every request, rejects `register` tokens on tool calls, tokens
59
+ issued for another tool and `erase`/`export` tokens outside the reserved tools, and merges
60
+ reported events into the result `_meta`.
61
+ - `add_data_subject_tools` registers `kade_erase_subject` and `kade_export_subject`; Kade strips
62
+ them from your listing and calls them only with `scope=erase`/`export` (SPEC §7.5).
63
+ - Not using FastMCP? Wrap your ASGI app in `marketplace_sdk.asgi.GatewayAuthMiddleware` and add
64
+ `billing_meta({"record_found": 3})` to your result `_meta`.
65
+ - `gateway_claims().test` is true for creator test calls and verification runs (never billed).
66
+
67
+ ## License
68
+
69
+ [PolyForm Shield 1.0.0](LICENSE). In short: use it, change it and ship it in your agents,
70
+ including agents you sell on Kade; suggest changes to us. What it does not allow is offering the
71
+ SDK, or a product built with it, that competes with it or with Kade, for example reselling the SDK
72
+ or running a rival marketplace on it. This is a source-available license, not an OSI open-source
73
+ license.
@@ -0,0 +1,53 @@
1
+ # kade-agent-sdk
2
+
3
+ Creator SDK for [Kade](https://kade.taskmastergpt.nl): verify that requests come from the Kade
4
+ gateway, report billable events and answer data-subject (export/erase) requests.
5
+
6
+ ```bash
7
+ uv add "kade-agent-sdk[fastmcp]" # import name: marketplace_sdk
8
+ ```
9
+
10
+ ```python
11
+ from fastmcp import FastMCP
12
+ from marketplace_sdk import GatewayVerifier, gateway_claims, report_event
13
+ from marketplace_sdk.fastmcp import MarketplaceMiddleware, add_data_subject_tools
14
+
15
+ mcp = FastMCP("my-agent")
16
+ verifier = GatewayVerifier(
17
+ jwks_url="https://kade.taskmastergpt.nl/.well-known/jwks.json",
18
+ issuer="https://mcp.kade.taskmastergpt.nl",
19
+ agent_id="agt_…", # from your creator console
20
+ )
21
+ mcp.add_middleware(MarketplaceMiddleware(verifier))
22
+
23
+ @mcp.tool
24
+ async def lookup(query: str) -> dict:
25
+ report_event("record_found", 3) # billed at your event price, capped per call
26
+ return {"run_id": gateway_claims().run_id, "query": query}
27
+
28
+ # Required to publish. Stateless agents:
29
+ add_data_subject_tools(mcp, stores_no_user_data=True)
30
+ # ...or, if you keep data per user (subject = pseudonymous `sub` from the gateway token):
31
+ # add_data_subject_tools(mcp, erase=lambda s: db.delete(s), export=lambda s: db.records(s))
32
+
33
+ app = mcp.http_app(path="/mcp") # serve with uvicorn on HTTPS
34
+ ```
35
+
36
+ - `GatewayVerifier` checks EdDSA signature (JWKS cached 1 h, `kid` rotation), `iss`, `aud =
37
+ agent:<agent_id>`, `exp` and `scope` (`call`, `register`, `verify`, `erase`, `export`).
38
+ - `MarketplaceMiddleware` verifies every request, rejects `register` tokens on tool calls, tokens
39
+ issued for another tool and `erase`/`export` tokens outside the reserved tools, and merges
40
+ reported events into the result `_meta`.
41
+ - `add_data_subject_tools` registers `kade_erase_subject` and `kade_export_subject`; Kade strips
42
+ them from your listing and calls them only with `scope=erase`/`export` (SPEC §7.5).
43
+ - Not using FastMCP? Wrap your ASGI app in `marketplace_sdk.asgi.GatewayAuthMiddleware` and add
44
+ `billing_meta({"record_found": 3})` to your result `_meta`.
45
+ - `gateway_claims().test` is true for creator test calls and verification runs (never billed).
46
+
47
+ ## License
48
+
49
+ [PolyForm Shield 1.0.0](LICENSE). In short: use it, change it and ship it in your agents,
50
+ including agents you sell on Kade; suggest changes to us. What it does not allow is offering the
51
+ SDK, or a product built with it, that competes with it or with Kade, for example reselling the SDK
52
+ or running a rival marketplace on it. This is a source-available license, not an OSI open-source
53
+ license.
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "kade-agent-sdk"
3
+ version = "0.1.0"
4
+ description = "Creator SDK for Kade: verify gateway requests and report billable events"
5
+ readme = "README.md"
6
+ license = "LicenseRef-PolyForm-Shield-1.0.0"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.12,<3.15"
9
+ keywords = ["kade", "mcp", "fastmcp", "agents", "marketplace"]
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ "Framework :: AsyncIO",
13
+ "Intended Audience :: Developers",
14
+ "Topic :: Software Development :: Libraries",
15
+ ]
16
+ dependencies = [
17
+ "pyjwt[crypto]>=2.10",
18
+ "httpx>=0.28",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://kade.taskmastergpt.nl/creators"
23
+ Documentation = "https://kade.taskmastergpt.nl/docs/creator"
24
+
25
+ [project.optional-dependencies]
26
+ fastmcp = ["fastmcp>=4.1"]
27
+
28
+ [build-system]
29
+ requires = ["hatchling"]
30
+ build-backend = "hatchling.build"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/marketplace_sdk"]
@@ -0,0 +1,18 @@
1
+ """Kade creator SDK: verify gateway requests and report billable events.
2
+
3
+ from marketplace_sdk import GatewayVerifier, report_event, gateway_claims
4
+ from marketplace_sdk.fastmcp import MarketplaceMiddleware
5
+ """
6
+
7
+ from marketplace_sdk.context import BILLING_META_KEY, billing_meta, gateway_claims, report_event
8
+ from marketplace_sdk.verifier import GatewayClaims, GatewayVerifier, VerificationError
9
+
10
+ __all__ = [
11
+ "BILLING_META_KEY",
12
+ "GatewayClaims",
13
+ "GatewayVerifier",
14
+ "VerificationError",
15
+ "billing_meta",
16
+ "gateway_claims",
17
+ "report_event",
18
+ ]
@@ -0,0 +1,44 @@
1
+ """ASGI middleware for non-FastMCP servers: 401 unless the gateway JWT verifies; claims are
2
+ available to handlers via `gateway_claims()`. Return events with `billing_meta(...)`."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from typing import Any
8
+
9
+ from marketplace_sdk.context import begin_call, end_call
10
+ from marketplace_sdk.verifier import GatewayVerifier, VerificationError
11
+
12
+
13
+ class GatewayAuthMiddleware:
14
+ def __init__(self, app: Any, verifier: GatewayVerifier, path_prefix: str = "/mcp") -> None:
15
+ self.app = app
16
+ self.verifier = verifier
17
+ self.path_prefix = path_prefix
18
+
19
+ async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
20
+ if scope["type"] != "http" or not scope["path"].startswith(self.path_prefix):
21
+ await self.app(scope, receive, send)
22
+ return
23
+ headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])}
24
+ try:
25
+ claims = self.verifier.verify_header(headers.get("authorization"))
26
+ except VerificationError as exc:
27
+ body = json.dumps({"error": "unauthorized", "detail": str(exc)}).encode()
28
+ await send(
29
+ {
30
+ "type": "http.response.start",
31
+ "status": 401,
32
+ "headers": [
33
+ (b"content-type", b"application/json"),
34
+ (b"www-authenticate", b'Bearer realm="kade"'),
35
+ ],
36
+ }
37
+ )
38
+ await send({"type": "http.response.body", "body": body})
39
+ return
40
+ tokens = begin_call(claims)
41
+ try:
42
+ await self.app(scope, receive, send)
43
+ finally:
44
+ end_call(tokens)
@@ -0,0 +1,57 @@
1
+ """Per-call context: verified gateway claims and accumulated billing events (contextvars)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from contextvars import ContextVar, Token
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ if TYPE_CHECKING:
11
+ from marketplace_sdk.verifier import GatewayClaims
12
+
13
+ META_NS = os.environ.get("MARKETPLACE_META_NS", "nl.taskmastergpt.kade")
14
+ BILLING_META_KEY = f"{META_NS}/billing"
15
+ _EVENT_NAME = re.compile(r"^[a-z0-9_]{1,32}$")
16
+
17
+ _claims: ContextVar[GatewayClaims | None] = ContextVar("marketplace_claims", default=None)
18
+ _events: ContextVar[dict[str, int] | None] = ContextVar("marketplace_events", default=None)
19
+
20
+
21
+ def gateway_claims() -> GatewayClaims | None:
22
+ """Claims of the gateway request being served (run_id, sub, max_cost_micros, test …)."""
23
+ return _claims.get()
24
+
25
+
26
+ def report_event(name: str, count: int = 1) -> None:
27
+ """Record `count` billable events for this call. The gateway caps counts at your configured
28
+ `max_per_call` and ignores names you have not priced."""
29
+ if not _EVENT_NAME.match(name):
30
+ raise ValueError("event name must match ^[a-z0-9_]{1,32}$")
31
+ if count < 0:
32
+ raise ValueError("count must be >= 0")
33
+ events = _events.get()
34
+ if events is None: # outside a gateway call (e.g. local tests): start a bucket
35
+ events = {}
36
+ _events.set(events)
37
+ events[name] = events.get(name, 0) + int(count)
38
+
39
+
40
+ def billing_meta(events: dict[str, int] | list[dict[str, Any]]) -> dict[str, Any]:
41
+ """`_meta` fragment for a tool result, for servers not using the FastMCP middleware."""
42
+ if isinstance(events, dict):
43
+ items = [{"name": n, "count": c} for n, c in events.items() if c]
44
+ else:
45
+ items = [e for e in events if e.get("count")]
46
+ return {BILLING_META_KEY: {"events": items}}
47
+
48
+
49
+ def begin_call(claims: GatewayClaims | None) -> tuple[Token, Token]:
50
+ return _claims.set(claims), _events.set({})
51
+
52
+
53
+ def end_call(tokens: tuple[Token, Token]) -> dict[str, int]:
54
+ events = _events.get() or {}
55
+ _claims.reset(tokens[0])
56
+ _events.reset(tokens[1])
57
+ return events
@@ -0,0 +1,131 @@
1
+ """FastMCP 4 middleware: verify the gateway JWT on every request, expose claims to tools, and
2
+ merge `report_event` counts into the tool result `_meta`."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import inspect
7
+ from collections.abc import Callable
8
+ from typing import Any
9
+
10
+ from fastmcp.exceptions import ToolError
11
+ from fastmcp.server.dependencies import get_http_headers
12
+ from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
13
+ from fastmcp.tools.base import ToolResult
14
+ from mcp.shared.exceptions import MCPError
15
+
16
+ from marketplace_sdk.context import BILLING_META_KEY, begin_call, end_call, gateway_claims
17
+ from marketplace_sdk.verifier import GatewayVerifier, VerificationError
18
+
19
+ UNAUTHORIZED = -32001
20
+ ERASE_TOOL = "kade_erase_subject"
21
+ EXPORT_TOOL = "kade_export_subject"
22
+ DATA_SCOPES = {ERASE_TOOL: "erase", EXPORT_TOOL: "export"}
23
+
24
+
25
+ class MarketplaceMiddleware(Middleware):
26
+ def __init__(self, verifier: GatewayVerifier, *, allow_unauthenticated_local: bool = False):
27
+ self.verifier = verifier
28
+ self.allow_unauthenticated_local = allow_unauthenticated_local
29
+
30
+ def _claims(self): # noqa: ANN202
31
+ headers = get_http_headers(include_all=True)
32
+ auth = headers.get("authorization")
33
+ if not headers: # in-process (stdio/memory) — no HTTP request at all
34
+ if self.allow_unauthenticated_local:
35
+ return None
36
+ raise MCPError(code=UNAUTHORIZED, message="gateway token required")
37
+ if auth is None and self.allow_unauthenticated_local:
38
+ return None
39
+ try:
40
+ return self.verifier.verify_header(auth)
41
+ except VerificationError as exc:
42
+ raise MCPError(code=UNAUTHORIZED, message=f"invalid gateway token: {exc}") from exc
43
+
44
+ async def on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any:
45
+ if context.method in ("tools/call",):
46
+ return await call_next(context) # verified in on_call_tool (needs tool name)
47
+ if context.method not in ("initialize", "ping", "server/discover"):
48
+ self._claims()
49
+ return await call_next(context)
50
+
51
+ async def on_call_tool(self, context: MiddlewareContext, call_next: CallNext) -> ToolResult:
52
+ claims = self._claims()
53
+ if claims is not None:
54
+ if claims.scope == "register":
55
+ raise MCPError(code=UNAUTHORIZED, message="register scope cannot call tools")
56
+ required = DATA_SCOPES.get(context.message.name)
57
+ if required and claims.scope != required:
58
+ raise MCPError(
59
+ code=UNAUTHORIZED, message=f"{context.message.name} needs {required}"
60
+ )
61
+ if not required and claims.scope in ("erase", "export"):
62
+ raise MCPError(code=UNAUTHORIZED, message=f"{claims.scope} scope cannot call tools")
63
+ if claims.tool and claims.tool != context.message.name:
64
+ raise MCPError(code=UNAUTHORIZED, message="token was issued for another tool")
65
+ tokens = begin_call(claims)
66
+ try:
67
+ result = await call_next(context)
68
+ finally:
69
+ events = end_call(tokens)
70
+ if not events or result.is_error:
71
+ return result
72
+ meta = dict(result.meta or {})
73
+ meta[BILLING_META_KEY] = {
74
+ "events": [{"name": n, "count": c} for n, c in events.items() if c]
75
+ }
76
+ return ToolResult(
77
+ content=result.content,
78
+ structured_content=result.structured_content,
79
+ meta=meta,
80
+ is_error=result.is_error,
81
+ )
82
+
83
+
84
+ DataFn = Callable[[str], Any]
85
+
86
+
87
+ async def _maybe_await(value: Any) -> Any:
88
+ return await value if inspect.isawaitable(value) else value
89
+
90
+
91
+ def _subject(scope: str) -> str:
92
+ claims = gateway_claims()
93
+ if claims is None or claims.scope != scope:
94
+ raise ToolError(f"a gateway token with scope={scope} is required")
95
+ return claims.sub
96
+
97
+
98
+ def add_data_subject_tools(
99
+ mcp: Any,
100
+ *,
101
+ erase: DataFn | None = None,
102
+ export: DataFn | None = None,
103
+ stores_no_user_data: bool = False,
104
+ ) -> None:
105
+ """Register Kade's reserved data-subject tools (required to publish).
106
+
107
+ - `erase(subject) -> bool`: delete everything stored for this pseudonymous subject; return
108
+ True if anything was deleted.
109
+ - `export(subject) -> list[dict]`: every record stored for this subject (JSON-serialisable).
110
+ - `stores_no_user_data=True`: the agent keeps nothing per user; both tools answer accordingly.
111
+
112
+ The subject always comes from the verified gateway token, never from arguments. Kade strips
113
+ these tools from your listing; consumers never see or call them.
114
+ """
115
+ if stores_no_user_data:
116
+ if erase or export:
117
+ raise ValueError("pass erase/export or stores_no_user_data=True, not both")
118
+ elif erase is None or export is None:
119
+ raise ValueError("provide both erase and export, or stores_no_user_data=True")
120
+
121
+ @mcp.tool(name=ERASE_TOOL, description="Kade data-subject erasure (platform only).")
122
+ async def kade_erase_subject(request_id: str) -> dict:
123
+ subject = _subject("erase")
124
+ deleted = False if stores_no_user_data else bool(await _maybe_await(erase(subject)))
125
+ return {"subject": subject, "status": "erased" if deleted else "nothing_stored"}
126
+
127
+ @mcp.tool(name=EXPORT_TOOL, description="Kade data-subject export (platform only).")
128
+ async def kade_export_subject(request_id: str) -> dict:
129
+ subject = _subject("export")
130
+ records = [] if stores_no_user_data else list(await _maybe_await(export(subject)))
131
+ return {"subject": subject, "records": records}
@@ -0,0 +1,122 @@
1
+ """Gateway JWT verification: EdDSA (Ed25519), JWKS cached for 1 h, `kid` rotation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+ import httpx
12
+ import jwt
13
+ from jwt.algorithms import OKPAlgorithm
14
+
15
+ ALLOWED_SCOPES = frozenset({"call", "register", "verify", "erase", "export"})
16
+
17
+
18
+ class VerificationError(Exception):
19
+ pass
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class GatewayClaims:
24
+ sub: str
25
+ scope: str
26
+ agent_id: str
27
+ run_id: str | None = None
28
+ tool: str | None = None
29
+ max_cost_micros: int | None = None
30
+ events: list[dict[str, Any]] = field(default_factory=list)
31
+ test: bool = False
32
+ raw: dict[str, Any] = field(default_factory=dict)
33
+
34
+
35
+ class GatewayVerifier:
36
+ def __init__(
37
+ self,
38
+ *,
39
+ jwks_url: str,
40
+ issuer: str,
41
+ agent_id: str,
42
+ cache_seconds: int = 3600,
43
+ leeway_seconds: int = 30,
44
+ http_client: httpx.Client | None = None,
45
+ allowed_scopes: frozenset[str] = ALLOWED_SCOPES,
46
+ ) -> None:
47
+ self.jwks_url = jwks_url
48
+ self.issuer = issuer.rstrip("/")
49
+ self.agent_id = agent_id
50
+ self.cache_seconds = cache_seconds
51
+ self.leeway = leeway_seconds
52
+ self.allowed_scopes = allowed_scopes
53
+ self._http = http_client
54
+ self._keys: dict[str, Any] = {}
55
+ self._fetched_at = 0.0
56
+ self._lock = threading.Lock()
57
+
58
+ # ------------------------------------------------------------ JWKS
59
+ def _fetch(self) -> None:
60
+ client = self._http or httpx.Client(timeout=10)
61
+ try:
62
+ resp = client.get(self.jwks_url)
63
+ resp.raise_for_status()
64
+ keys = {}
65
+ for jwk in resp.json().get("keys", []):
66
+ if jwk.get("kty") == "OKP" and jwk.get("crv") == "Ed25519" and jwk.get("kid"):
67
+ keys[jwk["kid"]] = OKPAlgorithm.from_jwk(json.dumps(jwk))
68
+ finally:
69
+ if self._http is None:
70
+ client.close()
71
+ self._keys = keys
72
+ self._fetched_at = time.monotonic()
73
+
74
+ def _key(self, kid: str) -> Any:
75
+ with self._lock:
76
+ stale = time.monotonic() - self._fetched_at > self.cache_seconds
77
+ if stale or (kid not in self._keys and time.monotonic() - self._fetched_at > 10):
78
+ self._fetch()
79
+ key = self._keys.get(kid)
80
+ if key is None:
81
+ raise VerificationError(f"unknown signing key {kid!r}")
82
+ return key
83
+
84
+ # ------------------------------------------------------------ verify
85
+ def verify(self, token: str) -> GatewayClaims:
86
+ """Verify signature, issuer, audience (`agent:<agent_id>`), expiry and scope."""
87
+ if not token:
88
+ raise VerificationError("missing gateway token")
89
+ try:
90
+ header = jwt.get_unverified_header(token)
91
+ if header.get("alg") != "EdDSA":
92
+ raise VerificationError("unexpected algorithm")
93
+ claims = jwt.decode(
94
+ token,
95
+ self._key(str(header.get("kid"))),
96
+ algorithms=["EdDSA"],
97
+ audience=f"agent:{self.agent_id}",
98
+ issuer=self.issuer,
99
+ leeway=self.leeway,
100
+ options={"require": ["exp", "iat", "aud", "iss", "sub"]},
101
+ )
102
+ except jwt.PyJWTError as exc:
103
+ raise VerificationError(str(exc)) from exc
104
+ scope = claims.get("scope")
105
+ if scope not in self.allowed_scopes:
106
+ raise VerificationError(f"scope {scope!r} not allowed")
107
+ return GatewayClaims(
108
+ sub=claims["sub"],
109
+ scope=scope,
110
+ agent_id=self.agent_id,
111
+ run_id=claims.get("run_id"),
112
+ tool=claims.get("tool"),
113
+ max_cost_micros=claims.get("max_cost_micros"),
114
+ events=list(claims.get("events") or []),
115
+ test=bool(claims.get("test", False)),
116
+ raw=claims,
117
+ )
118
+
119
+ def verify_header(self, authorization: str | None) -> GatewayClaims:
120
+ if not authorization or not authorization.lower().startswith("bearer "):
121
+ raise VerificationError("missing bearer token")
122
+ return self.verify(authorization[7:].strip())