mcp-server-madeonsol 2.0.0 → 3.0.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/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
5
  import { z } from "zod";
6
+ import { SolanaPaymentBudget, createSolanaPaidFetch, solanaPaymentPolicyFromConfig } from "./solana-payment.js";
6
7
  import { VERSION } from "./version.js";
7
8
  import { createPrivateHttpServer, readHttpConfig } from "./http-security.js";
8
9
  // MCP `initialize` response `instructions` field (ServerOptions.instructions in
@@ -55,22 +56,16 @@ async function initAuth() {
55
56
  return;
56
57
  }
57
58
  if (mode === "x402" && PRIVATE_KEY) {
59
+ const budget = new SolanaPaymentBudget(solanaPaymentPolicyFromConfig(key => process.env[key]));
58
60
  try {
59
- const { wrapFetchWithPayment } = await import("@x402/fetch");
60
- const { x402Client } = await import("@x402/core/client");
61
- const { ExactSvmScheme } = await import("@x402/svm/exact/client");
62
- const { createKeyPairSignerFromBytes } = await import("@solana/kit");
63
- const { base58 } = await import("@scure/base");
64
- const signer = await createKeyPairSignerFromBytes(base58.decode(PRIVATE_KEY));
65
- const client = new x402Client();
66
- client.register("solana:*", new ExactSvmScheme(signer));
67
- paidFetch = wrapFetchWithPayment(fetch, client);
61
+ paidFetch = await createSolanaPaidFetch(PRIVATE_KEY, budget, BASE_URL);
68
62
  authMode = "x402";
69
- console.error(`[madeonsol-mcp] x402 payments enabled, wallet: ${signer.address}`);
63
+ console.error("[madeonsol-mcp] x402 payments enabled with an explicit authorization budget");
70
64
  return;
71
65
  }
72
66
  catch (err) {
73
- console.error("[madeonsol-mcp] x402 setup failed:", err);
67
+ // Never silently fall back to unauthenticated mode after a policy/setup failure.
68
+ throw new Error("[madeonsol-mcp] x402 setup failed", { cause: err });
74
69
  }
75
70
  }
