@goassay-ai/sdk 0.0.0-bootstrap.0 → 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.
- package/README.md +250 -2
- package/dist/client.d.ts +88 -0
- package/dist/client.js +158 -0
- package/dist/core/binding.d.ts +56 -0
- package/dist/core/binding.js +132 -0
- package/dist/core/caip.d.ts +15 -0
- package/dist/core/caip.js +31 -0
- package/dist/core/chain.d.ts +37 -0
- package/dist/core/chain.js +148 -0
- package/dist/core/clock.d.ts +12 -0
- package/dist/core/clock.js +25 -0
- package/dist/core/errors.d.ts +9 -0
- package/dist/core/errors.js +13 -0
- package/dist/core/guard-types.d.ts +174 -0
- package/dist/core/guard-types.js +69 -0
- package/dist/core/guards.d.ts +54 -0
- package/dist/core/guards.js +408 -0
- package/dist/core/hex.d.ts +14 -0
- package/dist/core/hex.js +42 -0
- package/dist/core/index.d.ts +16 -0
- package/dist/core/index.js +16 -0
- package/dist/core/meta.d.ts +38 -0
- package/dist/core/meta.js +79 -0
- package/dist/core/voucher.d.ts +116 -0
- package/dist/core/voucher.js +137 -0
- package/dist/errors.d.ts +149 -0
- package/dist/errors.js +205 -0
- package/dist/http.d.ts +52 -0
- package/dist/http.js +139 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +4 -0
- package/dist/mcp-url.d.ts +17 -0
- package/dist/mcp-url.js +98 -0
- package/dist/mcp.d.ts +23 -0
- package/dist/mcp.js +121 -0
- package/dist/networks.d.ts +5 -0
- package/dist/networks.js +46 -0
- package/dist/resources/context.d.ts +48 -0
- package/dist/resources/context.js +28 -0
- package/dist/resources/paid-calls.d.ts +82 -0
- package/dist/resources/paid-calls.js +156 -0
- package/dist/resources/receipts.d.ts +25 -0
- package/dist/resources/receipts.js +21 -0
- package/dist/resources/tools.d.ts +96 -0
- package/dist/resources/tools.js +241 -0
- package/dist/rest.d.ts +17 -0
- package/dist/rest.js +43 -0
- package/dist/schemes/eip155/bind.d.ts +17 -0
- package/dist/schemes/eip155/bind.js +59 -0
- package/dist/schemes/eip155/index.d.ts +71 -0
- package/dist/schemes/eip155/index.js +264 -0
- package/dist/schemes/types.d.ts +54 -0
- package/dist/schemes/types.js +1 -0
- package/dist/signer.d.ts +39 -0
- package/dist/signer.js +29 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +62 -4
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ClientContext } from "./context.js";
|
|
2
|
+
export type PaidCallsState = "unbound" | "pending_approval" | "active" | "expired" | "bound_to_other_key" | "unknown";
|
|
3
|
+
export interface PaidCallsStatus {
|
|
4
|
+
/**
|
|
5
|
+
* - `unbound`: no payment key is bound to this API key;
|
|
6
|
+
* - `pending_approval`: this key is bound (or waits) and a person has not approved it yet;
|
|
7
|
+
* - `active`: paid calls are on for this key;
|
|
8
|
+
* - `expired`: the approval lapsed; approve it again;
|
|
9
|
+
* - `bound_to_other_key`: another key is bound; `bind()` this one;
|
|
10
|
+
* - `unknown`: Assay could not read the key's grant from the chain just now; ask again.
|
|
11
|
+
*/
|
|
12
|
+
state: PaidCallsState;
|
|
13
|
+
credentialId: string;
|
|
14
|
+
/** The bound key's `0x` address, or null. While a new key waits for approval, still the old key. */
|
|
15
|
+
delegate: string | null;
|
|
16
|
+
/** When `delegate` was bound (ISO 8601), or null. */
|
|
17
|
+
boundAt: string | null;
|
|
18
|
+
/** A key waiting for approval to replace `delegate`, or null. */
|
|
19
|
+
pendingDelegate: string | null;
|
|
20
|
+
/** When `pendingDelegate` was proved (ISO 8601), or null. */
|
|
21
|
+
pendingSince: string | null;
|
|
22
|
+
/** The on-chain caps of the bound key, in atomic units, when the server read them. */
|
|
23
|
+
caps: {
|
|
24
|
+
maxVoucherAmount: string | null;
|
|
25
|
+
maxPerWindow: string | null;
|
|
26
|
+
spentInWindow: string | null;
|
|
27
|
+
validUntil: string | null;
|
|
28
|
+
} | null;
|
|
29
|
+
/**
|
|
30
|
+
* Where a person approves this client's key: `{webUrl}/agents/enable-paid?credential=...#key=...`.
|
|
31
|
+
* The key travels only in the fragment. Null without a signer.
|
|
32
|
+
*/
|
|
33
|
+
approvalUrl: string | null;
|
|
34
|
+
/** This client's key as the approval page shows it (`1A2B 9F3C`); null without a signer. */
|
|
35
|
+
fingerprint: string | null;
|
|
36
|
+
}
|
|
37
|
+
export interface BindResult extends PaidCallsStatus {
|
|
38
|
+
/** The key was already bound (or already waiting for approval): nothing was signed. */
|
|
39
|
+
alreadyBound: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* - `ACTIVE`: this key is the bound key (`delegate`); a person still approves its caps on chain;
|
|
42
|
+
* - `PENDING_APPROVAL`: another key stays bound and keeps signing; this key is `pendingDelegate`
|
|
43
|
+
* and replaces it once a person approves it at `approvalUrl`.
|
|
44
|
+
*/
|
|
45
|
+
bindStatus: "ACTIVE" | "PENDING_APPROVAL";
|
|
46
|
+
}
|
|
47
|
+
export interface WaitOptions {
|
|
48
|
+
/** Default 15 minutes. */
|
|
49
|
+
timeoutMs?: number;
|
|
50
|
+
signal?: AbortSignal;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The approval link, with this key only in the fragment (browsers never send it to a server). A
|
|
54
|
+
* link the server sent is used only when it is on the configured web origin and names this
|
|
55
|
+
* credential; otherwise the link is built from the web origin.
|
|
56
|
+
*/
|
|
57
|
+
export declare function approvalLink(webOrigin: string, server: unknown, credentialId: string, address: string | null): string | null;
|
|
58
|
+
/** Binding a payment key for server-side integrators (spec section 5). */
|
|
59
|
+
export declare class PaidCalls {
|
|
60
|
+
#private;
|
|
61
|
+
constructor(context: ClientContext);
|
|
62
|
+
/** Where paid calls stand for this API key and this client's signer. */
|
|
63
|
+
status(): Promise<PaidCallsStatus>;
|
|
64
|
+
/**
|
|
65
|
+
* Binds this client's EVM signer to the API key: challenge, field check, sign, bind. A no-op
|
|
66
|
+
* when the key is already bound or already waiting. When another key is bound, this key waits as
|
|
67
|
+
* `pendingDelegate` (`bindStatus: "PENDING_APPROVAL"`) and the old key keeps signing until a
|
|
68
|
+
* person approves the new one at `approvalUrl`, comparing the fingerprint. Needs the `payments`
|
|
69
|
+
* scope. A key bound to another API key is refused with `DelegateAlreadyBoundError` (409).
|
|
70
|
+
* When another key wins a first bind at the same moment, this is `DelegateBindConflictError`
|
|
71
|
+
* (409, retryable): nothing was bound, and calling `bind()` once more parks this key as pending.
|
|
72
|
+
*/
|
|
73
|
+
bind(options?: {
|
|
74
|
+
network?: string;
|
|
75
|
+
}): Promise<BindResult>;
|
|
76
|
+
/**
|
|
77
|
+
* Polls until a person approved this client's key: `delegate` is this key and its paid calls are
|
|
78
|
+
* on. A key waiting to replace another one becomes `delegate` only when the approval lands.
|
|
79
|
+
* Every 5 s for 2 minutes, then every 15 s.
|
|
80
|
+
*/
|
|
81
|
+
waitUntilActive(options?: WaitOptions): Promise<PaidCallsStatus>;
|
|
82
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { eip155ChainId, parseCaip10 } from "../core/caip.js";
|
|
2
|
+
import { getAddress } from "../core/hex.js";
|
|
3
|
+
import { fingerprint, isObject } from "../core/meta.js";
|
|
4
|
+
import { AssayError, ConfigError, PaymentNetworkUnknownError, ServerError } from "../errors.js";
|
|
5
|
+
import { bindEvmKey } from "../schemes/eip155/bind.js";
|
|
6
|
+
const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
7
|
+
function addressOf(caip10) {
|
|
8
|
+
const p = typeof caip10 === "string" ? parseCaip10(caip10) : null;
|
|
9
|
+
return p ? p.address : null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The approval link, with this key only in the fragment (browsers never send it to a server). A
|
|
13
|
+
* link the server sent is used only when it is on the configured web origin and names this
|
|
14
|
+
* credential; otherwise the link is built from the web origin.
|
|
15
|
+
*/
|
|
16
|
+
export function approvalLink(webOrigin, server, credentialId, address) {
|
|
17
|
+
if (address === null)
|
|
18
|
+
return null;
|
|
19
|
+
let url = null;
|
|
20
|
+
if (typeof server === "string") {
|
|
21
|
+
try {
|
|
22
|
+
const u = new URL(server);
|
|
23
|
+
if (u.origin === webOrigin && !u.username && !u.password && u.searchParams.get("credential") === credentialId)
|
|
24
|
+
url = u;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
url = null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
url ??= new URL(`${webOrigin}/agents/enable-paid?credential=${encodeURIComponent(credentialId)}`);
|
|
31
|
+
url.hash = `key=${address}`;
|
|
32
|
+
return url.toString();
|
|
33
|
+
}
|
|
34
|
+
/** Binding a payment key for server-side integrators (spec section 5). */
|
|
35
|
+
export class PaidCalls {
|
|
36
|
+
#c;
|
|
37
|
+
constructor(context) {
|
|
38
|
+
this.#c = context;
|
|
39
|
+
}
|
|
40
|
+
#signer() {
|
|
41
|
+
return this.#c.signers.find((s) => s.namespace === "eip155") ?? null;
|
|
42
|
+
}
|
|
43
|
+
/** Where paid calls stand for this API key and this client's signer. */
|
|
44
|
+
async status() {
|
|
45
|
+
const { data } = await this.#c.api.get("/agents/me/delegate");
|
|
46
|
+
if (!isObject(data) || typeof data.credentialId !== "string" || !UUID.test(data.credentialId)) {
|
|
47
|
+
throw new ServerError("API_BAD_ANSWER", "the paid-call status is not readable");
|
|
48
|
+
}
|
|
49
|
+
const signer = this.#signer();
|
|
50
|
+
const mine = signer ? getAddress(signer.address) : null;
|
|
51
|
+
const delegate = addressOf(data.delegate);
|
|
52
|
+
const pending = addressOf(data.pendingDelegate);
|
|
53
|
+
const paid = isObject(data.paidCalls) ? data.paidCalls : null;
|
|
54
|
+
let state;
|
|
55
|
+
if (data.paidCallsUnknown === true && delegate !== null && (mine === null || delegate === mine))
|
|
56
|
+
state = "unknown";
|
|
57
|
+
else if (mine !== null && pending === mine)
|
|
58
|
+
state = "pending_approval";
|
|
59
|
+
else if (delegate === null)
|
|
60
|
+
state = "unbound";
|
|
61
|
+
else if (mine !== null && delegate !== mine)
|
|
62
|
+
state = "bound_to_other_key";
|
|
63
|
+
else if (paid?.status === "ACTIVE")
|
|
64
|
+
state = "active";
|
|
65
|
+
else if (paid?.status === "EXPIRED")
|
|
66
|
+
state = "expired";
|
|
67
|
+
else
|
|
68
|
+
state = "pending_approval";
|
|
69
|
+
const str = (v) => (typeof v === "string" ? v : null);
|
|
70
|
+
return {
|
|
71
|
+
state,
|
|
72
|
+
credentialId: data.credentialId,
|
|
73
|
+
delegate,
|
|
74
|
+
boundAt: str(data.boundAt),
|
|
75
|
+
pendingDelegate: pending,
|
|
76
|
+
pendingSince: str(data.pendingSince),
|
|
77
|
+
caps: paid
|
|
78
|
+
? {
|
|
79
|
+
maxVoucherAmount: str(paid.maxVoucherAmount),
|
|
80
|
+
maxPerWindow: str(paid.maxPerWindow),
|
|
81
|
+
spentInWindow: str(paid.spentInWindow),
|
|
82
|
+
validUntil: str(paid.validUntil),
|
|
83
|
+
}
|
|
84
|
+
: null,
|
|
85
|
+
approvalUrl: approvalLink(this.#c.webOrigin, data.approvalUrl, data.credentialId, mine),
|
|
86
|
+
fingerprint: mine ? fingerprint(mine) : null,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Binds this client's EVM signer to the API key: challenge, field check, sign, bind. A no-op
|
|
91
|
+
* when the key is already bound or already waiting. When another key is bound, this key waits as
|
|
92
|
+
* `pendingDelegate` (`bindStatus: "PENDING_APPROVAL"`) and the old key keeps signing until a
|
|
93
|
+
* person approves the new one at `approvalUrl`, comparing the fingerprint. Needs the `payments`
|
|
94
|
+
* scope. A key bound to another API key is refused with `DelegateAlreadyBoundError` (409).
|
|
95
|
+
* When another key wins a first bind at the same moment, this is `DelegateBindConflictError`
|
|
96
|
+
* (409, retryable): nothing was bound, and calling `bind()` once more parks this key as pending.
|
|
97
|
+
*/
|
|
98
|
+
async bind(options = {}) {
|
|
99
|
+
const signer = this.#signer();
|
|
100
|
+
if (signer === null)
|
|
101
|
+
throw new ConfigError("bind() needs an eip155 signer");
|
|
102
|
+
const mine = getAddress(signer.address);
|
|
103
|
+
const current = await this.status();
|
|
104
|
+
if (current.delegate === mine)
|
|
105
|
+
return { ...current, alreadyBound: true, bindStatus: "ACTIVE" };
|
|
106
|
+
if (current.pendingDelegate === mine)
|
|
107
|
+
return { ...current, alreadyBound: true, bindStatus: "PENDING_APPROVAL" };
|
|
108
|
+
const evm = [...this.#c.networks.keys()].filter((n) => n.startsWith("eip155:"));
|
|
109
|
+
const network = options.network ?? (evm.length === 1 ? evm[0] : undefined);
|
|
110
|
+
const chainId = network === undefined || !this.#c.networks.has(network) ? null : eip155ChainId(network);
|
|
111
|
+
if (chainId === null) {
|
|
112
|
+
if (network === undefined && evm.length > 1)
|
|
113
|
+
throw new ConfigError("bind() needs `network` naming one of the eip155 networks");
|
|
114
|
+
if (network === undefined)
|
|
115
|
+
throw new PaymentNetworkUnknownError("eip155", "no network is configured for this signer's namespace (eip155); pass `networks` to the Assay constructor with an entry for your chain");
|
|
116
|
+
throw new PaymentNetworkUnknownError(network);
|
|
117
|
+
}
|
|
118
|
+
const answer = await bindEvmKey({
|
|
119
|
+
signer,
|
|
120
|
+
chainId,
|
|
121
|
+
credentialId: current.credentialId,
|
|
122
|
+
api: this.#c.api,
|
|
123
|
+
now: this.#c.now,
|
|
124
|
+
});
|
|
125
|
+
const bindStatus = answer.status === "PENDING_APPROVAL" ? "PENDING_APPROVAL" : "ACTIVE";
|
|
126
|
+
return { ...(await this.status()), alreadyBound: false, bindStatus };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Polls until a person approved this client's key: `delegate` is this key and its paid calls are
|
|
130
|
+
* on. A key waiting to replace another one becomes `delegate` only when the approval lands.
|
|
131
|
+
* Every 5 s for 2 minutes, then every 15 s.
|
|
132
|
+
*/
|
|
133
|
+
async waitUntilActive(options = {}) {
|
|
134
|
+
const signer = this.#signer();
|
|
135
|
+
const mine = signer ? getAddress(signer.address) : null;
|
|
136
|
+
const started = this.#c.now();
|
|
137
|
+
const until = started + (options.timeoutMs ?? 15 * 60_000);
|
|
138
|
+
for (;;) {
|
|
139
|
+
if (options.signal?.aborted)
|
|
140
|
+
throw new AssayError("ABORTED", "the wait was aborted");
|
|
141
|
+
try {
|
|
142
|
+
const s = await this.status();
|
|
143
|
+
if (s.state === "active" && (mine === null || s.delegate === mine))
|
|
144
|
+
return s;
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
if (!(err instanceof AssayError) || !err.retryable)
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
const now = this.#c.now();
|
|
151
|
+
if (now >= until)
|
|
152
|
+
throw new AssayError("WAIT_TIMEOUT", "paid calls were not approved in time", { retryable: true });
|
|
153
|
+
await this.#c.sleep(Math.min(until - now, now - started < 120_000 ? 5_000 : 15_000));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Money } from "../core/voucher.js";
|
|
2
|
+
import type { ClientContext } from "./context.js";
|
|
3
|
+
/** `GET /receipts/{intentId}`: one call, with what it cost. Dates are ISO 8601 strings. */
|
|
4
|
+
export interface Receipt {
|
|
5
|
+
intentId: string;
|
|
6
|
+
toolId: string;
|
|
7
|
+
providerId: string | null;
|
|
8
|
+
status: string;
|
|
9
|
+
classification: string | null;
|
|
10
|
+
listedPrice: Money;
|
|
11
|
+
chargedPrice: Money;
|
|
12
|
+
withheld: Money;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
settledAt: string | null;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
export declare class Receipts {
|
|
18
|
+
#private;
|
|
19
|
+
constructor(context: ClientContext);
|
|
20
|
+
/**
|
|
21
|
+
* The receipt of a call, by its intent id (`result.receipt.intentId`). Needs the
|
|
22
|
+
* `activity:read` scope.
|
|
23
|
+
*/
|
|
24
|
+
get(intentId: string): Promise<Receipt>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ConfigError, ServerError } from "../errors.js";
|
|
2
|
+
import { isObject } from "../core/meta.js";
|
|
3
|
+
const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
4
|
+
export class Receipts {
|
|
5
|
+
#c;
|
|
6
|
+
constructor(context) {
|
|
7
|
+
this.#c = context;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The receipt of a call, by its intent id (`result.receipt.intentId`). Needs the
|
|
11
|
+
* `activity:read` scope.
|
|
12
|
+
*/
|
|
13
|
+
async get(intentId) {
|
|
14
|
+
if (typeof intentId !== "string" || !UUID.test(intentId))
|
|
15
|
+
throw new ConfigError("intentId must be a UUID");
|
|
16
|
+
const { data } = await this.#c.api.get(`/receipts/${intentId}`);
|
|
17
|
+
if (!isObject(data) || typeof data.intentId !== "string")
|
|
18
|
+
throw new ServerError("API_BAD_ANSWER", "the receipt answer is not readable");
|
|
19
|
+
return data;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type PriceQuote } from "../core/meta.js";
|
|
2
|
+
import type { Money } from "../core/voucher.js";
|
|
3
|
+
import type { ClientContext } from "./context.js";
|
|
4
|
+
/** `_meta["assay/receipt"]`: what a call cost (spec section 10.1). */
|
|
5
|
+
export interface CallReceipt {
|
|
6
|
+
/** The payment intent; `null` for a free call, which has no intent and no receipt to fetch. */
|
|
7
|
+
intentId: string | null;
|
|
8
|
+
charged?: Money & {
|
|
9
|
+
display?: string;
|
|
10
|
+
};
|
|
11
|
+
held?: Money & {
|
|
12
|
+
display?: string;
|
|
13
|
+
};
|
|
14
|
+
status?: string;
|
|
15
|
+
toolId?: string;
|
|
16
|
+
providerId?: string;
|
|
17
|
+
classification?: string;
|
|
18
|
+
attempts?: number;
|
|
19
|
+
fellBack?: boolean;
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
export interface ToolResult {
|
|
23
|
+
content: unknown[];
|
|
24
|
+
structuredContent?: Record<string, unknown>;
|
|
25
|
+
/** The provider's own tool error. Assay refusals are thrown, never returned. */
|
|
26
|
+
isError: boolean;
|
|
27
|
+
receipt: CallReceipt | null;
|
|
28
|
+
/** The stored result of an earlier attempt with the same idempotency key; not charged again. */
|
|
29
|
+
replayed: boolean;
|
|
30
|
+
/** The key this logical call used; pass it again to retry the same call safely. */
|
|
31
|
+
idempotencyKey: string;
|
|
32
|
+
}
|
|
33
|
+
export interface CallOptions {
|
|
34
|
+
/** Names one logical call. A UUID is created when it is not given. 1-128 printable ASCII. */
|
|
35
|
+
idempotencyKey?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface SearchOptions {
|
|
38
|
+
limit?: number;
|
|
39
|
+
category?: string;
|
|
40
|
+
detail?: "names" | "summary" | "full";
|
|
41
|
+
cursor?: string;
|
|
42
|
+
}
|
|
43
|
+
export interface ToolSummary {
|
|
44
|
+
name: string;
|
|
45
|
+
[key: string]: unknown;
|
|
46
|
+
}
|
|
47
|
+
export interface SearchResult {
|
|
48
|
+
results: ToolSummary[];
|
|
49
|
+
nextCursor: string | null;
|
|
50
|
+
}
|
|
51
|
+
export interface ListOptions {
|
|
52
|
+
cursor?: string;
|
|
53
|
+
}
|
|
54
|
+
export interface ListResult {
|
|
55
|
+
/** The tools this URL lists, as MCP describes them (`name`, `description`, `inputSchema`, `_meta` ...). */
|
|
56
|
+
tools: ToolInfo[];
|
|
57
|
+
nextCursor: string | null;
|
|
58
|
+
/**
|
|
59
|
+
* The list's `_meta`, as sent: `assay/toolsetVersion` (a tool set URL without `@n`),
|
|
60
|
+
* `assay/sharingEnds`, `assay/renamed` (an old handle) and `assay/ignoredTools` (`?tools=` names
|
|
61
|
+
* this URL does not serve).
|
|
62
|
+
*/
|
|
63
|
+
meta: Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
export interface ToolInfo {
|
|
66
|
+
name: string;
|
|
67
|
+
description?: string;
|
|
68
|
+
inputSchema?: Record<string, unknown>;
|
|
69
|
+
outputSchema?: Record<string, unknown>;
|
|
70
|
+
price?: Record<string, unknown>;
|
|
71
|
+
[key: string]: unknown;
|
|
72
|
+
}
|
|
73
|
+
/** Tools over MCP: search, describe and call, with paid calls signed by the caller's signer. */
|
|
74
|
+
export declare class Tools {
|
|
75
|
+
#private;
|
|
76
|
+
constructor(context: ClientContext);
|
|
77
|
+
/** `search_tools`: tools this URL can run, by what they do. Free. */
|
|
78
|
+
search(query: string, options?: SearchOptions): Promise<SearchResult>;
|
|
79
|
+
/**
|
|
80
|
+
* `tools/list`: the tools this URL lists. A tool set, topic, maker or `?tools=` URL lists its
|
|
81
|
+
* tools; Everything (`/`) lists only a few, plus `search_tools`, `get_tool` and `call_tool`.
|
|
82
|
+
* Free. Each tool's `_meta["assay/price"]` is kept as its quote for paid calls (G3).
|
|
83
|
+
*/
|
|
84
|
+
list(options?: ListOptions): Promise<ListResult>;
|
|
85
|
+
/** `get_tool`: one tool's description, schemas, price and version. Free. */
|
|
86
|
+
get(name: string): Promise<ToolInfo>;
|
|
87
|
+
/**
|
|
88
|
+
* Calls a tool. A paid tool's `PAYMENT_REQUIRED` is checked by every local guard, offered to
|
|
89
|
+
* `onPayment`, signed once, and the same call is sent again with the voucher and the same
|
|
90
|
+
* idempotency key. Network retries resend the same request; nothing is ever signed twice for
|
|
91
|
+
* one attempt. Assay refusals are thrown as typed errors.
|
|
92
|
+
*/
|
|
93
|
+
call(name: string, args?: Record<string, unknown>, options?: CallOptions): Promise<ToolResult>;
|
|
94
|
+
/** G3: the cached quote, or a fresh `get_tool` just before signing. */
|
|
95
|
+
quoteFor(tool: string): Promise<PriceQuote | null>;
|
|
96
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { namespaceOf } from "../core/caip.js";
|
|
3
|
+
import { CALL_TOOL, ERROR_META, GET_TOOL, IDEMPOTENCY_META, IN_PROGRESS_CODE, isIdempotencyKey, isObject, isToolName, PAYMENT_REQUIRED_CODE, PAYMENT_REQUIRED_META, PRICE_META, quoteOf, RECEIPT_META, REPLAYED_META, SEARCH_TOOLS, structuredOf, VOUCHER_META, } from "../core/meta.js";
|
|
4
|
+
import { AssayError, ConfigError, errorFromServer, PaymentError, PaymentNetworkUnknownError, PaymentRefusedError, PaymentRequiredError, PaymentSchemeUnsupportedError, ServerError, } from "../errors.js";
|
|
5
|
+
import { MAX_RETRY_AFTER_MS } from "../http.js";
|
|
6
|
+
function assayErrorOf(result) {
|
|
7
|
+
const meta = isObject(result._meta) ? result._meta : null;
|
|
8
|
+
return meta && isObject(meta[ERROR_META]) && typeof meta[ERROR_META].code === "string" ? meta[ERROR_META] : null;
|
|
9
|
+
}
|
|
10
|
+
function textOf(result) {
|
|
11
|
+
const c = Array.isArray(result.content) ? result.content : [];
|
|
12
|
+
const t = c.find((x) => isObject(x) && x.type === "text" && typeof x.text === "string");
|
|
13
|
+
return t?.text;
|
|
14
|
+
}
|
|
15
|
+
/** The typed error for `_meta["assay/error"]`. */
|
|
16
|
+
function toolError(e, result) {
|
|
17
|
+
const code = e.code;
|
|
18
|
+
const message = typeof e.message === "string" ? e.message : (textOf(result) ?? code);
|
|
19
|
+
const secs = typeof e.retryAfter === "number" ? e.retryAfter * 1000 : undefined;
|
|
20
|
+
const wait = typeof e.retryAfterMs === "number" ? e.retryAfterMs : secs;
|
|
21
|
+
const details = isObject(e.details) ? { ...e.details } : {};
|
|
22
|
+
if (typeof e.intentId === "string")
|
|
23
|
+
details.intentId = e.intentId;
|
|
24
|
+
// A scope refusal on a tool call names the scope on `assay/error` itself, not under `details`.
|
|
25
|
+
if (typeof e.requiredScope === "string" && details.requiredScope === undefined)
|
|
26
|
+
details.requiredScope = e.requiredScope;
|
|
27
|
+
return errorFromServer(code, message, {
|
|
28
|
+
retryable: e.retryable === true,
|
|
29
|
+
...(typeof e.fixUrl === "string" ? { fixUrl: e.fixUrl } : {}),
|
|
30
|
+
...(Object.keys(details).length > 0 ? { details } : {}),
|
|
31
|
+
...(wait !== undefined ? { retryAfterMs: wait } : {}),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function withKey(err, key) {
|
|
35
|
+
err.idempotencyKey ??= key;
|
|
36
|
+
return err;
|
|
37
|
+
}
|
|
38
|
+
function toResult(result, idempotencyKey) {
|
|
39
|
+
const meta = isObject(result._meta) ? result._meta : {};
|
|
40
|
+
const receipt = isObject(meta[RECEIPT_META]) ? meta[RECEIPT_META] : null;
|
|
41
|
+
return {
|
|
42
|
+
content: Array.isArray(result.content) ? result.content : [],
|
|
43
|
+
...(isObject(result.structuredContent) ? { structuredContent: result.structuredContent } : {}),
|
|
44
|
+
isError: result.isError === true,
|
|
45
|
+
receipt,
|
|
46
|
+
replayed: meta[REPLAYED_META] === true,
|
|
47
|
+
idempotencyKey,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Tools over MCP: search, describe and call, with paid calls signed by the caller's signer. */
|
|
51
|
+
export class Tools {
|
|
52
|
+
#c;
|
|
53
|
+
constructor(context) {
|
|
54
|
+
this.#c = context;
|
|
55
|
+
}
|
|
56
|
+
/** `search_tools`: tools this URL can run, by what they do. Free. */
|
|
57
|
+
async search(query, options = {}) {
|
|
58
|
+
const out = await this.#meta(SEARCH_TOOLS, { query, ...options });
|
|
59
|
+
const results = Array.isArray(out.results)
|
|
60
|
+
? out.results.filter((r) => isObject(r) && isToolName(r.name))
|
|
61
|
+
: [];
|
|
62
|
+
for (const r of results)
|
|
63
|
+
this.#remember(r.name, quoteOf(r.assayPrice));
|
|
64
|
+
return { results, nextCursor: typeof out.nextCursor === "string" ? out.nextCursor : null };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* `tools/list`: the tools this URL lists. A tool set, topic, maker or `?tools=` URL lists its
|
|
68
|
+
* tools; Everything (`/`) lists only a few, plus `search_tools`, `get_tool` and `call_tool`.
|
|
69
|
+
* Free. Each tool's `_meta["assay/price"]` is kept as its quote for paid calls (G3).
|
|
70
|
+
*/
|
|
71
|
+
async list(options = {}) {
|
|
72
|
+
const out = await this.#c.mcp.request("tools/list", options.cursor !== undefined ? { cursor: options.cursor } : {});
|
|
73
|
+
const tools = Array.isArray(out.tools)
|
|
74
|
+
? out.tools.filter((t) => isObject(t) && isToolName(t.name))
|
|
75
|
+
: [];
|
|
76
|
+
for (const t of tools)
|
|
77
|
+
this.#remember(t.name, quoteOf(isObject(t._meta) ? t._meta[PRICE_META] : undefined));
|
|
78
|
+
return {
|
|
79
|
+
tools,
|
|
80
|
+
nextCursor: typeof out.nextCursor === "string" ? out.nextCursor : null,
|
|
81
|
+
meta: isObject(out._meta) ? { ...out._meta } : {},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** `get_tool`: one tool's description, schemas, price and version. Free. */
|
|
85
|
+
async get(name) {
|
|
86
|
+
const out = await this.#meta(GET_TOOL, { name });
|
|
87
|
+
if (typeof out.name !== "string")
|
|
88
|
+
throw new ServerError("MCP_BAD_ANSWER", "get_tool answered without a tool name");
|
|
89
|
+
this.#remember(out.name, quoteOf(out.price));
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Calls a tool. A paid tool's `PAYMENT_REQUIRED` is checked by every local guard, offered to
|
|
94
|
+
* `onPayment`, signed once, and the same call is sent again with the voucher and the same
|
|
95
|
+
* idempotency key. Network retries resend the same request; nothing is ever signed twice for
|
|
96
|
+
* one attempt. Assay refusals are thrown as typed errors.
|
|
97
|
+
*/
|
|
98
|
+
async call(name, args = {}, options = {}) {
|
|
99
|
+
if (!isToolName(name))
|
|
100
|
+
throw new ConfigError("name is not a tool name");
|
|
101
|
+
if (!isObject(args))
|
|
102
|
+
throw new ConfigError("arguments must be an object");
|
|
103
|
+
const key = options.idempotencyKey ?? randomUUID();
|
|
104
|
+
if (!isIdempotencyKey(key))
|
|
105
|
+
throw new ConfigError("idempotencyKey must be 1 to 128 printable ASCII characters");
|
|
106
|
+
// One logical call per key in this client: a second call with the key while the first runs
|
|
107
|
+
// waits for it, so at most one voucher is ever signed for it.
|
|
108
|
+
const fingerprint = JSON.stringify([name, args]);
|
|
109
|
+
const running = this.#c.inFlight.get(key);
|
|
110
|
+
if (running) {
|
|
111
|
+
if (running.fingerprint !== fingerprint) {
|
|
112
|
+
throw withKey(new AssayError("IDEMPOTENCY_KEY_REUSED", "this idempotency key is in use for another call"), key);
|
|
113
|
+
}
|
|
114
|
+
return running.promise;
|
|
115
|
+
}
|
|
116
|
+
const promise = this.#call(name, args, key).catch((err) => {
|
|
117
|
+
throw err instanceof AssayError ? withKey(err, key) : err;
|
|
118
|
+
});
|
|
119
|
+
this.#c.inFlight.set(key, { fingerprint, promise });
|
|
120
|
+
try {
|
|
121
|
+
return await promise;
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
this.#c.inFlight.delete(key);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async #call(name, args, key) {
|
|
128
|
+
const params = { name, arguments: args, _meta: { [IDEMPOTENCY_META]: key } };
|
|
129
|
+
const calledAt = Math.floor(this.#c.now() / 1000);
|
|
130
|
+
const first = await this.#send(params);
|
|
131
|
+
const refusal = assayErrorOf(first);
|
|
132
|
+
if (refusal === null)
|
|
133
|
+
return toResult(first, key);
|
|
134
|
+
const meta = first._meta;
|
|
135
|
+
const requirement = meta[PAYMENT_REQUIRED_META];
|
|
136
|
+
if (refusal.code !== PAYMENT_REQUIRED_CODE || requirement === undefined || requirement === null)
|
|
137
|
+
throw toolError(refusal, first);
|
|
138
|
+
const fixUrl = typeof refusal.fixUrl === "string" ? { fixUrl: refusal.fixUrl } : {};
|
|
139
|
+
if (this.#c.signers.length === 0) {
|
|
140
|
+
throw new PaymentRequiredError(`${name} is a paid tool and this client has no signer`, requirement, fixUrl);
|
|
141
|
+
}
|
|
142
|
+
const network = isObject(requirement) && typeof requirement.network === "string" ? requirement.network : "unknown";
|
|
143
|
+
const ns = namespaceOf(network);
|
|
144
|
+
const signer = this.#c.signers.find((s) => s.namespace === ns);
|
|
145
|
+
const scheme = ns === null ? undefined : this.#c.schemes.get(ns);
|
|
146
|
+
if (!signer || !scheme)
|
|
147
|
+
throw new PaymentSchemeUnsupportedError(network);
|
|
148
|
+
const tool = name === CALL_TOOL ? args.name : name;
|
|
149
|
+
if (!isToolName(tool))
|
|
150
|
+
throw new PaymentRefusedError("G1", "G1_NOT_FORWARDED", "the paid tool's name is not readable");
|
|
151
|
+
const input = {
|
|
152
|
+
requirement,
|
|
153
|
+
tool,
|
|
154
|
+
calledAt,
|
|
155
|
+
signer,
|
|
156
|
+
idempotencyKey: key,
|
|
157
|
+
quote: (t) => this.quoteFor(t),
|
|
158
|
+
};
|
|
159
|
+
const prepared = await scheme.prepare(input);
|
|
160
|
+
if (!prepared.ok) {
|
|
161
|
+
if (prepared.code === "G2_NETWORK")
|
|
162
|
+
throw new PaymentNetworkUnknownError(network);
|
|
163
|
+
throw new PaymentRefusedError(prepared.guard, prepared.code, prepared.reason, fixUrl);
|
|
164
|
+
}
|
|
165
|
+
if (this.#c.onPayment) {
|
|
166
|
+
let verdict;
|
|
167
|
+
try {
|
|
168
|
+
verdict = await this.#c.onPayment(Object.freeze({ ...prepared.details }));
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
prepared.release();
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
if (verdict === false) {
|
|
175
|
+
prepared.release();
|
|
176
|
+
throw new PaymentRefusedError("onPayment", "PAYMENT_REFUSED_BY_CALLER", "the onPayment hook refused it");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
let envelope;
|
|
180
|
+
try {
|
|
181
|
+
envelope = await prepared.sign();
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
prepared.release();
|
|
185
|
+
if (err instanceof AssayError)
|
|
186
|
+
throw err;
|
|
187
|
+
throw new PaymentError("SIGNER_FAILED", "the signer did not sign the voucher", { cause: err });
|
|
188
|
+
}
|
|
189
|
+
const paid = await this.#send({ ...params, _meta: { ...params._meta, [VOUCHER_META]: envelope } });
|
|
190
|
+
const after = assayErrorOf(paid);
|
|
191
|
+
if (after === null)
|
|
192
|
+
return toResult(paid, key);
|
|
193
|
+
await scheme.onRefused(after.code, input);
|
|
194
|
+
throw toolError(after, paid);
|
|
195
|
+
}
|
|
196
|
+
/** One `tools/call`; an `IDEMPOTENCY_IN_PROGRESS` answer waits and sends the same request again. */
|
|
197
|
+
async #send(params) {
|
|
198
|
+
for (let attempt = 0;; attempt++) {
|
|
199
|
+
const result = await this.#c.mcp.request("tools/call", params);
|
|
200
|
+
const e = assayErrorOf(result);
|
|
201
|
+
// The first attempt still runs, or a refusal-rate limit: wait and send the same bytes again.
|
|
202
|
+
if ((e?.code !== IN_PROGRESS_CODE && e?.code !== "RATE_LIMITED") || attempt >= this.#c.maxRetries)
|
|
203
|
+
return result;
|
|
204
|
+
const wait = typeof e.retryAfterMs === "number"
|
|
205
|
+
? e.retryAfterMs
|
|
206
|
+
: typeof e.retryAfter === "number"
|
|
207
|
+
? e.retryAfter * 1000
|
|
208
|
+
: 1000;
|
|
209
|
+
if (wait > MAX_RETRY_AFTER_MS)
|
|
210
|
+
return result;
|
|
211
|
+
await this.#c.sleep(wait);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async #meta(tool, args) {
|
|
215
|
+
const result = await this.#send({ name: tool, arguments: args });
|
|
216
|
+
const e = assayErrorOf(result);
|
|
217
|
+
if (e)
|
|
218
|
+
throw toolError(e, result);
|
|
219
|
+
const out = structuredOf(result);
|
|
220
|
+
if (out === null)
|
|
221
|
+
throw new AssayError("TOOL_ERROR", textOf(result) ?? `${tool} failed`);
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
#remember(name, quote) {
|
|
225
|
+
if (quote)
|
|
226
|
+
this.#c.quotes.set(name, quote);
|
|
227
|
+
}
|
|
228
|
+
/** G3: the cached quote, or a fresh `get_tool` just before signing. */
|
|
229
|
+
async quoteFor(tool) {
|
|
230
|
+
const cached = this.#c.quotes.get(tool);
|
|
231
|
+
if (cached)
|
|
232
|
+
return cached;
|
|
233
|
+
try {
|
|
234
|
+
const info = await this.get(tool);
|
|
235
|
+
return info.name === tool ? this.#c.quotes.get(tool) : null;
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
package/dist/rest.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Http } from "./http.js";
|
|
2
|
+
/** A REST answer's `data` and its `Date` header. */
|
|
3
|
+
export interface RestAnswer {
|
|
4
|
+
data: unknown;
|
|
5
|
+
date: string | null;
|
|
6
|
+
}
|
|
7
|
+
/** The REST API (spec section 2.2): `{data}` on success, a typed error from `{error}` otherwise. */
|
|
8
|
+
export declare class RestClient {
|
|
9
|
+
#private;
|
|
10
|
+
private readonly http;
|
|
11
|
+
private readonly base;
|
|
12
|
+
private readonly now;
|
|
13
|
+
constructor(http: Http, base: string, now?: () => number);
|
|
14
|
+
/** GETs are retried; POSTs are not (a challenge or a bind is not resent blindly). */
|
|
15
|
+
get(path: string): Promise<RestAnswer>;
|
|
16
|
+
post(path: string, body: unknown): Promise<RestAnswer>;
|
|
17
|
+
}
|