mcp-server-madeonsol 1.27.1 → 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/README.md CHANGED
@@ -15,6 +15,10 @@ MCP server for [MadeOnSol](https://madeonsol.com) Solana KOL intelligence API. U
15
15
 
16
16
  > Real-time Solana trading intelligence: track 2,000+ KOL wallets with <3s latency on paid keys and x402 pay-per-call (free-tier live feeds are 5-min delayed), score 85K+ Pump.fun deployers, surface deshred deploy signals **~500ms before on-chain confirmation**, detect multi-KOL coordination, surface bundle-cohort holdings (which same-slot wallets still hold a token's supply), verify any wallet's CURRENT on-chain holdings straight from its token accounts, and stream every DEX trade across 9+ programs. Free tier: 200 requests/day across 40+ endpoints (live feeds 5-min delayed) — no signup payment. Get a key at [madeonsol.com/pricing](https://madeonsol.com/pricing).
17
17
 
18
+ > **New in 3.0.0 — BREAKING for x402 (keyless) mode only: five env vars are now required and startup FAILS instead of silently degrading (security fix, SDK-01).** Set `X402_PAY_TO`, `X402_FEE_PAYER`, `X402_MAX_AMOUNT_ATOMIC`, `X402_MAX_TOTAL_AMOUNT_ATOMIC` and `SVM_RPC_URL` alongside `SVM_PRIVATE_KEY`. Before, keyless mode signed whatever Solana USDC amount, recipient and fee payer a 402 challenge asked for. Now every challenge is checked BEFORE signing against a trusted merchant `payTo`, a trusted facilitator `feePayer` (which must differ from your wallet), the USDC mint, `solana:5eykt…` mainnet, the `exact` scheme, a per-call cap and a lifetime cap. Use the canonical values in the keyless section below; caps must be at least `20000` (0.02 USDC) per call to reach every endpoint. The budget is per client instance / process: not wallet-wide, not shared between processes, reset on a new instance or restart. Keyless requires the base URL exactly `https://madeonsol.com`. **API-key (`msk_`) users: no change, no new config.** HTTP mode still refuses wallet signers (SEC-02, unchanged).
19
+
20
+ > **New in 2.0.0 — BREAKING: HTTP mode now requires a separate token (security fix, SEC-02).** If you run this server with `MCP_TRANSPORT=http`, you must now also set `MCP_HTTP_TOKEN` (a random secret you generate) and send it as `Authorization: Bearer <token>` on **every** request, including `GET /health` and the server-card endpoint — previously, anything that could reach the bound port could call every tool using the operator's own API key with no authentication at all. HTTP mode is now hard-restricted to literal loopback (`127.0.0.1` / `::1`), rejects `Origin`/`X-Forwarded-*` headers and duplicate `Authorization`/`Host` headers outright, exposes only `POST /mcp` plus the two GET routes, and **refuses to start** if a wallet/payment signer is configured — HTTP mode can never carry a payer key. **If you use the default `stdio` transport (Claude Desktop, Cursor, most MCP clients), nothing changes — no action needed.** This is a single shared-operator-token fix, not a new multi-user or OAuth system; each HTTP caller still shares the operator's own MadeOnSol API key. Full writeup: `docs/audit/SEC02_PRIVATE_HTTP_MCP.md`.
21
+ >
18
22
  > **New in 1.27.1 — the server now reports MCP `instructions`.** The `initialize` response's `instructions` field (operational guidance for the calling agent — distinct from this README/package description) was never set; directories that introspect the live server (Glama) reported "no recorded MCP instructions." No new tools; still 104 total.
19
23
  >
20
24
  > **New in 1.27.0 — top traders, sniper watchlist management, and two update tools (104 tools total).** Six additions found by an internal agentic-infra coverage audit. `madeonsol_token_top_traders` (`GET /tokens/{mint}/top-traders`, PRO/ULTRA) ranks a token's wallets by realized PnL or ROI, enriched with KOL/alpha-wallet identity — this endpoint already existed on the REST API and x402 rail but had no MCP tool. `madeonsol_sniper_watchlist_list` / `_add` / `_remove` (PRO+/ULTRA) manage your custom sniper-deploy watchlist (`madeonsol_sniper_recent(watchlist: true)` narrows to it) — previously only reachable via raw HTTP. `madeonsol_update_webhook` (PATCH) changes a webhook's URL, events, or active state without deleting and recreating it. `madeonsol_wallet_tracker_relabel` (PATCH) renames or clears a tracked wallet's label.
@@ -97,7 +101,7 @@ Restart Claude Desktop and ask: *"What are KOLs buying right now?"*
97
101
 
98
102
  ## AI agent quickstart (x402 / pay-per-call)
99
103
 
100
- Building an autonomous agent? Skip the signup. Point a **funded Solana wallet** at the server and every tool call **auto-pays a micropayment** over [x402](https://x402.org) — no API key, no account, no rate-limit dance.
104
+ Building an autonomous agent? Skip the signup. Configure a **funded Solana wallet** and an explicit payment budget. Paid tool calls authorize micropayments within that budget over [x402](https://x402.org) — no API key, no account, no rate-limit dance.
101
105
 
102
106
  ```json
103
107
  {
@@ -105,7 +109,12 @@ Building an autonomous agent? Skip the signup. Point a **funded Solana wallet**
105
109
  "madeonsol": {
106
110
  "command": "mcp-server-madeonsol",
107
111
  "env": {
108
- "SVM_PRIVATE_KEY": "<base58 solana private key>"
112
+ "SVM_PRIVATE_KEY": "<base58 solana private key>",
113
+ "X402_PAY_TO": "GLu63pRCYrp4BJu5P5ciYKxgeZFW9c8TJ8jWzK3TB9AR",
114
+ "X402_FEE_PAYER": "2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4",
115
+ "X402_MAX_AMOUNT_ATOMIC": "20000",
116
+ "X402_MAX_TOTAL_AMOUNT_ATOMIC": "1000000",
117
+ "SVM_RPC_URL": "https://<your-trusted-solana-rpc>"
109
118
  }
110
119
  }
111
120
  }
@@ -114,7 +123,7 @@ Building an autonomous agent? Skip the signup. Point a **funded Solana wallet**
114
123
 
115
124
  How it works:
116
125
 
117
- - The wallet behind `SVM_PRIVATE_KEY` settles each request as a **USDC micropayment on Solana** (~$0.005–$0.02 per call, settled on-chain). No subscription, no quota.
126
+ - The wallet behind `SVM_PRIVATE_KEY` settles each request as a **USDC micropayment on Solana** (~$0.005–$0.02 per call, settled on-chain). No subscription; the configured authorization budget bounds spending.
118
127
  - The keyless catalog covers **25 endpoints** — the latest additions: token candles ($0.01), almost-bonded ($0.01), top-traders ($0.02), cap-table ($0.02), sniper recent deploys ($0.01), token flow ($0.01), and deployer trajectory ($0.01).
119
128
  - The free **`madeonsol_discovery`** tool needs no auth and returns every endpoint with its exact per-call price — call it first to see what each tool costs.
120
129
  - Install the x402 peer deps alongside the server (only required for this mode):
@@ -127,6 +136,34 @@ How it works:
127
136
 
128
137
  Prefer a fixed monthly bill, free tier, or no wallet? Use the developer path below.
129
138
 
139
+ ## Required payment policy (breaking keyless upgrade)
140
+
141
+ Keyless Solana payments require an explicit trusted merchant, facilitator and authorization budget. API-key mode is unchanged and takes precedence over a configured wallet.
142
+
143
+ Only exact payments in mainnet USDC are permitted. Set the two addresses from your trusted operator configuration, independently of a server challenge. `SVM_RPC_URL` must be your trusted HTTPS RPC; there is no public RPC fallback. The agent wallet cannot also be the facilitator fee payer.
144
+
145
+ | Setting | Meaning |
146
+ |---|---|
147
+ | `X402_PAY_TO` | Trusted merchant wallet receiving USDC |
148
+ | `X402_FEE_PAYER` | Trusted facilitator wallet paying transaction fees |
149
+ | `X402_MAX_AMOUNT_ATOMIC` | Maximum per payment, as a positive integer string |
150
+ | `X402_MAX_TOTAL_AMOUNT_ATOMIC` | Lifetime authorization allowance, as a positive integer string |
151
+ | `SVM_RPC_URL` | Explicit trusted HTTPS Solana RPC URL |
152
+
153
+ USDC uses 6 decimals: `20000` = 0.02 USDC and `1000000` = 1 USDC. Choose limits that cover the endpoints you intend to use; these examples are not a price guarantee.
154
+
155
+ **Canonical MadeOnSol values (Solana mainnet USDC).** Pinned here (GitHub + npm README) so you do not have to take them from a 402:
156
+ - merchant `payTo` / `X402_PAY_TO`: `GLu63pRCYrp4BJu5P5ciYKxgeZFW9c8TJ8jWzK3TB9AR` (also shown on https://madeonsol.com/x402 and https://madeonsol.com/.well-known/x402)
157
+ - facilitator `feePayer` / `X402_FEE_PAYER`: `2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4`. This is the fee payer of **PayAI**, the third-party facilitator MadeOnSol's Solana rail uses. If PayAI rotates it, keyless calls fail closed (the client refuses to sign) until you update this value; a MadeOnSol release will announce the new one.
158
+ - prices: Solana legs are 5000–20000 atomic (0.005–0.02 USDC), so `maxAmountAtomic` / `X402_MAX_AMOUNT_ATOMIC` must be at least `20000` to reach every endpoint.
159
+
160
+ The budget is per client instance / process: not wallet-wide, not shared between processes, reset when a new instance or process starts. Keyless mode requires the base URL exactly `https://madeonsol.com`.
161
+
162
+
163
+ The allowance is reserved before concurrent calls can approve/sign. An unsigned denial releases it; entering payment creation retains it even if RPC, signing or the paid response fails. It measures **authorized attempts, not settled spend**. There is no automatic refund or payment replay. A timeout cannot undo a proof already sent.
164
+
165
+ The allowance is per server process; restarting starts a new allowance. Reuse the running process. Multiple MCP servers or other clients sharing a wallet need an external shared budget. Missing/invalid keyless settings stop startup; wallet signing remains restricted to stdio. The payment attempt timeout defaults to 30 seconds.
166
+
130
167
  ## Authentication
131
168
 
132
169
  Two options (in priority order):
@@ -169,6 +206,24 @@ Add to `claude_desktop_config.json`:
169
206
 
170
207
  Add to MCP settings with the same command and env vars.
171
208
 
209
+
210
+ ## Private HTTP transport (SEC-02)
211
+
212
+ Stdio remains the default for Claude Desktop, Cursor and other local MCP clients. HTTP now requires an explicit private-operator configuration; previously unauthenticated HTTP launch settings will fail closed.
213
+
214
+ 1. Set `MADEONSOL_API_KEY` to the operator's `msk_` key.
215
+ 2. Generate a separate random access token, for example `node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"`, and store it as `MCP_HTTP_TOKEN`. Do not reuse the upstream API key or put either credential in a URL.
216
+ 3. Set `MCP_TRANSPORT=http`, `HOST=127.0.0.1` (default, or `::1`) and optionally `PORT` (default `3100`). Non-loopback bindings, missing credentials and any `SVM_PRIVATE_KEY` or `RHC_PAYER_KEY` are refused before startup. Solana wallet/x402 mode remains available through stdio.
217
+ 4. Connect to `http://127.0.0.1:3100/mcp` with `Authorization: Bearer <MCP_HTTP_TOKEN>` on **every request**, plus the normal MCP `Content-Type` and `Accept` headers. Use stdio if the client cannot attach headers. The local token is checked by the MCP adapter; only `MADEONSOL_API_KEY` is sent upstream.
218
+
219
+ Only `POST /mcp`, `GET /health` and `GET /.well-known/mcp/server-card.json` are exposed, all authenticated. `/` and arbitrary paths are no longer MCP endpoints. The adapter is stateless: it does not issue session IDs, rejects supplied `Mcp-Session-Id`, and returns 405 for GET/DELETE on `/mcp`. Host must be the selected loopback literal with its port, or `localhost` with that exact port. Browser Origin headers, forwarded/proxy headers and cross-origin requests are rejected; no CORS access is granted.
220
+
221
+ Limits: 256 KiB uncompressed JSON bodies, 8 KiB headers, a 10-second body-upload deadline, and 16 active authenticated requests. Oversized or malformed inputs are rejected before tool dispatch. This is not an overall tool-execution deadline; an upstream action already submitted may continue after a disconnect.
222
+
223
+ Everyone holding the local token acts as the **same operator**, including access to that operator's mutation tools. This is not a multi-user/OAuth server: do not put it behind a public proxy, share it with untrusted users or expose a funded signer. Separate users require isolated processes/credentials or a future transport that authenticates each principal and maps their own credentials. Restart with a new `MCP_HTTP_TOKEN` to rotate access.
224
+
225
+ The Docker image uses the same loopback restriction and requires both environment credentials. Its healthcheck authenticates without putting the token in the URL. It does not support a publicly published Docker port; use stdio or a client in the same trusted network namespace.
226
+
172
227
  ## Tools
173
228
 
174
229
  ### KOL Intelligence
@@ -369,3 +424,4 @@ Free tier returns the full REST response shape on 40+ endpoints — real wallets
369
424
  ## License
370
425
 
371
426
  MIT
427
+
@@ -0,0 +1,14 @@
1
+ import { type IncomingMessage, type ServerResponse } from "node:http";
2
+ export declare const MAX_BODY_BYTES: number;
3
+ export declare const BODY_TIMEOUT_MS = 10000;
4
+ export declare const MAX_ACTIVE_REQUESTS = 16;
5
+ export interface HttpConfig {
6
+ host: "127.0.0.1" | "::1";
7
+ port: number;
8
+ allowedHosts: string[];
9
+ tokenDigest: Buffer;
10
+ }
11
+ export declare function readHttpConfig(env?: NodeJS.ProcessEnv): HttpConfig;
12
+ /** Bounded body ingestion; callers must authenticate before invoking this. */
13
+ export declare function readJsonBody(req: IncomingMessage, maxBytes?: number, timeoutMs?: number): Promise<unknown>;
14
+ export declare function createPrivateHttpServer(config: HttpConfig, handle: (req: IncomingMessage, res: ServerResponse, body?: unknown) => Promise<void>): import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
@@ -0,0 +1,180 @@
1
+ // Keep this standalone-package helper identical in both MCP packages.
2
+ // HTTP is a private, single-operator adapter. Public/multi-user hosting is
3
+ // intentionally unsupported until credentials can be scoped per principal.
4
+ import { createServer } from "node:http";
5
+ import { createHash, timingSafeEqual } from "node:crypto";
6
+ export const MAX_BODY_BYTES = 256 * 1024;
7
+ export const BODY_TIMEOUT_MS = 10_000;
8
+ export const MAX_ACTIVE_REQUESTS = 16;
9
+ export function readHttpConfig(env = process.env) {
10
+ const host = env.HOST || "127.0.0.1";
11
+ if (host !== "127.0.0.1" && host !== "::1") {
12
+ throw new Error("HTTP MCP requires HOST=127.0.0.1 or ::1; public binding is unsupported. Use stdio for local clients.");
13
+ }
14
+ const portText = env.PORT || "3100";
15
+ const port = Number(portText);
16
+ if (!/^\d+$/.test(portText) || !Number.isInteger(port) || port < 1 || port > 65535) {
17
+ throw new Error("HTTP MCP requires a valid PORT between 1 and 65535.");
18
+ }
19
+ if (env.SVM_PRIVATE_KEY || env.RHC_PAYER_KEY) {
20
+ throw new Error("HTTP MCP cannot expose a payment signer. Remove wallet credentials or use stdio.");
21
+ }
22
+ if (!env.MADEONSOL_API_KEY?.startsWith("msk_")) {
23
+ throw new Error("HTTP MCP requires the operator's MADEONSOL_API_KEY.");
24
+ }
25
+ const token = env.MCP_HTTP_TOKEN || "";
26
+ if (!/^[A-Za-z0-9_-]{32,256}$/.test(token) || token === env.MADEONSOL_API_KEY) {
27
+ throw new Error("HTTP MCP requires a separate random MCP_HTTP_TOKEN (32–256 base64url characters).");
28
+ }
29
+ const authority = host === "::1" ? `[::1]:${port}` : `${host}:${port}`;
30
+ return {
31
+ host, port, allowedHosts: [authority, `localhost:${port}`],
32
+ tokenDigest: createHash("sha256").update(token).digest(),
33
+ };
34
+ }
35
+ class HttpFailure extends Error {
36
+ status;
37
+ constructor(status, message) {
38
+ super(message);
39
+ this.status = status;
40
+ }
41
+ }
42
+ function fail(res, status, message) {
43
+ if (res.headersSent) {
44
+ res.destroy();
45
+ return;
46
+ }
47
+ res.writeHead(status, {
48
+ "Content-Type": "application/json",
49
+ "Cache-Control": "no-store",
50
+ "Connection": "close",
51
+ ...(status === 401 ? { "WWW-Authenticate": 'Bearer realm="private-mcp"' } : {}),
52
+ });
53
+ res.end(JSON.stringify({ error: message }));
54
+ }
55
+ function headerCount(req, name) {
56
+ let count = 0;
57
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
58
+ if (req.rawHeaders[i].toLowerCase() === name)
59
+ count++;
60
+ }
61
+ return count;
62
+ }
63
+ function authorize(req, res, config) {
64
+ const remote = req.socket.remoteAddress;
65
+ if ((remote !== "127.0.0.1" && remote !== "::1") ||
66
+ headerCount(req, "host") !== 1 || !config.allowedHosts.includes(req.headers.host || "")) {
67
+ fail(res, 403, "Forbidden host");
68
+ return false;
69
+ }
70
+ // Native local MCP clients do not need browser origins, CORS or proxy headers.
71
+ if (req.headers.origin !== undefined || Object.keys(req.headers).some(h => h === "forwarded" || h.startsWith("x-forwarded-"))) {
72
+ fail(res, 403, "Browser origins and forwarded requests are unsupported");
73
+ return false;
74
+ }
75
+ const auth = req.headers.authorization;
76
+ const match = typeof auth === "string" ? /^Bearer ([A-Za-z0-9_-]{32,256})$/i.exec(auth) : null;
77
+ if (headerCount(req, "authorization") !== 1 || !match || !timingSafeEqual(createHash("sha256").update(match[1]).digest(), config.tokenDigest)) {
78
+ fail(res, 401, "Unauthorized");
79
+ return false;
80
+ }
81
+ return true;
82
+ }
83
+ /** Bounded body ingestion; callers must authenticate before invoking this. */
84
+ export function readJsonBody(req, maxBytes = MAX_BODY_BYTES, timeoutMs = BODY_TIMEOUT_MS) {
85
+ return new Promise((resolve, reject) => {
86
+ const chunks = [];
87
+ let size = 0;
88
+ const cleanup = () => {
89
+ clearTimeout(timer);
90
+ req.off("data", data);
91
+ req.off("end", end);
92
+ req.off("aborted", aborted);
93
+ req.off("error", error);
94
+ };
95
+ const stop = (reason) => { cleanup(); req.pause(); reject(reason); };
96
+ const timer = setTimeout(() => stop(new HttpFailure(408, "Request body timed out")), timeoutMs);
97
+ const data = (chunk) => {
98
+ size += chunk.length;
99
+ if (size > maxBytes) {
100
+ stop(new HttpFailure(413, "Request body too large"));
101
+ return;
102
+ }
103
+ chunks.push(chunk);
104
+ };
105
+ const end = () => {
106
+ cleanup();
107
+ try {
108
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
109
+ }
110
+ catch {
111
+ reject(new HttpFailure(400, "Invalid JSON"));
112
+ }
113
+ };
114
+ const aborted = () => stop(new HttpFailure(400, "Request aborted"));
115
+ const error = () => stop(new HttpFailure(400, "Request failed"));
116
+ req.on("data", data);
117
+ req.once("end", end);
118
+ req.once("aborted", aborted);
119
+ req.once("error", error);
120
+ });
121
+ }
122
+ export function createPrivateHttpServer(config, handle) {
123
+ let active = 0;
124
+ const server = createServer({ maxHeaderSize: 8192 }, (req, res) => {
125
+ void (async () => {
126
+ if (!authorize(req, res, config))
127
+ return;
128
+ const isMcp = req.url === "/mcp";
129
+ const isInfo = req.url === "/health" || req.url === "/.well-known/mcp/server-card.json";
130
+ if (!isMcp && !isInfo) {
131
+ fail(res, 404, "Not found");
132
+ return;
133
+ }
134
+ const method = isMcp ? "POST" : "GET";
135
+ if (req.method !== method) {
136
+ res.setHeader("Allow", method);
137
+ fail(res, 405, "Method not allowed");
138
+ return;
139
+ }
140
+ if (isMcp && (req.headers["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/json" ||
141
+ (req.headers["content-encoding"] !== undefined && req.headers["content-encoding"] !== "identity"))) {
142
+ fail(res, 415, "Only uncompressed application/json is supported");
143
+ return;
144
+ }
145
+ if (Number(req.headers["content-length"] || 0) > MAX_BODY_BYTES) {
146
+ fail(res, 413, "Request body too large");
147
+ return;
148
+ }
149
+ // No sessions are issued: a supplied session id must never become an auth bypass.
150
+ if (req.headers["mcp-session-id"] !== undefined) {
151
+ fail(res, 400, "HTTP MCP is stateless; session identifiers are unsupported");
152
+ return;
153
+ }
154
+ if (active >= MAX_ACTIVE_REQUESTS) {
155
+ fail(res, 503, "Too many active requests");
156
+ return;
157
+ }
158
+ active++;
159
+ let released = false;
160
+ const release = () => { if (!released) {
161
+ released = true;
162
+ active--;
163
+ } };
164
+ res.once("close", release);
165
+ res.once("finish", release);
166
+ res.setHeader("Cache-Control", "no-store");
167
+ const body = isMcp ? await readJsonBody(req) : undefined;
168
+ if (!res.destroyed)
169
+ await handle(req, res, body);
170
+ })().catch(error => {
171
+ if (!res.destroyed)
172
+ fail(res, error instanceof HttpFailure ? error.status : 500, error instanceof HttpFailure ? error.message : "Internal server error");
173
+ });
174
+ });
175
+ server.headersTimeout = 10_000;
176
+ server.requestTimeout = 15_000;
177
+ server.keepAliveTimeout = 5_000;
178
+ server.maxRequestsPerSocket = 100;
179
+ return server;
180
+ }
package/dist/index.d.ts CHANGED
File without changes
package/dist/index.js CHANGED
@@ -3,8 +3,9 @@ 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
- import { createServer } from "node:http";
8
+ import { createPrivateHttpServer, readHttpConfig } from "./http-security.js";
8
9
  // MCP `initialize` response `instructions` field (ServerOptions.instructions in
9
10
  // the SDK) — operational guidance for the calling agent, distinct from the
10
11
  // package/marketing description in package.json and glama.json. Left unset
@@ -14,7 +15,6 @@ const SERVER_INSTRUCTIONS = "Real-time Solana on-chain intelligence: KOL wallet
14
15
  const BASE_URL = process.env.MADEONSOL_API_URL || "https://madeonsol.com";
15
16
  const MADEONSOL_API_KEY = process.env.MADEONSOL_API_KEY; // Native key from madeonsol.com/pricing
16
17
  const PRIVATE_KEY = process.env.SVM_PRIVATE_KEY; // x402 micropayments (for AI agents)
17
- const PORT = parseInt(process.env.PORT || "3100", 10);
18
18
  const MODE = process.env.MCP_TRANSPORT || "stdio"; // "stdio" or "http"
19
19
  let authMode = "none";
20
20
  let paidFetch = fetch;
@@ -56,22 +56,16 @@ async function initAuth() {
56
56
  return;
57
57
  }
58
58
  if (mode === "x402" && PRIVATE_KEY) {
59
+ const budget = new SolanaPaymentBudget(solanaPaymentPolicyFromConfig(key => process.env[key]));
59
60
  try {
60
- const { wrapFetchWithPayment } = await import("@x402/fetch");
61
- const { x402Client } = await import("@x402/core/client");
62
- const { ExactSvmScheme } = await import("@x402/svm/exact/client");
63
- const { createKeyPairSignerFromBytes } = await import("@solana/kit");
64
- const { base58 } = await import("@scure/base");
65
- const signer = await createKeyPairSignerFromBytes(base58.decode(PRIVATE_KEY));
66
- const client = new x402Client();
67
- client.register("solana:*", new ExactSvmScheme(signer));
68
- paidFetch = wrapFetchWithPayment(fetch, client);
61
+ paidFetch = await createSolanaPaidFetch(PRIVATE_KEY, budget, BASE_URL);
69
62
  authMode = "x402";
70
- console.error(`[madeonsol-mcp] x402 payments enabled, wallet: ${signer.address}`);
63
+ console.error("[madeonsol-mcp] x402 payments enabled with an explicit authorization budget");
71
64
  return;
72
65
  }
73
66
  catch (err) {
74
- 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 });
75
69
  }
76
70
  }
77
71
  console.error("\n[madeonsol-mcp] No auth configured — every tool call will fail.\n" +
@@ -1275,12 +1269,11 @@ function registerTools(server) {
1275
1269
  });
1276
1270
  }
1277
1271
  async function main() {
1272
+ const httpConfig = MODE === "http" ? readHttpConfig() : undefined;
1278
1273
  await initAuth();
1279
1274
  if (MODE === "http") {
1280
- // HTTP transport for hosted environments (Smithery, etc.)
1281
- const httpServer = createServer();
1282
- const transports = new Map();
1283
- httpServer.on("request", async (req, res) => {
1275
+ // Configuration is validated before authentication can initialize a signer.
1276
+ const httpServer = createPrivateHttpServer(httpConfig, async (req, res, body) => {
1284
1277
  // Health check
1285
1278
  if (req.method === "GET" && req.url === "/health") {
1286
1279
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -1292,7 +1285,7 @@ async function main() {
1292
1285
  res.writeHead(200, { "Content-Type": "application/json" });
1293
1286
  res.end(JSON.stringify({
1294
1287
  name: "madeonsol",
1295
- description: "Solana KOL trading intelligence and deployer analytics. Real-time data from 1,000+ KOL wallets, 15,500+ Pump.fun deployers, 25,000+ scored alpha wallets, copy-trade rules, and wallet tracker. Supports MadeOnSol API key (msk_) or x402 micropayments.",
1288
+ description: "Solana KOL trading intelligence and deployer analytics. Real-time data from 1,000+ KOL wallets, 15,500+ Pump.fun deployers, 25,000+ scored alpha wallets, copy-trade rules, and wallet tracker. Private HTTP access with the operator's MadeOnSol API key (msk_).",
1296
1289
  version: VERSION,
1297
1290
  tools: [
1298
1291
  { name: "madeonsol_kol_feed", description: "Get real-time Solana KOL trades from 1,000+ tracked wallets." },
@@ -1390,49 +1383,30 @@ async function main() {
1390
1383
  }));
1391
1384
  return;
1392
1385
  }
1393
- // MCP endpoint
1394
- const sessionId = req.headers["mcp-session-id"];
1395
- {
1396
- if (req.method === "POST") {
1397
- let transport = sessionId ? transports.get(sessionId) : undefined;
1398
- if (!transport) {
1399
- transport = new StreamableHTTPServerTransport({
1400
- sessionIdGenerator: undefined,
1401
- });
1402
- const server = new McpServer({ name: "madeonsol", version: VERSION }, { instructions: SERVER_INSTRUCTIONS });
1403
- registerTools(server);
1404
- await server.connect(transport);
1405
- }
1406
- await transport.handleRequest(req, res);
1407
- return;
1408
- }
1409
- if (req.method === "GET" && sessionId) {
1410
- const transport = transports.get(sessionId);
1411
- if (transport) {
1412
- await transport.handleRequest(req, res);
1413
- return;
1414
- }
1415
- }
1416
- if (req.method === "DELETE" && sessionId) {
1417
- const transport = transports.get(sessionId);
1418
- if (transport) {
1419
- await transport.handleRequest(req, res);
1420
- transports.delete(sessionId);
1421
- return;
1422
- }
1423
- }
1386
+ // One server/transport per authenticated stateless POST /mcp.
1387
+ const transport = new StreamableHTTPServerTransport({
1388
+ sessionIdGenerator: undefined,
1389
+ enableJsonResponse: true,
1390
+ enableDnsRebindingProtection: true,
1391
+ allowedHosts: httpConfig.allowedHosts,
1392
+ allowedOrigins: [],
1393
+ });
1394
+ const server = new McpServer({ name: "madeonsol", version: VERSION }, { instructions: SERVER_INSTRUCTIONS });
1395
+ res.once("close", () => { void server.close().catch(() => { }); });
1396
+ registerTools(server);
1397
+ await server.connect(transport);
1398
+ if (res.destroyed) {
1399
+ await server.close();
1400
+ return;
1424
1401
  }
1425
- res.writeHead(404);
1426
- res.end("Not found");
1402
+ await transport.handleRequest(req, res, body);
1427
1403
  });
1428
- // Bind to 127.0.0.1 only — defense in depth. UFW already blocks the port
1429
- // externally, but binding to all interfaces would expose the server to any
1430
- // misconfigured firewall rule. Override with HOST=0.0.0.0 if you ever need
1431
- // to expose it directly (e.g. for hosted environments behind a separate
1432
- // reverse proxy).
1433
- const HOST = process.env.HOST || "127.0.0.1";
1434
- httpServer.listen(PORT, HOST, () => {
1435
- console.error(`[madeonsol-mcp] HTTP server listening on ${HOST}:${PORT}`);
1404
+ httpServer.on("error", () => {
1405
+ console.error("[madeonsol-mcp] HTTP listener failed");
1406
+ process.exitCode = 1;
1407
+ });
1408
+ httpServer.listen(httpConfig.port, httpConfig.host, () => {
1409
+ console.error(`[madeonsol-mcp] HTTP server listening on ${httpConfig.host}:${httpConfig.port}/mcp (private operator only)`);
1436
1410
  });
1437
1411
  }
1438
1412
  else {
@@ -1446,5 +1420,8 @@ async function main() {
1446
1420
  // Only auto-run when executed as a program (CLI / spawned process), not when
1447
1421
  // the module is imported by a test for its exported pure helpers.
1448
1422
  if (process.env.MADEONSOL_MCP_NO_AUTORUN !== "1") {
1449
- main().catch(console.error);
1423
+ main().catch(error => {
1424
+ console.error(error instanceof Error ? error.message : "MCP startup failed");
1425
+ process.exitCode = 1;
1426
+ });
1450
1427
  }
@@ -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 = "1.27.1";
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 = "1.27.1";
2
+ export const VERSION = "3.0.0";
package/glama.json CHANGED
@@ -453,9 +453,6 @@
453
453
  "description": "Wallets that made (or lost) the most on a token, ranked by realized PnL or ROI, enriched with KOL/alpha-wallet identity."
454
454
  }
455
455
  ],
456
- "transports": [
457
- "stdio",
458
- "http"
459
- ],
456
+ "transports": ["stdio"],
460
457
  "runtime": "node"
461
458
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-server-madeonsol",
3
- "version": "1.27.1",
3
+ "version": "3.0.0",
4
4
  "mcpName": "io.github.MadeOnSol/madeonsol",
5
5
  "description": "MCP server for MadeOnSol Solana KOL intelligence API — use from Claude, Cursor, or any MCP client. Free tier 200 req/day (live feeds 5-min delayed; paid is real-time), no payment to start.",
6
6
  "type": "module",