@goassay-ai/sdk 0.0.0-stage → 0.1.0

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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +252 -2
  3. package/dist/client.d.ts +88 -0
  4. package/dist/client.js +158 -0
  5. package/dist/core/binding.d.ts +56 -0
  6. package/dist/core/binding.js +132 -0
  7. package/dist/core/caip.d.ts +15 -0
  8. package/dist/core/caip.js +31 -0
  9. package/dist/core/chain.d.ts +37 -0
  10. package/dist/core/chain.js +148 -0
  11. package/dist/core/clock.d.ts +12 -0
  12. package/dist/core/clock.js +25 -0
  13. package/dist/core/errors.d.ts +9 -0
  14. package/dist/core/errors.js +13 -0
  15. package/dist/core/guard-types.d.ts +174 -0
  16. package/dist/core/guard-types.js +69 -0
  17. package/dist/core/guards.d.ts +54 -0
  18. package/dist/core/guards.js +408 -0
  19. package/dist/core/hex.d.ts +14 -0
  20. package/dist/core/hex.js +42 -0
  21. package/dist/core/index.d.ts +16 -0
  22. package/dist/core/index.js +16 -0
  23. package/dist/core/meta.d.ts +38 -0
  24. package/dist/core/meta.js +79 -0
  25. package/dist/core/voucher.d.ts +116 -0
  26. package/dist/core/voucher.js +137 -0
  27. package/dist/errors.d.ts +149 -0
  28. package/dist/errors.js +205 -0
  29. package/dist/http.d.ts +52 -0
  30. package/dist/http.js +139 -0
  31. package/dist/index.d.ts +12 -0
  32. package/dist/index.js +4 -0
  33. package/dist/mcp-url.d.ts +17 -0
  34. package/dist/mcp-url.js +98 -0
  35. package/dist/mcp.d.ts +23 -0
  36. package/dist/mcp.js +121 -0
  37. package/dist/networks.d.ts +5 -0
  38. package/dist/networks.js +46 -0
  39. package/dist/resources/context.d.ts +48 -0
  40. package/dist/resources/context.js +28 -0
  41. package/dist/resources/paid-calls.d.ts +82 -0
  42. package/dist/resources/paid-calls.js +156 -0
  43. package/dist/resources/receipts.d.ts +25 -0
  44. package/dist/resources/receipts.js +21 -0
  45. package/dist/resources/tools.d.ts +96 -0
  46. package/dist/resources/tools.js +241 -0
  47. package/dist/rest.d.ts +17 -0
  48. package/dist/rest.js +43 -0
  49. package/dist/schemes/eip155/bind.d.ts +17 -0
  50. package/dist/schemes/eip155/bind.js +59 -0
  51. package/dist/schemes/eip155/index.d.ts +71 -0
  52. package/dist/schemes/eip155/index.js +264 -0
  53. package/dist/schemes/types.d.ts +54 -0
  54. package/dist/schemes/types.js +1 -0
  55. package/dist/signer.d.ts +39 -0
  56. package/dist/signer.js +29 -0
  57. package/dist/version.d.ts +2 -0
  58. package/dist/version.js +2 -0
  59. package/package.json +67 -4
