@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
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
|
|
2
|
+
/** Where the SDK writes what it does. Silent by default. Fields never carry keys or signatures. */
|
|
3
|
+
export interface Logger {
|
|
4
|
+
debug(message: string, fields?: Record<string, unknown>): void;
|
|
5
|
+
warn(message: string, fields?: Record<string, unknown>): void;
|
|
6
|
+
}
|
|
7
|
+
export declare const silentLogger: Logger;
|
|
8
|
+
/** JSON-RPC "rate limited" (spec section 2.1). */
|
|
9
|
+
export declare const RPC_RATE_LIMITED = -32029;
|
|
10
|
+
/** A server asking for a longer wait than this is not retried; the caller gets the error. */
|
|
11
|
+
export declare const MAX_RETRY_AFTER_MS = 60000;
|
|
12
|
+
/** A copy of `fields` with anything that looks secret removed. Applied to every log line. */
|
|
13
|
+
export declare function redact(fields: Record<string, unknown>): Record<string, unknown>;
|
|
14
|
+
export interface HttpOptions {
|
|
15
|
+
fetch: FetchLike;
|
|
16
|
+
apiKey: string;
|
|
17
|
+
/** The only origins the API key is sent to: the MCP and the API origin. */
|
|
18
|
+
origins: readonly string[];
|
|
19
|
+
timeoutMs: number;
|
|
20
|
+
maxRetries: number;
|
|
21
|
+
logger: Logger;
|
|
22
|
+
now: () => number;
|
|
23
|
+
sleep: (ms: number) => Promise<void>;
|
|
24
|
+
userAgent: string;
|
|
25
|
+
}
|
|
26
|
+
export interface HttpRequest {
|
|
27
|
+
url: string;
|
|
28
|
+
method: "GET" | "POST";
|
|
29
|
+
headers?: Record<string, string>;
|
|
30
|
+
body?: string;
|
|
31
|
+
/** Retry connection failures and retryable statuses (safe: reads, and idempotent MCP calls). */
|
|
32
|
+
retry: boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface HttpResponse {
|
|
35
|
+
status: number;
|
|
36
|
+
headers: Headers;
|
|
37
|
+
text: string;
|
|
38
|
+
}
|
|
39
|
+
/** `Retry-After` (seconds or an HTTP date) in milliseconds, or undefined. */
|
|
40
|
+
export declare function retryAfterMs(value: string | null, nowMs: number): number | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* One origin-pinned HTTP client for the MCP and API hosts: the bearer goes only to the configured
|
|
43
|
+
* origins, redirects are an error, every attempt has a timeout, and retries back off and honour
|
|
44
|
+
* `Retry-After`. Every answer's `Date` header updates the clock offset (spec section 7).
|
|
45
|
+
*/
|
|
46
|
+
export declare class Http {
|
|
47
|
+
#private;
|
|
48
|
+
/** Server clock minus local clock, seconds, from the last answer. */
|
|
49
|
+
dateOffset: number;
|
|
50
|
+
constructor(options: HttpOptions);
|
|
51
|
+
request(req: HttpRequest): Promise<HttpResponse>;
|
|
52
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { dateHeaderOffset } from "./core/clock.js";
|
|
2
|
+
import { NetworkError, TimeoutError } from "./errors.js";
|
|
3
|
+
import { redactMcpUrl } from "./mcp-url.js";
|
|
4
|
+
export const silentLogger = { debug() { }, warn() { } };
|
|
5
|
+
/** Statuses retried with backoff (with `Retry-After` honoured). */
|
|
6
|
+
const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
|
|
7
|
+
/** JSON-RPC "rate limited" (spec section 2.1). */
|
|
8
|
+
export const RPC_RATE_LIMITED = -32029;
|
|
9
|
+
const BASE_DELAY_MS = 500;
|
|
10
|
+
const MAX_BACKOFF_MS = 8_000;
|
|
11
|
+
/** A server asking for a longer wait than this is not retried; the caller gets the error. */
|
|
12
|
+
export const MAX_RETRY_AFTER_MS = 60_000;
|
|
13
|
+
const SECRET_KEY = /authorization|api[-_]?key|signature|secret|private|token|voucher/i;
|
|
14
|
+
const LONG_HEX = /0x[0-9a-fA-F]{100,}/g;
|
|
15
|
+
/** A copy of `fields` with anything that looks secret removed. Applied to every log line. */
|
|
16
|
+
export function redact(fields) {
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
19
|
+
if (SECRET_KEY.test(k))
|
|
20
|
+
out[k] = "[redacted]";
|
|
21
|
+
else if (typeof v === "string")
|
|
22
|
+
out[k] = v.replace(LONG_HEX, "[redacted]");
|
|
23
|
+
else
|
|
24
|
+
out[k] = v;
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/** `Retry-After` (seconds or an HTTP date) in milliseconds, or undefined. */
|
|
29
|
+
export function retryAfterMs(value, nowMs) {
|
|
30
|
+
if (value === null || value.trim() === "")
|
|
31
|
+
return undefined;
|
|
32
|
+
if (/^\d+$/.test(value.trim()))
|
|
33
|
+
return Number(value.trim()) * 1000;
|
|
34
|
+
const at = Date.parse(value);
|
|
35
|
+
return Number.isFinite(at) ? Math.max(0, at - nowMs) : undefined;
|
|
36
|
+
}
|
|
37
|
+
/** `data.retryAfterMs` of a JSON-RPC -32029 answer, or `undefined` when the body is not one. */
|
|
38
|
+
function rpcRateLimit(text) {
|
|
39
|
+
if (!text.includes(String(RPC_RATE_LIMITED)))
|
|
40
|
+
return undefined;
|
|
41
|
+
try {
|
|
42
|
+
const msg = JSON.parse(text);
|
|
43
|
+
if (msg?.error?.code !== RPC_RATE_LIMITED)
|
|
44
|
+
return undefined;
|
|
45
|
+
const ms = msg.error.data?.retryAfterMs;
|
|
46
|
+
return { retryAfterMs: typeof ms === "number" && Number.isFinite(ms) && ms >= 0 ? ms : undefined };
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
class RedirectRefused extends Error {
|
|
53
|
+
}
|
|
54
|
+
/** `fetch` with `redirect: "error"` rejects a redirect with a TypeError whose cause says so. */
|
|
55
|
+
function isRedirectError(err) {
|
|
56
|
+
const cause = err instanceof Error ? err.cause : undefined;
|
|
57
|
+
return typeof cause?.message === "string" && /redirect/i.test(cause.message);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* One origin-pinned HTTP client for the MCP and API hosts: the bearer goes only to the configured
|
|
61
|
+
* origins, redirects are an error, every attempt has a timeout, and retries back off and honour
|
|
62
|
+
* `Retry-After`. Every answer's `Date` header updates the clock offset (spec section 7).
|
|
63
|
+
*/
|
|
64
|
+
export class Http {
|
|
65
|
+
#o;
|
|
66
|
+
/** Server clock minus local clock, seconds, from the last answer. */
|
|
67
|
+
dateOffset = 0;
|
|
68
|
+
constructor(options) {
|
|
69
|
+
this.#o = options;
|
|
70
|
+
}
|
|
71
|
+
async request(req) {
|
|
72
|
+
const url = new URL(req.url);
|
|
73
|
+
if (!this.#o.origins.includes(url.origin))
|
|
74
|
+
throw new NetworkError("refusing a request to an origin the client was not configured for");
|
|
75
|
+
const attempts = req.retry ? this.#o.maxRetries + 1 : 1;
|
|
76
|
+
for (let attempt = 0;; attempt++) {
|
|
77
|
+
const last = attempt + 1 >= attempts;
|
|
78
|
+
const started = this.#o.now();
|
|
79
|
+
let res;
|
|
80
|
+
let text;
|
|
81
|
+
const signal = AbortSignal.timeout(this.#o.timeoutMs);
|
|
82
|
+
try {
|
|
83
|
+
res = await this.#o.fetch(req.url, {
|
|
84
|
+
method: req.method,
|
|
85
|
+
headers: {
|
|
86
|
+
accept: "application/json",
|
|
87
|
+
"user-agent": this.#o.userAgent,
|
|
88
|
+
...req.headers,
|
|
89
|
+
authorization: `Bearer ${this.#o.apiKey}`,
|
|
90
|
+
},
|
|
91
|
+
...(req.body !== undefined ? { body: req.body } : {}),
|
|
92
|
+
redirect: "error",
|
|
93
|
+
signal,
|
|
94
|
+
});
|
|
95
|
+
// Also refuse a redirect an injected fetch followed: the bearer must not leave the origin.
|
|
96
|
+
if (res.redirected ||
|
|
97
|
+
(res.url !== "" && new URL(res.url).origin !== url.origin) ||
|
|
98
|
+
(res.status >= 300 && res.status < 400)) {
|
|
99
|
+
await res.body?.cancel().catch(() => undefined);
|
|
100
|
+
throw new RedirectRefused();
|
|
101
|
+
}
|
|
102
|
+
text = await res.text();
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
if (err instanceof RedirectRefused || isRedirectError(err))
|
|
106
|
+
throw new NetworkError("refusing to follow a redirect", err, "REDIRECT_REFUSED");
|
|
107
|
+
const timedOut = signal.aborted;
|
|
108
|
+
this.#log(req, attempt, { error: timedOut ? "timeout" : "network" }, started);
|
|
109
|
+
if (!last) {
|
|
110
|
+
await this.#o.sleep(this.#backoff(attempt));
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
throw timedOut ? new TimeoutError(undefined, err) : new NetworkError("could not reach Assay", err);
|
|
114
|
+
}
|
|
115
|
+
this.dateOffset = dateHeaderOffset(res.headers.get("date"), this.#o.now() / 1000);
|
|
116
|
+
this.#log(req, attempt, { status: res.status }, started);
|
|
117
|
+
if (!last) {
|
|
118
|
+
const rpc = rpcRateLimit(text);
|
|
119
|
+
if (RETRY_STATUSES.has(res.status) || rpc !== undefined) {
|
|
120
|
+
const wait = retryAfterMs(res.headers.get("retry-after"), this.#o.now()) ?? rpc?.retryAfterMs;
|
|
121
|
+
if (wait === undefined || wait <= MAX_RETRY_AFTER_MS) {
|
|
122
|
+
await this.#o.sleep(wait ?? this.#backoff(attempt));
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { status: res.status, headers: res.headers, text };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
#backoff(attempt) {
|
|
131
|
+
const base = Math.min(MAX_BACKOFF_MS, BASE_DELAY_MS * 2 ** attempt);
|
|
132
|
+
return Math.round(base * (0.75 + Math.random() * 0.25));
|
|
133
|
+
}
|
|
134
|
+
#log(req, attempt, extra, started) {
|
|
135
|
+
// A link-only token (`/u/{token}`) is a secret: logged as `/u/…`.
|
|
136
|
+
const path = redactMcpUrl(new URL(req.url).pathname.split("/").slice(0, 4).join("/"));
|
|
137
|
+
this.#o.logger.debug("assay request", redact({ method: req.method, path, attempt, ms: this.#o.now() - started, ...extra }));
|
|
138
|
+
}
|
|
139
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { Assay, DEFAULT_API_URL, DEFAULT_MCP_URL, DEFAULT_WEB_URL, type AssayOptions } from "./client.js";
|
|
2
|
+
export { BUILT_IN_NETWORKS } from "./networks.js";
|
|
3
|
+
export { AssayError, AuthError, ConfigError, DelegateAlreadyBoundError, DelegateBindConflictError, HumanSessionRequiredError, McpUrlNotFoundError, NetworkError, PaymentError, PaymentNetworkUnknownError, PaymentRefusedError, PaymentRequiredError, PaymentSchemeUnsupportedError, RateLimitError, ScopeMissingError, ServerError, TimeoutError, } from "./errors.js";
|
|
4
|
+
export { evmSigner, type EvmSigner, type Signer, type TypedData, type ViemLikeAccount } from "./signer.js";
|
|
5
|
+
export type { Logger } from "./http.js";
|
|
6
|
+
export type { OnPayment } from "./resources/context.js";
|
|
7
|
+
export type { PaymentDetails } from "./schemes/types.js";
|
|
8
|
+
export type { Eip155Network, Limits } from "./schemes/eip155/index.js";
|
|
9
|
+
export type { CallOptions, CallReceipt, ListOptions, ListResult, SearchOptions, SearchResult, ToolInfo, ToolResult, ToolSummary, } from "./resources/tools.js";
|
|
10
|
+
export type { Receipt } from "./resources/receipts.js";
|
|
11
|
+
export type { BindResult, PaidCallsState, PaidCallsStatus, WaitOptions } from "./resources/paid-calls.js";
|
|
12
|
+
export type { Money } from "./core/voucher.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { Assay, DEFAULT_API_URL, DEFAULT_MCP_URL, DEFAULT_WEB_URL } from "./client.js";
|
|
2
|
+
export { BUILT_IN_NETWORKS } from "./networks.js";
|
|
3
|
+
export { AssayError, AuthError, ConfigError, DelegateAlreadyBoundError, DelegateBindConflictError, HumanSessionRequiredError, McpUrlNotFoundError, NetworkError, PaymentError, PaymentNetworkUnknownError, PaymentRefusedError, PaymentRequiredError, PaymentSchemeUnsupportedError, RateLimitError, ScopeMissingError, ServerError, TimeoutError, } from "./errors.js";
|
|
4
|
+
export { evmSigner } from "./signer.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP URL shapes the Assay MCP host serves (spec section 2.3), checked when a client is built
|
|
3
|
+
* so a typo is a `ConfigError` at once, not a 404 on the first call. Only the shape is checked:
|
|
4
|
+
* whether the tool set exists, or is shared with this key, is the server's answer.
|
|
5
|
+
*
|
|
6
|
+
* To support a new shape, add one line to `PATH_SHAPES` or `FILTERS`.
|
|
7
|
+
*/
|
|
8
|
+
/** The MCP origin a bare path (`"/my/research"`) resolves against. */
|
|
9
|
+
export declare const DEFAULT_MCP_ORIGIN = "https://mcp.goassay.ai";
|
|
10
|
+
/**
|
|
11
|
+
* `value` (a full MCP URL, or a path such as `"/my/research"` resolved against `origin`) as the
|
|
12
|
+
* URL to send to. Throws `ConfigError` for a path the MCP host does not serve. The URL is sent as
|
|
13
|
+
* given: the server reads it into its canonical form.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveMcpUrl(name: string, value: string, origin: string): string;
|
|
16
|
+
/** `url` with a link-only token shown as `/u/…`: for `toString`, `toJSON`, `inspect` and logs. */
|
|
17
|
+
export declare function redactMcpUrl(url: string): string;
|
package/dist/mcp-url.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { ConfigError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* The MCP URL shapes the Assay MCP host serves (spec section 2.3), checked when a client is built
|
|
4
|
+
* so a typo is a `ConfigError` at once, not a 404 on the first call. Only the shape is checked:
|
|
5
|
+
* whether the tool set exists, or is shared with this key, is the server's answer.
|
|
6
|
+
*
|
|
7
|
+
* To support a new shape, add one line to `PATH_SHAPES` or `FILTERS`.
|
|
8
|
+
*/
|
|
9
|
+
/** The MCP origin a bare path (`"/my/research"`) resolves against. */
|
|
10
|
+
export const DEFAULT_MCP_ORIGIN = "https://mcp.goassay.ai";
|
|
11
|
+
const SLUG = "[a-z0-9-]{2,64}";
|
|
12
|
+
const HANDLE = "[a-z][a-z0-9-]{2,23}";
|
|
13
|
+
const CATEGORY = "[a-z][a-z0-9-]{1,63}";
|
|
14
|
+
/** A link-only token: a secret, never printed (see {@link redactMcpUrl}). */
|
|
15
|
+
const TOKEN = "[a-z0-9_-]{22,64}";
|
|
16
|
+
const VERSION = "(?:@[1-9][0-9]{0,5})?";
|
|
17
|
+
/** Paths, matched lowercased with `%40` read as `@` and one trailing slash removed. */
|
|
18
|
+
const PATH_SHAPES = [
|
|
19
|
+
/^\/$/, // Everything
|
|
20
|
+
new RegExp(`^/my/${SLUG}${VERSION}$`), // my tool set: Draft, or version n
|
|
21
|
+
new RegExp(`^/@${HANDLE}/${SLUG}${VERSION}$`), // a public tool set
|
|
22
|
+
new RegExp(`^/u/${TOKEN}${VERSION}$`), // a link-only tool set
|
|
23
|
+
new RegExp(`^/c/${CATEGORY}$`), // a topic
|
|
24
|
+
new RegExp(`^/@${HANDLE}$`), // one maker's tools
|
|
25
|
+
];
|
|
26
|
+
const TOOL_SELECTOR = /^[a-z][a-z0-9_-]{1,47}(?:@[1-9][0-9]{0,5})?$/;
|
|
27
|
+
const CATEGORY_RE = new RegExp(`^${CATEGORY}$`);
|
|
28
|
+
const MAX_TOOLS = 50;
|
|
29
|
+
const MAX_URL_LENGTH = 2048;
|
|
30
|
+
/** `scheme://authority` at the start of a full URL. */
|
|
31
|
+
const ORIGIN = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i;
|
|
32
|
+
/** A comma list, trimmed and lowercased, empty items dropped. */
|
|
33
|
+
const items = (v) => v
|
|
34
|
+
.split(",")
|
|
35
|
+
.map((s) => s.trim().toLowerCase())
|
|
36
|
+
.filter((s) => s !== "");
|
|
37
|
+
/** Query filters (they only narrow what a URL serves): name -> is this value accepted. */
|
|
38
|
+
const FILTERS = {
|
|
39
|
+
tools: (v) => items(v).every((t) => TOOL_SELECTOR.test(t)),
|
|
40
|
+
category: (v) => items(v).every((c) => CATEGORY_RE.test(c)),
|
|
41
|
+
free: (v) => v.trim() === "0" || v.trim() === "1",
|
|
42
|
+
discovery: (v) => ["auto", "full", "search"].includes(v.trim().toLowerCase()),
|
|
43
|
+
};
|
|
44
|
+
/** Throws `ConfigError` unless `pathAndQuery` (from the first `/`, no fragment) has a served shape. */
|
|
45
|
+
function checkShape(name, pathAndQuery) {
|
|
46
|
+
if (pathAndQuery.length > MAX_URL_LENGTH)
|
|
47
|
+
throw new ConfigError(`${name} is too long`);
|
|
48
|
+
const q = pathAndQuery.indexOf("?");
|
|
49
|
+
const rawPath = q === -1 ? pathAndQuery : pathAndQuery.slice(0, q);
|
|
50
|
+
const decoded = rawPath.replace(/%40/gi, "@");
|
|
51
|
+
if (decoded.includes("%"))
|
|
52
|
+
throw new ConfigError(`${name} has a percent-escape in its path`);
|
|
53
|
+
let path = decoded.toLowerCase();
|
|
54
|
+
if (path.length > 1 && path.endsWith("/"))
|
|
55
|
+
path = path.slice(0, -1);
|
|
56
|
+
if (!PATH_SHAPES.some((shape) => shape.test(path)))
|
|
57
|
+
throw new ConfigError(`${name} is not an MCP URL Assay serves (see "Choose a URL")`);
|
|
58
|
+
const params = new URLSearchParams(q === -1 ? "" : pathAndQuery.slice(q + 1));
|
|
59
|
+
const tools = new Set();
|
|
60
|
+
for (const [key, value] of params) {
|
|
61
|
+
const accepts = Object.hasOwn(FILTERS, key) ? FILTERS[key] : undefined;
|
|
62
|
+
if (!accepts)
|
|
63
|
+
throw new ConfigError(`${name} has an unknown filter "${key.slice(0, 32)}"`);
|
|
64
|
+
if (!accepts(value))
|
|
65
|
+
throw new ConfigError(`${name} has an invalid "${key}" filter`);
|
|
66
|
+
if (key === "tools")
|
|
67
|
+
for (const t of items(value))
|
|
68
|
+
tools.add(t);
|
|
69
|
+
}
|
|
70
|
+
if (tools.size > MAX_TOOLS)
|
|
71
|
+
throw new ConfigError(`${name} names more than ${MAX_TOOLS} tools`);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* `value` (a full MCP URL, or a path such as `"/my/research"` resolved against `origin`) as the
|
|
75
|
+
* URL to send to. Throws `ConfigError` for a path the MCP host does not serve. The URL is sent as
|
|
76
|
+
* given: the server reads it into its canonical form.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveMcpUrl(name, value, origin) {
|
|
79
|
+
if (typeof value !== "string" || value.trim() !== value || value === "")
|
|
80
|
+
throw new ConfigError(`${name} is not a URL`);
|
|
81
|
+
if (value.includes("#"))
|
|
82
|
+
throw new ConfigError(`${name} must not have a fragment`);
|
|
83
|
+
if (value.startsWith("/")) {
|
|
84
|
+
if (value.startsWith("//"))
|
|
85
|
+
throw new ConfigError(`${name} is not a URL`);
|
|
86
|
+
checkShape(name, value);
|
|
87
|
+
return origin + value;
|
|
88
|
+
}
|
|
89
|
+
// The raw text after the origin: `URL` would quietly resolve `.` and `..`, which the host refuses.
|
|
90
|
+
const rest = value.replace(ORIGIN, "");
|
|
91
|
+
checkShape(name, rest === "" ? "/" : rest.startsWith("?") ? `/${rest}` : rest);
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
/** `url` with a link-only token shown as `/u/…`: for `toString`, `toJSON`, `inspect` and logs. */
|
|
95
|
+
export function redactMcpUrl(url) {
|
|
96
|
+
const origin = ORIGIN.exec(url)?.[0] ?? "";
|
|
97
|
+
return origin + url.slice(origin.length).replace(/\/u\/[^/?#@]+/gi, "/u/…");
|
|
98
|
+
}
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { AssayError } from "./errors.js";
|
|
2
|
+
import { type Http } from "./http.js";
|
|
3
|
+
/** The MCP protocol revision this release speaks: per-request envelope, no handshake. */
|
|
4
|
+
export declare const MCP_PROTOCOL_VERSION = "2026-07-28";
|
|
5
|
+
/**
|
|
6
|
+
* A minimal MCP client over HTTP (spec section 2.1): one JSON-RPC request per POST, no session,
|
|
7
|
+
* no handshake. The protocol revision rides each request in `_meta` and in headers.
|
|
8
|
+
*/
|
|
9
|
+
export declare class McpTransport {
|
|
10
|
+
#private;
|
|
11
|
+
private readonly http;
|
|
12
|
+
private readonly url;
|
|
13
|
+
private readonly clientInfo;
|
|
14
|
+
private readonly now;
|
|
15
|
+
constructor(http: Http, url: string, clientInfo: {
|
|
16
|
+
name: string;
|
|
17
|
+
version: string;
|
|
18
|
+
}, now?: () => number);
|
|
19
|
+
/** Sends one request and returns its `result`. Network retries resend the same bytes. */
|
|
20
|
+
request(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
21
|
+
}
|
|
22
|
+
/** The platform's REST error envelope (spec section 2.2) on a non-JSON-RPC answer, or `null`. */
|
|
23
|
+
export declare function restError(status: number, text: string, retryAfter: string | null, nowMs: number): AssayError | null;
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { isObject } from "./core/meta.js";
|
|
2
|
+
import { AssayError, errorFromServer, McpUrlNotFoundError, RateLimitError, ServerError } from "./errors.js";
|
|
3
|
+
import { RPC_RATE_LIMITED, retryAfterMs } from "./http.js";
|
|
4
|
+
/** The MCP protocol revision this release speaks: per-request envelope, no handshake. */
|
|
5
|
+
export const MCP_PROTOCOL_VERSION = "2026-07-28";
|
|
6
|
+
/**
|
|
7
|
+
* A minimal MCP client over HTTP (spec section 2.1): one JSON-RPC request per POST, no session,
|
|
8
|
+
* no handshake. The protocol revision rides each request in `_meta` and in headers.
|
|
9
|
+
*/
|
|
10
|
+
export class McpTransport {
|
|
11
|
+
http;
|
|
12
|
+
url;
|
|
13
|
+
clientInfo;
|
|
14
|
+
now;
|
|
15
|
+
#id = 1;
|
|
16
|
+
constructor(http, url, clientInfo, now = Date.now) {
|
|
17
|
+
this.http = http;
|
|
18
|
+
this.url = url;
|
|
19
|
+
this.clientInfo = clientInfo;
|
|
20
|
+
this.now = now;
|
|
21
|
+
}
|
|
22
|
+
/** Sends one request and returns its `result`. Network retries resend the same bytes. */
|
|
23
|
+
async request(method, params) {
|
|
24
|
+
const meta = isObject(params._meta) ? params._meta : {};
|
|
25
|
+
const body = {
|
|
26
|
+
jsonrpc: "2.0",
|
|
27
|
+
id: this.#id++,
|
|
28
|
+
method,
|
|
29
|
+
params: {
|
|
30
|
+
...params,
|
|
31
|
+
_meta: {
|
|
32
|
+
...meta,
|
|
33
|
+
"io.modelcontextprotocol/protocolVersion": MCP_PROTOCOL_VERSION,
|
|
34
|
+
"io.modelcontextprotocol/clientCapabilities": {},
|
|
35
|
+
"io.modelcontextprotocol/clientInfo": this.clientInfo,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
const headers = {
|
|
40
|
+
"content-type": "application/json",
|
|
41
|
+
accept: "application/json, text/event-stream",
|
|
42
|
+
"mcp-protocol-version": MCP_PROTOCOL_VERSION,
|
|
43
|
+
"mcp-method": method,
|
|
44
|
+
};
|
|
45
|
+
if (typeof params.name === "string")
|
|
46
|
+
headers["mcp-name"] = params.name;
|
|
47
|
+
const res = await this.http.request({
|
|
48
|
+
url: this.url,
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers,
|
|
51
|
+
body: JSON.stringify(body),
|
|
52
|
+
retry: true,
|
|
53
|
+
});
|
|
54
|
+
const msg = parseMessage(res.text, res.headers.get("content-type"));
|
|
55
|
+
if (!isObject(msg) || msg.jsonrpc !== "2.0") {
|
|
56
|
+
// A 404 from the MCP host is always about the URL (MCP_URL_NOT_FOUND, sent as the REST
|
|
57
|
+
// envelope). The fixed message never repeats the URL, which may hold a link-only token.
|
|
58
|
+
if (res.status === 404)
|
|
59
|
+
throw new McpUrlNotFoundError();
|
|
60
|
+
throw (restError(res.status, res.text, res.headers.get("retry-after"), this.now()) ??
|
|
61
|
+
new ServerError("MCP_BAD_ANSWER", "Assay sent an answer the SDK cannot read", { status: res.status }));
|
|
62
|
+
}
|
|
63
|
+
if (isObject(msg.error))
|
|
64
|
+
throw rpcError(msg.error, res.status, res.headers.get("retry-after"), this.now());
|
|
65
|
+
if (!isObject(msg.result)) {
|
|
66
|
+
throw (restError(res.status, res.text, res.headers.get("retry-after"), this.now()) ??
|
|
67
|
+
new ServerError("MCP_BAD_ANSWER", "Assay sent an answer the SDK cannot read", { status: res.status }));
|
|
68
|
+
}
|
|
69
|
+
return msg.result;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function parseMessage(text, contentType) {
|
|
73
|
+
try {
|
|
74
|
+
if (contentType?.includes("text/event-stream")) {
|
|
75
|
+
for (const line of text.split(/\r?\n/)) {
|
|
76
|
+
if (!line.startsWith("data:"))
|
|
77
|
+
continue;
|
|
78
|
+
const m = JSON.parse(line.slice(5).trim());
|
|
79
|
+
if (isObject(m) && ("result" in m || "error" in m))
|
|
80
|
+
return m;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
return JSON.parse(text);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function rpcError(error, status, retryAfter, nowMs) {
|
|
91
|
+
const message = typeof error.message === "string" ? error.message : "the MCP request failed";
|
|
92
|
+
const data = isObject(error.data) ? error.data : {};
|
|
93
|
+
if (error.code === RPC_RATE_LIMITED) {
|
|
94
|
+
const ms = typeof data.retryAfterMs === "number" ? data.retryAfterMs : retryAfterMs(retryAfter, nowMs);
|
|
95
|
+
return new RateLimitError("RATE_LIMITED", message, { status, ...(ms !== undefined ? { retryAfterMs: ms } : {}) });
|
|
96
|
+
}
|
|
97
|
+
const code = typeof data.code === "string" ? data.code : `MCP_RPC_${String(error.code)}`;
|
|
98
|
+
return errorFromServer(code, message, { status, details: { rpcCode: error.code } });
|
|
99
|
+
}
|
|
100
|
+
/** The platform's REST error envelope (spec section 2.2) on a non-JSON-RPC answer, or `null`. */
|
|
101
|
+
export function restError(status, text, retryAfter, nowMs) {
|
|
102
|
+
let body;
|
|
103
|
+
try {
|
|
104
|
+
body = JSON.parse(text);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
body = null;
|
|
108
|
+
}
|
|
109
|
+
const e = isObject(body) && isObject(body.error) ? body.error : null;
|
|
110
|
+
if (e === null && status >= 200 && status < 300)
|
|
111
|
+
return null;
|
|
112
|
+
const code = e && typeof e.code === "string" ? e.code : undefined;
|
|
113
|
+
const message = e && typeof e.message === "string" ? e.message : `Assay answered HTTP ${status}`;
|
|
114
|
+
const details = e && isObject(e.context) ? e.context : undefined;
|
|
115
|
+
const wait = retryAfterMs(retryAfter, nowMs);
|
|
116
|
+
return errorFromServer(code, message, {
|
|
117
|
+
status,
|
|
118
|
+
...(details ? { details } : {}),
|
|
119
|
+
...(wait !== undefined ? { retryAfterMs: wait } : {}),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type Eip155Network, type NetworkEntry } from "./schemes/eip155/index.js";
|
|
2
|
+
/** The built-in production table. */
|
|
3
|
+
export declare const BUILT_IN_NETWORKS: Readonly<Record<string, Eip155Network>>;
|
|
4
|
+
/** The checked table: the built-in one with `networks` merged over it, then `rpcUrls` applied. */
|
|
5
|
+
export declare function networkTable(networks: Record<string, Eip155Network> | undefined, rpcUrls: Record<string, string[]> | undefined, builtIn?: Readonly<Record<string, Eip155Network>>): ReadonlyMap<string, NetworkEntry>;
|
package/dist/networks.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The payment network table, pinned in the SDK: per CAIP-2 network, the escrow, the asset, its
|
|
3
|
+
* decimals and default public RPC URLs. Payments are checked against this table only, never
|
|
4
|
+
* against the server, so a compromised API cannot redirect them. Changing an address means a new
|
|
5
|
+
* SDK release.
|
|
6
|
+
*
|
|
7
|
+
* The built-in table holds the network Assay's production runs on today, Robinhood Chain testnet
|
|
8
|
+
* (`eip155:46630`), with two RPC hosts from different operators. A paid call or a bind on a
|
|
9
|
+
* network that is not in the table is refused with `PaymentNetworkUnknownError`.
|
|
10
|
+
*
|
|
11
|
+
* The `networks` option configures any chain from outside (a testnet, another deployment): its
|
|
12
|
+
* entries are merged over the built-in table, adding networks, and an entry for a network the
|
|
13
|
+
* table has replaces that entry whole (fields are never mixed from two deployments). `rpcUrls`
|
|
14
|
+
* replaces only a network's RPC URLs.
|
|
15
|
+
*/
|
|
16
|
+
import { eip155ChainId } from "./core/caip.js";
|
|
17
|
+
import { isUsableRpcUrl } from "./core/chain.js";
|
|
18
|
+
import { ConfigError } from "./errors.js";
|
|
19
|
+
import { eip155Networks } from "./schemes/eip155/index.js";
|
|
20
|
+
/** The built-in production table. */
|
|
21
|
+
export const BUILT_IN_NETWORKS = Object.freeze({
|
|
22
|
+
"eip155:46630": Object.freeze({
|
|
23
|
+
chainId: 46630,
|
|
24
|
+
escrow: "eip155:46630:0x2723AD1fEC053Fe05305e4D0c874B0402959Ce18",
|
|
25
|
+
asset: "eip155:46630/erc20:0xDbbC156dD414f43f2c952C1Be0f16A0B9a826Cd3",
|
|
26
|
+
decimals: 6,
|
|
27
|
+
rpcUrls: Object.freeze([
|
|
28
|
+
"https://rpc.testnet.chain.robinhood.com",
|
|
29
|
+
"https://robinhood-sepolia-rpc.publicnode.com",
|
|
30
|
+
]),
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
/** The checked table: the built-in one with `networks` merged over it, then `rpcUrls` applied. */
|
|
34
|
+
export function networkTable(networks, rpcUrls, builtIn = BUILT_IN_NETWORKS) {
|
|
35
|
+
const table = { ...builtIn, ...networks };
|
|
36
|
+
for (const [id, urls] of Object.entries(rpcUrls ?? {})) {
|
|
37
|
+
const ok = Array.isArray(urls) && urls.length > 0 && urls.every((u) => typeof u === "string" && isUsableRpcUrl(u));
|
|
38
|
+
if (eip155ChainId(id) === null || !ok) {
|
|
39
|
+
throw new ConfigError(`rpcUrls.${id} must be a non-empty list of https URLs for an eip155 network`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const merged = {};
|
|
43
|
+
for (const [id, n] of Object.entries(table))
|
|
44
|
+
merged[id] = { ...n, rpcUrls: rpcUrls?.[id] ?? n.rpcUrls };
|
|
45
|
+
return eip155Networks(merged);
|
|
46
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { NetworkEntry } from "../schemes/eip155/index.js";
|
|
2
|
+
import type { PaymentDetails, PaymentScheme } from "../schemes/types.js";
|
|
3
|
+
import type { Http } from "../http.js";
|
|
4
|
+
import type { McpTransport } from "../mcp.js";
|
|
5
|
+
import type { RestClient } from "../rest.js";
|
|
6
|
+
import type { Signer } from "../signer.js";
|
|
7
|
+
import type { PriceQuote } from "../core/meta.js";
|
|
8
|
+
/** The caller's hook before a voucher is signed. Return `false` to refuse; nothing is signed. */
|
|
9
|
+
export type OnPayment = (details: PaymentDetails) => boolean | void | Promise<boolean | void>;
|
|
10
|
+
/**
|
|
11
|
+
* What every resource module shares. One resource area = one module that takes this. A sibling
|
|
12
|
+
* client (`assay.at(url)`) gets a copy with its own `mcpUrl`, `mcp` and `quotes`; every other
|
|
13
|
+
* field is the same object, so the HTTP stack, signers, schemes (spend limits and ledger),
|
|
14
|
+
* `onPayment` and the in-flight map are shared.
|
|
15
|
+
*/
|
|
16
|
+
export interface ClientContext {
|
|
17
|
+
readonly http: Http;
|
|
18
|
+
/** The MCP URL this client sends to. May hold a link-only token: never print it unredacted. */
|
|
19
|
+
readonly mcpUrl: string;
|
|
20
|
+
readonly mcp: McpTransport;
|
|
21
|
+
readonly api: RestClient;
|
|
22
|
+
readonly signers: readonly Signer[];
|
|
23
|
+
readonly schemes: ReadonlyMap<string, PaymentScheme>;
|
|
24
|
+
readonly networks: ReadonlyMap<string, NetworkEntry>;
|
|
25
|
+
/** The web app origin, where a person approves a payment key. */
|
|
26
|
+
readonly webOrigin: string;
|
|
27
|
+
/** Logical calls in flight, by idempotency key (one voucher per logical call). */
|
|
28
|
+
readonly inFlight: Map<string, {
|
|
29
|
+
fingerprint: string;
|
|
30
|
+
promise: Promise<unknown>;
|
|
31
|
+
}>;
|
|
32
|
+
readonly quotes: QuoteCache;
|
|
33
|
+
readonly onPayment: OnPayment | undefined;
|
|
34
|
+
readonly maxRetries: number;
|
|
35
|
+
readonly now: () => number;
|
|
36
|
+
readonly sleep: (ms: number) => Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* G3's quotes, as seen in `tools/list`, `search_tools` and `get_tool` results, kept 60 s per tool.
|
|
40
|
+
* One cache per MCP URL: the same tool can quote differently on two URLs (entry rules).
|
|
41
|
+
*/
|
|
42
|
+
export declare class QuoteCache {
|
|
43
|
+
#private;
|
|
44
|
+
private readonly now;
|
|
45
|
+
constructor(now: () => number);
|
|
46
|
+
set(tool: string, quote: PriceQuote): void;
|
|
47
|
+
get(tool: string): PriceQuote | null;
|
|
48
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G3's quotes, as seen in `tools/list`, `search_tools` and `get_tool` results, kept 60 s per tool.
|
|
3
|
+
* One cache per MCP URL: the same tool can quote differently on two URLs (entry rules).
|
|
4
|
+
*/
|
|
5
|
+
export class QuoteCache {
|
|
6
|
+
now;
|
|
7
|
+
#entries = new Map();
|
|
8
|
+
constructor(now) {
|
|
9
|
+
this.now = now;
|
|
10
|
+
}
|
|
11
|
+
set(tool, quote) {
|
|
12
|
+
this.#entries.delete(tool);
|
|
13
|
+
this.#entries.set(tool, { quote: { ...quote }, at: this.now() });
|
|
14
|
+
if (this.#entries.size > 2048)
|
|
15
|
+
this.#entries.delete(this.#entries.keys().next().value);
|
|
16
|
+
}
|
|
17
|
+
get(tool) {
|
|
18
|
+
const e = this.#entries.get(tool);
|
|
19
|
+
if (!e)
|
|
20
|
+
return null;
|
|
21
|
+
const age = this.now() - e.at;
|
|
22
|
+
if (age < 0 || age > 60_000) {
|
|
23
|
+
this.#entries.delete(tool);
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return { ...e.quote };
|
|
27
|
+
}
|
|
28
|
+
}
|