mpp32-mcp-server 1.1.3 → 1.2.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/CHANGELOG.md CHANGED
@@ -4,6 +4,77 @@ All notable changes to `mpp32-mcp-server` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the
5
5
  project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [1.2.0] - 2026-05-11
8
+
9
+ This release makes x402 payments actually work end-to-end. Prior versions
10
+ shipped a non-spec-compliant signing path that the official x402.org
11
+ facilitator rejected with HTTP 400 on every paid call, so no settlement ever
12
+ occurred. The signing path has been rewritten from scratch against the
13
+ [Coinbase x402 reference implementation](https://github.com/coinbase/x402).
14
+
15
+ ### Fixed (the headline)
16
+
17
+ * **Real Solana x402 payments.** When a server returns a `Payment-Required`
18
+ challenge on a `solana:*` network, the MCP client now builds a real Solana
19
+ `VersionedTransaction` with the three instructions the `exact` SVM scheme
20
+ requires — `SetComputeUnitLimit`, `SetComputeUnitPrice`, and SPL-Token
21
+ `TransferChecked` between the payer's and recipient's Associated Token
22
+ Accounts. The transaction is partially signed by the payer (the fee-payer
23
+ slot is left empty for the facilitator to fill at `/settle` time, per spec)
24
+ and base64-encoded into the `payload.transaction` field. The official
25
+ `x402.org/facilitator` now accepts and settles these payments.
26
+
27
+ * **Real EVM x402 payments on Base.** For challenges with `network: "base"`
28
+ or `network: "base-sepolia"` (and the `eip155:*` aliases), the client now
29
+ signs an EIP-3009 `transferWithAuthorization` typed-data message using
30
+ `viem` and the EVM private key in `MPP32_PRIVATE_KEY`. This unblocks the
31
+ ~85% of the federated catalog (~3,900 of 4,581 entries) that lives on
32
+ Base — Exa Search, Firecrawl, OpenAI's x402 gateway, Anthropic's,
33
+ Alchemy RPC, CoinGecko Pro, Nansen, Cloudflare Workers AI, and the rest.
34
+
35
+ ### Added
36
+
37
+ * **`path` argument to `call_mpp32_endpoint`.** Many curated catalog
38
+ entries store only an upstream base URL (e.g. `https://api.exa.ai`). Pass
39
+ the upstream path (e.g. `/search`) via the new `path` parameter to hit a
40
+ real endpoint instead of `POST /` (which returned 404). The agent server
41
+ forwards the path and appends it safely to the catalog base URL.
42
+
43
+ * **`MPP32_SOLANA_RPC_URL` env var.** Override the Solana RPC used to fetch
44
+ recent blockhashes when building x402 transactions. Defaults to
45
+ `https://api.mainnet-beta.solana.com`. Set this if you hit public-endpoint
46
+ rate limits.
47
+
48
+ * **`@solana/kit`, `@solana-program/token`, `@solana-program/compute-budget`,
49
+ `viem` as real dependencies.** Tree-shakeable, no `rpc-websockets`
50
+ transitive dependency, and no ESM/CJS landmines on Node 20+. `viem` was
51
+ previously an optional peer; it is now required because the EVM x402
52
+ signer cannot work without it.
53
+
54
+ ### Migration
55
+
56
+ * No config changes required if you only use the Solana intelligence oracle
57
+ (it still uses `MPP32_SOLANA_PRIVATE_KEY`). To pay for Base-network
58
+ services like Exa Search, set `MPP32_PRIVATE_KEY` to your EVM private key
59
+ and ensure that wallet holds USDC on Base.
60
+
61
+ ## [1.1.4] - 2026-05-11
62
+
63
+ ### Added
64
+
65
+ * **`get_mpp32_diagnostics` MCP tool.** Reports server version, API URL,
66
+ and per-variable detection state for `MPP32_AGENT_KEY`,
67
+ `MPP32_SOLANA_PRIVATE_KEY`, and `MPP32_PRIVATE_KEY` — without ever
68
+ echoing the secret. The single most common payment failure has been
69
+ "I set the key but the MCP server doesn't see it" (wrong
70
+ `claude_desktop_config.json` file, `env` block at the wrong level in
71
+ the JSON, typo, or stale process from an incomplete restart). This
72
+ tool turns that into a one-call diagnosis.
73
+ * **Per-variable startup banner lines.** The stderr banner now prints
74
+ one `[mpp32] MPP32_X: SET (fingerprint) / NOT SET` line per managed
75
+ env var, so users who can find their MCP log file can see the same
76
+ diagnosis without calling a tool.
77
+
7
78
  ## [1.1.3] - 2026-05-11
8
79
 
9
80
  ### Fixed
package/dist/index.js CHANGED
@@ -2,7 +2,8 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
- const SERVER_VERSION = "1.1.3";
5
+ import { signX402Payment } from "./x402-signers.js";
6
+ const SERVER_VERSION = "1.2.0";
6
7
  // ── Env loading: trim and sanitize aggressively ─────────────────────────────
7
8
  // Copy-paste from Claude Desktop / Cursor / Windsurf JSON config UIs frequently
8
9
  // adds trailing \n, \r, NBSP, BOM, or wraps the value in literal quotes. Any
@@ -173,6 +174,63 @@ function isHttpCallable(svc) {
173
174
  return false;
174
175
  return /^https?:\/\//.test(url);
175
176
  }
177
+ // ── Tool 0: get_mpp32_diagnostics ───────────────────────────────────────────
178
+ // Lets the user (and Claude) see exactly what the MCP process detected at
179
+ // startup. The single most common failure mode is "I set the env var but it
180
+ // didn't reach the server" — wrong claude_desktop_config.json file edited,
181
+ // `env` block at the wrong level, typo in the variable name, stale process
182
+ // from an incomplete restart. This tool answers all of those without
183
+ // asking the user to dig through MCP log files.
184
+ function describeEnvVarStatus(name, value) {
185
+ const raw = process.env[name];
186
+ if (raw === undefined)
187
+ return `${name}: NOT SET (variable absent from MCP process env)`;
188
+ if (raw.length === 0)
189
+ return `${name}: EMPTY (set but blank)`;
190
+ if (value === undefined) {
191
+ return `${name}: REJECTED (raw length ${raw.length}, but failed validation — check startup log for reason)`;
192
+ }
193
+ // Show a short, non-secret fingerprint so the user can confirm it's the
194
+ // right value without us exfiltrating the key.
195
+ const fingerprint = value.length <= 12
196
+ ? `${value.length} chars`
197
+ : `${value.slice(0, 6)}…${value.slice(-4)} (${value.length} chars)`;
198
+ return `${name}: SET (${fingerprint})`;
199
+ }
200
+ server.tool("get_mpp32_diagnostics", "Report what the mpp32-mcp-server detected at startup: version, API URL, and which env vars (MPP32_AGENT_KEY, MPP32_SOLANA_PRIVATE_KEY, MPP32_PRIVATE_KEY) were loaded into this process. Use this FIRST if payments fail with 'no key configured' even though you set one in claude_desktop_config.json — it confirms whether your env vars actually reached the MCP process or got dropped by a typo / wrong file / stale restart.", {}, async () => {
201
+ const lines = [
202
+ `**mpp32-mcp-server diagnostics**`,
203
+ ``,
204
+ `Version: ${SERVER_VERSION}`,
205
+ `API URL: ${API_URL}`,
206
+ `Timeout: ${TIMEOUT_MS}ms`,
207
+ `Node: ${process.version} on ${process.platform}/${process.arch}`,
208
+ ``,
209
+ `**Environment variable detection** (values are fingerprinted, never returned in full):`,
210
+ ``,
211
+ describeEnvVarStatus("MPP32_AGENT_KEY", AGENT_KEY),
212
+ describeEnvVarStatus("MPP32_SOLANA_PRIVATE_KEY", SOLANA_PRIVATE_KEY),
213
+ describeEnvVarStatus("MPP32_PRIVATE_KEY", PRIVATE_KEY),
214
+ ``,
215
+ `**Capabilities:**`,
216
+ `- Catalog browsing: yes (always available)`,
217
+ `- Federated service execution: ${AGENT_KEY ? "yes" : "no — set MPP32_AGENT_KEY"}`,
218
+ `- x402 (USDC on Solana) payment: ${SOLANA_PRIVATE_KEY ? "yes" : "no — set MPP32_SOLANA_PRIVATE_KEY"}`,
219
+ `- Tempo (pathUSD on Eth L2) payment: ${PRIVATE_KEY ? "yes" : "no — set MPP32_PRIVATE_KEY"}`,
220
+ ``,
221
+ `If a variable shows NOT SET but you put it in claude_desktop_config.json:`,
222
+ `1. Confirm the file path Claude Desktop actually reads from:`,
223
+ ` - macOS: ~/Library/Application Support/Claude/claude_desktop_config.json`,
224
+ ` - Windows: %APPDATA%\\Claude\\claude_desktop_config.json`,
225
+ `2. The 'env' block must sit INSIDE the server entry, alongside 'command' and 'args' — not at the top level.`,
226
+ `3. Fully quit Claude Desktop (Cmd+Q on Mac, right-click tray → Quit on Windows). Closing the window is not enough.`,
227
+ `4. Re-open Claude Desktop. The new MCP process inherits the env from the JSON.`,
228
+ `5. Call get_mpp32_diagnostics again — if it still shows NOT SET, the JSON did not load (check for a syntax error).`,
229
+ ];
230
+ return {
231
+ content: [{ type: "text", text: lines.join("\n") }],
232
+ };
233
+ });
176
234
  // ── Tool 1: list_mpp32_services ─────────────────────────────────────────────
177
235
  server.tool("list_mpp32_services", "Browse the MPP32 federated catalog of 4,500+ machine-payable APIs and data services. Includes native MPP32 services (callable end-to-end through this MCP), the x402 Bazaar (USDC on Solana), curated free APIs (DexScreener, Jupiter, CoinGecko health, httpbin, etc.), and the public MCP Registry (npx-installable servers; listing-only). Each result indicates whether it is callable through `call_mpp32_endpoint` or listing-only. The catalog is large (~4,500 entries) — by default a single call returns up to 100 results and the response will tell you the true total and whether the page was truncated. Use `q` (free-text search), `category`, `source`, or `protocol` to narrow down, or raise `limit` (max 500) for broader pages.", {
178
236
  category: z
@@ -285,14 +343,18 @@ server.tool("list_mpp32_services", "Browse the MPP32 federated catalog of 4,500+
285
343
  }
286
344
  });
287
345
  // ── Tool 2: call_mpp32_endpoint ─────────────────────────────────────────────
288
- server.tool("call_mpp32_endpoint", "Call any HTTP-callable service in the MPP32 federated catalog. Free services return immediately. Paid services return a 402 challenge that this tool will sign and retry automatically when a payment key (MPP32_SOLANA_PRIVATE_KEY for x402/USDC, MPP32_PRIVATE_KEY for Tempo/pathUSD) is configured. Set MPP32_AGENT_KEY for dashboard tracking. Use `list_mpp32_services` first to find a slug. Listing-only entries (npx-installable MCP servers, x402 Bazaar non-mirrored items) cannot be called through this tool — install them directly per the catalog instructions.", {
346
+ server.tool("call_mpp32_endpoint", "Call any HTTP-callable service in the MPP32 federated catalog. Free services return immediately. Paid services return a 402 challenge that this tool will sign and retry automatically when a payment key (MPP32_SOLANA_PRIVATE_KEY for x402-on-Solana, MPP32_PRIVATE_KEY for x402-on-Base/Ethereum and Tempo pathUSD) is configured. Set MPP32_AGENT_KEY for dashboard tracking. Use `list_mpp32_services` first to find a slug. Many catalog entries store only the upstream BASE URL (e.g. `https://api.exa.ai`) — pass the upstream path (e.g. `/search`) via the `path` argument when calling those. Listing-only entries (npx-installable MCP servers, etc.) cannot be called through this tool.", {
289
347
  slug: z
290
348
  .string()
291
- .describe("Service slug from `list_mpp32_services` (e.g. 'mpp32-intelligence')."),
349
+ .describe("Service slug from `list_mpp32_services` (e.g. 'curated:exa', 'mpp32-intelligence')."),
292
350
  method: z
293
351
  .enum(["GET", "POST", "PUT", "DELETE"])
294
352
  .default("POST")
295
353
  .describe("HTTP method."),
354
+ path: z
355
+ .string()
356
+ .optional()
357
+ .describe("Upstream path appended to the service's base URL (e.g. '/search' for Exa, '/v1/chat/completions' for OpenAI). Leave empty for catalog entries that already store a full path, or for native MPP32 services. Always begins with '/'."),
296
358
  body: z
297
359
  .union([z.string(), z.record(z.unknown())])
298
360
  .optional()
@@ -301,7 +363,7 @@ server.tool("call_mpp32_endpoint", "Call any HTTP-callable service in the MPP32
301
363
  .record(z.string())
302
364
  .optional()
303
365
  .describe("URL query parameters as key-value pairs."),
304
- }, async ({ slug, method, body, query }) => {
366
+ }, async ({ slug, method, path, body, query }) => {
305
367
  // Normalize body to an object so it can be JSON.stringified by the upstream call
306
368
  let parsedBody = body;
307
369
  if (typeof body === "string") {
@@ -313,10 +375,10 @@ server.tool("call_mpp32_endpoint", "Call any HTTP-callable service in the MPP32
313
375
  }
314
376
  }
315
377
  if (AGENT_KEY) {
316
- return await callViaAgentExecute(slug, method, parsedBody, query);
378
+ return await callViaAgentExecute(slug, method, parsedBody, query, path);
317
379
  }
318
380
  // Legacy path — only works for native services with payment keys
319
- return await callViaLegacyProxy(slug, method, parsedBody, query);
381
+ return await callViaLegacyProxy(slug, method, parsedBody, query, path);
320
382
  });
321
383
  // ── Tool 3: get_solana_token_intelligence ───────────────────────────────────
322
384
  server.tool("get_solana_token_intelligence", "Get real-time Solana token intelligence from the MPP32 Intelligence Oracle. Returns alpha score (0-100), rug risk assessment, whale activity, smart money signals, 24h pump probability, projected ROI ranges, and aggregated DexScreener/Jupiter/CoinGecko market data. Costs $0.008 per query, paid automatically via x402 (USDC on Solana) or Tempo (pathUSD on Eth L2). M32 token holders receive up to 40% discount once their wallet is signature-verified. Set MPP32_AGENT_KEY in config to attribute calls to your dashboard.", {
@@ -336,7 +398,7 @@ server.tool("get_solana_token_intelligence", "Get real-time Solana token intelli
336
398
  return await legacyIntelligenceCall(token, walletAddress);
337
399
  });
338
400
  // ── Core: agent/execute path with 402 sign-and-retry ────────────────────────
339
- async function callViaAgentExecute(service, method, body, query) {
401
+ async function callViaAgentExecute(service, method, body, query, path) {
340
402
  try {
341
403
  const execUrl = new URL("/api/agent/execute", API_URL).toString();
342
404
  const reqBody = JSON.stringify({
@@ -344,6 +406,7 @@ async function callViaAgentExecute(service, method, body, query) {
344
406
  method,
345
407
  ...(body !== undefined ? { body } : {}),
346
408
  ...(query ? { query } : {}),
409
+ ...(path ? { path } : {}),
347
410
  });
348
411
  // Round 1: no payment headers
349
412
  const firstRes = await fetchWithTimeout(execUrl, {
@@ -395,11 +458,18 @@ function detectPaymentRequired(resp) {
395
458
  async function signAndRetry(execUrl, reqBody, challenge) {
396
459
  const paymentHeaders = {};
397
460
  let usedProtocol = "";
398
- // Prefer x402 if Solana key present and server offered Payment-Required
399
- if (challenge.paymentRequired && SOLANA_PRIVATE_KEY) {
461
+ // Prefer x402 if a payment-required challenge is present AND we hold a key
462
+ // for *either* the SVM or EVM side. The signer module inspects the
463
+ // challenge's `network` field and routes to the right signer; we just need
464
+ // to pass it whichever keys we have.
465
+ if (challenge.paymentRequired && (SOLANA_PRIVATE_KEY || PRIVATE_KEY)) {
400
466
  try {
401
- paymentHeaders["X-Payment"] = await completeX402Payment(challenge.paymentRequired, SOLANA_PRIVATE_KEY);
402
- usedProtocol = "USDC (x402)";
467
+ const completed = await completeX402Payment(challenge.paymentRequired, {
468
+ solana: SOLANA_PRIVATE_KEY,
469
+ evm: PRIVATE_KEY,
470
+ });
471
+ paymentHeaders["X-Payment"] = completed.xPaymentHeader;
472
+ usedProtocol = completed.protocolUsed === "x402-evm" ? "USDC (x402, Base)" : "USDC (x402, Solana)";
403
473
  }
404
474
  catch (err) {
405
475
  // Fall through to Tempo if available
@@ -626,9 +696,12 @@ function paymentFailedMessage(challenge, proto, err) {
626
696
  };
627
697
  }
628
698
  // ── Legacy path (no MPP32_AGENT_KEY) ────────────────────────────────────────
629
- async function callViaLegacyProxy(slug, method, body, query) {
699
+ async function callViaLegacyProxy(slug, method, body, query, path) {
630
700
  try {
631
701
  // Without an agent key, only native /api/proxy/<slug> is reachable.
702
+ // Native services do not need a `path` argument; if one is passed, we
703
+ // ignore it here. (The agent-execute path forwards it for external entries.)
704
+ void path;
632
705
  // We fetch /info first to detect that the slug exists as a native service.
633
706
  const infoUrl = new URL(`/api/proxy/${encodeURIComponent(slug)}/info`, API_URL).toString();
634
707
  const infoRes = await fetchWithTimeout(infoUrl);
@@ -703,10 +776,14 @@ async function callViaLegacyProxy(slug, method, body, query) {
703
776
  }
704
777
  const paymentHeaders = {};
705
778
  let usedProtocol = "";
706
- if (paymentRequired && SOLANA_PRIVATE_KEY) {
779
+ if (paymentRequired && (SOLANA_PRIVATE_KEY || PRIVATE_KEY)) {
707
780
  try {
708
- paymentHeaders["X-Payment"] = await completeX402Payment(paymentRequired, SOLANA_PRIVATE_KEY);
709
- usedProtocol = "USDC (x402)";
781
+ const completed = await completeX402Payment(paymentRequired, {
782
+ solana: SOLANA_PRIVATE_KEY,
783
+ evm: PRIVATE_KEY,
784
+ });
785
+ paymentHeaders["X-Payment"] = completed.xPaymentHeader;
786
+ usedProtocol = completed.protocolUsed === "x402-evm" ? "USDC (x402, Base)" : "USDC (x402, Solana)";
710
787
  }
711
788
  catch (err) {
712
789
  if (wwwAuth && PRIVATE_KEY) {
@@ -844,10 +921,14 @@ async function legacyIntelligenceCall(token, walletAddress) {
844
921
  const paymentRequired = res.headers.get("payment-required") ?? undefined;
845
922
  const paymentHeaders = {};
846
923
  let usedProtocol = "";
847
- if (paymentRequired && SOLANA_PRIVATE_KEY) {
924
+ if (paymentRequired && (SOLANA_PRIVATE_KEY || PRIVATE_KEY)) {
848
925
  try {
849
- paymentHeaders["X-Payment"] = await completeX402Payment(paymentRequired, SOLANA_PRIVATE_KEY);
850
- usedProtocol = "USDC (x402)";
926
+ const completed = await completeX402Payment(paymentRequired, {
927
+ solana: SOLANA_PRIVATE_KEY,
928
+ evm: PRIVATE_KEY,
929
+ });
930
+ paymentHeaders["X-Payment"] = completed.xPaymentHeader;
931
+ usedProtocol = completed.protocolUsed === "x402-evm" ? "USDC (x402, Base)" : "USDC (x402, Solana)";
851
932
  }
852
933
  catch (x402Err) {
853
934
  if (wwwAuth && PRIVATE_KEY) {
@@ -868,7 +949,7 @@ async function legacyIntelligenceCall(token, walletAddress) {
868
949
  else {
869
950
  return {
870
951
  content: [
871
- { type: "text", text: `x402 payment failed: ${x402Err instanceof Error ? x402Err.message : String(x402Err)}. Check Solana wallet balance.` },
952
+ { type: "text", text: `x402 payment failed: ${x402Err instanceof Error ? x402Err.message : String(x402Err)}. Check that the wallet for the challenge network has sufficient USDC balance.` },
872
953
  ],
873
954
  };
874
955
  }
@@ -969,89 +1050,25 @@ async function completeTempoPayment(challengeParams, privateKey) {
969
1050
  throw new Error(`Tempo payment failed: ${payErr instanceof Error ? payErr.message : String(payErr)}`);
970
1051
  }
971
1052
  }
972
- async function completeX402Payment(paymentRequiredHeader, solanaPrivateKey) {
973
- let requirements;
974
- try {
975
- requirements = JSON.parse(Buffer.from(paymentRequiredHeader, "base64").toString("utf-8"));
976
- }
977
- catch {
978
- throw new Error("Could not decode Payment-Required header");
979
- }
980
- // We sign x402 challenges with raw Ed25519 (Solana keys are Ed25519). No
981
- // @solana/web3.js needed — it pulls in rpc-websockets which has a
982
- // CJS/ESM uuid incompat on Node 20+ that breaks every paid call.
983
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
984
- let tweetnacl;
985
- try {
986
- const pkg = "tweetnacl";
987
- tweetnacl = await import(pkg);
988
- }
989
- catch (err) {
990
- throw new Error(`x402 signing requires tweetnacl, which ships with mpp32-mcp-server. ` +
991
- `If you're seeing this on a clean npx install, upgrade to mpp32-mcp-server@latest. ` +
992
- `Underlying error: ${err instanceof Error ? err.message : String(err)}`);
993
- }
994
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
995
- let bs58;
996
- try {
997
- const pkg = "bs58";
998
- bs58 = await import(pkg);
999
- }
1000
- catch (err) {
1001
- throw new Error(`x402 signing requires bs58, which ships with mpp32-mcp-server. ` +
1002
- `Upgrade to mpp32-mcp-server@latest. ` +
1003
- `Underlying error: ${err instanceof Error ? err.message : String(err)}`);
1004
- }
1005
- const bs58Decode = bs58.default?.decode ?? bs58.decode;
1006
- const bs58Encode = bs58.default?.encode ?? bs58.encode;
1007
- const naclSign = tweetnacl.default?.sign ?? tweetnacl.sign;
1008
- let rawKey;
1009
- try {
1010
- if (solanaPrivateKey.startsWith("[")) {
1011
- rawKey = new Uint8Array(JSON.parse(solanaPrivateKey));
1012
- }
1013
- else if (/^[0-9a-fA-F]+$/.test(solanaPrivateKey) && solanaPrivateKey.length % 2 === 0) {
1014
- rawKey = new Uint8Array(Buffer.from(solanaPrivateKey, "hex"));
1015
- }
1016
- else {
1017
- rawKey = bs58Decode(solanaPrivateKey);
1018
- }
1019
- }
1020
- catch (err) {
1021
- throw new Error(`Could not decode Solana private key: ${err instanceof Error ? err.message : String(err)}`);
1022
- }
1023
- let secretKey;
1024
- let publicKey;
1025
- if (rawKey.length === 64) {
1026
- secretKey = rawKey;
1027
- publicKey = rawKey.slice(32);
1028
- }
1029
- else if (rawKey.length === 32) {
1030
- const kp = naclSign.keyPair.fromSeed(rawKey);
1031
- secretKey = kp.secretKey;
1032
- publicKey = kp.publicKey;
1033
- }
1034
- else {
1035
- throw new Error(`Solana private key must be a 32-byte seed or 64-byte expanded key; got ${rawKey.length} bytes.`);
1036
- }
1037
- const payload = {
1038
- x402Version: 1,
1039
- scheme: requirements.scheme ?? "exact",
1040
- network: requirements.network ?? "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
1041
- payload: {
1042
- signature: "",
1043
- from: bs58Encode(publicKey),
1044
- amount: requirements.maxAmountRequired,
1045
- asset: requirements.asset,
1046
- payTo: requirements.payTo,
1047
- nonce: Date.now().toString(),
1048
- },
1053
+ // Build a real, x402-spec-compliant payment payload from the server's
1054
+ // Payment-Required challenge. For Solana-family networks, this produces a
1055
+ // base64 partially-signed VersionedTransaction (3 instructions, fee-payer
1056
+ // slot reserved for the facilitator). For Base/Base-Sepolia/Ethereum, it
1057
+ // produces an EIP-3009 transferWithAuthorization signature. Returns the
1058
+ // envelope ready to drop into the `X-Payment` HTTP header.
1059
+ async function completeX402Payment(paymentRequiredHeader, keys) {
1060
+ const solanaRpcUrl = readEnv("MPP32_SOLANA_RPC_URL");
1061
+ const result = await signX402Payment({
1062
+ paymentRequiredHeader,
1063
+ solanaKey: keys.solana,
1064
+ evmKey: keys.evm,
1065
+ solanaRpcUrl,
1066
+ });
1067
+ return {
1068
+ xPaymentHeader: result.xPaymentHeader,
1069
+ network: result.network,
1070
+ protocolUsed: result.protocolUsed,
1049
1071
  };
1050
- const message = JSON.stringify(payload.payload);
1051
- const messageBytes = new TextEncoder().encode(message);
1052
- const signed = naclSign.detached(messageBytes, secretKey);
1053
- payload.payload.signature = Buffer.from(signed).toString("base64");
1054
- return Buffer.from(JSON.stringify(payload)).toString("base64");
1055
1072
  }
1056
1073
  // ── Start ───────────────────────────────────────────────────────────────────
1057
1074
  async function main() {
@@ -1065,6 +1082,13 @@ async function main() {
1065
1082
  .filter(Boolean)
1066
1083
  .join(", ") || "no keys (catalog-only legacy mode)";
1067
1084
  console.error(`[mpp32] MCP server v${SERVER_VERSION} on stdio. API ${API_URL}. Configured: ${features}. Timeout ${TIMEOUT_MS}ms.`);
1085
+ // Per-variable status so a user staring at this log can immediately see
1086
+ // whether their env vars made it through. Values are fingerprinted.
1087
+ const fp = (v) => !v ? "NOT SET" : v.length <= 12 ? `SET (${v.length}c)` : `SET (${v.slice(0, 6)}…${v.slice(-4)}, ${v.length}c)`;
1088
+ console.error(`[mpp32] MPP32_AGENT_KEY: ${fp(AGENT_KEY)}`);
1089
+ console.error(`[mpp32] MPP32_SOLANA_PRIVATE_KEY: ${fp(SOLANA_PRIVATE_KEY)}`);
1090
+ console.error(`[mpp32] MPP32_PRIVATE_KEY: ${fp(PRIVATE_KEY)}`);
1091
+ console.error(`[mpp32] If a key shows NOT SET but you set it in claude_desktop_config.json, call the get_mpp32_diagnostics tool for help, or fully quit Claude Desktop and reopen.`);
1068
1092
  }
1069
1093
  main().catch((err) => {
1070
1094
  console.error("Fatal:", err);
@@ -0,0 +1,42 @@
1
+ export interface X402PaymentRequirements {
2
+ scheme: string;
3
+ network: string;
4
+ maxAmountRequired: string;
5
+ resource: string;
6
+ description?: string;
7
+ mimeType?: string;
8
+ payTo: string;
9
+ maxTimeoutSeconds?: number;
10
+ asset: string;
11
+ outputSchema?: unknown;
12
+ extra?: {
13
+ feePayer?: string;
14
+ name?: string;
15
+ version?: string;
16
+ decimals?: number;
17
+ [k: string]: unknown;
18
+ };
19
+ }
20
+ export interface X402PaymentEnvelope {
21
+ x402Version: number;
22
+ scheme: string;
23
+ network: string;
24
+ payload: unknown;
25
+ }
26
+ export declare function isSvmNetwork(network: string): boolean;
27
+ export declare function isEvmNetwork(network: string): boolean;
28
+ export declare function signX402PaymentSvm(requirements: X402PaymentRequirements, rawKey: string, rpcUrlOverride?: string): Promise<string>;
29
+ export declare function signX402PaymentEvm(requirements: X402PaymentRequirements, rawKey: string): Promise<string>;
30
+ export interface SignX402Args {
31
+ paymentRequiredHeader: string;
32
+ solanaKey?: string;
33
+ evmKey?: string;
34
+ solanaRpcUrl?: string;
35
+ }
36
+ export interface SignX402Result {
37
+ xPaymentHeader: string;
38
+ network: string;
39
+ scheme: string;
40
+ protocolUsed: "x402-svm" | "x402-evm";
41
+ }
42
+ export declare function signX402Payment(args: SignX402Args): Promise<SignX402Result>;
@@ -0,0 +1,267 @@
1
+ // x402 protocol-compliant payment signers.
2
+ //
3
+ // Two schemes are implemented end-to-end here. Both follow the official
4
+ // `exact` scheme from https://x402.org and the reference implementation at
5
+ // https://github.com/coinbase/x402.
6
+ //
7
+ // • SVM (Solana): build a 3-instruction Solana VersionedTransaction
8
+ // (SetComputeUnitLimit, SetComputeUnitPrice, SPL-Token TransferChecked
9
+ // between Associated Token Accounts), set the facilitator-advertised
10
+ // fee payer, partially sign with the payer's Ed25519 keypair, and
11
+ // base64-encode the wire transaction. The fee-payer signature slot is
12
+ // left empty — the facilitator fills it during /settle.
13
+ // • EVM (Base / Base-Sepolia): sign an EIP-3009 `transferWithAuthorization`
14
+ // typed data message with the payer's secp256k1 key using viem. The
15
+ // resulting signature plus authorization parameters form the payload.
16
+ //
17
+ // In both cases the outer envelope is
18
+ // { x402Version: 1, scheme: "exact", network, payload: <scheme payload> }
19
+ // base64-encoded into the `X-Payment` HTTP header.
20
+ import { address, createKeyPairSignerFromBytes, createSolanaRpc, createTransactionMessage, setTransactionMessageFeePayer, setTransactionMessageLifetimeUsingBlockhash, appendTransactionMessageInstructions, partiallySignTransactionMessageWithSigners, getBase64EncodedWireTransaction, pipe, } from "@solana/kit";
21
+ import { getTransferCheckedInstruction, findAssociatedTokenPda, TOKEN_PROGRAM_ADDRESS, } from "@solana-program/token";
22
+ import { getSetComputeUnitLimitInstruction, getSetComputeUnitPriceInstruction, } from "@solana-program/compute-budget";
23
+ import { privateKeyToAccount } from "viem/accounts";
24
+ import bs58 from "bs58";
25
+ import nacl from "tweetnacl";
26
+ // ── Network classification ──────────────────────────────────────────────────
27
+ const SOLANA_MAINNET = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
28
+ const SOLANA_DEVNET = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1";
29
+ export function isSvmNetwork(network) {
30
+ return network.startsWith("solana") || network === "solana-mainnet" || network === "solana-devnet";
31
+ }
32
+ export function isEvmNetwork(network) {
33
+ if (network.startsWith("eip155:"))
34
+ return true;
35
+ return ["base", "base-sepolia", "ethereum", "ethereum-sepolia"].includes(network);
36
+ }
37
+ function chainSpecFor(network) {
38
+ if (network === "base" || network === "eip155:8453") {
39
+ return { chainId: 8453, name: "Base", rpcUrl: "https://mainnet.base.org" };
40
+ }
41
+ if (network === "base-sepolia" || network === "eip155:84532") {
42
+ return { chainId: 84532, name: "Base Sepolia", rpcUrl: "https://sepolia.base.org" };
43
+ }
44
+ if (network === "ethereum" || network === "eip155:1") {
45
+ return { chainId: 1, name: "Ethereum", rpcUrl: "https://eth.llamarpc.com" };
46
+ }
47
+ throw new Error(`Unsupported EVM network "${network}". x402 EVM payments currently support: base, base-sepolia, ethereum.`);
48
+ }
49
+ // ── Key decoding ────────────────────────────────────────────────────────────
50
+ function decodeSolanaSecret(raw) {
51
+ if (raw.startsWith("[")) {
52
+ const arr = JSON.parse(raw);
53
+ if (!Array.isArray(arr))
54
+ throw new Error("Solana secret JSON array malformed");
55
+ return new Uint8Array(arr);
56
+ }
57
+ if (/^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0) {
58
+ return new Uint8Array(Buffer.from(raw, "hex"));
59
+ }
60
+ return bs58.decode(raw);
61
+ }
62
+ async function buildSolanaSigner(rawKey) {
63
+ let bytes = decodeSolanaSecret(rawKey);
64
+ if (bytes.length === 32) {
65
+ // 32-byte seed — kit's createKeyPairSignerFromBytes wants the 64-byte
66
+ // expanded key. Derive via tweetnacl.
67
+ const kp = nacl.sign.keyPair.fromSeed(bytes);
68
+ bytes = kp.secretKey;
69
+ }
70
+ else if (bytes.length !== 64) {
71
+ throw new Error(`Solana private key must be a 32-byte seed or a 64-byte expanded key; got ${bytes.length} bytes.`);
72
+ }
73
+ return await createKeyPairSignerFromBytes(bytes);
74
+ }
75
+ // ── SVM signer ──────────────────────────────────────────────────────────────
76
+ const DEFAULT_SOLANA_RPC = "https://api.mainnet-beta.solana.com";
77
+ export async function signX402PaymentSvm(requirements, rawKey, rpcUrlOverride) {
78
+ if (requirements.scheme !== "exact") {
79
+ throw new Error(`SVM x402 scheme "${requirements.scheme}" not implemented; only "exact" is supported.`);
80
+ }
81
+ if (!requirements.extra?.feePayer) {
82
+ throw new Error(`SVM x402 challenge is missing extra.feePayer. The facilitator must advertise a fee-payer address per the x402 spec. ` +
83
+ `If you are calling MPP32 itself, upgrade the backend; if a third-party service, ask them to fix their challenge.`);
84
+ }
85
+ const decimals = requirements.extra?.decimals ?? 6;
86
+ const amount = BigInt(requirements.maxAmountRequired);
87
+ if (amount <= 0n)
88
+ throw new Error(`Invalid maxAmountRequired: ${requirements.maxAmountRequired}`);
89
+ const signer = await buildSolanaSigner(rawKey);
90
+ const payerAddress = signer.address;
91
+ const mintAddress = address(requirements.asset);
92
+ const recipientAddress = address(requirements.payTo);
93
+ const feePayerAddress = address(requirements.extra.feePayer);
94
+ // Derive both sides' associated token accounts (classic SPL Token program).
95
+ const [sourceAtaTuple, destinationAtaTuple] = await Promise.all([
96
+ findAssociatedTokenPda({
97
+ owner: payerAddress,
98
+ mint: mintAddress,
99
+ tokenProgram: TOKEN_PROGRAM_ADDRESS,
100
+ }),
101
+ findAssociatedTokenPda({
102
+ owner: recipientAddress,
103
+ mint: mintAddress,
104
+ tokenProgram: TOKEN_PROGRAM_ADDRESS,
105
+ }),
106
+ ]);
107
+ const sourceAta = sourceAtaTuple[0];
108
+ const destinationAta = destinationAtaTuple[0];
109
+ const rpcUrl = rpcUrlOverride && rpcUrlOverride.length > 0 ? rpcUrlOverride : DEFAULT_SOLANA_RPC;
110
+ const rpc = createSolanaRpc(rpcUrl);
111
+ const { value: latestBlockhash } = await rpc.getLatestBlockhash({ commitment: "confirmed" }).send();
112
+ const instructions = [
113
+ getSetComputeUnitLimitInstruction({ units: 150_000 }),
114
+ getSetComputeUnitPriceInstruction({ microLamports: 1000n }),
115
+ getTransferCheckedInstruction({
116
+ source: sourceAta,
117
+ mint: mintAddress,
118
+ destination: destinationAta,
119
+ authority: signer,
120
+ amount,
121
+ decimals,
122
+ }),
123
+ ];
124
+ const message = pipe(createTransactionMessage({ version: 0 }), (m) => setTransactionMessageFeePayer(feePayerAddress, m), (m) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m), (m) => appendTransactionMessageInstructions(instructions, m));
125
+ // Partially sign — fills the payer's signature slot, leaves the fee payer's
126
+ // slot empty for the facilitator to fill in at /settle time.
127
+ const partiallySigned = await partiallySignTransactionMessageWithSigners(message);
128
+ const base64Tx = getBase64EncodedWireTransaction(partiallySigned);
129
+ const envelope = {
130
+ x402Version: 1,
131
+ scheme: "exact",
132
+ network: requirements.network,
133
+ payload: { transaction: base64Tx },
134
+ };
135
+ return Buffer.from(JSON.stringify(envelope)).toString("base64");
136
+ }
137
+ // ── EVM signer (EIP-3009 transferWithAuthorization) ─────────────────────────
138
+ function randomHex32() {
139
+ const buf = Buffer.alloc(32);
140
+ for (let i = 0; i < 32; i++)
141
+ buf[i] = Math.floor(Math.random() * 256);
142
+ return ("0x" + buf.toString("hex"));
143
+ }
144
+ export async function signX402PaymentEvm(requirements, rawKey) {
145
+ if (requirements.scheme !== "exact") {
146
+ throw new Error(`EVM x402 scheme "${requirements.scheme}" not implemented; only "exact" is supported.`);
147
+ }
148
+ const chain = chainSpecFor(requirements.network);
149
+ const tokenName = requirements.extra?.name ?? "USD Coin";
150
+ const tokenVersion = requirements.extra?.version ?? "2";
151
+ const assetAddr = requirements.asset;
152
+ if (!/^0x[0-9a-fA-F]{40}$/.test(assetAddr)) {
153
+ throw new Error(`EVM x402 challenge asset is not a valid 0x address: ${requirements.asset}`);
154
+ }
155
+ const recipientAddr = requirements.payTo;
156
+ if (!/^0x[0-9a-fA-F]{40}$/.test(recipientAddr)) {
157
+ throw new Error(`EVM x402 challenge payTo is not a valid 0x address: ${requirements.payTo}`);
158
+ }
159
+ const value = BigInt(requirements.maxAmountRequired);
160
+ if (value <= 0n)
161
+ throw new Error(`Invalid maxAmountRequired: ${requirements.maxAmountRequired}`);
162
+ const keyHex = rawKey.startsWith("0x") ? rawKey : `0x${rawKey}`;
163
+ if (!/^0x[0-9a-fA-F]{64}$/.test(keyHex)) {
164
+ throw new Error("MPP32_PRIVATE_KEY must be a 64-character hex EVM private key (0x-prefixed or bare).");
165
+ }
166
+ const account = privateKeyToAccount(keyHex);
167
+ const now = Math.floor(Date.now() / 1000);
168
+ const validAfter = BigInt(0);
169
+ const validBefore = BigInt(now + (requirements.maxTimeoutSeconds ?? 600));
170
+ const nonce = randomHex32();
171
+ const domain = {
172
+ name: tokenName,
173
+ version: tokenVersion,
174
+ chainId: chain.chainId,
175
+ verifyingContract: assetAddr,
176
+ };
177
+ const types = {
178
+ TransferWithAuthorization: [
179
+ { name: "from", type: "address" },
180
+ { name: "to", type: "address" },
181
+ { name: "value", type: "uint256" },
182
+ { name: "validAfter", type: "uint256" },
183
+ { name: "validBefore", type: "uint256" },
184
+ { name: "nonce", type: "bytes32" },
185
+ ],
186
+ };
187
+ const messageObj = {
188
+ from: account.address,
189
+ to: recipientAddr,
190
+ value,
191
+ validAfter,
192
+ validBefore,
193
+ nonce,
194
+ };
195
+ const signature = await account.signTypedData({
196
+ domain,
197
+ types,
198
+ primaryType: "TransferWithAuthorization",
199
+ message: messageObj,
200
+ });
201
+ const envelope = {
202
+ x402Version: 1,
203
+ scheme: "exact",
204
+ network: requirements.network,
205
+ payload: {
206
+ signature,
207
+ authorization: {
208
+ from: messageObj.from,
209
+ to: messageObj.to,
210
+ value: messageObj.value.toString(),
211
+ validAfter: messageObj.validAfter.toString(),
212
+ validBefore: messageObj.validBefore.toString(),
213
+ nonce: messageObj.nonce,
214
+ },
215
+ },
216
+ };
217
+ return Buffer.from(JSON.stringify(envelope)).toString("base64");
218
+ }
219
+ export async function signX402Payment(args) {
220
+ let requirements;
221
+ try {
222
+ const json = Buffer.from(args.paymentRequiredHeader, "base64").toString("utf-8");
223
+ requirements = JSON.parse(json);
224
+ }
225
+ catch (err) {
226
+ throw new Error(`Could not decode Payment-Required header as base64 JSON: ${err instanceof Error ? err.message : String(err)}`);
227
+ }
228
+ if (!requirements.network)
229
+ throw new Error("x402 payment requirements missing 'network'");
230
+ if (!requirements.asset)
231
+ throw new Error("x402 payment requirements missing 'asset'");
232
+ if (!requirements.payTo)
233
+ throw new Error("x402 payment requirements missing 'payTo'");
234
+ if (!requirements.maxAmountRequired)
235
+ throw new Error("x402 payment requirements missing 'maxAmountRequired'");
236
+ if (isSvmNetwork(requirements.network)) {
237
+ if (!args.solanaKey) {
238
+ throw new Error(`Provider requires SVM payment on ${requirements.network}, but MPP32_SOLANA_PRIVATE_KEY is not configured. ` +
239
+ `Set it in your MCP config to enable USDC-on-Solana payments.`);
240
+ }
241
+ const header = await signX402PaymentSvm(requirements, args.solanaKey, args.solanaRpcUrl);
242
+ return {
243
+ xPaymentHeader: header,
244
+ network: requirements.network,
245
+ scheme: requirements.scheme,
246
+ protocolUsed: "x402-svm",
247
+ };
248
+ }
249
+ if (isEvmNetwork(requirements.network)) {
250
+ if (!args.evmKey) {
251
+ throw new Error(`Provider requires EVM payment on ${requirements.network}, but MPP32_PRIVATE_KEY is not configured. ` +
252
+ `Set it in your MCP config to enable USDC-on-Base payments.`);
253
+ }
254
+ const header = await signX402PaymentEvm(requirements, args.evmKey);
255
+ return {
256
+ xPaymentHeader: header,
257
+ network: requirements.network,
258
+ scheme: requirements.scheme,
259
+ protocolUsed: "x402-evm",
260
+ };
261
+ }
262
+ if (requirements.network === "" || requirements.network === undefined) {
263
+ throw new Error(`Provider's x402 challenge does not specify a network. We cannot pay it. Ask the provider to fix their challenge.`);
264
+ }
265
+ throw new Error(`Unsupported x402 network "${requirements.network}". Supported: solana:*, base, base-sepolia, ethereum (and their eip155:* aliases). ` +
266
+ `If this network is real and we should support it, file an issue.`);
267
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mpp32-mcp-server",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
4
4
  "mcpName": "io.github.MPP32/mpp32-mcp-server",
5
5
  "description": "Payment layer for AI agents. One MCP, five protocols, thousands of paid APIs your agent can call.",
6
6
  "type": "module",
@@ -69,20 +69,20 @@
69
69
  },
70
70
  "dependencies": {
71
71
  "@modelcontextprotocol/sdk": "^1.12.0",
72
+ "@solana-program/compute-budget": "^0.15.0",
73
+ "@solana-program/token": "^0.13.0",
74
+ "@solana/kit": "^6.9.0",
72
75
  "bs58": "^6.0.0",
73
76
  "tweetnacl": "^1.0.3",
77
+ "viem": "^2.48.11",
74
78
  "zod": "^3.23.0"
75
79
  },
76
80
  "peerDependencies": {
77
- "mppx": ">=0.4.0",
78
- "viem": ">=2.0.0"
81
+ "mppx": ">=0.4.0"
79
82
  },
80
83
  "peerDependenciesMeta": {
81
84
  "mppx": {
82
85
  "optional": true
83
- },
84
- "viem": {
85
- "optional": true
86
86
  }
87
87
  },
88
88
  "devDependencies": {