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.
@@ -0,0 +1,13 @@
1
+ node_modules/
2
+ dist/
3
+ coverage/
4
+ __pycache__/
5
+ *.pyc
6
+ .venv/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .DS_Store
12
+ .env
13
+ .env.*
@@ -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).
@@ -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"]