76
71
  console.error("\n[madeonsol-mcp] No auth configured — every tool call will fail.\n" +
@@ -0,0 +1,59 @@
1
+ export declare const SOLANA_PAYMENT_NETWORK = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
2
+ export declare const SOLANA_PAYMENT_ASSET = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
3
+ interface ApprovedOffer {
4
+ scheme: "exact";
5
+ network: typeof SOLANA_PAYMENT_NETWORK;
6
+ asset: string;
7
+ payTo: string;
8
+ amount: string;
9
+ maxTimeoutSeconds: number;
10
+ extra: {
11
+ feePayer: string;
12
+ };
13
+ }
14
+ interface ApprovedChallenge {
15
+ x402Version: 2;
16
+ resource: {
17
+ url: string;
18
+ };
19
+ accepts: ApprovedOffer[];
20
+ }
21
+ export interface SolanaPaymentProposal {
22
+ readonly url: string;
23
+ readonly network: string;
24
+ readonly asset: string;
25
+ readonly payTo: string;
26
+ readonly feePayer: string;
27
+ readonly amountAtomic: string;
28
+ }
29
+ export interface SolanaPaymentPolicy {
30
+ /** Trusted merchant wallet, obtained independently of a payment challenge. */
31
+ payTo: string;
32
+ /** Trusted facilitator wallet; must not be the paying agent's wallet. */
33
+ feePayer: string;
34
+ /** Positive atomic USDC integers (6 decimals); numbers are not accepted. */
35
+ maxAmountAtomic: string | bigint;
36
+ maxTotalAmountAtomic: string | bigint;
37
+ /** Explicit trusted HTTPS RPC; there is no public RPC fallback. */
38
+ rpcUrl: string;
39
+ /** Whole payment attempt deadline, default 30 seconds. */
40
+ timeoutMs?: number;
41
+ /** Optional additional approval; only literal true permits signing. */
42
+ beforePayment?: (proposal: SolanaPaymentProposal) => boolean | Promise<boolean>;
43
+ }
44
+ /** Lifetime authorization allowance, not settled spend. Share one long-lived instance.
45
+ * Retained after entering payment creation, including uncertain/failed outcomes.
46
+ */
47
+ export declare class SolanaPaymentBudget {
48
+ #private;
49
+ readonly policy: Readonly<SolanaPaymentPolicy>;
50
+ constructor(policy: SolanaPaymentPolicy);
51
+ get authorizedAmountAtomic(): string;
52
+ select(envelope: unknown, requestUrl: string): ApprovedChallenge;
53
+ /** Atomic synchronous reservation before any approval/signing await. */
54
+ reserve(atomic: string): () => void;
55
+ }
56
+ export declare function solanaPaymentPolicyFromConfig(get: (key: string) => string | undefined): SolanaPaymentPolicy;
57
+ /** One challenge and at most one paid request. No automatic payment replay. */
58
+ export declare function createSolanaPaidFetch(privateKey: string, budget: SolanaPaymentBudget, baseUrl: string, transport?: typeof fetch): Promise<typeof fetch>;
59
+ export {};
@@ -0,0 +1,259 @@
1
+ // Canonical source. Mirrored into the two Solana adapters by
2
+ // packages/sync-solana-payment.mjs; CI rejects drift between published copies.
3
+ export const SOLANA_PAYMENT_NETWORK = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
4
+ export const SOLANA_PAYMENT_ASSET = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
5
+ function fail(message) { throw new Error(`Solana payment policy: ${message}`); }
6
+ const MAX_AMOUNT = (1n << 64n) - 1n;
7
+ function amount(value) {
8
+ if (typeof value !== "bigint" && (typeof value !== "string" || !/^[1-9][0-9]{0,19}$/.test(value))) {
9
+ return fail("amount must be a positive atomic integer string or bigint");
10
+ }
11
+ const parsed = BigInt(value);
12
+ if (parsed <= 0n || parsed > MAX_AMOUNT)
13
+ return fail("amount exceeds uint64 range");
14
+ return parsed;
15
+ }
16
+ function wallet(value) {
17
+ if (typeof value !== "string" || !/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(value))
18
+ return fail("invalid wallet address");
19
+ const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
20
+ let decoded = 0n;
21
+ for (const char of value)
22
+ decoded = decoded * 58n + BigInt(alphabet.indexOf(char));
23
+ const bytes = decoded === 0n ? 0 : Math.ceil(decoded.toString(16).length / 2);
24
+ if (decoded === 0n || bytes + (value.match(/^1*/)?.[0].length ?? 0) !== 32)
25
+ return fail("invalid wallet address");
26
+ return value;
27
+ }
28
+ function httpsUrl(value) {
29
+ if (typeof value !== "string")
30
+ return fail("invalid HTTPS URL");
31
+ let url;
32
+ try {
33
+ url = new URL(value);
34
+ }
35
+ catch {
36
+ return fail("invalid HTTPS URL");
37
+ }
38
+ if (url.protocol !== "https:" || url.username || url.password || url.hash)
39
+ return fail("HTTPS URL without credentials or fragment required");
40
+ return url;
41
+ }
42
+ /** Lifetime authorization allowance, not settled spend. Share one long-lived instance.
43
+ * Retained after entering payment creation, including uncertain/failed outcomes.
44
+ */
45
+ export class SolanaPaymentBudget {
46
+ policy;
47
+ #authorized = 0n;
48
+ #max;
49
+ #total;
50
+ constructor(policy) {
51
+ if (!policy)
52
+ fail("paymentPolicy is required for keyless payments");
53
+ const payTo = wallet(policy.payTo), feePayer = wallet(policy.feePayer);
54
+ this.#max = amount(policy.maxAmountAtomic);
55
+ this.#total = amount(policy.maxTotalAmountAtomic);
56
+ const rpcUrl = httpsUrl(policy.rpcUrl).href;
57
+ const timeoutMs = policy.timeoutMs ?? 30_000;
58
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2_147_483_647)
59
+ fail("invalid timeoutMs");
60
+ if (policy.beforePayment !== undefined && typeof policy.beforePayment !== "function")
61
+ fail("invalid beforePayment hook");
62
+ this.policy = Object.freeze({ payTo, feePayer, rpcUrl, timeoutMs,
63
+ maxAmountAtomic: this.#max.toString(), maxTotalAmountAtomic: this.#total.toString(), beforePayment: policy.beforePayment });
64
+ }
65
+ get authorizedAmountAtomic() { return this.#authorized.toString(); }
66
+ select(envelope, requestUrl) {
67
+ const challenge = envelope;
68
+ if (!challenge || challenge.x402Version !== 2 || !Array.isArray(challenge.accepts) ||
69
+ challenge.accepts.length < 1 || challenge.accepts.length > 32)
70
+ fail("unsupported payment challenge");
71
+ const request = httpsUrl(requestUrl);
72
+ if (challenge.resource !== undefined) {
73
+ const resource = httpsUrl(challenge.resource?.url);
74
+ // The current server advertises its path without the request query.
75
+ if (resource.origin !== request.origin || resource.pathname !== request.pathname ||
76
+ (resource.search && resource.search !== request.search))
77
+ fail("resource mismatch");
78
+ }
79
+ for (const offer of challenge.accepts) {
80
+ if (!offer || offer.scheme !== "exact" || offer.network !== SOLANA_PAYMENT_NETWORK ||
81
+ offer.asset !== SOLANA_PAYMENT_ASSET || offer.payTo !== this.policy.payTo ||
82
+ offer.extra?.feePayer !== this.policy.feePayer)
83
+ continue;
84
+ if (typeof offer.amount !== "string")
85
+ continue;
86
+ let cost;
87
+ try {
88
+ cost = amount(offer.amount);
89
+ }
90
+ catch {
91
+ continue;
92
+ }
93
+ if (cost > this.#max || !Number.isSafeInteger(offer.maxTimeoutSeconds) || offer.maxTimeoutSeconds <= 0)
94
+ continue;
95
+ // Rebuild instead of forwarding arbitrary remote fields/extensions to a signer.
96
+ const accepted = Object.freeze({ scheme: "exact", network: SOLANA_PAYMENT_NETWORK,
97
+ asset: SOLANA_PAYMENT_ASSET, payTo: this.policy.payTo, amount: cost.toString(),
98
+ maxTimeoutSeconds: offer.maxTimeoutSeconds, extra: Object.freeze({ feePayer: this.policy.feePayer }) });
99
+ return { x402Version: 2, resource: { url: request.href }, accepts: [accepted] };
100
+ }
101
+ return fail("no permitted exact mainnet USDC offer");
102
+ }
103
+ /** Atomic synchronous reservation before any approval/signing await. */
104
+ reserve(atomic) {
105
+ const cost = amount(atomic);
106
+ if (cost > this.#max || this.#authorized + cost > this.#total)
107
+ fail("authorization budget exceeded");
108
+ this.#authorized += cost;
109
+ let released = false;
110
+ return () => { if (!released) {
111
+ this.#authorized -= cost;
112
+ released = true;
113
+ } };
114
+ }
115
+ }
116
+ export function solanaPaymentPolicyFromConfig(get) {
117
+ const required = (name) => get(name) || fail(`${name} is required in keyless mode`);
118
+ return {
119
+ payTo: required("X402_PAY_TO"), feePayer: required("X402_FEE_PAYER"),
120
+ maxAmountAtomic: required("X402_MAX_AMOUNT_ATOMIC"), maxTotalAmountAtomic: required("X402_MAX_TOTAL_AMOUNT_ATOMIC"),
121
+ rpcUrl: required("SVM_RPC_URL"),
122
+ };
123
+ }
124
+ async function challengeBody(response) {
125
+ const header = response.headers.get("PAYMENT-REQUIRED");
126
+ if (header) {
127
+ if (header.length > 65_536)
128
+ fail("payment header too large");
129
+ // The protocol library decodes the header. Do not buffer an unrelated body.
130
+ void response.body?.cancel().catch(() => { });
131
+ return undefined;
132
+ }
133
+ if (!response.body)
134
+ return undefined;
135
+ const reader = response.body.getReader();
136
+ let size = 0;
137
+ const chunks = [];
138
+ try {
139
+ while (true) {
140
+ const { value, done } = await reader.read();
141
+ if (done)
142
+ break;
143
+ size += value.byteLength;
144
+ if (size > 65_536)
145
+ fail("payment challenge too large");
146
+ chunks.push(value);
147
+ }
148
+ }
149
+ finally {
150
+ void reader.cancel().catch(() => { });
151
+ }
152
+ const bytes = new Uint8Array(size);
153
+ let offset = 0;
154
+ for (const chunk of chunks) {
155
+ bytes.set(chunk, offset);
156
+ offset += chunk.length;
157
+ }
158
+ try {
159
+ return JSON.parse(new TextDecoder().decode(bytes));
160
+ }
161
+ catch {
162
+ return fail("invalid payment challenge JSON");
163
+ }
164
+ }
165
+ /** One challenge and at most one paid request. No automatic payment replay. */
166
+ export async function createSolanaPaidFetch(privateKey, budget, baseUrl, transport = fetch) {
167
+ const origin = httpsUrl(baseUrl).origin;
168
+ const { x402Client, x402HTTPClient } = await import("@x402/core/client");
169
+ const { ExactSvmScheme } = await import("@x402/svm/exact/client");
170
+ const { createKeyPairSignerFromBytes } = await import("@solana/kit");
171
+ const { base58 } = await import("@scure/base");
172
+ const signer = await createKeyPairSignerFromBytes(base58.decode(privateKey));
173
+ if (signer.address === budget.policy.feePayer)
174
+ fail("the agent wallet cannot pay facilitator gas");
175
+ return async (input, init) => {
176
+ const original = new Request(input, init);
177
+ if (httpsUrl(original.url).origin !== origin)
178
+ fail("request origin mismatch");
179
+ if (original.headers.has("PAYMENT-SIGNATURE") || original.headers.has("X-PAYMENT"))
180
+ fail("payment already attempted");
181
+ const controller = new AbortController();
182
+ const deadline = Date.now() + budget.policy.timeoutMs;
183
+ const check = () => {
184
+ if (Date.now() >= deadline && !controller.signal.aborted)
185
+ controller.abort(new Error("Solana payment timeout"));
186
+ controller.signal.throwIfAborted();
187
+ };
188
+ const abort = () => controller.abort(original.signal.reason);
189
+ original.signal.addEventListener("abort", abort, { once: true });
190
+ if (original.signal.aborted)
191
+ abort();
192
+ const timer = setTimeout(() => controller.abort(new Error("Solana payment timeout")), budget.policy.timeoutMs);
193
+ let onAbort = () => { };
194
+ const cancelled = new Promise((_, reject) => {
195
+ onAbort = () => reject(controller.signal.reason);
196
+ controller.signal.addEventListener("abort", onAbort, { once: true });
197
+ });
198
+ const bounded = async (work) => {
199
+ const result = await Promise.race([work, cancelled]);
200
+ check();
201
+ return result;
202
+ };
203
+ let release;
204
+ let creating = false;
205
+ try {
206
+ check();
207
+ const request = new Request(original, { redirect: "error", signal: controller.signal });
208
+ const retry = request.clone();
209
+ const response = await bounded(transport(request));
210
+ if (response.status !== 402)
211
+ return response;
212
+ const client = new x402Client();
213
+ const http = new x402HTTPClient(client);
214
+ const body = await bounded(challengeBody(response));
215
+ // v2 normally uses PAYMENT-REQUIRED; accept the same validated envelope
216
+ // in JSON too. The upstream HTTP decoder's body fallback is v1-only.
217
+ let envelope;
218
+ try {
219
+ envelope = response.headers.has("PAYMENT-REQUIRED")
220
+ ? http.getPaymentRequiredResponse(name => response.headers.get(name))
221
+ : body;
222
+ }
223
+ catch {
224
+ return fail("invalid payment challenge header");
225
+ }
226
+ const required = budget.select(envelope, request.url);
227
+ const offer = required.accepts[0];
228
+ release = budget.reserve(offer.amount);
229
+ if (budget.policy.beforePayment) {
230
+ const approved = await bounded(Promise.resolve(budget.policy.beforePayment(Object.freeze({ url: request.url,
231
+ network: offer.network, asset: offer.asset, payTo: offer.payTo,
232
+ feePayer: budget.policy.feePayer, amountAtomic: offer.amount }))));
233
+ if (approved !== true)
234
+ fail("payment declined");
235
+ }
236
+ check();
237
+ // RPC work can finish after our deadline. Guard the actual signer too so
238
+ // an abandoned payment creation cannot sign later in the background.
239
+ const guardedSigner = { ...signer, signTransactions: async (...args) => {
240
+ check();
241
+ return signer.signTransactions(...args);
242
+ } };
243
+ client.register(SOLANA_PAYMENT_NETWORK, new ExactSvmScheme(guardedSigner, { rpcUrl: budget.policy.rpcUrl }));
244
+ creating = true;
245
+ const payload = await bounded(client.createPaymentPayload(required));
246
+ check();
247
+ for (const [name, value] of Object.entries(http.encodePaymentSignatureHeader(payload)))
248
+ retry.headers.set(name, value);
249
+ return await bounded(transport(retry));
250
+ }
251
+ finally {
252
+ if (!creating)
253
+ release?.();
254
+ clearTimeout(timer);
255
+ original.signal.removeEventListener("abort", abort);
256
+ controller.signal.removeEventListener("abort", onAbort);
257
+ }
258
+ };
259
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "2.0.0";
1
+ export declare const VERSION = "3.0.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // AUTO-GENERATED by ../gen-version.mjs (npm prebuild) from package.json. Do not edit.
2
- export const VERSION = "2.0.0";
2
+ export const VERSION = "3.0.0";