@three-ws/mcp-server 1.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.
@@ -0,0 +1,194 @@
1
+ // Resilient HTTP layer shared by every MCP tool.
2
+ //
3
+ // The tools previously each called bare `fetch()` — most with no timeout and
4
+ // none with a retry. A single transient blip (a 429 burst, a 502 from a CDN, a
5
+ // dropped socket) failed the whole tool call. This module gives every outbound
6
+ // request three guarantees:
7
+ //
8
+ // 1. It ALWAYS times out. No request can hang forever and block a paid tool
9
+ // (which, on a hang, could settle a payment with no result).
10
+ // 2. Transient failures are retried with exponential backoff + full jitter,
11
+ // honoring the server's `Retry-After` on 429/503.
12
+ // 3. Retries are SAFE: only idempotent methods (GET/HEAD) are retried by
13
+ // default, so a non-idempotent POST (start a job, send an agent message)
14
+ // is never silently duplicated. A caller that knows its POST is safe to
15
+ // replay can opt in with `retryNonIdempotent: true`.
16
+ //
17
+ // `resilientFetch` returns a `Response` (drop-in for `fetch`); `fetchJson`
18
+ // layers JSON parsing + non-2xx → throw on top of it.
19
+
20
+ const DEFAULT_RETRY_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
21
+ const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
22
+
23
+ /**
24
+ * Sleep that rejects promptly if an AbortSignal fires, so a caller-supplied
25
+ * deadline can cut a backoff wait short instead of waiting it out.
26
+ */
27
+ function abortableDelay(ms, signal) {
28
+ return new Promise((resolve, reject) => {
29
+ if (signal?.aborted) {
30
+ reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
31
+ return;
32
+ }
33
+ const timer = setTimeout(() => {
34
+ signal?.removeEventListener?.('abort', onAbort);
35
+ resolve();
36
+ }, ms);
37
+ function onAbort() {
38
+ clearTimeout(timer);
39
+ reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
40
+ }
41
+ signal?.addEventListener?.('abort', onAbort, { once: true });
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Parse a `Retry-After` header into milliseconds. Supports both the
47
+ * delta-seconds form (`120`) and the HTTP-date form. Returns null when absent
48
+ * or unparseable so the caller falls back to computed backoff.
49
+ */
50
+ function parseRetryAfterMs(res) {
51
+ const raw = res?.headers?.get?.('retry-after');
52
+ if (!raw) return null;
53
+ const secs = Number(raw);
54
+ if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
55
+ const when = Date.parse(raw);
56
+ if (Number.isFinite(when)) return Math.max(0, when - Date.now());
57
+ return null;
58
+ }
59
+
60
+ /**
61
+ * Full-jitter exponential backoff: a random wait in [0, cap] where the cap
62
+ * doubles each attempt. Full jitter (vs. fixed backoff) spreads a thundering
63
+ * herd of simultaneous retries so they don't re-collide on the next attempt.
64
+ */
65
+ function backoffMs(attempt, baseDelayMs, maxDelayMs) {
66
+ const cap = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
67
+ return Math.floor(Math.random() * cap);
68
+ }
69
+
70
+ /**
71
+ * fetch() with a hard timeout and safe, jittered retries.
72
+ *
73
+ * @param {string} url
74
+ * @param {RequestInit} [init]
75
+ * @param {object} [opts]
76
+ * @param {number} [opts.timeoutMs=10000] per-attempt timeout
77
+ * @param {number} [opts.retries=2] retry count (total attempts = retries + 1)
78
+ * @param {number} [opts.baseDelayMs=250] backoff base
79
+ * @param {number} [opts.maxDelayMs=4000] backoff cap
80
+ * @param {Set<number>|number[]} [opts.retryStatuses] HTTP statuses worth retrying
81
+ * @param {boolean} [opts.retryNonIdempotent=false] allow retrying non-GET/HEAD
82
+ * @param {AbortSignal} [opts.signal] external deadline/cancel
83
+ * @param {string} [opts.label] short name used in thrown error messages
84
+ * @returns {Promise<Response>}
85
+ */
86
+ export async function resilientFetch(url, init = {}, opts = {}) {
87
+ const {
88
+ timeoutMs = 10_000,
89
+ retries = 2,
90
+ baseDelayMs = 250,
91
+ maxDelayMs = 4_000,
92
+ retryStatuses,
93
+ retryNonIdempotent = false,
94
+ signal: externalSignal,
95
+ label,
96
+ } = opts;
97
+
98
+ const statuses = retryStatuses
99
+ ? retryStatuses instanceof Set
100
+ ? retryStatuses
101
+ : new Set(retryStatuses)
102
+ : DEFAULT_RETRY_STATUSES;
103
+ const method = (init.method || 'GET').toUpperCase();
104
+ const mayRetry = retryNonIdempotent || IDEMPOTENT_METHODS.has(method);
105
+ const name = label || `${method} ${url}`;
106
+
107
+ let lastErr = null;
108
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
109
+ if (externalSignal?.aborted) {
110
+ throw externalSignal.reason instanceof Error
111
+ ? externalSignal.reason
112
+ : new Error(`${name}: aborted`);
113
+ }
114
+
115
+ // Fresh controller per attempt (abort is one-shot). Abort on either the
116
+ // per-attempt timeout OR the caller's external signal.
117
+ const controller = new AbortController();
118
+ let timedOut = false;
119
+ const timer = setTimeout(() => {
120
+ timedOut = true;
121
+ controller.abort();
122
+ }, timeoutMs);
123
+ const onExternalAbort = () => controller.abort();
124
+ externalSignal?.addEventListener?.('abort', onExternalAbort, { once: true });
125
+
126
+ try {
127
+ const res = await fetch(url, { ...init, signal: controller.signal });
128
+
129
+ // Retry on transient status codes if attempts remain and the method
130
+ // is replay-safe. Drain the body so the socket can be reused.
131
+ if (statuses.has(res.status) && attempt < retries && mayRetry) {
132
+ const retryAfter = parseRetryAfterMs(res);
133
+ try {
134
+ await res.arrayBuffer();
135
+ } catch {
136
+ /* ignore drain failure */
137
+ }
138
+ lastErr = new Error(`${name} → HTTP ${res.status}`);
139
+ const wait = retryAfter ?? backoffMs(attempt, baseDelayMs, maxDelayMs);
140
+ await abortableDelay(wait, externalSignal);
141
+ continue;
142
+ }
143
+ return res;
144
+ } catch (err) {
145
+ // Distinguish a timeout from an external cancel: an external abort is
146
+ // the caller's deadline and must not be retried.
147
+ if (externalSignal?.aborted && !timedOut) {
148
+ throw externalSignal.reason instanceof Error
149
+ ? externalSignal.reason
150
+ : new Error(`${name}: aborted`);
151
+ }
152
+ const normalized = timedOut
153
+ ? Object.assign(new Error(`${name}: timed out after ${timeoutMs}ms`), {
154
+ name: 'TimeoutError',
155
+ })
156
+ : err;
157
+ lastErr = normalized;
158
+
159
+ // A thrown fetch() error is always a network drop or timeout (HTTP
160
+ // error statuses resolve, they don't throw), so it is retryable for
161
+ // any replay-safe method while attempts remain.
162
+ if (attempt < retries && mayRetry) {
163
+ await abortableDelay(backoffMs(attempt, baseDelayMs, maxDelayMs), externalSignal);
164
+ continue;
165
+ }
166
+ throw normalized;
167
+ } finally {
168
+ clearTimeout(timer);
169
+ externalSignal?.removeEventListener?.('abort', onExternalAbort);
170
+ }
171
+ }
172
+ throw lastErr || new Error(`${name}: exhausted retries`);
173
+ }
174
+
175
+ /**
176
+ * resilientFetch + JSON. Throws on a non-2xx final response (after retries) and
177
+ * on a body that isn't valid JSON, matching the `fetchJson` contract the tools
178
+ * already expect. Use for read endpoints that return JSON.
179
+ *
180
+ * @param {string} url
181
+ * @param {RequestInit} [init]
182
+ * @param {object} [opts] same options as resilientFetch
183
+ * @returns {Promise<any>}
184
+ */
185
+ export async function fetchJson(url, init = {}, opts = {}) {
186
+ const label = opts.label || `${(init.method || 'GET').toUpperCase()} ${url}`;
187
+ const res = await resilientFetch(url, init, opts);
188
+ if (!res.ok) {
189
+ throw new Error(`${label} → HTTP ${res.status}`);
190
+ }
191
+ return res.json();
192
+ }
193
+
194
+ export { DEFAULT_RETRY_STATUSES, parseRetryAfterMs, backoffMs };
@@ -0,0 +1,130 @@
1
+ // Multi-endpoint Solana RPC with automatic failover.
2
+ //
3
+ // Tools previously hit a SINGLE Solana RPC (defaulting to the public, heavily
4
+ // rate-limited api.mainnet-beta.solana.com). When that endpoint throttled or
5
+ // blipped, the tool failed outright. This module gives every Solana read a
6
+ // prioritized list of endpoints and a bounded per-call timeout: the operation
7
+ // is tried against each endpoint in turn until one succeeds, so a single
8
+ // provider outage no longer takes the tool down.
9
+ //
10
+ // Endpoint precedence (highest first):
11
+ // 1. SOLANA_RPC_URLS — comma-separated list (the explicit failover chain)
12
+ // 2. SOLANA_RPC_URL — single primary (kept for back-compat)
13
+ // 3. built-in public mainnet endpoints (last-resort redundancy)
14
+ //
15
+ // A Connection is created per (endpoint, commitment) and memoized, and each is
16
+ // wired with a fetch that hard-times-out so a stuck socket can't wedge a paid
17
+ // call. Failover order rotates after a failure so a flaky endpoint moves to the
18
+ // back instead of being retried first on the next call.
19
+
20
+ import { Connection } from '@solana/web3.js';
21
+
22
+ import { resilientFetch } from './resilient-fetch.js';
23
+
24
+ // Last-resort redundancy when the operator configures nothing. These are
25
+ // well-known public mainnet endpoints; an operator who needs guaranteed
26
+ // throughput should set SOLANA_RPC_URLS to dedicated providers.
27
+ const DEFAULT_MAINNET_ENDPOINTS = [
28
+ 'https://api.mainnet-beta.solana.com',
29
+ 'https://solana-rpc.publicnode.com',
30
+ 'https://rpc.ankr.com/solana',
31
+ ];
32
+
33
+ function dedupe(list) {
34
+ const seen = new Set();
35
+ const out = [];
36
+ for (const item of list) {
37
+ const v = (item || '').trim();
38
+ if (v && !seen.has(v)) {
39
+ seen.add(v);
40
+ out.push(v);
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /**
47
+ * Resolve the ordered Solana mainnet endpoint list from env, falling back to
48
+ * the built-in public set. Always returns at least one endpoint.
49
+ *
50
+ * @returns {string[]}
51
+ */
52
+ export function getSolanaEndpoints() {
53
+ const fromList = (process.env.SOLANA_RPC_URLS || '')
54
+ .split(',')
55
+ .map((s) => s.trim())
56
+ .filter(Boolean);
57
+ const primary = (process.env.SOLANA_RPC_URL || '').trim();
58
+ const ordered = dedupe([...fromList, primary, ...DEFAULT_MAINNET_ENDPOINTS]);
59
+ return ordered.length ? ordered : [...DEFAULT_MAINNET_ENDPOINTS];
60
+ }
61
+
62
+ // Cache one Connection per (endpoint|commitment). web3.js Connections are cheap
63
+ // but hold an http agent; reusing them keeps sockets warm across tool calls.
64
+ const connectionCache = new Map();
65
+
66
+ function connectionFor(endpoint, commitment, timeoutMs) {
67
+ const key = `${endpoint}|${commitment}`;
68
+ let conn = connectionCache.get(key);
69
+ if (!conn) {
70
+ conn = new Connection(endpoint, {
71
+ commitment,
72
+ // Bound every RPC HTTP call. web3.js otherwise inherits Node's
73
+ // unbounded default, which is the source of indefinite hangs.
74
+ fetch: (url, init) =>
75
+ resilientFetch(url, init, {
76
+ timeoutMs,
77
+ // web3.js POSTs JSON-RPC; its reads are idempotent queries, so
78
+ // allow a single transport-level replay on a blip.
79
+ retries: 1,
80
+ retryNonIdempotent: true,
81
+ label: `solana-rpc ${endpoint}`,
82
+ }),
83
+ disableRetryOnRateLimit: false,
84
+ });
85
+ connectionCache.set(key, conn);
86
+ }
87
+ return conn;
88
+ }
89
+
90
+ // Endpoints that just failed are rotated to the back so the next call starts
91
+ // with the one most likely to be healthy.
92
+ let rotation = 0;
93
+
94
+ /**
95
+ * Run a Solana read against the endpoint list with failover.
96
+ *
97
+ * `fn` receives a live `Connection` and should perform exactly one logical
98
+ * read. If it throws, the next endpoint is tried. The error from the last
99
+ * endpoint is surfaced when every endpoint fails.
100
+ *
101
+ * @template T
102
+ * @param {(conn: import('@solana/web3.js').Connection) => Promise<T>} fn
103
+ * @param {object} [opts]
104
+ * @param {string} [opts.commitment='confirmed']
105
+ * @param {number} [opts.timeoutMs=12000] per-endpoint HTTP timeout
106
+ * @returns {Promise<T>}
107
+ */
108
+ export async function withSolanaConnection(fn, opts = {}) {
109
+ const { commitment = 'confirmed', timeoutMs = 12_000 } = opts;
110
+ const endpoints = getSolanaEndpoints();
111
+ const start = rotation % endpoints.length;
112
+ rotation += 1;
113
+
114
+ let lastErr = null;
115
+ for (let i = 0; i < endpoints.length; i += 1) {
116
+ const endpoint = endpoints[(start + i) % endpoints.length];
117
+ try {
118
+ return await fn(connectionFor(endpoint, commitment, timeoutMs));
119
+ } catch (err) {
120
+ lastErr = err;
121
+ }
122
+ }
123
+ throw lastErr || new Error('solana-rpc: all endpoints failed');
124
+ }
125
+
126
+ // Test-only: drop cached Connections so a test can swap env between cases.
127
+ export function _resetSolanaCache() {
128
+ connectionCache.clear();
129
+ rotation = 0;
130
+ }
@@ -0,0 +1,319 @@
1
+ // Shared x402 payment wiring for paid MCP tools — Solana mainnet only.
2
+ //
3
+ // Every tool in mcp-server/src/tools/*.js wraps its handler in
4
+ // `paid(cfg, fn)`. This file builds the single shared x402ResourceServer
5
+ // (one per process) that verifies + settles USDC payments on Solana via
6
+ // PayAI's Solana facilitator, and exposes `paid()` that produces the
7
+ // McpServer.tool() callback per the @x402/mcp transport spec
8
+ // (PaymentRequired in structuredContent + content[0].text, settlement
9
+ // response under _meta["x402/payment-response"]).
10
+ //
11
+ // Network: Solana mainnet (solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp).
12
+ // Receiver: MCP_SVM_PAYMENT_ADDRESS (falls back to X402_PAY_TO_SOLANA /
13
+ // X402_PAY_TO). Asset: USDC (EPjFW…), 6 decimals. Fee payer:
14
+ // X402_FEE_PAYER_SOLANA.
15
+ //
16
+ // Only the `exact` scheme is supported: @x402/svm ships no `upto` scheme, so
17
+ // metered/`upto` billing is not available on Solana. Tools that previously
18
+ // metered (the vanity grinder) charge a flat exact price instead.
19
+ //
20
+ // `createPaymentWrapper` from @x402/mcp returns a function that wraps your
21
+ // async tool handler into an MCP-compatible callback. It handles the entire
22
+ // 402 dance: returns a 402 PaymentRequired result with both structuredContent
23
+ // + content[0].text when the client calls without _meta["x402/payment"];
24
+ // verifies the payment, runs the handler, settles, and attaches the
25
+ // SettleResponse to _meta["x402/payment-response"].
26
+
27
+ import { HTTPFacilitatorClient, x402ResourceServer } from '@x402/core/server';
28
+ import { registerExactSvmScheme } from '@x402/svm/exact/server';
29
+ import { createPaymentWrapper, createToolResourceUrl } from '@x402/mcp';
30
+ import { declareDiscoveryExtension } from '@x402/extensions/bazaar';
31
+
32
+ // CAIP-2 id for Solana mainnet (mirrors api/_lib/x402-spec.js).
33
+ const NETWORK_SOLANA_MAINNET = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp';
34
+
35
+ const DEFAULT_SOLANA_USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
36
+
37
+ const env = (key, fallback) => {
38
+ const v = process.env[key];
39
+ return v && v.trim() ? v.trim() : fallback;
40
+ };
41
+
42
+ function requireSvmPayTo() {
43
+ const addr = env('MCP_SVM_PAYMENT_ADDRESS') || env('X402_PAY_TO_SOLANA') || env('X402_PAY_TO');
44
+ if (!addr) {
45
+ throw new Error(
46
+ 'mcp-server: set MCP_SVM_PAYMENT_ADDRESS to receive Solana USDC payments (or X402_PAY_TO_SOLANA / X402_PAY_TO)',
47
+ );
48
+ }
49
+ return addr;
50
+ }
51
+
52
+ /**
53
+ * Assert the receiving Solana payment address is configured. Called once by the
54
+ * stdio entry point so a running server fails fast with a single clean line
55
+ * instead of only erroring on the first paid call. Does NOT run during
56
+ * `buildServer()`/tests — tool registration stays secret-free.
57
+ *
58
+ * @throws {Error} with a single actionable message when no pay-to is set
59
+ */
60
+ export function assertPaymentEnv() {
61
+ requireSvmPayTo();
62
+ }
63
+
64
+ function svmFeePayer() {
65
+ return env('X402_FEE_PAYER_SOLANA', '2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4');
66
+ }
67
+
68
+ // Resolve the ordered facilitator URL list. A comma-separated
69
+ // X402_FACILITATOR_URLS_SOLANA configures redundancy; the single-URL env vars
70
+ // are kept for back-compat, and PayAI is the last-resort default. Earlier URLs
71
+ // get precedence at init — if the primary's /supported fetch is unreachable, a
72
+ // later facilitator that responded takes over the Solana `exact` kind, so a
73
+ // facilitator outage no longer leaves the server unable to settle.
74
+ function solanaFacilitatorUrls() {
75
+ const list = env('X402_FACILITATOR_URLS_SOLANA', '')
76
+ .split(',')
77
+ .map((s) => s.trim())
78
+ .filter(Boolean);
79
+ const single = env('X402_FACILITATOR_URL_SOLANA') || env('X402_FACILITATOR_URL');
80
+ const ordered = [];
81
+ const seen = new Set();
82
+ for (const url of [...list, single, 'https://facilitator.payai.network']) {
83
+ if (url && !seen.has(url)) {
84
+ seen.add(url);
85
+ ordered.push(url);
86
+ }
87
+ }
88
+ return ordered;
89
+ }
90
+
91
+ function buildSolanaFacilitators() {
92
+ const token = env('X402_FACILITATOR_TOKEN_SOLANA') || env('X402_FACILITATOR_TOKEN');
93
+ const createAuthHeaders = token
94
+ ? async () => ({ headers: { Authorization: `Bearer ${token}` } })
95
+ : undefined;
96
+ return solanaFacilitatorUrls().map(
97
+ (url) => new HTTPFacilitatorClient({ url, createAuthHeaders }),
98
+ );
99
+ }
100
+
101
+ let resourceServerPromise = null;
102
+ let lastInitError = null;
103
+
104
+ // Build a single shared x402ResourceServer, register the Solana `exact`
105
+ // scheme, and call .initialize() to fetch the facilitator's /supported (caches
106
+ // kinds + extensions for the verify/settle path).
107
+ //
108
+ // `initialize()` MUST run before any verify/settle — without it the server has
109
+ // no notion of which facilitator handles Solana and will throw on the first
110
+ // paid call. We memoize the promise so concurrent tool calls don't race during
111
+ // startup.
112
+ export function getResourceServer() {
113
+ if (resourceServerPromise) return resourceServerPromise;
114
+ resourceServerPromise = (async () => {
115
+ const server = new x402ResourceServer(buildSolanaFacilitators());
116
+ registerExactSvmScheme(server, {});
117
+ try {
118
+ await server.initialize();
119
+ } catch (err) {
120
+ lastInitError = err;
121
+ // Don't fatally throw — the server can still emit 402 challenges and
122
+ // /supported may have been partially populated. Operators will see
123
+ // the real failure when a tool tries to verify a payment.
124
+ console.error(`[mcp-server] facilitator initialize() failed: ${err.message}`);
125
+ }
126
+ return server;
127
+ })();
128
+ return resourceServerPromise;
129
+ }
130
+
131
+ export function getLastFacilitatorInitError() {
132
+ return lastInitError;
133
+ }
134
+
135
+ // Build the per-tool `accepts` list. Every paid tool settles in USDC on Solana
136
+ // mainnet via the `exact` scheme.
137
+ async function buildAcceptsForTool({
138
+ resourceServer,
139
+ scheme,
140
+ priceUsd,
141
+ networks,
142
+ resourceUrl,
143
+ extra,
144
+ }) {
145
+ const opts = [];
146
+ for (const net of networks) {
147
+ if (net !== NETWORK_SOLANA_MAINNET) {
148
+ throw new Error(`mcp-server: unsupported network ${net} (Solana mainnet only)`);
149
+ }
150
+ opts.push({
151
+ scheme,
152
+ network: NETWORK_SOLANA_MAINNET,
153
+ payTo: requireSvmPayTo(),
154
+ price: priceUsd,
155
+ maxTimeoutSeconds: 60,
156
+ extra: {
157
+ name: 'USDC',
158
+ decimals: 6,
159
+ asset: env('X402_ASSET_MINT_SOLANA', DEFAULT_SOLANA_USDC),
160
+ feePayer: svmFeePayer(),
161
+ ...(extra?.svm || {}),
162
+ },
163
+ });
164
+ }
165
+ if (opts.length === 0) {
166
+ throw new Error(`mcp-server: no networks resolved for scheme=${scheme}`);
167
+ }
168
+ return resourceServer.buildPaymentRequirementsFromOptions(opts, { resourceUrl });
169
+ }
170
+
171
+ /**
172
+ * Wrap a tool handler with x402 payment (USDC on Solana, `exact` scheme).
173
+ *
174
+ * The x402 wiring (resource server init, `accepts` requirements, payment
175
+ * wrapper) is built LAZILY on the first invocation — NOT when the tool is
176
+ * registered. This keeps tool registration (names/descriptions/schemas)
177
+ * free of any runtime payment env: `buildServer()` can enumerate every tool
178
+ * without MCP_SVM_PAYMENT_ADDRESS, and only an actual paid call triggers the
179
+ * env requirement. The wrapper is memoized so the first call pays the init cost
180
+ * once and every subsequent call reuses it.
181
+ *
182
+ * @param {object} cfg
183
+ * @param {string} cfg.toolName — e.g. "get_pose_seed"
184
+ * @param {string} cfg.description — human-readable description
185
+ * @param {string} [cfg.scheme='exact'] — only 'exact' is supported on Solana
186
+ * @param {string|number} cfg.priceUsd — Price like "$0.001"
187
+ * @param {string[]} [cfg.networks] — default ['solana:5eykt4…']
188
+ * @param {object} cfg.inputSchema — JSON Schema for the tool's args
189
+ * @param {object} [cfg.example] — example invocation for bazaar
190
+ * @param {object} [cfg.outputExample] — example output for bazaar
191
+ * @param {object} [cfg.extra] — extra fields (extra.svm)
192
+ * @param {object} [cfg.hooks] — { onBeforeExecution, onAfterExecution, onAfterSettlement }
193
+ * @param {Function} handler — async (args, { settle? }) → result
194
+ * @returns {Function} MCP tool callback for McpServer.tool()
195
+ */
196
+ export function paid(cfg, handler) {
197
+ const {
198
+ toolName,
199
+ description,
200
+ scheme = 'exact',
201
+ priceUsd,
202
+ networks = [NETWORK_SOLANA_MAINNET],
203
+ inputSchema,
204
+ example,
205
+ outputExample,
206
+ extra,
207
+ hooks,
208
+ } = cfg;
209
+
210
+ if (!toolName) throw new Error('paid(): toolName is required');
211
+ if (!description) throw new Error('paid(): description is required');
212
+ if (!priceUsd) throw new Error('paid(): priceUsd is required (e.g. "$0.001")');
213
+ if (!inputSchema) throw new Error('paid(): inputSchema is required');
214
+ if (scheme !== 'exact') {
215
+ throw new Error(`paid(): only the 'exact' scheme is supported on Solana (got '${scheme}')`);
216
+ }
217
+
218
+ // Lazily build (and memoize) the payment wrapper. This is the ONLY place
219
+ // that touches payment env (requireSvmPayTo) and the facilitator, so it
220
+ // runs on first invocation rather than at registration time.
221
+ let wrapperPromise = null;
222
+ async function getWrapper() {
223
+ if (wrapperPromise) return wrapperPromise;
224
+ wrapperPromise = (async () => {
225
+ const resourceServer = await getResourceServer();
226
+ const resourceUrl = createToolResourceUrl(toolName);
227
+ const accepts = await buildAcceptsForTool({
228
+ resourceServer,
229
+ scheme,
230
+ priceUsd,
231
+ networks,
232
+ resourceUrl,
233
+ extra,
234
+ });
235
+
236
+ const bazaar = declareDiscoveryExtension({
237
+ toolName,
238
+ description,
239
+ transport: 'stdio',
240
+ inputSchema,
241
+ example,
242
+ output: outputExample ? { example: outputExample } : undefined,
243
+ });
244
+
245
+ const wrap = createPaymentWrapper(resourceServer, {
246
+ accepts,
247
+ resource: { url: resourceUrl, description, mimeType: 'application/json' },
248
+ extensions: bazaar,
249
+ hooks,
250
+ });
251
+
252
+ return wrap(async (args, context) => {
253
+ const result = await handler(args, context);
254
+ return buildToolResult(result);
255
+ });
256
+ })();
257
+ return wrapperPromise;
258
+ }
259
+
260
+ // The callback McpServer.registerTool() invokes. Defers all payment wiring
261
+ // to the first real call.
262
+ return async function paidToolCallback(args, context) {
263
+ const wrapped = await getWrapper();
264
+ return wrapped(args, context);
265
+ };
266
+ }
267
+
268
+ /**
269
+ * Build the MCP `CallToolResult` envelope from a handler's return value.
270
+ *
271
+ * This is the single place every paid tool's output is shaped, so all 15 tools
272
+ * get the same modern MCP contract for free:
273
+ *
274
+ * - `content[0].text` — the JSON (or raw string) blob. Always present, so
275
+ * pre-2025-06-18 clients that only read text keep working unchanged.
276
+ * - `structuredContent` — the handler's object verbatim, surfaced as MCP
277
+ * structured tool output (spec 2025-06-18). Clients that support it get a
278
+ * ready-to-use object and skip the `JSON.parse(content[0].text)` dance.
279
+ * Only emitted for plain objects (the spec requires an object, not an
280
+ * array or scalar); string/array returns fall back to text-only.
281
+ * - `isError: true` — set ONLY for the explicit `toolError()` envelope
282
+ * (`ok === false`). This flags the failure to the LLM AND, via the x402
283
+ * payment wrapper, cancels the payment instead of settling it — so a caller
284
+ * is never charged for an invalid-input / provider / timeout error. It is
285
+ * deliberately NOT set for partial-data successes (e.g. a snapshot whose
286
+ * `price` sub-field carries `{ error }` but whose overall call succeeded),
287
+ * which have no top-level `ok: false`.
288
+ *
289
+ * @param {unknown} result — whatever the tool handler returned
290
+ * @returns {{ content: Array<{type:'text',text:string}>, structuredContent?: object, isError?: true }}
291
+ */
292
+ export function buildToolResult(result) {
293
+ const text = typeof result === 'string' ? result : JSON.stringify(result);
294
+ const envelope = { content: [{ type: 'text', text }] };
295
+ const isPlainObject = result !== null && typeof result === 'object' && !Array.isArray(result);
296
+ if (isPlainObject) {
297
+ envelope.structuredContent = result;
298
+ if (result.ok === false) {
299
+ envelope.isError = true;
300
+ }
301
+ }
302
+ return envelope;
303
+ }
304
+
305
+ /**
306
+ * Standard tool error envelope. Every tool's error path returns this shape so
307
+ * MCP clients can branch on a stable `{ ok: false, error: <code>, message }`
308
+ * contract instead of the per-tool ad-hoc shapes this server used to emit.
309
+ *
310
+ * @param {string} code — machine-readable error code (snake_case)
311
+ * @param {string} message — human-readable explanation
312
+ * @param {object} [extra] — optional extra fields merged into the envelope
313
+ * @returns {{ ok: false, error: string, message: string }}
314
+ */
315
+ export function toolError(code, message, extra) {
316
+ return { ok: false, error: code, message, ...(extra || {}) };
317
+ }
318
+
319
+ export { NETWORK_SOLANA_MAINNET };
@@ -0,0 +1,41 @@
1
+ // Shared helpers for the paid MCP tools.
2
+ //
3
+ // Single-sources the tool input schema: each tool declares its arguments ONCE
4
+ // as a Zod shape (an object of `{ field: ZodType }`), and the JSON Schema the
5
+ // MCP client / x402 bazaar sees is DERIVED from that shape via
6
+ // zod-to-json-schema. This kills the JSON-Schema↔Zod drift class — the two
7
+ // representations can no longer disagree because there is only one source.
8
+ //
9
+ // Re-exports `toolError` so tools can import their error contract + schema
10
+ // helper from a single module.
11
+
12
+ import { z } from 'zod';
13
+ import { zodToJsonSchema } from 'zod-to-json-schema';
14
+
15
+ export { toolError } from '../payments.js';
16
+
17
+ /**
18
+ * Derive a MCP-shaped JSON Schema from a Zod shape (the same `{ field: ZodType }`
19
+ * object passed to `McpServer.registerTool({ inputSchema })`).
20
+ *
21
+ * The output matches what the tools previously hand-wrote: a top-level
22
+ * `{ type: 'object', properties, required, additionalProperties: false }`
23
+ * with no `$schema`/`$ref` envelope, so the external contract MCP clients and
24
+ * the x402 bazaar see is unchanged.
25
+ *
26
+ * @param {Record<string, import('zod').ZodTypeAny>} shape
27
+ * @returns {object} JSON Schema (draft-07 object schema)
28
+ */
29
+ export function jsonSchemaFromZod(shape) {
30
+ const schema = zodToJsonSchema(z.object(shape).strict(), {
31
+ // Inline everything — MCP clients expect a self-contained object schema,
32
+ // not a `$ref`-into-`definitions` document.
33
+ $refStrategy: 'none',
34
+ target: 'jsonSchema7',
35
+ });
36
+ // zod-to-json-schema wraps the result with a `$schema` meta key; strip it so
37
+ // the emitted schema is byte-for-byte the lean object schema the tools used
38
+ // to hand-maintain.
39
+ delete schema.$schema;
40
+ return schema;
41
+ }