nftdata-mcp 1.0.0 → 1.0.1
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 +5 -5
- package/dist/lib.js +24 -5
- package/dist/server.js +28 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
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
4
|
|
|
5
|
+
- **npm:** https://www.npmjs.com/package/nftdata-mcp
|
|
6
|
+
- **MCP Registry:** https://registry.modelcontextprotocol.io/?q=nftdata
|
|
7
|
+
- **NFT Data GitHub Repository:** https://github.com/Nomad07/nft-data
|
|
8
|
+
|
|
5
9
|
## Tool
|
|
6
10
|
|
|
7
11
|
### `get_collection_data`
|
|
@@ -25,8 +29,6 @@ Each call costs **$0.002** (USDC on Base, paid via x402).
|
|
|
25
29
|
|
|
26
30
|
## Quick start (npx)
|
|
27
31
|
|
|
28
|
-
> Works only after the package has been published to npm. Until then, use "Run from source" below.
|
|
29
|
-
|
|
30
32
|
Add to `claude_desktop_config.json`:
|
|
31
33
|
|
|
32
34
|
```json
|
|
@@ -45,8 +47,6 @@ Add to `claude_desktop_config.json`:
|
|
|
45
47
|
|
|
46
48
|
Restart Claude Desktop, then ask for example: "What is the floor price and 7-day volume of azuki?"
|
|
47
49
|
|
|
48
|
-
If the package is published under a scope, use that name instead (e.g. `@your-scope/nftdata-mcp`).
|
|
49
|
-
|
|
50
50
|
## Run from source
|
|
51
51
|
|
|
52
52
|
```bash
|
|
@@ -97,7 +97,7 @@ npm test # offline unit tests, no network and no payments
|
|
|
97
97
|
|
|
98
98
|
- Sends `POST /api/get-collection-data` with body `{"collection": "<slug>"}`.
|
|
99
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
|
|
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 exactly 2000 atomic units ($0.002), and recipient (must be `0x344143199642e87320785823c20c2df107d17534`). Anything else is refused without signing.
|
|
101
101
|
- Each signed payment counts against `NFTDATA_MAX_REQUESTS`, including payments for requests that later fail or time out.
|
|
102
102
|
- No automatic retries: one tool call makes at most one payment attempt.
|
|
103
103
|
- Caches successful responses for 5 minutes per slug. Concurrent identical calls share a single in-flight request.
|
package/dist/lib.js
CHANGED
|
@@ -108,8 +108,27 @@ export class SpendLimiter {
|
|
|
108
108
|
}
|
|
109
109
|
export const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
110
110
|
export const fail = (text) => ({ isError: true, content: [{ type: "text", text }] });
|
|
111
|
+
// Appends a payment receipt line to a successful tool result.
|
|
112
|
+
// txHash must already be validated (0x + 64 hex chars).
|
|
113
|
+
export function appendPaymentInfo(result, txHash) {
|
|
114
|
+
if (result.isError)
|
|
115
|
+
return result;
|
|
116
|
+
const receipt = `\n\nPayment: ${txHash}` +
|
|
117
|
+
`\nVerify: https://basescan.org/tx/${txHash}`;
|
|
118
|
+
return ok(result.content[0].text + receipt);
|
|
119
|
+
}
|
|
120
|
+
// Validates a tx hash string. Returns the hash if it looks like a valid
|
|
121
|
+
// Ethereum tx hash (0x + 64 hex chars), or null otherwise.
|
|
122
|
+
// This function has no x402 imports so it can be tested offline.
|
|
123
|
+
export function validateTxHash(raw) {
|
|
124
|
+
if (typeof raw !== "string")
|
|
125
|
+
return null;
|
|
126
|
+
return /^0x[0-9a-fA-F]{64}$/.test(raw) ? raw : null;
|
|
127
|
+
}
|
|
111
128
|
// Cache (5 min per slug) + reuse of in-flight requests so concurrent identical calls
|
|
112
129
|
// produce a single (paid) request. Failures are never cached.
|
|
130
|
+
// The loader returns { result, txHash }. Only result.content[0].text (the collection
|
|
131
|
+
// body) is cached; txHash is never stored, so cache hits always return txHash: null.
|
|
113
132
|
export class CachedFetcher {
|
|
114
133
|
cache = new Map();
|
|
115
134
|
inflight = new Map();
|
|
@@ -122,23 +141,23 @@ export class CachedFetcher {
|
|
|
122
141
|
async get(slug) {
|
|
123
142
|
const hit = this.cache.get(slug);
|
|
124
143
|
if (hit && this.now() - hit.at < CACHE_TTL_MS)
|
|
125
|
-
return ok(hit.body);
|
|
144
|
+
return { result: ok(hit.body), txHash: null };
|
|
126
145
|
if (hit)
|
|
127
146
|
this.cache.delete(slug);
|
|
128
147
|
const pending = this.inflight.get(slug);
|
|
129
148
|
if (pending)
|
|
130
149
|
return pending;
|
|
131
150
|
const p = this.load(slug)
|
|
132
|
-
.then((
|
|
133
|
-
if (!
|
|
151
|
+
.then((lr) => {
|
|
152
|
+
if (!lr.result.isError) {
|
|
134
153
|
if (this.cache.size >= CACHE_MAX_ENTRIES) {
|
|
135
154
|
const oldest = this.cache.keys().next().value;
|
|
136
155
|
if (oldest !== undefined)
|
|
137
156
|
this.cache.delete(oldest);
|
|
138
157
|
}
|
|
139
|
-
this.cache.set(slug, { at: this.now(), body:
|
|
158
|
+
this.cache.set(slug, { at: this.now(), body: lr.result.content[0].text });
|
|
140
159
|
}
|
|
141
|
-
return
|
|
160
|
+
return lr;
|
|
142
161
|
})
|
|
143
162
|
.finally(() => {
|
|
144
163
|
this.inflight.delete(slug);
|
package/dist/server.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
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
|
-
import { x402Client, wrapFetchWithPayment } from "@x402/fetch";
|
|
5
|
+
import { x402Client, wrapFetchWithPayment, decodePaymentResponseHeader } from "@x402/fetch";
|
|
6
6
|
import { ExactEvmScheme } from "@x402/evm/exact/client";
|
|
7
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";
|
|
8
|
+
import { BASE_MAINNET, CachedFetcher, REFUSAL_PREFIX, SpendLimiter, appendPaymentInfo, fail, isValidPrivateKey, mapError, mapResponse, normalizeSlug, parseConfig, validateRequirements, validateTxHash, withTimeout, } from "./lib.js";
|
|
9
9
|
let config;
|
|
10
10
|
try {
|
|
11
11
|
config = parseConfig(process.env);
|
|
@@ -73,12 +73,33 @@ async function load(slug) {
|
|
|
73
73
|
redirect: "error",
|
|
74
74
|
signal,
|
|
75
75
|
});
|
|
76
|
-
|
|
76
|
+
// Extract the payment response header before consuming the body.
|
|
77
|
+
const paymentHeader = r.headers.get("PAYMENT-RESPONSE") ?? r.headers.get("X-PAYMENT-RESPONSE");
|
|
78
|
+
return {
|
|
79
|
+
status: r.status,
|
|
80
|
+
body: await r.text(),
|
|
81
|
+
retryAfter: r.headers.get("Retry-After"),
|
|
82
|
+
paymentHeader,
|
|
83
|
+
};
|
|
77
84
|
}, config.timeoutMs);
|
|
78
|
-
|
|
85
|
+
const result = mapResponse(res.status, res.body, res.retryAfter);
|
|
86
|
+
// Decode the payment header and extract the tx hash only on success.
|
|
87
|
+
// Any failure (malformed header, missing field, wrong format) is silently
|
|
88
|
+
// ignored — the collection result is still returned without payment info.
|
|
89
|
+
let txHash = null;
|
|
90
|
+
if (!result.isError && res.paymentHeader) {
|
|
91
|
+
try {
|
|
92
|
+
const settle = decodePaymentResponseHeader(res.paymentHeader);
|
|
93
|
+
txHash = validateTxHash(settle.transaction);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Malformed header: proceed without payment info.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { result, txHash };
|
|
79
100
|
}
|
|
80
101
|
catch (e) {
|
|
81
|
-
return mapError(e);
|
|
102
|
+
return { result: mapError(e), txHash: null };
|
|
82
103
|
}
|
|
83
104
|
}
|
|
84
105
|
const fetcher = new CachedFetcher(load);
|
|
@@ -95,6 +116,7 @@ server.tool("get_collection_data", "Get OpenSea NFT collection market data in on
|
|
|
95
116
|
if (slug === null) {
|
|
96
117
|
return fail("Invalid slug: use only letters, digits, '_' and '-' (max 100 characters).");
|
|
97
118
|
}
|
|
98
|
-
|
|
119
|
+
const { result, txHash } = await fetcher.get(slug);
|
|
120
|
+
return txHash ? appendPaymentInfo(result, txHash) : result;
|
|
99
121
|
});
|
|
100
122
|
await server.connect(new StdioServerTransport());
|
package/package.json
CHANGED