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.
- kade_agent_sdk-0.1.0/.gitignore +15 -0
- kade_agent_sdk-0.1.0/LICENSE +93 -0
- kade_agent_sdk-0.1.0/PKG-INFO +73 -0
- kade_agent_sdk-0.1.0/README.md +53 -0
- kade_agent_sdk-0.1.0/pyproject.toml +33 -0
- kade_agent_sdk-0.1.0/src/marketplace_sdk/__init__.py +18 -0
- kade_agent_sdk-0.1.0/src/marketplace_sdk/asgi.py +44 -0
- kade_agent_sdk-0.1.0/src/marketplace_sdk/context.py +57 -0
- kade_agent_sdk-0.1.0/src/marketplace_sdk/fastmcp.py +131 -0
- kade_agent_sdk-0.1.0/src/marketplace_sdk/verifier.py +122 -0
|
@@ -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())
|