@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.
- package/LICENSE +180 -0
- package/README.md +304 -0
- package/package.json +79 -0
- package/server.json +54 -0
- package/src/index.js +201 -0
- package/src/lib/evm-rpc.js +130 -0
- package/src/lib/pose-presets.js +421 -0
- package/src/lib/pump-vanity.js +124 -0
- package/src/lib/resilient-fetch.js +194 -0
- package/src/lib/solana-rpc.js +130 -0
- package/src/payments.js +319 -0
- package/src/tools/_shared.js +41 -0
- package/src/tools/agenc-client.js +136 -0
- package/src/tools/agenc-get-agent.js +145 -0
- package/src/tools/agenc-get-task.js +187 -0
- package/src/tools/agenc-list-tasks.js +110 -0
- package/src/tools/agent-delegate-action.js +113 -0
- package/src/tools/agent-reputation.js +284 -0
- package/src/tools/aixbt-intel.js +108 -0
- package/src/tools/aixbt-projects.js +116 -0
- package/src/tools/ens-sns-resolve.js +209 -0
- package/src/tools/mesh-forge.js +379 -0
- package/src/tools/pose-seed.js +169 -0
- package/src/tools/pump-snapshot.js +262 -0
- package/src/tools/rig-mesh.js +207 -0
- package/src/tools/sentiment-pulse.js +118 -0
- package/src/tools/text-to-avatar.js +289 -0
- package/src/tools/vanity-grinder.js +178 -0
|
@@ -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
|
+
}
|
package/src/payments.js
ADDED
|
@@ -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
|
+
}
|