nftdata-mcp 0.0.0-stage → 1.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/LICENSE +21 -0
- package/README.md +115 -2
- package/dist/lib.js +203 -0
- package/dist/server.js +100 -0
- package/package.json +38 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nomad07
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,116 @@
|
|
|
1
|
-
#
|
|
1
|
+
# nftdata-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An MCP server that exposes the [NFT Data](https://www.nftdata.app/) API to AI clients (Claude Desktop, Cursor, etc.) as a single tool. It handles the x402 payment flow automatically.
|
|
4
|
+
|
|
5
|
+
## Tool
|
|
6
|
+
|
|
7
|
+
### `get_collection_data`
|
|
8
|
+
|
|
9
|
+
Returns OpenSea collection market data in one call.
|
|
10
|
+
|
|
11
|
+
| Parameter | Type | Description |
|
|
12
|
+
|--------------|--------|-----------------------------------------------------------------------------|
|
|
13
|
+
| `collection` | string | OpenSea collection slug, from `opensea.io/collection/<slug>` (e.g. `azuki`) |
|
|
14
|
+
|
|
15
|
+
Returned fields: `name`, `slug`, `contracts`, `floor_price`, `floor_price_currency`, `one_day_sales`, `one_day_volume`, `seven_day_sales`, `seven_day_volume`, `thirty_day_sales`, `thirty_day_volume`, `total_sales`, `total_volume`, `num_owners`, `total_supply`, `created_date`. Volume and price currencies are given in the matching `*_currency` fields (ETH, USDC, HYPE, ...).
|
|
16
|
+
|
|
17
|
+
Each call costs **$0.002** (USDC on Base, paid via x402).
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
- Node.js 20+
|
|
22
|
+
- A wallet private key funded with a small amount of USDC on Base
|
|
23
|
+
|
|
24
|
+
> Use a dedicated wallet with only a small balance. Never use your main wallet.
|
|
25
|
+
|
|
26
|
+
## Quick start (npx)
|
|
27
|
+
|
|
28
|
+
> Works only after the package has been published to npm. Until then, use "Run from source" below.
|
|
29
|
+
|
|
30
|
+
Add to `claude_desktop_config.json`:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"mcpServers": {
|
|
35
|
+
"nftdata": {
|
|
36
|
+
"command": "npx",
|
|
37
|
+
"args": ["-y", "nftdata-mcp"],
|
|
38
|
+
"env": {
|
|
39
|
+
"EVM_PRIVATE_KEY": "0x..."
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Restart Claude Desktop, then ask for example: "What is the floor price and 7-day volume of azuki?"
|
|
47
|
+
|
|
48
|
+
If the package is published under a scope, use that name instead (e.g. `@your-scope/nftdata-mcp`).
|
|
49
|
+
|
|
50
|
+
## Run from source
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm install
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Set the key for the current shell session only, then start:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# macOS / Linux
|
|
60
|
+
export EVM_PRIVATE_KEY=0x...
|
|
61
|
+
npm start
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```powershell
|
|
65
|
+
# Windows PowerShell
|
|
66
|
+
$env:EVM_PRIVATE_KEY = "0x..."
|
|
67
|
+
npm start
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```bat
|
|
71
|
+
:: Windows cmd
|
|
72
|
+
set EVM_PRIVATE_KEY=0x...
|
|
73
|
+
npm start
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The server waits for an MCP client on stdin, so running it by hand just shows no output; it is meant to be launched by an MCP client.
|
|
77
|
+
|
|
78
|
+
To use a local checkout with Claude Desktop, point `args` to `["tsx", "/absolute/path/to/nftdata-mcp/server.ts"]` with `"command": "npx"`.
|
|
79
|
+
|
|
80
|
+
## Build
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npm run build # compiles server.ts and lib.ts to dist/
|
|
84
|
+
npm test # offline unit tests, no network and no payments
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Configuration
|
|
88
|
+
|
|
89
|
+
| Variable | Required | Default | Description |
|
|
90
|
+
|------------------------|----------|---------|-------------|
|
|
91
|
+
| `EVM_PRIVATE_KEY` | yes | – | Private key (`0x` + 64 hex chars) of the wallet that pays for calls |
|
|
92
|
+
| `NFTDATA_MAX_REQUESTS` | no | `100` | Max signed payments per session. Integer 1–1000; any other value stops startup with an error |
|
|
93
|
+
| `NFTDATA_TIMEOUT_MS` | no | `15000` | Request timeout in milliseconds (1000–60000) |
|
|
94
|
+
| `NFTDATA_URL` | no | `https://www.nftdata.app/api/get-collection-data` | Accepts only the official NFT Data endpoint (`www.nftdata.app` or `nftdata.app`); anything else stops startup |
|
|
95
|
+
|
|
96
|
+
## Behavior
|
|
97
|
+
|
|
98
|
+
- Sends `POST /api/get-collection-data` with body `{"collection": "<slug>"}`.
|
|
99
|
+
- The slug is trimmed and lowercased, then must match `^[a-z0-9][a-z0-9_-]*$` (max 100 characters); otherwise no request is made.
|
|
100
|
+
- Only Base mainnet (`eip155:8453`) is registered with the x402 client. Before signing, every payment challenge is checked: scheme `exact`, network Base mainnet, token USDC on Base, amount at most 2000 units ($0.002), and recipient (must be `0x344143199642e87320785823c20c2df107d17534`). Anything else is refused without signing.
|
|
101
|
+
- Each signed payment counts against `NFTDATA_MAX_REQUESTS`, including payments for requests that later fail or time out.
|
|
102
|
+
- No automatic retries: one tool call makes at most one payment attempt.
|
|
103
|
+
- Caches successful responses for 5 minutes per slug. Concurrent identical calls share a single in-flight request.
|
|
104
|
+
- Returns generic errors for `400` (invalid slug), `404` (collection not found), `429` (rate limit, with `Retry-After`), timeouts and other failures; server and payment details are not passed to the MCP client.
|
|
105
|
+
|
|
106
|
+
## Limitations
|
|
107
|
+
|
|
108
|
+
- Only x402 on Base is supported. Solana and MPP payment methods are not implemented in this wrapper.
|
|
109
|
+
- The expected recipient address is hardcoded in `lib.ts`. If the NFT Data payout address ever changes, update `OFFICIAL_PAY_TO` and release a new version.
|
|
110
|
+
- The payment hook (`server.ts`) depends on the installed `@x402/*` SDK and is not covered by the offline tests.
|
|
111
|
+
- The x402 client libraries change quickly; if imports fail, check the current [x402 docs](https://docs.x402.org/).
|
|
112
|
+
|
|
113
|
+
## API reference
|
|
114
|
+
|
|
115
|
+
- OpenAPI: https://www.nftdata.app/openapi.json
|
|
116
|
+
- Docs: https://www.nftdata.app/docs
|
package/dist/lib.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Pure logic for nftdata-mcp. No imports from the x402 SDK so it can be tested offline,
|
|
2
|
+
// without network access and without spending anything.
|
|
3
|
+
export const OFFICIAL_ENDPOINT = "https://www.nftdata.app/api/get-collection-data";
|
|
4
|
+
const ALLOWED_ENDPOINTS = new Set([OFFICIAL_ENDPOINT, "https://nftdata.app/api/get-collection-data"]);
|
|
5
|
+
// Base mainnet, USDC (6 decimals). Price is $0.002 = exactly 2000 atomic units.
|
|
6
|
+
export const BASE_MAINNET = "eip155:8453";
|
|
7
|
+
export const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
|
|
8
|
+
export const EXACT_AMOUNT_ATOMIC = 2000n;
|
|
9
|
+
// Official NFT Data payment recipient on Base (lowercase for comparison).
|
|
10
|
+
export const OFFICIAL_PAY_TO = "0x344143199642e87320785823c20c2df107d17534";
|
|
11
|
+
export const MAX_REQUESTS_CEILING = 1000;
|
|
12
|
+
export const DEFAULT_MAX_REQUESTS = 100;
|
|
13
|
+
export const DEFAULT_TIMEOUT_MS = 15_000;
|
|
14
|
+
export const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
15
|
+
const CACHE_MAX_ENTRIES = 500;
|
|
16
|
+
function parseIntStrict(name, raw, min, max) {
|
|
17
|
+
if (!/^\d+$/.test(raw.trim())) {
|
|
18
|
+
throw new Error(`${name} must be an integer between ${min} and ${max}`);
|
|
19
|
+
}
|
|
20
|
+
const n = Number(raw.trim());
|
|
21
|
+
if (!Number.isSafeInteger(n) || n < min || n > max) {
|
|
22
|
+
throw new Error(`${name} must be an integer between ${min} and ${max}`);
|
|
23
|
+
}
|
|
24
|
+
return n;
|
|
25
|
+
}
|
|
26
|
+
// NFTDATA_URL is an untrusted override: only the official endpoint is accepted.
|
|
27
|
+
export function resolveEndpoint(override) {
|
|
28
|
+
if (override === undefined || override.trim() === "")
|
|
29
|
+
return OFFICIAL_ENDPOINT;
|
|
30
|
+
let normalized;
|
|
31
|
+
try {
|
|
32
|
+
const u = new URL(override.trim());
|
|
33
|
+
normalized = `${u.protocol}//${u.host}${u.pathname}`;
|
|
34
|
+
if (u.username || u.password || u.search || u.hash)
|
|
35
|
+
throw new Error("extra URL parts");
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw new Error("NFTDATA_URL is not an accepted NFT Data endpoint");
|
|
39
|
+
}
|
|
40
|
+
if (!ALLOWED_ENDPOINTS.has(normalized)) {
|
|
41
|
+
throw new Error("NFTDATA_URL is not an accepted NFT Data endpoint");
|
|
42
|
+
}
|
|
43
|
+
return normalized;
|
|
44
|
+
}
|
|
45
|
+
export function parseConfig(env) {
|
|
46
|
+
const maxRaw = env.NFTDATA_MAX_REQUESTS;
|
|
47
|
+
const timeoutRaw = env.NFTDATA_TIMEOUT_MS;
|
|
48
|
+
return {
|
|
49
|
+
endpoint: resolveEndpoint(env.NFTDATA_URL),
|
|
50
|
+
maxRequests: maxRaw === undefined || maxRaw.trim() === ""
|
|
51
|
+
? DEFAULT_MAX_REQUESTS
|
|
52
|
+
: parseIntStrict("NFTDATA_MAX_REQUESTS", maxRaw, 1, MAX_REQUESTS_CEILING),
|
|
53
|
+
timeoutMs: timeoutRaw === undefined || timeoutRaw.trim() === ""
|
|
54
|
+
? DEFAULT_TIMEOUT_MS
|
|
55
|
+
: parseIntStrict("NFTDATA_TIMEOUT_MS", timeoutRaw, 1000, 60_000),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export function isValidPrivateKey(pk) {
|
|
59
|
+
return typeof pk === "string" && /^0x[0-9a-fA-F]{64}$/.test(pk);
|
|
60
|
+
}
|
|
61
|
+
// Trim + lowercase, then validate. Returns null if the slug is not acceptable.
|
|
62
|
+
export function normalizeSlug(input) {
|
|
63
|
+
const s = input.trim().toLowerCase();
|
|
64
|
+
if (s.length === 0 || s.length > 100)
|
|
65
|
+
return null;
|
|
66
|
+
return /^[a-z0-9][a-z0-9_-]*$/.test(s) ? s : null;
|
|
67
|
+
}
|
|
68
|
+
// Returns a refusal reason, or null if the challenge is acceptable.
|
|
69
|
+
export function validateRequirements(req) {
|
|
70
|
+
if (req.scheme !== "exact")
|
|
71
|
+
return "unexpected payment scheme";
|
|
72
|
+
if (req.network !== BASE_MAINNET)
|
|
73
|
+
return "unexpected payment network";
|
|
74
|
+
if (typeof req.asset !== "string" || req.asset.toLowerCase() !== USDC_BASE.toLowerCase()) {
|
|
75
|
+
return "unexpected payment token";
|
|
76
|
+
}
|
|
77
|
+
if (typeof req.amount !== "string" || !/^\d+$/.test(req.amount))
|
|
78
|
+
return "invalid payment amount";
|
|
79
|
+
if (BigInt(req.amount) !== EXACT_AMOUNT_ATOMIC)
|
|
80
|
+
return "payment amount does not match the expected price";
|
|
81
|
+
if (typeof req.payTo !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(req.payTo)) {
|
|
82
|
+
return "invalid payment recipient";
|
|
83
|
+
}
|
|
84
|
+
if (req.payTo.toLowerCase() !== OFFICIAL_PAY_TO)
|
|
85
|
+
return "unexpected payment recipient";
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
// Counts signed payments. Reserve before signing, release if signing fails.
|
|
89
|
+
export class SpendLimiter {
|
|
90
|
+
reserved = 0;
|
|
91
|
+
max;
|
|
92
|
+
constructor(max) {
|
|
93
|
+
this.max = max;
|
|
94
|
+
}
|
|
95
|
+
tryReserve() {
|
|
96
|
+
if (this.reserved >= this.max)
|
|
97
|
+
return false;
|
|
98
|
+
this.reserved++;
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
release() {
|
|
102
|
+
if (this.reserved > 0)
|
|
103
|
+
this.reserved--;
|
|
104
|
+
}
|
|
105
|
+
get used() {
|
|
106
|
+
return this.reserved;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
110
|
+
export const fail = (text) => ({ isError: true, content: [{ type: "text", text }] });
|
|
111
|
+
// Cache (5 min per slug) + reuse of in-flight requests so concurrent identical calls
|
|
112
|
+
// produce a single (paid) request. Failures are never cached.
|
|
113
|
+
export class CachedFetcher {
|
|
114
|
+
cache = new Map();
|
|
115
|
+
inflight = new Map();
|
|
116
|
+
load;
|
|
117
|
+
now;
|
|
118
|
+
constructor(load, now = Date.now) {
|
|
119
|
+
this.load = load;
|
|
120
|
+
this.now = now;
|
|
121
|
+
}
|
|
122
|
+
async get(slug) {
|
|
123
|
+
const hit = this.cache.get(slug);
|
|
124
|
+
if (hit && this.now() - hit.at < CACHE_TTL_MS)
|
|
125
|
+
return ok(hit.body);
|
|
126
|
+
if (hit)
|
|
127
|
+
this.cache.delete(slug);
|
|
128
|
+
const pending = this.inflight.get(slug);
|
|
129
|
+
if (pending)
|
|
130
|
+
return pending;
|
|
131
|
+
const p = this.load(slug)
|
|
132
|
+
.then((res) => {
|
|
133
|
+
if (!res.isError) {
|
|
134
|
+
if (this.cache.size >= CACHE_MAX_ENTRIES) {
|
|
135
|
+
const oldest = this.cache.keys().next().value;
|
|
136
|
+
if (oldest !== undefined)
|
|
137
|
+
this.cache.delete(oldest);
|
|
138
|
+
}
|
|
139
|
+
this.cache.set(slug, { at: this.now(), body: res.content[0].text });
|
|
140
|
+
}
|
|
141
|
+
return res;
|
|
142
|
+
})
|
|
143
|
+
.finally(() => {
|
|
144
|
+
this.inflight.delete(slug);
|
|
145
|
+
});
|
|
146
|
+
this.inflight.set(slug, p);
|
|
147
|
+
return p;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Marker used so refusals raised by our own payment hook can be told apart from other errors.
|
|
151
|
+
export const REFUSAL_PREFIX = "nftdata-mcp policy: ";
|
|
152
|
+
export class TimeoutError extends Error {
|
|
153
|
+
}
|
|
154
|
+
export function withTimeout(run, ms) {
|
|
155
|
+
const ctrl = new AbortController();
|
|
156
|
+
let timer;
|
|
157
|
+
const timeout = new Promise((_, reject) => {
|
|
158
|
+
timer = setTimeout(() => {
|
|
159
|
+
ctrl.abort();
|
|
160
|
+
reject(new TimeoutError("timeout"));
|
|
161
|
+
}, ms);
|
|
162
|
+
});
|
|
163
|
+
return Promise.race([run(ctrl.signal), timeout]).finally(() => {
|
|
164
|
+
if (timer)
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
// Maps an HTTP response to a tool result without leaking server or payment details.
|
|
169
|
+
export function mapResponse(status, body, retryAfter) {
|
|
170
|
+
if (status >= 200 && status < 300) {
|
|
171
|
+
try {
|
|
172
|
+
JSON.parse(body);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return fail("The NFT Data API returned an unreadable response.");
|
|
176
|
+
}
|
|
177
|
+
return ok(body);
|
|
178
|
+
}
|
|
179
|
+
if (status === 400)
|
|
180
|
+
return fail("Invalid collection slug.");
|
|
181
|
+
if (status === 404)
|
|
182
|
+
return fail("Collection not found. Check the slug in the OpenSea collection URL.");
|
|
183
|
+
if (status === 429) {
|
|
184
|
+
const secs = retryAfter && /^\d{1,5}$/.test(retryAfter) ? retryAfter : "a few";
|
|
185
|
+
return fail(`Rate limit exceeded. Retry in ${secs} seconds.`);
|
|
186
|
+
}
|
|
187
|
+
if (status === 402)
|
|
188
|
+
return fail("Payment was not accepted by the NFT Data API.");
|
|
189
|
+
return fail(`The NFT Data API returned an error (HTTP ${status}).`);
|
|
190
|
+
}
|
|
191
|
+
// Maps a thrown error to a safe message. Never includes err.message of unknown errors.
|
|
192
|
+
export function mapError(err) {
|
|
193
|
+
if (err instanceof TimeoutError) {
|
|
194
|
+
return fail("The request timed out. A payment may have been authorized; it was not retried automatically.");
|
|
195
|
+
}
|
|
196
|
+
const msg = err instanceof Error ? err.message : "";
|
|
197
|
+
const i = msg.indexOf(REFUSAL_PREFIX);
|
|
198
|
+
if (i >= 0) {
|
|
199
|
+
const reason = msg.slice(i + REFUSAL_PREFIX.length).split(/[\n.]/)[0].slice(0, 120);
|
|
200
|
+
return fail(`Payment refused by safety policy: ${reason}.`);
|
|
201
|
+
}
|
|
202
|
+
return fail("The request failed.");
|
|
203
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { x402Client, wrapFetchWithPayment } from "@x402/fetch";
|
|
6
|
+
import { ExactEvmScheme } from "@x402/evm/exact/client";
|
|
7
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
8
|
+
import { BASE_MAINNET, CachedFetcher, REFUSAL_PREFIX, SpendLimiter, fail, isValidPrivateKey, mapError, mapResponse, normalizeSlug, parseConfig, validateRequirements, withTimeout, } from "./lib.js";
|
|
9
|
+
let config;
|
|
10
|
+
try {
|
|
11
|
+
config = parseConfig(process.env);
|
|
12
|
+
}
|
|
13
|
+
catch (e) {
|
|
14
|
+
console.error(`Configuration error: ${e.message}`);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
// Never print or echo the key itself, only whether it has the right shape.
|
|
18
|
+
const pk = process.env.EVM_PRIVATE_KEY?.trim();
|
|
19
|
+
if (!isValidPrivateKey(pk)) {
|
|
20
|
+
console.error("EVM_PRIVATE_KEY is missing or malformed (expected 0x followed by 64 hex characters; wallet with USDC on Base).");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
let account;
|
|
24
|
+
try {
|
|
25
|
+
account = privateKeyToAccount(pk);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
console.error("EVM_PRIVATE_KEY could not be used to create a wallet.");
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
const limiter = new SpendLimiter(config.maxRequests);
|
|
32
|
+
// Only Base mainnet is registered (no "eip155:*" wildcard), so challenges for other
|
|
33
|
+
// EVM networks are rejected before this hook runs: selectPaymentRequirements throws
|
|
34
|
+
// when no registered network/scheme matches, so the hook only fires for eip155:8453.
|
|
35
|
+
// The hook re-checks the selected requirements against our policy before signing.
|
|
36
|
+
//
|
|
37
|
+
// ctx.selectedRequirements is the SDK's PaymentRequirements type:
|
|
38
|
+
// { scheme, network, asset, amount, payTo, maxTimeoutSeconds, extra }
|
|
39
|
+
// validateRequirements reads scheme/network/asset/amount/payTo — matching that shape.
|
|
40
|
+
const client = new x402Client();
|
|
41
|
+
client.register(BASE_MAINNET, new ExactEvmScheme(account));
|
|
42
|
+
client
|
|
43
|
+
.onBeforePaymentCreation(async (ctx) => {
|
|
44
|
+
// Validate all four payment fields before the wallet signs anything.
|
|
45
|
+
const reason = validateRequirements(ctx.selectedRequirements);
|
|
46
|
+
if (reason)
|
|
47
|
+
return { abort: true, reason: `${REFUSAL_PREFIX}${reason}` };
|
|
48
|
+
// Reserve a slot against the per-session payment limit.
|
|
49
|
+
if (!limiter.tryReserve()) {
|
|
50
|
+
return {
|
|
51
|
+
abort: true,
|
|
52
|
+
reason: `${REFUSAL_PREFIX}paid request limit (${config.maxRequests}) for this session reached`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
.onPaymentCreationFailure(async () => {
|
|
57
|
+
// Signing failed after the slot was reserved; release it so the counter stays accurate.
|
|
58
|
+
limiter.release();
|
|
59
|
+
});
|
|
60
|
+
// onPaymentResponse is intentionally NOT registered. The SDK uses it to drive a
|
|
61
|
+
// recovery-retry path (wrapFetchWithPayment calls createPaymentPayload a second time
|
|
62
|
+
// when a hook returns { recovered: true }). Leaving it unregistered ensures at most
|
|
63
|
+
// one payment-creation attempt per tool call, which is this server's "no retries" policy.
|
|
64
|
+
const payFetch = wrapFetchWithPayment(fetch, client);
|
|
65
|
+
async function load(slug) {
|
|
66
|
+
try {
|
|
67
|
+
// No automatic retries: one tool call is at most one payment attempt.
|
|
68
|
+
const res = await withTimeout(async (signal) => {
|
|
69
|
+
const r = await payFetch(config.endpoint, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: { "Content-Type": "application/json" },
|
|
72
|
+
body: JSON.stringify({ collection: slug }),
|
|
73
|
+
redirect: "error",
|
|
74
|
+
signal,
|
|
75
|
+
});
|
|
76
|
+
return { status: r.status, body: await r.text(), retryAfter: r.headers.get("Retry-After") };
|
|
77
|
+
}, config.timeoutMs);
|
|
78
|
+
return mapResponse(res.status, res.body, res.retryAfter);
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
return mapError(e);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const fetcher = new CachedFetcher(load);
|
|
85
|
+
const server = new McpServer({ name: "nftdata", version: "1.0.0" });
|
|
86
|
+
server.tool("get_collection_data", "Get OpenSea NFT collection market data in one call: name, contracts, floor price, " +
|
|
87
|
+
"1/7/30-day and lifetime sales and volume, unique owners, total supply, creation date. " +
|
|
88
|
+
"Costs $0.002 per call (x402). " +
|
|
89
|
+
"`collection` is the OpenSea slug from the collection URL: opensea.io/collection/<slug> " +
|
|
90
|
+
"(e.g. 'azuki', 'basedpunks'); it is trimmed and lowercased. " +
|
|
91
|
+
"Prices are in the currency given in the *_currency fields (ETH, USDC, HYPE...).", {
|
|
92
|
+
collection: z.string().max(200).describe("OpenSea collection slug, e.g. 'azuki'"),
|
|
93
|
+
}, async ({ collection }) => {
|
|
94
|
+
const slug = normalizeSlug(collection);
|
|
95
|
+
if (slug === null) {
|
|
96
|
+
return fail("Invalid slug: use only letters, digits, '_' and '-' (max 100 characters).");
|
|
97
|
+
}
|
|
98
|
+
return fetcher.get(slug);
|
|
99
|
+
});
|
|
100
|
+
await server.connect(new StdioServerTransport());
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nftdata-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for the NFT Data API: OpenSea collection market data, paid per call via x402",
|
|
5
|
+
"mcpName": "io.github.Nomad07/nftdata-mcp",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/Nomad07/nftdata-mcp.git"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"nftdata-mcp": "dist/server.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc",
|
|
24
|
+
"start": "tsx server.ts",
|
|
25
|
+
"test": "tsx --test lib.test.ts",
|
|
26
|
+
"prepublishOnly": "npm run build && npm test"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
30
|
+
"@x402/evm": "^2.5.0",
|
|
31
|
+
"@x402/fetch": "^2.5.0",
|
|
32
|
+
"viem": "^2.0.0",
|
|
33
|
+
"zod": "^3.23.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"tsx": "^4.0.0",
|
|
37
|
+
"typescript": "^5.5.0",
|
|
38
|
+
"@types/node": "^20.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|