package/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.
package/README.md CHANGED
@@ -1,3 +1,253 @@
1
- # Temporary Holding Version
1
+ # @goassay-ai/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Node 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/node/CHANGELOG.md).
6
+
7
+ ```sh
8
+ npm install @goassay-ai/sdk
9
+ ```
10
+
11
+ Requires Node 22 or newer. ESM only. Documentation: [docs.goassay.ai/developers](https://docs.goassay.ai/developers).
12
+
13
+ The client reads your API key from the `apiKey` option 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
+ ## Free call
16
+
17
+ ```ts
18
+ import { Assay } from "@goassay-ai/sdk";
19
+
20
+ const assay = new Assay(); // ASSAY_API_KEY
21
+
22
+ const found = await assay.tools.search("weather forecast", { limit: 5 });
23
+ const tool = await assay.tools.get("weather_forecast"); // schemas, price
24
+ const res = await assay.tools.call("weather_forecast", { city: "Lisbon" });
25
+
26
+ res.structuredContent; // the tool's output
27
+ res.isError; // true only for the provider's own tool error
28
+ res.receipt; // what the call cost
29
+ ```
30
+
31
+ ## Choose a URL
32
+
33
+ A client calls tools through one MCP URL. `mcpUrl` 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(urlOrPath)` returns a client for another URL that shares this one's API key, signers, spend limits and ledger, `onPayment`, `fetch` and logger, and keeps its own quote cache.
34
+
35
+ ```ts
36
+ import { Assay } from "@goassay-ai/sdk";
37
+
38
+ const everything = new Assay(); // "/": the whole catalogue, found with tools.search
39
+ const research = new Assay({ mcpUrl: "/my/research" }); // your tool set (Draft; "/my/research@2" for version 2)
40
+ const weather = new Assay({ mcpUrl: "/?tools=get_weather" }); // a single tool
41
+
42
+ // Two URLs in one program: one key, one set of limits.
43
+ const kit = everything.at("/@acme/twitter-kit@3"); // a public tool set, version 3
44
+ const { tools } = await kit.tools.list(); // tools/list: the set's tools, with prices
45
+ await kit.tools.call("acme__get_profile", { user: "x" }); // a provider tool: {handle}__{tool}, sent unchanged
46
+ await everything.tools.call("get_weather", { city: "Lisbon" });
47
+ ```
48
+
49
+ | Path | Serves |
50
+ | -------------------------------------------- | --------------------------------------------------------------------------------------------- |
51
+ | `/` | Everything (default) |
52
+ | `/my/{slug}`, `/my/{slug}@{n}` | your tool set: the Draft, or version `n` |
53
+ | `/@{handle}/{slug}`, `/@{handle}/{slug}@{n}` | a public tool set |
54
+ | `/u/{token}`, `/u/{token}@{n}` | a link-only tool set; the token is a secret, shown as `/u/…` in `toString`, `toJSON` and logs |
55
+ | `/c/{category}`, `/@{handle}` | one topic, one maker |
56
+
57
+ 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 throws `McpUrlNotFoundError`.
58
+
59
+ 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 `onPayment`. A tool set grant that allows its tools only through its own URL needs that URL; on `/` those tools are `TOOL_NOT_ALLOWED`.
60
+
61
+ ## Paid call with a local key
62
+
63
+ The SDK never stores or creates keys. You pass a signer; for a local key, wrap a viem account:
64
+
65
+ ```sh
66
+ npm install viem
67
+ ```
68
+
69
+ ```ts
70
+ import { Assay, evmSigner } from "@goassay-ai/sdk";
71
+ import { privateKeyToAccount } from "viem/accounts";
72
+
73
+ const assay = new Assay({
74
+ signer: evmSigner(privateKeyToAccount(process.env.PAYMENT_KEY as `0x${string}`)),
75
+ limits: { maxPerCall: "0.50", maxPerDay: "5" }, // dollars; see "Spend limits"
76
+ onPayment: (p) => BigInt(p.amount.amount) <= 100_000n, // your own rule; false refuses
77
+ });
78
+
79
+ const res = await assay.tools.call("weather_forecast", { city: "Lisbon" });
80
+ ```
81
+
82
+ `evmSigner()` returns a frozen object. To wrap signing (for logging or counting), build a new signer object around it instead of changing it in place:
83
+
84
+ ```ts
85
+ import type { TypedData } from "@goassay-ai/sdk";
86
+
87
+ const base = evmSigner(account);
88
+ const signer = {
89
+ namespace: base.namespace,
90
+ address: base.address,
91
+ signTypedData: async (typedData: TypedData) => {
92
+ console.log("signing", typedData.primaryType);
93
+ return base.signTypedData(typedData);
94
+ },
95
+ };
96
+ ```
97
+
98
+ 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 `onPayment`, 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 you start the same call twice at once.
99
+
100
+ ### Retrying safely
101
+
102
+ Every call has an idempotency key: yours (`{ idempotencyKey }`) or one the SDK creates. Every error out of `tools.call` carries it as `err.idempotencyKey`. Calling again with that key is safe: Assay replays a call it already served and does not charge twice.
103
+
104
+ ```ts
105
+ try {
106
+ return await assay.tools.call("weather_forecast", { city: "Lisbon" });
107
+ } catch (e) {
108
+ if (e instanceof AssayError && e.retryable && e.idempotencyKey) {
109
+ return await assay.tools.call("weather_forecast", { city: "Lisbon" }, { idempotencyKey: e.idempotencyKey });
110
+ }
111
+ throw e;
112
+ }
113
+ ```
114
+
115
+ For work you may redo after a restart, pass your own key (for example `order-${orderId}-weather`).
116
+
117
+ ### Spend limits
118
+
119
+ With a signer, every voucher is checked against local limits before it is signed:
120
+
121
+ - **Defaults: $0.50 per call and $5 per rolling 24 hours**, per signing key.
122
+ - Change them with `limits: { maxPerCall, maxPerDay }` (dollars, as strings).
123
+ - The record is in memory, per process: a restart starts again at zero, and worker threads or several processes each have their own. The on-chain caps that the payer set when approving the key bound it across processes.
124
+
125
+ `onPayment(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:
126
+
127
+ ```ts
128
+ const allowed = new Set(["eip155:<chainId>:<provider address>"]);
129
+ const assay = new Assay({ signer, onPayment: (p) => allowed.has(p.provider) });
130
+ ```
131
+
132
+ Answer quickly: the voucher's lifetime (at most 240 s) starts before the hook runs.
133
+
134
+ ### Networks
135
+
136
+ 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.
137
+
138
+ - 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.
139
+ - `networks` configures any chain yourself: a test chain, or another deployment together with its own `mcpUrl`, `apiUrl` and `webUrl`. 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.
140
+ - `rpcUrls: { "eip155:<chainId>": ["https://your-rpc"] }` replaces only a network's RPC URLs, for example with your private RPC.
141
+
142
+ Addresses must be checksummed CAIP-10 / CAIP-19 ids on the network's chain. RPC URLs must be https (plain http only to localhost).
143
+
144
+ ```ts
145
+ const assay = new Assay({
146
+ mcpUrl: "https://mcp.your-deployment.example/",
147
+ apiUrl: "https://api.your-deployment.example/api/v1",
148
+ webUrl: "https://portal.your-deployment.example",
149
+ signer,
150
+ networks: {
151
+ "eip155:<chainId>": {
152
+ // chainId: optional; when given it must be the id in the key
153
+ escrow: "eip155:<chainId>:<escrow contract address>",
154
+ asset: "eip155:<chainId>/erc20:<token address>",
155
+ decimals: 6,
156
+ rpcUrls: ["https://<rpc 1>", "https://<rpc 2>"],
157
+ },
158
+ },
159
+ });
160
+ ```
161
+
162
+ ### Binding the key (once)
163
+
164
+ Before its first paid call, the key is bound to your API key and a person approves it:
165
+
166
+ ```ts
167
+ const status = await assay.paidCalls.status();
168
+ if (status.state !== "active") {
169
+ const setup = await assay.paidCalls.bind(); // challenge, field check, sign, bind
170
+ console.log(`Approve at ${setup.approvalUrl}; the page must show ${setup.fingerprint}`);
171
+ await assay.paidCalls.waitUntilActive({ timeoutMs: 15 * 60_000 });
172
+ }
173
+ ```
174
+
175
+ If another key is already bound to the API key, `bind()` answers `bindStatus: "PENDING_APPROVAL"`: the old key stays `delegate` and keeps signing, this key is `pendingDelegate`, and it replaces the old one only when the person approves it. `waitUntilActive()` resolves 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 `pendingDelegate` (`bindStatus: "PENDING_APPROVAL"`) for the person to approve.
176
+
177
+ The approval link is built on the web app's origin (`webUrl`, 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.
178
+
179
+ ## Reading a receipt
180
+
181
+ Only a paid call has a receipt to fetch. A free call's `res.receipt.intentId` is `null`: it has no intent, and `res.receipt` already says it cost nothing.
182
+
183
+ ```ts
184
+ const intentId = res.receipt?.intentId;
185
+ if (intentId) {
186
+ const receipt = await assay.receipts.get(intentId);
187
+ receipt.chargedPrice; // { amount: "100000", asset: "eip155:...", decimals: 6 }
188
+ }
189
+ ```
190
+
191
+ ## Handling errors
192
+
193
+ 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 `requiredScope`, 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`).
194
+
195
+ ```ts
196
+ import {
197
+ AssayError,
198
+ HumanSessionRequiredError,
199
+ PaymentNetworkUnknownError,
200
+ PaymentRefusedError,
201
+ McpUrlNotFoundError,
202
+ PaymentRequiredError,
203
+ RateLimitError,
204
+ ScopeMissingError,
205
+ } from "@goassay-ai/sdk";
206
+
207
+ try {
208
+ await assay.tools.call("weather_forecast", { city: "Lisbon" });
209
+ } catch (e) {
210
+ if (e instanceof ScopeMissingError) console.error(`the API key needs the ${e.requiredScope} scope`);
211
+ else if (e instanceof HumanSessionRequiredError) console.error("only a signed-in person can do this");
212
+ else if (e instanceof McpUrlNotFoundError) console.error("this MCP URL is not served to this key");
213
+ else if (e instanceof PaymentRequiredError) console.error("paid tool: pass a signer");
214
+ else if (e instanceof PaymentNetworkUnknownError) console.error(`network ${e.network} is not supported yet`);
215
+ else if (e instanceof PaymentRefusedError) console.error(`refused before signing by ${e.guard}: ${e.code}`);
216
+ else if (e instanceof RateLimitError) console.error(`retry in ${e.retryAfterMs} ms`);
217
+ else if (e instanceof AssayError) console.error(e.code, e.message);
218
+ else throw e;
219
+ }
220
+ ```
221
+
222
+ ## Options
223
+
224
+ | Option | Default |
225
+ | -------------------- | ------------------------------------------------------------------------------------------------ |
226
+ | `apiKey` | `ASSAY_API_KEY` |
227
+ | `mcpUrl`, `apiUrl` | `https://mcp.goassay.ai/` (a URL or a path; see "Choose a URL"), `https://api.goassay.ai/api/v1` |
228
+ | `signer` / `signers` | none (free calls only) |
229
+ | `networks` | none (needed for paid calls) |
230
+ | `limits` | `{ maxPerCall: "0.50", maxPerDay: "5" }` |
231
+ | `onPayment` | none |
232
+ | `fetch` | the global `fetch` |
233
+ | `timeoutMs` | 120 000 per attempt |
234
+ | `maxRetries` | 2, with backoff, honouring `Retry-After` |
235
+ | `logger` | silent; never logs keys, headers, bodies or signatures |
236
+ | `now`, `sleep` | the system clock (corrected by the server's `Date` for vouchers), `setTimeout` |
237
+
238
+ The protocol core (EIP-712 voucher and binding, guards, clock) is at `@goassay-ai/sdk/core`.
239
+
240
+ ## Development
241
+
242
+ ```sh
243
+ npm ci
244
+ npm run lint
245
+ npm run typecheck
246
+ npm test
247
+ ```
248
+
249
+ 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.
250
+
251
+ ## License
252
+
253
+ MIT, see [LICENSE](https://github.com/assay-ai/assay-sdk/blob/main/node/LICENSE).
@@ -0,0 +1,88 @@
1
+ import { inspect } from "node:util";
2
+ import { type FetchLike, type Logger } from "./http.js";
3
+ import { PaidCalls } from "./resources/paid-calls.js";
4
+ import { type OnPayment } from "./resources/context.js";
5
+ import { Receipts } from "./resources/receipts.js";
6
+ import { Tools } from "./resources/tools.js";
7
+ import { type Eip155Network, type Limits } from "./schemes/eip155/index.js";
8
+ import type { NetworkEntry } from "./schemes/eip155/index.js";
9
+ import { type Signer } from "./signer.js";
10
+ export declare const DEFAULT_MCP_URL = "https://mcp.goassay.ai/";
11
+ export declare const DEFAULT_API_URL = "https://api.goassay.ai/api/v1";
12
+ /** The web app, where a person approves a payment key. */
13
+ export declare const DEFAULT_WEB_URL = "https://portal.goassay.ai";
14
+ export interface AssayOptions {
15
+ /** Your Assay API key. Default: the `ASSAY_API_KEY` environment variable. */
16
+ apiKey?: string;
17
+ /**
18
+ * The MCP URL this client calls tools through: a full URL, or a path resolved against
19
+ * `https://mcp.goassay.ai`, such as `"/my/research"`, `"/@acme/kit@3"` or `"/?tools=get_weather"`.
20
+ * Checked against the URL shapes Assay serves. Default `https://mcp.goassay.ai/` (Everything).
21
+ * Paid calls go through whatever URL you choose; use `at()` for another URL in the same program.
22
+ */
23
+ mcpUrl?: string;
24
+ /** The REST API base. Default `https://api.goassay.ai/api/v1`. */
25
+ apiUrl?: string;
26
+ /** A signer for paid calls. Free calls need none. */
27
+ signer?: Signer;
28
+ /** Several signers; a payment uses the one whose namespace matches the requirement's network. */
29
+ signers?: Signer[];
30
+ /** The web app, for the payment-key approval link. Default `https://portal.goassay.ai`. */
31
+ webUrl?: string;
32
+ /**
33
+ * Payment networks you configure, keyed by CAIP-2 (`eip155:<chainId>`): chainId, escrow, asset,
34
+ * decimals and RPC URLs, for example a test chain or another deployment (with its own `mcpUrl`,
35
+ * `apiUrl` and `webUrl`). Merged over the SDK's built-in table: a new network is added, and an
36
+ * entry for a built-in network replaces it whole. Never read from the server.
37
+ */
38
+ networks?: Record<string, Eip155Network>;
39
+ /** Your own RPC URLs for a network, replacing the table's public ones: `{"eip155:<id>": [url]}`. */
40
+ rpcUrls?: Record<string, string[]>;
41
+ /**
42
+ * Local spend limits in dollars, checked before every signature (G7), per signing key and per
43
+ * process. Default `{maxPerCall: "0.50", maxPerDay: "5"}` (rolling 24 hours).
44
+ */
45
+ limits?: Limits;
46
+ /** Called before each voucher is signed, after every local check passed. Return `false` to refuse. */
47
+ onPayment?: OnPayment;
48
+ /** Default: the global `fetch`. */
49
+ fetch?: FetchLike;
50
+ /** Per attempt, in milliseconds. Default 120 000. */
51
+ timeoutMs?: number;
52
+ /** Retries of a failed request (network errors, 408/429/5xx, rate limits). Default 2. */
53
+ maxRetries?: number;
54
+ /** Default: silent. Lines never carry keys or signatures. */
55
+ logger?: Logger;
56
+ /** Milliseconds clock. Default `Date.now`; corrected by the server's `Date` for vouchers. */
57
+ now?: () => number;
58
+ /** How the client waits between retries. Default `setTimeout`. */
59
+ sleep?: (ms: number) => Promise<void>;
60
+ }
61
+ /**
62
+ * The Assay client, for one MCP URL. Stateless over HTTP: there is nothing to connect or close.
63
+ *
64
+ * - `tools.list / search / get / call` over MCP;
65
+ * - `receipts.get` to see what a call cost;
66
+ * - `paidCalls.status / bind / waitUntilActive` to set up a payment key;
67
+ * - `at(url)` for a client on another MCP URL that shares this one's key, signers and limits.
68
+ */
69
+ export declare class Assay {
70
+ #private;
71
+ readonly tools: Tools;
72
+ readonly receipts: Receipts;
73
+ readonly paidCalls: PaidCalls;
74
+ /** The payment network table in use: the built-in one with `networks` merged, after `rpcUrls`. */
75
+ readonly networks: ReadonlyMap<string, NetworkEntry>;
76
+ constructor(options?: AssayOptions);
77
+ /**
78
+ * A client on another MCP URL: a full URL on this client's MCP origin, or a path resolved
79
+ * against it (`"/my/research"`, `"/@acme/kit@3"`, `"/?tools=get_weather"`). It shares this
80
+ * client's API key, HTTP stack (fetch, retries, logger), signers, spend limits and ledger,
81
+ * `onPayment` and the in-flight calls by idempotency key, and has its own quote cache. Cheap:
82
+ * nothing is sent until it is used.
83
+ */
84
+ at(urlOrPath: string): Assay;
85
+ toJSON(): Record<string, unknown>;
86
+ toString(): string;
87
+ [inspect.custom](): string;
88
+ }
package/dist/client.js ADDED
@@ -0,0 +1,158 @@
1
+ import { inspect } from "node:util";
2
+ import { ChainReader } from "./core/chain.js";
3
+ import { ConfigError } from "./errors.js";
4
+ import { Http, silentLogger } from "./http.js";
5
+ import { McpTransport } from "./mcp.js";
6
+ import { DEFAULT_MCP_ORIGIN, redactMcpUrl, resolveMcpUrl } from "./mcp-url.js";
7
+ import { networkTable } from "./networks.js";
8
+ import { PaidCalls } from "./resources/paid-calls.js";
9
+ import { QuoteCache } from "./resources/context.js";
10
+ import { Receipts } from "./resources/receipts.js";
11
+ import { Tools } from "./resources/tools.js";
12
+ import { RestClient } from "./rest.js";
13
+ import { checkLimits, DEFAULT_LIMITS, Eip155Scheme } from "./schemes/eip155/index.js";
14
+ import { signerNamespace } from "./signer.js";
15
+ import { VERSION } from "./version.js";
16
+ export const DEFAULT_MCP_URL = "https://mcp.goassay.ai/";
17
+ export const DEFAULT_API_URL = "https://api.goassay.ai/api/v1";
18
+ /** The web app, where a person approves a payment key. */
19
+ export const DEFAULT_WEB_URL = "https://portal.goassay.ai";
20
+ function originOf(name, value) {
21
+ let url;
22
+ try {
23
+ url = new URL(value);
24
+ }
25
+ catch {
26
+ throw new ConfigError(`${name} is not a URL`);
27
+ }
28
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
29
+ if (!(url.protocol === "https:" || (url.protocol === "http:" && loopback)) || url.username || url.password) {
30
+ throw new ConfigError(`${name} must be an https URL`);
31
+ }
32
+ return url.origin;
33
+ }
34
+ const CLIENT_INFO = { name: "goassay-node", version: VERSION };
35
+ /** Builds a sibling client from a parent's context (`at()`); not reachable through the public options. */
36
+ const SIBLING = Symbol("assay.sibling");
37
+ /**
38
+ * The Assay client, for one MCP URL. Stateless over HTTP: there is nothing to connect or close.
39
+ *
40
+ * - `tools.list / search / get / call` over MCP;
41
+ * - `receipts.get` to see what a call cost;
42
+ * - `paidCalls.status / bind / waitUntilActive` to set up a payment key;
43
+ * - `at(url)` for a client on another MCP URL that shares this one's key, signers and limits.
44
+ */
45
+ export class Assay {
46
+ tools;
47
+ receipts;
48
+ paidCalls;
49
+ /** The payment network table in use: the built-in one with `networks` merged, after `rpcUrls`. */
50
+ networks;
51
+ #ctx;
52
+ #mcpOrigin;
53
+ constructor(options = {}) {
54
+ const sibling = options[SIBLING];
55
+ const ctx = sibling ?? buildContext(options);
56
+ this.#ctx = ctx;
57
+ this.#mcpOrigin = new URL(ctx.mcpUrl).origin;
58
+ this.tools = new Tools(ctx);
59
+ this.receipts = new Receipts(ctx);
60
+ this.paidCalls = new PaidCalls(ctx);
61
+ this.networks = ctx.networks;
62
+ }
63
+ /**
64
+ * A client on another MCP URL: a full URL on this client's MCP origin, or a path resolved
65
+ * against it (`"/my/research"`, `"/@acme/kit@3"`, `"/?tools=get_weather"`). It shares this
66
+ * client's API key, HTTP stack (fetch, retries, logger), signers, spend limits and ledger,
67
+ * `onPayment` and the in-flight calls by idempotency key, and has its own quote cache. Cheap:
68
+ * nothing is sent until it is used.
69
+ */
70
+ at(urlOrPath) {
71
+ const mcpUrl = resolveMcpUrl("url", urlOrPath, this.#mcpOrigin);
72
+ if (new URL(mcpUrl).origin !== this.#mcpOrigin)
73
+ throw new ConfigError("at() takes a path or a URL on this client's MCP origin; build a new Assay for another");
74
+ const c = this.#ctx;
75
+ const ctx = {
76
+ ...c,
77
+ mcpUrl,
78
+ mcp: new McpTransport(c.http, mcpUrl, CLIENT_INFO, c.now),
79
+ quotes: new QuoteCache(c.now),
80
+ };
81
+ return new Assay({ [SIBLING]: ctx });
82
+ }
83
+ toJSON() {
84
+ return { mcpUrl: redactMcpUrl(this.#ctx.mcpUrl) };
85
+ }
86
+ toString() {
87
+ return `Assay(${redactMcpUrl(this.#ctx.mcpUrl)})`;
88
+ }
89
+ [inspect.custom]() {
90
+ return this.toString();
91
+ }
92
+ }
93
+ function buildContext(options) {
94
+ const apiKey = (options.apiKey ?? process.env.ASSAY_API_KEY ?? "").trim();
95
+ if (apiKey === "")
96
+ throw new ConfigError("an API key is required: pass apiKey or set ASSAY_API_KEY");
97
+ if (!/^[\x21-\x7e]+$/.test(apiKey))
98
+ throw new ConfigError("apiKey is not valid");
99
+ const mcpUrl = resolveMcpUrl("mcpUrl", options.mcpUrl ?? DEFAULT_MCP_URL, DEFAULT_MCP_ORIGIN);
100
+ const apiUrl = (options.apiUrl ?? DEFAULT_API_URL).replace(/\/+$/, "");
101
+ const origins = [originOf("mcpUrl", mcpUrl), originOf("apiUrl", apiUrl)];
102
+ const webOrigin = originOf("webUrl", options.webUrl ?? DEFAULT_WEB_URL);
103
+ const signers = [...(options.signer ? [options.signer] : []), ...(options.signers ?? [])];
104
+ for (const s of signers) {
105
+ if (signerNamespace(s) === null)
106
+ throw new ConfigError("a signer needs namespace, address and signTypedData");
107
+ }
108
+ if (new Set(signers.map((s) => s.namespace)).size !== signers.length)
109
+ throw new ConfigError("give one signer per namespace");
110
+ const maxRetries = options.maxRetries ?? 2;
111
+ const timeoutMs = options.timeoutMs ?? 120_000;
112
+ if (!Number.isInteger(maxRetries) || maxRetries < 0 || maxRetries > 10)
113
+ throw new ConfigError("maxRetries must be 0 to 10");
114
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
115
+ throw new ConfigError("timeoutMs must be positive");
116
+ const now = options.now ?? Date.now;
117
+ const fetchFn = options.fetch ?? ((input, init) => fetch(input, init));
118
+ const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
119
+ const http = new Http({
120
+ fetch: fetchFn,
121
+ apiKey,
122
+ origins,
123
+ timeoutMs,
124
+ maxRetries,
125
+ logger: options.logger ?? silentLogger,
126
+ now,
127
+ sleep,
128
+ userAgent: `goassay-node/${VERSION} node/${process.versions.node}`,
129
+ });
130
+ const networks = networkTable(options.networks, options.rpcUrls);
131
+ const schemes = new Map();
132
+ if (signers.some((s) => s.namespace === "eip155")) {
133
+ schemes.set("eip155", new Eip155Scheme({
134
+ networks,
135
+ limits: checkLimits(options.limits ?? DEFAULT_LIMITS),
136
+ // Chain reads go to the caller's RPCs, without the API key.
137
+ chain: new ChainReader({ fetch: fetchFn, now }),
138
+ now,
139
+ dateOffset: () => http.dateOffset,
140
+ }));
141
+ }
142
+ return {
143
+ http,
144
+ mcpUrl,
145
+ mcp: new McpTransport(http, mcpUrl, CLIENT_INFO, now),
146
+ api: new RestClient(http, apiUrl, now),
147
+ signers: Object.freeze(signers),
148
+ schemes,
149
+ networks,
150
+ webOrigin,
151
+ inFlight: new Map(),
152
+ quotes: new QuoteCache(now),
153
+ onPayment: options.onPayment,
154
+ maxRetries,
155
+ now,
156
+ sleep,
157
+ };
158
+ }
@@ -0,0 +1,56 @@
1
+ import { type Address } from "./hex.js";
2
+ /** The binding proof's own EIP-712 domain (spec section 5). Not the escrow's. */
3
+ export declare const DELEGATE_BINDING_DOMAIN_NAME = "GoAssay Agent Binding";
4
+ export declare const DELEGATE_BINDING_DOMAIN_VERSION = "1";
5
+ /** The longest challenge life a client accepts: the server's challenge TTL. */
6
+ export declare const MAX_BINDING_CHALLENGE_SECONDS = 300;
7
+ /** Exactly these fields, in this order. The order is part of the type hash. */
8
+ export declare const DELEGATE_BINDING_TYPES: {
9
+ readonly DelegateBinding: readonly [{
10
+ readonly name: "delegate";
11
+ readonly type: "address";
12
+ }, {
13
+ readonly name: "credentialId";
14
+ readonly type: "string";
15
+ }, {
16
+ readonly name: "nonce";
17
+ readonly type: "string";
18
+ }, {
19
+ readonly name: "expiresAt";
20
+ readonly type: "uint64";
21
+ }];
22
+ };
23
+ /** What a client signs: rebuilt from its own constants after every challenge field was checked. */
24
+ export interface DelegateBindingTypedData {
25
+ domain: {
26
+ name: string;
27
+ version: string;
28
+ chainId: number;
29
+ };
30
+ types: typeof DELEGATE_BINDING_TYPES;
31
+ primaryType: "DelegateBinding";
32
+ message: {
33
+ delegate: Address;
34
+ credentialId: string;
35
+ nonce: string;
36
+ expiresAt: bigint;
37
+ };
38
+ }
39
+ /** The typed data for checked values. */
40
+ export declare function bindingTypedData(chainId: number, message: DelegateBindingTypedData["message"]): DelegateBindingTypedData;
41
+ export interface BindingContext {
42
+ /** The client's own payment key address. */
43
+ localAddress: string;
44
+ /** The credential id of the API key. */
45
+ credentialId: string;
46
+ /** The chain id of the network the client is configured for, from its own table. */
47
+ chainId: number;
48
+ /** The corrected clock, Unix seconds (spec section 7). */
49
+ nowSeconds: number;
50
+ }
51
+ /**
52
+ * Checks the server's `DelegateBinding` challenge (the `data` of the challenge answer) field by
53
+ * field (spec section 5.2) and returns the typed data to sign, rebuilt from the client's own
54
+ * constants. Throws `SignerRefusal` naming the first field that fails.
55
+ */
56
+ export declare function checkBindingChallenge(challenge: unknown, ctx: BindingContext): DelegateBindingTypedData;