goassay 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.
- goassay-0.1.0/.gitignore +13 -0
- goassay-0.1.0/CHANGELOG.md +20 -0
- goassay-0.1.0/LICENSE +21 -0
- goassay-0.1.0/PKG-INFO +275 -0
- goassay-0.1.0/README.md +242 -0
- goassay-0.1.0/pyproject.toml +59 -0
- goassay-0.1.0/src/goassay/__init__.py +69 -0
- goassay-0.1.0/src/goassay/_binding.py +150 -0
- goassay-0.1.0/src/goassay/_caip.py +41 -0
- goassay-0.1.0/src/goassay/_chain.py +162 -0
- goassay-0.1.0/src/goassay/_client.py +370 -0
- goassay-0.1.0/src/goassay/_clock.py +27 -0
- goassay-0.1.0/src/goassay/_context.py +62 -0
- goassay-0.1.0/src/goassay/_eip155.py +424 -0
- goassay-0.1.0/src/goassay/_errors.py +9 -0
- goassay-0.1.0/src/goassay/_guards.py +384 -0
- goassay-0.1.0/src/goassay/_http.py +154 -0
- goassay-0.1.0/src/goassay/_mcp_url.py +107 -0
- goassay-0.1.0/src/goassay/_meta.py +81 -0
- goassay-0.1.0/src/goassay/_networks.py +57 -0
- goassay-0.1.0/src/goassay/_ops.py +57 -0
- goassay-0.1.0/src/goassay/_paid_calls.py +227 -0
- goassay-0.1.0/src/goassay/_receipts.py +43 -0
- goassay-0.1.0/src/goassay/_tools.py +429 -0
- goassay-0.1.0/src/goassay/_voucher.py +172 -0
- goassay-0.1.0/src/goassay/_wire.py +130 -0
- goassay-0.1.0/src/goassay/errors.py +210 -0
- goassay-0.1.0/src/goassay/py.typed +0 -0
- goassay-0.1.0/src/goassay/signers.py +72 -0
goassay-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `goassay` are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the package uses [Semantic Versioning](https://semver.org/); before 1.0 a minor version may change the API.
|
|
4
|
+
|
|
5
|
+
## 0.1.0 - 2026-10-22
|
|
6
|
+
|
|
7
|
+
First public release.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `Assay` and `AsyncAssay` clients: API key from the `api_key` argument or `ASSAY_API_KEY`; Python 3.10 or newer, fully typed (`py.typed`).
|
|
12
|
+
- Tools over MCP: `tools.search`, `tools.get`, `tools.list`, `tools.call`, with the receipt of each call.
|
|
13
|
+
- MCP URLs: `mcp_url` takes a full URL or a path on `https://mcp.goassay.ai` (`/`, `/my/{slug}`, `/@{handle}/{slug}`, `/u/{token}`, `/c/{category}`, `/@{handle}`, with `?tools=`, `?category=`, `?free=1`, `?discovery=` filters), checked when the client is built; `assay.at()` returns a sibling client that shares the key, signers, limits and ledger. Link-only tokens are redacted in `repr` and logs.
|
|
14
|
+
- Paid calls signed by a signer you pass (`evm_signer(eth_account)` or your own object, for example KMS-backed); the SDK never stores keys.
|
|
15
|
+
- Local spend limits per signing key (defaults $0.50 per call, $5 per rolling 24 hours) and an `on_payment` hook that can refuse a payment before it is signed.
|
|
16
|
+
- Client-held network table (`networks`, `rpc_urls`): payments are checked against it, never against what the server says. The built-in table pins Robinhood Chain testnet (`eip155:46630`).
|
|
17
|
+
- Payment-key setup: `paid_calls.status`, `paid_calls.bind`, `paid_calls.wait_until_active`, including rebinding a new key that waits for approval.
|
|
18
|
+
- Receipts: `receipts.get`.
|
|
19
|
+
- Safe retries: every call carries an idempotency key (yours or generated), returned on every error as `err.idempotency_key`.
|
|
20
|
+
- Typed errors under `AssayError` (`AuthError`, `ScopeMissingError`, `McpUrlNotFoundError`, `PaymentRefusedError`, `PaymentNetworkUnknownError`, `DelegateAlreadyBoundError`, `DelegateBindConflictError`, `RateLimitError`, `TimeoutError` and others), each with `retryable`.
|
goassay-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Assay
|
|
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.
|
goassay-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: goassay
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Call tools through Assay over MCP, with free and paid calls signed by your own key.
|
|
5
|
+
Project-URL: Homepage, https://docs.goassay.ai/developers
|
|
6
|
+
Project-URL: Source, https://github.com/assay-ai/assay-sdk/tree/main/python
|
|
7
|
+
Project-URL: Issues, https://github.com/assay-ai/assay-sdk/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/assay-ai/assay-sdk/blob/main/python/CHANGELOG.md
|
|
9
|
+
Project-URL: Documentation, https://docs.goassay.ai/developers
|
|
10
|
+
Author: Assay
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: ai-agents,assay,evm,mcp,payments,tools
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: eth-account<0.15,>=0.13
|
|
27
|
+
Requires-Dist: httpx<1,>=0.27
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: mypy==2.4.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest==9.1.1; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff==0.16.10; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# goassay
|
|
35
|
+
|
|
36
|
+
The Python SDK for [Assay](https://goassay.ai): call tools over MCP, with free calls and paid calls signed by a key you hold.
|
|
37
|
+
|
|
38
|
+
**Status: early release (0.x).** The API can still change before 1.0; see the [changelog](https://github.com/assay-ai/assay-sdk/blob/main/python/CHANGELOG.md).
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
pip install goassay
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Requires Python 3.10 or newer. Typed (`py.typed`). Documentation: [docs.goassay.ai/developers](https://docs.goassay.ai/developers).
|
|
45
|
+
|
|
46
|
+
The client reads your API key from `api_key=` or the `ASSAY_API_KEY` environment variable. What the key may do is set on the key, as scopes: `tools:call` for calls, `payments` for paid calls and binding a key, `activity:read` for receipts.
|
|
47
|
+
|
|
48
|
+
There is a sync client, `Assay`, and an async one, `AsyncAssay`, with the same methods.
|
|
49
|
+
|
|
50
|
+
## Free call
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from goassay import Assay
|
|
54
|
+
|
|
55
|
+
with Assay() as assay: # ASSAY_API_KEY
|
|
56
|
+
found = assay.tools.search("weather forecast", limit=5)
|
|
57
|
+
tool = assay.tools.get("weather_forecast") # schemas, price
|
|
58
|
+
res = assay.tools.call("weather_forecast", {"city": "Lisbon"})
|
|
59
|
+
|
|
60
|
+
res.structured_content # the tool's output
|
|
61
|
+
res.is_error # True only for the provider's own tool error
|
|
62
|
+
res.receipt # what the call cost
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Choose a URL
|
|
66
|
+
|
|
67
|
+
A client calls tools through one MCP URL. `mcp_url` takes a full URL or a path on `https://mcp.goassay.ai`; the path is checked against the shapes Assay serves when the client is built, so a typo is a `ConfigError` at once. `assay.at(url_or_path)` (also on `AsyncAssay`) returns a client for another URL that shares this one's API key, signers, spend limits and ledger, `on_payment`, HTTP client and logger, and keeps its own quote cache. Close the client it came from; closing a sibling does nothing.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from goassay import Assay
|
|
71
|
+
|
|
72
|
+
everything = Assay() # "/": the whole catalogue, found with tools.search
|
|
73
|
+
research = Assay(mcp_url="/my/research") # your tool set (Draft; "/my/research@2" for version 2)
|
|
74
|
+
weather = Assay(mcp_url="/?tools=get_weather") # a single tool
|
|
75
|
+
|
|
76
|
+
# Two URLs in one program: one key, one set of limits.
|
|
77
|
+
kit = everything.at("/@acme/twitter-kit@3") # a public tool set, version 3
|
|
78
|
+
listed = kit.tools.list() # tools/list: the set's tools, with prices
|
|
79
|
+
kit.tools.call("acme__get_profile", {"user": "x"}) # a provider tool: {handle}__{tool}, sent unchanged
|
|
80
|
+
everything.tools.call("get_weather", {"city": "Lisbon"})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
| Path | Serves |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `/` | Everything (default) |
|
|
86
|
+
| `/my/{slug}`, `/my/{slug}@{n}` | your tool set: the Draft, or version `n` |
|
|
87
|
+
| `/@{handle}/{slug}`, `/@{handle}/{slug}@{n}` | a public tool set |
|
|
88
|
+
| `/u/{token}`, `/u/{token}@{n}` | a link-only tool set; the token is a secret, shown as `/u/…` in `repr` and logs |
|
|
89
|
+
| `/c/{category}`, `/@{handle}` | one topic, one maker |
|
|
90
|
+
|
|
91
|
+
Filters on any path: `?tools=a,b` (one name gives a single-tool URL; `name@1` pins a contract major), `?category=x`, `?free=1`, `?discovery=auto|full|search`. A URL the server does not serve to your key raises `McpUrlNotFoundError`.
|
|
92
|
+
|
|
93
|
+
Paid calls go through whatever URL you choose: the SDK keeps no per-URL paid list. To keep a client free-only, use a key without the `payments` scope, add `?free=1` to its URL, or refuse in `on_payment`. A tool set grant that allows its tools only through its own URL needs that URL; on `/` those tools are `TOOL_NOT_ALLOWED`.
|
|
94
|
+
|
|
95
|
+
## Paid call with a local key
|
|
96
|
+
|
|
97
|
+
The SDK never stores or creates keys. You pass a signer; for a local key, wrap an eth-account account:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
import os
|
|
101
|
+
|
|
102
|
+
from eth_account import Account
|
|
103
|
+
from goassay import Assay, evm_signer
|
|
104
|
+
|
|
105
|
+
assay = Assay(
|
|
106
|
+
signer=evm_signer(Account.from_key(os.environ["PAYMENT_KEY"])),
|
|
107
|
+
limits={"max_per_call": "0.50", "max_per_day": "5"}, # dollars; see "Spend limits"
|
|
108
|
+
on_payment=lambda p: int(p["amount"]["amount"]) <= 100_000, # your own rule; False refuses
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
res = assay.tools.call("weather_forecast", {"city": "Lisbon"})
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
On a paid tool the server answers `PAYMENT_REQUIRED`. The SDK then checks the requirement with its local guards (G1 to G12, including the chain reads of your key's grant), calls `on_payment`, signs one voucher, and sends the same call again with the same idempotency key. Network retries resend the same request; a voucher is never signed twice for one logical call, even when two threads or tasks start the same call at once.
|
|
115
|
+
|
|
116
|
+
A signer is any object with `namespace = "eip155"`, `address` and `sign_typed_data(typed_data)`, so a KMS or HSM key works the same way. With `AsyncAssay`, `sign_typed_data` and `on_payment` may be async. The object `evm_signer()` returns cannot be changed in place; to wrap signing (for logging or counting), build a new signer object that calls it:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
class LoggingSigner:
|
|
120
|
+
namespace = "eip155"
|
|
121
|
+
|
|
122
|
+
def __init__(self, base):
|
|
123
|
+
self.base, self.address = base, base.address
|
|
124
|
+
|
|
125
|
+
def sign_typed_data(self, typed_data):
|
|
126
|
+
print("signing", typed_data["primaryType"])
|
|
127
|
+
return self.base.sign_typed_data(typed_data)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Retrying safely
|
|
131
|
+
|
|
132
|
+
Every call has an idempotency key: yours (`idempotency_key=`) or one the SDK creates. Every `AssayError` raised by `tools.call` carries it as `err.idempotency_key`. Calling again with that key is safe: Assay replays a call it already served and does not charge twice.
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
from goassay import errors
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
res = assay.tools.call("weather_forecast", {"city": "Lisbon"})
|
|
139
|
+
except errors.AssayError as e:
|
|
140
|
+
if not (e.retryable and e.idempotency_key):
|
|
141
|
+
raise
|
|
142
|
+
res = assay.tools.call("weather_forecast", {"city": "Lisbon"}, idempotency_key=e.idempotency_key)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
For work you may redo after a restart, pass your own key (for example `f"order-{order_id}-weather"`).
|
|
146
|
+
|
|
147
|
+
### Spend limits
|
|
148
|
+
|
|
149
|
+
With a signer, every voucher is checked against local limits before it is signed:
|
|
150
|
+
|
|
151
|
+
- **Defaults: $0.50 per call and $5 per rolling 24 hours**, per signing key.
|
|
152
|
+
- Change them with `limits={"max_per_call": ..., "max_per_day": ...}` (dollars, as strings).
|
|
153
|
+
- The record is in memory, per process, and safe across threads: a restart starts again at zero, and several processes each have their own. The on-chain caps that the payer set when approving the key bound it across processes.
|
|
154
|
+
|
|
155
|
+
`on_payment(details)` sees each payment (tool, network, amount, provider, payer, idempotency key) after every check passed and before it is signed. Use it for your own rules, for example an allow-list of providers:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
allowed = {"eip155:<chainId>:<provider address>"}
|
|
159
|
+
assay = Assay(signer=signer, on_payment=lambda p: p["provider"] in allowed)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Answer quickly: the voucher's lifetime (at most 240 s) starts before the hook runs.
|
|
163
|
+
|
|
164
|
+
### Networks
|
|
165
|
+
|
|
166
|
+
Payments are checked against a network table the client holds (escrow, asset, decimals and RPC URLs per network), never against what the server says, so a compromised API cannot redirect a payment.
|
|
167
|
+
|
|
168
|
+
- The SDK ships a built-in table, pinned in the release; an address change ships as a new SDK release. It holds Robinhood Chain testnet (`eip155:46630`), the network Assay's production runs on today: the USDG escrow `0x2723…Ce18`, 6 decimals, and two public RPCs from different operators. A paid call or a bind on a network that is in no table is refused with `PaymentNetworkUnknownError`, which names the network.
|
|
169
|
+
- `networks=` configures any chain yourself: a test chain, or another deployment together with its own `mcp_url`, `api_url` and `web_url`. Your entries are merged over the built-in table. A new network is added; an entry for a built-in network replaces it whole, so fields are never mixed from two deployments.
|
|
170
|
+
- `rpc_urls={"eip155:<chainId>": ["https://your-rpc"]}` replaces only a network's RPC URLs, for example with your private RPC.
|
|
171
|
+
|
|
172
|
+
Addresses must be checksummed CAIP-10 / CAIP-19 ids on the network's chain. RPC URLs must be https (plain http only to localhost).
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
assay = Assay(
|
|
176
|
+
mcp_url="https://mcp.your-deployment.example/",
|
|
177
|
+
api_url="https://api.your-deployment.example/api/v1",
|
|
178
|
+
web_url="https://portal.your-deployment.example",
|
|
179
|
+
signer=signer,
|
|
180
|
+
networks={
|
|
181
|
+
"eip155:<chainId>": {
|
|
182
|
+
# "chainId": optional; when given it must be the id in the key
|
|
183
|
+
"escrow": "eip155:<chainId>:<escrow contract address>",
|
|
184
|
+
"asset": "eip155:<chainId>/erc20:<token address>",
|
|
185
|
+
"decimals": 6,
|
|
186
|
+
"rpcUrls": ["https://<rpc 1>", "https://<rpc 2>"],
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Binding the key (once)
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
status = assay.paid_calls.status()
|
|
196
|
+
if status.state != "active":
|
|
197
|
+
setup = assay.paid_calls.bind() # challenge, field check, sign, bind
|
|
198
|
+
print(f"Approve at {setup.approval_url}; the page must show {setup.fingerprint}")
|
|
199
|
+
assay.paid_calls.wait_until_active(timeout=15 * 60)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
If another key is already bound to the API key, `bind()` answers `bind_status == "PENDING_APPROVAL"`: the old key stays `delegate` and keeps signing, this key is `pending_delegate`, and it replaces the old one only when the person approves it. `wait_until_active()` returns once `delegate` is this client's key and its paid calls are on. A key that is bound to another API key is refused with `DelegateAlreadyBoundError` (409). If another key wins a first bind at the same moment, `bind()` raises `DelegateBindConflictError` (409, `retryable`): nothing was bound, and calling `bind()` once more parks this key as `pending_delegate` (`bind_status == "PENDING_APPROVAL"`) for the person to approve.
|
|
203
|
+
|
|
204
|
+
The approval link is built on the web app's origin (`web_url`, default `https://portal.goassay.ai`). A link the server sends is used only when it is on that same origin. The key's address travels only in the URL fragment. The approval cannot be automated: a person opens the link and compares the fingerprint.
|
|
205
|
+
|
|
206
|
+
## Reading a receipt
|
|
207
|
+
|
|
208
|
+
Only a paid call has a receipt to fetch. A free call's `res.receipt["intentId"]` is `None`: it has no intent, and `res.receipt` already says it cost nothing.
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
intent_id = res.receipt.get("intentId") if res.receipt else None
|
|
212
|
+
if intent_id:
|
|
213
|
+
receipt = assay.receipts.get(intent_id)
|
|
214
|
+
receipt["chargedPrice"] # {"amount": "100000", "asset": "eip155:...", "decimals": 6}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Handling errors
|
|
218
|
+
|
|
219
|
+
Every error is an `AssayError` with a stable `code`. Branch on the class or the code, never on the message. A missing scope is a `ScopeMissingError` naming `required_scope`, on the MCP host and on the API alike; a key without `payments` that calls a paid tool gets a `ScopeMissingError` naming `payments`, and nothing is signed or sent again; a route only a signed-in person may use is a `HumanSessionRequiredError` (an `AuthError`).
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
from goassay import errors
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
assay.tools.call("weather_forecast", {"city": "Lisbon"})
|
|
226
|
+
except errors.ScopeMissingError as e:
|
|
227
|
+
print(f"the API key needs the {e.required_scope} scope")
|
|
228
|
+
except errors.HumanSessionRequiredError:
|
|
229
|
+
print("only a signed-in person can do this")
|
|
230
|
+
except errors.McpUrlNotFoundError:
|
|
231
|
+
print("this MCP URL is not served to this key")
|
|
232
|
+
except errors.PaymentRequiredError:
|
|
233
|
+
print("paid tool: pass a signer")
|
|
234
|
+
except errors.PaymentNetworkUnknownError as e:
|
|
235
|
+
print(f"network {e.network} is not supported yet")
|
|
236
|
+
except errors.PaymentRefusedError as e:
|
|
237
|
+
print(f"refused before signing by {e.guard}: {e.code}")
|
|
238
|
+
except errors.RateLimitError as e:
|
|
239
|
+
print(f"retry in {e.retry_after} s")
|
|
240
|
+
except errors.AssayError as e:
|
|
241
|
+
print(e.code, e.message)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## Options
|
|
245
|
+
|
|
246
|
+
| Option | Default |
|
|
247
|
+
|---|---|
|
|
248
|
+
| `api_key` | `ASSAY_API_KEY` |
|
|
249
|
+
| `mcp_url`, `api_url`, `web_url` | `https://mcp.goassay.ai/` (a URL or a path; see "Choose a URL"), `https://api.goassay.ai/api/v1`, `https://portal.goassay.ai` |
|
|
250
|
+
| `signer` / `signers` | none (free calls only) |
|
|
251
|
+
| `limits` | `{"max_per_call": "0.50", "max_per_day": "5"}` |
|
|
252
|
+
| `rpc_urls` | the table's public RPC URLs |
|
|
253
|
+
| `networks` | the built-in table; yours are merged over it |
|
|
254
|
+
| `on_payment` | none |
|
|
255
|
+
| `http_client` | a new `httpx.Client` (`httpx.AsyncClient` for `AsyncAssay`) |
|
|
256
|
+
| `timeout` | 120 s per attempt (httpx applies it to each of connect, read, write and pool) |
|
|
257
|
+
| `max_retries` | 2, with backoff, honouring `Retry-After` |
|
|
258
|
+
| `logger` | none; never logs keys, headers, bodies or signatures |
|
|
259
|
+
| `now`, `sleep` | the system clock (corrected by the server's `Date` for vouchers), `time.sleep` / `asyncio.sleep` |
|
|
260
|
+
|
|
261
|
+
## Development
|
|
262
|
+
|
|
263
|
+
```sh
|
|
264
|
+
python -m venv .venv && . .venv/bin/activate
|
|
265
|
+
pip install -e '.[dev]'
|
|
266
|
+
ruff check . && ruff format --check .
|
|
267
|
+
mypy
|
|
268
|
+
pytest
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
The tests run the shared vectors in [`vectors/`](https://github.com/assay-ai/assay-sdk/tree/main/vectors) (`vectors/eip155/`) and unit tests on a fake transport.
|
|
272
|
+
|
|
273
|
+
## License
|
|
274
|
+
|
|
275
|
+
MIT, see [LICENSE](https://github.com/assay-ai/assay-sdk/blob/main/python/LICENSE).
|
goassay-0.1.0/README.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# goassay
|
|
2
|
+
|
|
3
|
+
The Python SDK for [Assay](https://goassay.ai): call tools over MCP, with free calls and paid calls signed by a key you hold.
|
|
4
|
+
|
|
5
|
+
**Status: early release (0.x).** The API can still change before 1.0; see the [changelog](https://github.com/assay-ai/assay-sdk/blob/main/python/CHANGELOG.md).
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install goassay
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Python 3.10 or newer. Typed (`py.typed`). Documentation: [docs.goassay.ai/developers](https://docs.goassay.ai/developers).
|
|
12
|
+
|
|
13
|
+
The client reads your API key from `api_key=` or the `ASSAY_API_KEY` environment variable. What the key may do is set on the key, as scopes: `tools:call` for calls, `payments` for paid calls and binding a key, `activity:read` for receipts.
|
|
14
|
+
|
|
15
|
+
There is a sync client, `Assay`, and an async one, `AsyncAssay`, with the same methods.
|
|
16
|
+
|
|
17
|
+
## Free call
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from goassay import Assay
|
|
21
|
+
|
|
22
|
+
with Assay() as assay: # ASSAY_API_KEY
|
|
23
|
+
found = assay.tools.search("weather forecast", limit=5)
|
|
24
|
+
tool = assay.tools.get("weather_forecast") # schemas, price
|
|
25
|
+
res = assay.tools.call("weather_forecast", {"city": "Lisbon"})
|
|
26
|
+
|
|
27
|
+
res.structured_content # the tool's output
|
|
28
|
+
res.is_error # True only for the provider's own tool error
|
|
29
|
+
res.receipt # what the call cost
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Choose a URL
|
|
33
|
+
|
|
34
|
+
A client calls tools through one MCP URL. `mcp_url` takes a full URL or a path on `https://mcp.goassay.ai`; the path is checked against the shapes Assay serves when the client is built, so a typo is a `ConfigError` at once. `assay.at(url_or_path)` (also on `AsyncAssay`) returns a client for another URL that shares this one's API key, signers, spend limits and ledger, `on_payment`, HTTP client and logger, and keeps its own quote cache. Close the client it came from; closing a sibling does nothing.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from goassay import Assay
|
|
38
|
+
|
|
39
|
+
everything = Assay() # "/": the whole catalogue, found with tools.search
|
|
40
|
+
research = Assay(mcp_url="/my/research") # your tool set (Draft; "/my/research@2" for version 2)
|
|
41
|
+
weather = Assay(mcp_url="/?tools=get_weather") # a single tool
|
|
42
|
+
|
|
43
|
+
# Two URLs in one program: one key, one set of limits.
|
|
44
|
+
kit = everything.at("/@acme/twitter-kit@3") # a public tool set, version 3
|
|
45
|
+
listed = kit.tools.list() # tools/list: the set's tools, with prices
|
|
46
|
+
kit.tools.call("acme__get_profile", {"user": "x"}) # a provider tool: {handle}__{tool}, sent unchanged
|
|
47
|
+
everything.tools.call("get_weather", {"city": "Lisbon"})
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
| Path | Serves |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `/` | Everything (default) |
|
|
53
|
+
| `/my/{slug}`, `/my/{slug}@{n}` | your tool set: the Draft, or version `n` |
|
|
54
|
+
| `/@{handle}/{slug}`, `/@{handle}/{slug}@{n}` | a public tool set |
|
|
55
|
+
| `/u/{token}`, `/u/{token}@{n}` | a link-only tool set; the token is a secret, shown as `/u/…` in `repr` and logs |
|
|
56
|
+
| `/c/{category}`, `/@{handle}` | one topic, one maker |
|
|
57
|
+
|
|
58
|
+
Filters on any path: `?tools=a,b` (one name gives a single-tool URL; `name@1` pins a contract major), `?category=x`, `?free=1`, `?discovery=auto|full|search`. A URL the server does not serve to your key raises `McpUrlNotFoundError`.
|
|
59
|
+
|
|
60
|
+
Paid calls go through whatever URL you choose: the SDK keeps no per-URL paid list. To keep a client free-only, use a key without the `payments` scope, add `?free=1` to its URL, or refuse in `on_payment`. A tool set grant that allows its tools only through its own URL needs that URL; on `/` those tools are `TOOL_NOT_ALLOWED`.
|
|
61
|
+
|
|
62
|
+
## Paid call with a local key
|
|
63
|
+
|
|
64
|
+
The SDK never stores or creates keys. You pass a signer; for a local key, wrap an eth-account account:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import os
|
|
68
|
+
|
|
69
|
+
from eth_account import Account
|
|
70
|
+
from goassay import Assay, evm_signer
|
|
71
|
+
|
|
72
|
+
assay = Assay(
|
|
73
|
+
signer=evm_signer(Account.from_key(os.environ["PAYMENT_KEY"])),
|
|
74
|
+
limits={"max_per_call": "0.50", "max_per_day": "5"}, # dollars; see "Spend limits"
|
|
75
|
+
on_payment=lambda p: int(p["amount"]["amount"]) <= 100_000, # your own rule; False refuses
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
res = assay.tools.call("weather_forecast", {"city": "Lisbon"})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
On a paid tool the server answers `PAYMENT_REQUIRED`. The SDK then checks the requirement with its local guards (G1 to G12, including the chain reads of your key's grant), calls `on_payment`, signs one voucher, and sends the same call again with the same idempotency key. Network retries resend the same request; a voucher is never signed twice for one logical call, even when two threads or tasks start the same call at once.
|
|
82
|
+
|
|
83
|
+
A signer is any object with `namespace = "eip155"`, `address` and `sign_typed_data(typed_data)`, so a KMS or HSM key works the same way. With `AsyncAssay`, `sign_typed_data` and `on_payment` may be async. The object `evm_signer()` returns cannot be changed in place; to wrap signing (for logging or counting), build a new signer object that calls it:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
class LoggingSigner:
|
|
87
|
+
namespace = "eip155"
|
|
88
|
+
|
|
89
|
+
def __init__(self, base):
|
|
90
|
+
self.base, self.address = base, base.address
|
|
91
|
+
|
|
92
|
+
def sign_typed_data(self, typed_data):
|
|
93
|
+
print("signing", typed_data["primaryType"])
|
|
94
|
+
return self.base.sign_typed_data(typed_data)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Retrying safely
|
|
98
|
+
|
|
99
|
+
Every call has an idempotency key: yours (`idempotency_key=`) or one the SDK creates. Every `AssayError` raised by `tools.call` carries it as `err.idempotency_key`. Calling again with that key is safe: Assay replays a call it already served and does not charge twice.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from goassay import errors
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
res = assay.tools.call("weather_forecast", {"city": "Lisbon"})
|
|
106
|
+
except errors.AssayError as e:
|
|
107
|
+
if not (e.retryable and e.idempotency_key):
|
|
108
|
+
raise
|
|
109
|
+
res = assay.tools.call("weather_forecast", {"city": "Lisbon"}, idempotency_key=e.idempotency_key)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
For work you may redo after a restart, pass your own key (for example `f"order-{order_id}-weather"`).
|
|
113
|
+
|
|
114
|
+
### Spend limits
|
|
115
|
+
|
|
116
|
+
With a signer, every voucher is checked against local limits before it is signed:
|
|
117
|
+
|
|
118
|
+
- **Defaults: $0.50 per call and $5 per rolling 24 hours**, per signing key.
|
|
119
|
+
- Change them with `limits={"max_per_call": ..., "max_per_day": ...}` (dollars, as strings).
|
|
120
|
+
- The record is in memory, per process, and safe across threads: a restart starts again at zero, and several processes each have their own. The on-chain caps that the payer set when approving the key bound it across processes.
|
|
121
|
+
|
|
122
|
+
`on_payment(details)` sees each payment (tool, network, amount, provider, payer, idempotency key) after every check passed and before it is signed. Use it for your own rules, for example an allow-list of providers:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
allowed = {"eip155:<chainId>:<provider address>"}
|
|
126
|
+
assay = Assay(signer=signer, on_payment=lambda p: p["provider"] in allowed)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Answer quickly: the voucher's lifetime (at most 240 s) starts before the hook runs.
|
|
130
|
+
|
|
131
|
+
### Networks
|
|
132
|
+
|
|
133
|
+
Payments are checked against a network table the client holds (escrow, asset, decimals and RPC URLs per network), never against what the server says, so a compromised API cannot redirect a payment.
|
|
134
|
+
|
|
135
|
+
- The SDK ships a built-in table, pinned in the release; an address change ships as a new SDK release. It holds Robinhood Chain testnet (`eip155:46630`), the network Assay's production runs on today: the USDG escrow `0x2723…Ce18`, 6 decimals, and two public RPCs from different operators. A paid call or a bind on a network that is in no table is refused with `PaymentNetworkUnknownError`, which names the network.
|
|
136
|
+
- `networks=` configures any chain yourself: a test chain, or another deployment together with its own `mcp_url`, `api_url` and `web_url`. Your entries are merged over the built-in table. A new network is added; an entry for a built-in network replaces it whole, so fields are never mixed from two deployments.
|
|
137
|
+
- `rpc_urls={"eip155:<chainId>": ["https://your-rpc"]}` replaces only a network's RPC URLs, for example with your private RPC.
|
|
138
|
+
|
|
139
|
+
Addresses must be checksummed CAIP-10 / CAIP-19 ids on the network's chain. RPC URLs must be https (plain http only to localhost).
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
assay = Assay(
|
|
143
|
+
mcp_url="https://mcp.your-deployment.example/",
|
|
144
|
+
api_url="https://api.your-deployment.example/api/v1",
|
|
145
|
+
web_url="https://portal.your-deployment.example",
|
|
146
|
+
signer=signer,
|
|
147
|
+
networks={
|
|
148
|
+
"eip155:<chainId>": {
|
|
149
|
+
# "chainId": optional; when given it must be the id in the key
|
|
150
|
+
"escrow": "eip155:<chainId>:<escrow contract address>",
|
|
151
|
+
"asset": "eip155:<chainId>/erc20:<token address>",
|
|
152
|
+
"decimals": 6,
|
|
153
|
+
"rpcUrls": ["https://<rpc 1>", "https://<rpc 2>"],
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Binding the key (once)
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
status = assay.paid_calls.status()
|
|
163
|
+
if status.state != "active":
|
|
164
|
+
setup = assay.paid_calls.bind() # challenge, field check, sign, bind
|
|
165
|
+
print(f"Approve at {setup.approval_url}; the page must show {setup.fingerprint}")
|
|
166
|
+
assay.paid_calls.wait_until_active(timeout=15 * 60)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
If another key is already bound to the API key, `bind()` answers `bind_status == "PENDING_APPROVAL"`: the old key stays `delegate` and keeps signing, this key is `pending_delegate`, and it replaces the old one only when the person approves it. `wait_until_active()` returns once `delegate` is this client's key and its paid calls are on. A key that is bound to another API key is refused with `DelegateAlreadyBoundError` (409). If another key wins a first bind at the same moment, `bind()` raises `DelegateBindConflictError` (409, `retryable`): nothing was bound, and calling `bind()` once more parks this key as `pending_delegate` (`bind_status == "PENDING_APPROVAL"`) for the person to approve.
|
|
170
|
+
|
|
171
|
+
The approval link is built on the web app's origin (`web_url`, default `https://portal.goassay.ai`). A link the server sends is used only when it is on that same origin. The key's address travels only in the URL fragment. The approval cannot be automated: a person opens the link and compares the fingerprint.
|
|
172
|
+
|
|
173
|
+
## Reading a receipt
|
|
174
|
+
|
|
175
|
+
Only a paid call has a receipt to fetch. A free call's `res.receipt["intentId"]` is `None`: it has no intent, and `res.receipt` already says it cost nothing.
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
intent_id = res.receipt.get("intentId") if res.receipt else None
|
|
179
|
+
if intent_id:
|
|
180
|
+
receipt = assay.receipts.get(intent_id)
|
|
181
|
+
receipt["chargedPrice"] # {"amount": "100000", "asset": "eip155:...", "decimals": 6}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Handling errors
|
|
185
|
+
|
|
186
|
+
Every error is an `AssayError` with a stable `code`. Branch on the class or the code, never on the message. A missing scope is a `ScopeMissingError` naming `required_scope`, on the MCP host and on the API alike; a key without `payments` that calls a paid tool gets a `ScopeMissingError` naming `payments`, and nothing is signed or sent again; a route only a signed-in person may use is a `HumanSessionRequiredError` (an `AuthError`).
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from goassay import errors
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
assay.tools.call("weather_forecast", {"city": "Lisbon"})
|
|
193
|
+
except errors.ScopeMissingError as e:
|
|
194
|
+
print(f"the API key needs the {e.required_scope} scope")
|
|
195
|
+
except errors.HumanSessionRequiredError:
|
|
196
|
+
print("only a signed-in person can do this")
|
|
197
|
+
except errors.McpUrlNotFoundError:
|
|
198
|
+
print("this MCP URL is not served to this key")
|
|
199
|
+
except errors.PaymentRequiredError:
|
|
200
|
+
print("paid tool: pass a signer")
|
|
201
|
+
except errors.PaymentNetworkUnknownError as e:
|
|
202
|
+
print(f"network {e.network} is not supported yet")
|
|
203
|
+
except errors.PaymentRefusedError as e:
|
|
204
|
+
print(f"refused before signing by {e.guard}: {e.code}")
|
|
205
|
+
except errors.RateLimitError as e:
|
|
206
|
+
print(f"retry in {e.retry_after} s")
|
|
207
|
+
except errors.AssayError as e:
|
|
208
|
+
print(e.code, e.message)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Options
|
|
212
|
+
|
|
213
|
+
| Option | Default |
|
|
214
|
+
|---|---|
|
|
215
|
+
| `api_key` | `ASSAY_API_KEY` |
|
|
216
|
+
| `mcp_url`, `api_url`, `web_url` | `https://mcp.goassay.ai/` (a URL or a path; see "Choose a URL"), `https://api.goassay.ai/api/v1`, `https://portal.goassay.ai` |
|
|
217
|
+
| `signer` / `signers` | none (free calls only) |
|
|
218
|
+
| `limits` | `{"max_per_call": "0.50", "max_per_day": "5"}` |
|
|
219
|
+
| `rpc_urls` | the table's public RPC URLs |
|
|
220
|
+
| `networks` | the built-in table; yours are merged over it |
|
|
221
|
+
| `on_payment` | none |
|
|
222
|
+
| `http_client` | a new `httpx.Client` (`httpx.AsyncClient` for `AsyncAssay`) |
|
|
223
|
+
| `timeout` | 120 s per attempt (httpx applies it to each of connect, read, write and pool) |
|
|
224
|
+
| `max_retries` | 2, with backoff, honouring `Retry-After` |
|
|
225
|
+
| `logger` | none; never logs keys, headers, bodies or signatures |
|
|
226
|
+
| `now`, `sleep` | the system clock (corrected by the server's `Date` for vouchers), `time.sleep` / `asyncio.sleep` |
|
|
227
|
+
|
|
228
|
+
## Development
|
|
229
|
+
|
|
230
|
+
```sh
|
|
231
|
+
python -m venv .venv && . .venv/bin/activate
|
|
232
|
+
pip install -e '.[dev]'
|
|
233
|
+
ruff check . && ruff format --check .
|
|
234
|
+
mypy
|
|
235
|
+
pytest
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The tests run the shared vectors in [`vectors/`](https://github.com/assay-ai/assay-sdk/tree/main/vectors) (`vectors/eip155/`) and unit tests on a fake transport.
|
|
239
|
+
|
|
240
|
+
## License
|
|
241
|
+
|
|
242
|
+
MIT, see [LICENSE](https://github.com/assay-ai/assay-sdk/blob/main/python/LICENSE).
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "goassay"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Call tools through Assay over MCP, with free and paid calls signed by your own key."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "Assay" }]
|
|
14
|
+
keywords = ["assay", "mcp", "ai-agents", "tools", "payments", "evm"]
|
|
15
|
+
dependencies = ["eth-account>=0.13,<0.15", "httpx>=0.27,<1"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = ["pytest==9.1.1", "ruff==0.16.10", "mypy==2.4.0"]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://docs.goassay.ai/developers"
|
|
35
|
+
Source = "https://github.com/assay-ai/assay-sdk/tree/main/python"
|
|
36
|
+
Issues = "https://github.com/assay-ai/assay-sdk/issues"
|
|
37
|
+
Changelog = "https://github.com/assay-ai/assay-sdk/blob/main/python/CHANGELOG.md"
|
|
38
|
+
Documentation = "https://docs.goassay.ai/developers"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/goassay"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.sdist]
|
|
44
|
+
include = ["src/goassay", "README.md", "LICENSE", "CHANGELOG.md", "pyproject.toml"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 120
|
|
51
|
+
target-version = "py310"
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint]
|
|
54
|
+
select = ["E", "F", "W", "I", "B", "UP", "SIM"]
|
|
55
|
+
|
|
56
|
+
[tool.mypy]
|
|
57
|
+
strict = true
|
|
58
|
+
python_version = "3.10"
|
|
59
|
+
files = ["src", "tests"]
|