@chaindaddy/mcp 0.4.2
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 +249 -0
- package/build/index.d.ts +22 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +2153 -0
- package/build/index.js.map +1 -0
- package/package.json +61 -0
package/build/index.js
ADDED
|
@@ -0,0 +1,2153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Chain Daddy MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Exposes Chain Daddy functionality as MCP tools for use with Claude.
|
|
6
|
+
* Lets an assistant search tickers, launch or import a token, and run its
|
|
7
|
+
* creator store.
|
|
8
|
+
*/
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
import { SiweMessage } from "siwe";
|
|
15
|
+
import nacl from "tweetnacl";
|
|
16
|
+
import bs58 from "bs58";
|
|
17
|
+
// Configuration
|
|
18
|
+
const API_URL = process.env.CHAINDADDY_API_URL || "https://api.chaindaddy.io";
|
|
19
|
+
// A recommender service you run yourself. The hosted API has none, so with
|
|
20
|
+
// this unset get_recommendations says it is unavailable.
|
|
21
|
+
const RECOMMENDER_URL = (process.env.CHAINDADDY_RECOMMENDER_URL || "").trim();
|
|
22
|
+
// A label in chaindaddy://config only. The default API is mainnet.
|
|
23
|
+
const NETWORK = process.env.CHAINDADDY_NETWORK || "mainnet";
|
|
24
|
+
// Account API key (`cd_live_…`). The API answers callers outside
|
|
25
|
+
// chaindaddy.io only with a key, so it is sent as `X-API-Key` on every call,
|
|
26
|
+
// and as the Bearer credential when the caller passes no SIWE session_token.
|
|
27
|
+
const API_KEY = (process.env.CHAINDADDY_API_KEY || "").trim();
|
|
28
|
+
// Store key (`cd_iap_…`): one store, explicit scopes, for app servers. Used
|
|
29
|
+
// as the Bearer credential on store-scoped creator-store tools when no
|
|
30
|
+
// session_token is passed. Never accepted for opening a store, listing your
|
|
31
|
+
// stores or managing store keys.
|
|
32
|
+
const IAP_KEY = (process.env.CHAINDADDY_IAP_KEY || "").trim();
|
|
33
|
+
// Supported chains
|
|
34
|
+
const SUPPORTED_CHAINS = {
|
|
35
|
+
"eth": { id: 1, name: "Ethereum" },
|
|
36
|
+
"arbitrum": { id: 42161, name: "Arbitrum" },
|
|
37
|
+
"arbitrum-sepolia": { id: 421614, name: "Arbitrum Sepolia" },
|
|
38
|
+
"base": { id: 8453, name: "Base" },
|
|
39
|
+
"polygon": { id: 137, name: "Polygon" },
|
|
40
|
+
"bnb": { id: 56, name: "BNB Chain" },
|
|
41
|
+
"solana": { id: "solana-mainnet", name: "Solana" },
|
|
42
|
+
};
|
|
43
|
+
export const sessions = new Map();
|
|
44
|
+
// Session guard helper - validates token and returns session data
|
|
45
|
+
// Lazy cleanup: expired sessions are removed on access
|
|
46
|
+
export function requireSession(session_token) {
|
|
47
|
+
const session = sessions.get(session_token);
|
|
48
|
+
if (!session) {
|
|
49
|
+
throw new Error("NO_SESSION: No session found for the provided token");
|
|
50
|
+
}
|
|
51
|
+
if (new Date(session.expires_at) <= new Date()) {
|
|
52
|
+
sessions.delete(session_token);
|
|
53
|
+
throw new Error("SESSION_EXPIRED: Session has expired, please re-authenticate");
|
|
54
|
+
}
|
|
55
|
+
return session;
|
|
56
|
+
}
|
|
57
|
+
// Returns the backend JWT for a session or throws. Use this from any tool
|
|
58
|
+
// that hits a JWT-gated route — surfaces a single, machine-parseable error
|
|
59
|
+
// instead of a 401 leak from the backend.
|
|
60
|
+
export function requireSessionJWT(session_token) {
|
|
61
|
+
const session = requireSession(session_token);
|
|
62
|
+
if (!session.jwt) {
|
|
63
|
+
throw new Error("NO_JWT: Session has no backend access token. Re-run `authenticate` to exchange the signed message for a JWT.");
|
|
64
|
+
}
|
|
65
|
+
return { session, jwt: session.jwt };
|
|
66
|
+
}
|
|
67
|
+
const NO_API_KEY_MESSAGE = "NO_API_KEY: CHAINDADDY_API_KEY is not set, and the Chain Daddy API answers callers outside chaindaddy.io " +
|
|
68
|
+
"only with an API key. Get a free one: connect your wallet at https://chaindaddy.io/_api and click " +
|
|
69
|
+
"Generate API Key (a developer-program member can also run `chaindaddy app key create`). Then set " +
|
|
70
|
+
"CHAINDADDY_API_KEY=cd_live_… where this server starts. For read-only search, token and health lookups " +
|
|
71
|
+
"with no key, connect the remote server https://api.chaindaddy.io/mcp instead.";
|
|
72
|
+
// Paths the API serves with no key (pkg/middleware/origin.go PublicPaths).
|
|
73
|
+
const KEYLESS_PATHS = ["/api/v2/tokenlist", "/api/v2/iap", "/api/v2/heartbeat/oracle"];
|
|
74
|
+
// Headers for a call to the API: X-API-Key when it is set. With no key, a call
|
|
75
|
+
// that needs one fails here with what to do, rather than as a bare 401. A local
|
|
76
|
+
// API (dev mode, localhost) needs no key.
|
|
77
|
+
function apiHeaders(endpoint, baseUrl = API_URL) {
|
|
78
|
+
const headers = {};
|
|
79
|
+
if (API_KEY) {
|
|
80
|
+
headers["X-API-Key"] = API_KEY;
|
|
81
|
+
return headers;
|
|
82
|
+
}
|
|
83
|
+
const path = endpoint.split("?")[0];
|
|
84
|
+
const keyless = KEYLESS_PATHS.some((p) => path === p || path.startsWith(`${p}/`));
|
|
85
|
+
const local = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/.test(baseUrl);
|
|
86
|
+
if (!keyless && !local)
|
|
87
|
+
throw new Error(NO_API_KEY_MESSAGE);
|
|
88
|
+
return headers;
|
|
89
|
+
}
|
|
90
|
+
// The API's error body is {error, message?}; keep both.
|
|
91
|
+
async function apiError(response) {
|
|
92
|
+
const body = (await response.json().catch(() => ({ error: response.statusText })));
|
|
93
|
+
const text = [body.error, body.message].filter(Boolean).join(": ");
|
|
94
|
+
return new Error(text || `API error: ${response.status}`);
|
|
95
|
+
}
|
|
96
|
+
// Helper to make API calls. Pass `jwt` to attach Authorization: Bearer.
|
|
97
|
+
async function apiCall(endpoint, options = {}) {
|
|
98
|
+
const baseUrl = options.baseUrl ?? API_URL;
|
|
99
|
+
const url = `${baseUrl}${endpoint}`;
|
|
100
|
+
const headers = baseUrl === API_URL ? apiHeaders(endpoint) : {};
|
|
101
|
+
Object.assign(headers, options.headers);
|
|
102
|
+
if (options.jwt)
|
|
103
|
+
headers["Authorization"] = `Bearer ${options.jwt}`;
|
|
104
|
+
const response = await fetch(url, { headers });
|
|
105
|
+
if (!response.ok)
|
|
106
|
+
throw await apiError(response);
|
|
107
|
+
return response.json();
|
|
108
|
+
}
|
|
109
|
+
// Helper for POST requests
|
|
110
|
+
async function apiPost(endpoint, body, headers = {}, options = {}) {
|
|
111
|
+
const url = `${API_URL}${endpoint}`;
|
|
112
|
+
const finalHeaders = {
|
|
113
|
+
...apiHeaders(endpoint),
|
|
114
|
+
"Content-Type": "application/json",
|
|
115
|
+
...headers,
|
|
116
|
+
};
|
|
117
|
+
if (options.jwt)
|
|
118
|
+
finalHeaders["Authorization"] = `Bearer ${options.jwt}`;
|
|
119
|
+
const response = await fetch(url, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: finalHeaders,
|
|
122
|
+
body: JSON.stringify(body),
|
|
123
|
+
});
|
|
124
|
+
if (!response.ok)
|
|
125
|
+
throw await apiError(response);
|
|
126
|
+
return response.json();
|
|
127
|
+
}
|
|
128
|
+
// Helper for PUT requests. JWT is required for every PUT route we hit today,
|
|
129
|
+
// so it's a mandatory option rather than `optional: jwt?`.
|
|
130
|
+
async function apiPut(endpoint, body, options) {
|
|
131
|
+
const url = `${API_URL}${endpoint}`;
|
|
132
|
+
const response = await fetch(url, {
|
|
133
|
+
method: "PUT",
|
|
134
|
+
headers: {
|
|
135
|
+
...apiHeaders(endpoint),
|
|
136
|
+
"Content-Type": "application/json",
|
|
137
|
+
"Authorization": `Bearer ${options.jwt}`,
|
|
138
|
+
},
|
|
139
|
+
body: JSON.stringify(body),
|
|
140
|
+
});
|
|
141
|
+
if (!response.ok)
|
|
142
|
+
throw await apiError(response);
|
|
143
|
+
return response.json();
|
|
144
|
+
}
|
|
145
|
+
// Helper for DELETE requests
|
|
146
|
+
async function apiDelete(endpoint, headers = {}) {
|
|
147
|
+
const url = `${API_URL}${endpoint}`;
|
|
148
|
+
const response = await fetch(url, {
|
|
149
|
+
method: "DELETE",
|
|
150
|
+
headers: {
|
|
151
|
+
...apiHeaders(endpoint),
|
|
152
|
+
...headers,
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
if (!response.ok)
|
|
156
|
+
throw await apiError(response);
|
|
157
|
+
return response.json();
|
|
158
|
+
}
|
|
159
|
+
// Creator store (/api/v2/iap) request helper.
|
|
160
|
+
//
|
|
161
|
+
// `auth` says which credentials a route accepts:
|
|
162
|
+
// "account" — a SIWE session JWT or CHAINDADDY_API_KEY (opening a store,
|
|
163
|
+
// listing your stores, managing store keys)
|
|
164
|
+
// "store" — additionally a store key (CHAINDADDY_IAP_KEY)
|
|
165
|
+
// "public" — none needed; X-API-Key is still sent when configured
|
|
166
|
+
// Precedence: an explicit session_token, then the store key (store routes
|
|
167
|
+
// only), then the account API key. Every POST carries an Idempotency-Key —
|
|
168
|
+
// the caller's, or a fresh UUID — and it is returned so a retry can reuse it.
|
|
169
|
+
class IapError extends Error {
|
|
170
|
+
code;
|
|
171
|
+
status;
|
|
172
|
+
constructor(code, message, status) {
|
|
173
|
+
super(message);
|
|
174
|
+
this.code = code;
|
|
175
|
+
this.status = status;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function iapBearer(auth, session_token) {
|
|
179
|
+
if (session_token)
|
|
180
|
+
return requireSessionJWT(session_token).jwt;
|
|
181
|
+
if (auth === "public")
|
|
182
|
+
return undefined;
|
|
183
|
+
if (auth === "store" && IAP_KEY)
|
|
184
|
+
return IAP_KEY;
|
|
185
|
+
if (API_KEY)
|
|
186
|
+
return API_KEY;
|
|
187
|
+
throw new IapError("NO_CREDENTIALS", auth === "store"
|
|
188
|
+
? "Pass session_token, or set CHAINDADDY_IAP_KEY (a store key) or CHAINDADDY_API_KEY (a cd_live_ key)"
|
|
189
|
+
: "Pass session_token, or set CHAINDADDY_API_KEY (a cd_live_ key)", 401);
|
|
190
|
+
}
|
|
191
|
+
async function iapRequest(method, path, opts) {
|
|
192
|
+
const headers = { Accept: "application/json" };
|
|
193
|
+
const bearer = iapBearer(opts.auth, opts.session_token);
|
|
194
|
+
if (bearer)
|
|
195
|
+
headers["Authorization"] = `Bearer ${bearer}`;
|
|
196
|
+
// /api/v2/iap routes pass the project gate on their own: a store key goes
|
|
197
|
+
// alone. An account call keeps sending its cd_live_ key the usual way.
|
|
198
|
+
if (API_KEY.startsWith("cd_live_") && !bearer?.startsWith("cd_iap_"))
|
|
199
|
+
headers["X-API-Key"] = API_KEY;
|
|
200
|
+
let idempotencyKey;
|
|
201
|
+
if (method === "POST") {
|
|
202
|
+
idempotencyKey = opts.idempotencyKey || randomUUID();
|
|
203
|
+
headers["Idempotency-Key"] = idempotencyKey;
|
|
204
|
+
}
|
|
205
|
+
if (opts.body !== undefined)
|
|
206
|
+
headers["Content-Type"] = "application/json";
|
|
207
|
+
const response = await fetch(`${API_URL}/api/v2/iap${path}`, {
|
|
208
|
+
method,
|
|
209
|
+
headers,
|
|
210
|
+
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
|
211
|
+
});
|
|
212
|
+
const text = await response.text();
|
|
213
|
+
let data = null;
|
|
214
|
+
if (text) {
|
|
215
|
+
try {
|
|
216
|
+
data = JSON.parse(text);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
data = text;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (!response.ok) {
|
|
223
|
+
const obj = (data && typeof data === "object" ? data : {});
|
|
224
|
+
throw new IapError(obj.code || `HTTP_${response.status}`, obj.error || (typeof data === "string" && data) || `API error: ${response.status}`, response.status);
|
|
225
|
+
}
|
|
226
|
+
return { data, idempotencyKey };
|
|
227
|
+
}
|
|
228
|
+
function iapQuery(params) {
|
|
229
|
+
const q = new URLSearchParams();
|
|
230
|
+
for (const [k, v] of Object.entries(params)) {
|
|
231
|
+
if (v !== undefined && v !== "")
|
|
232
|
+
q.set(k, String(v));
|
|
233
|
+
}
|
|
234
|
+
const s = q.toString();
|
|
235
|
+
return s ? `?${s}` : "";
|
|
236
|
+
}
|
|
237
|
+
function iapOk(result) {
|
|
238
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
239
|
+
}
|
|
240
|
+
function iapFail(error, idempotencyKey) {
|
|
241
|
+
const code = error instanceof IapError ? error.code : error.message.split(":")[0] || "API_ERROR";
|
|
242
|
+
const payload = { error: code, message: error.message };
|
|
243
|
+
if (error instanceof IapError)
|
|
244
|
+
payload.status = error.status;
|
|
245
|
+
if (idempotencyKey)
|
|
246
|
+
payload.idempotency_key = idempotencyKey;
|
|
247
|
+
return { content: [{ type: "text", text: JSON.stringify(payload) }], isError: true };
|
|
248
|
+
}
|
|
249
|
+
// Format helpers
|
|
250
|
+
function formatAddress(addr) {
|
|
251
|
+
if (!addr || addr.length < 10)
|
|
252
|
+
return addr || "-";
|
|
253
|
+
return `${addr.slice(0, 6)}...${addr.slice(-4)}`;
|
|
254
|
+
}
|
|
255
|
+
function formatNumber(num) {
|
|
256
|
+
if (!num)
|
|
257
|
+
return "-";
|
|
258
|
+
if (num >= 1_000_000_000)
|
|
259
|
+
return `$${(num / 1_000_000_000).toFixed(1)}B`;
|
|
260
|
+
if (num >= 1_000_000)
|
|
261
|
+
return `$${(num / 1_000_000).toFixed(1)}M`;
|
|
262
|
+
if (num >= 1_000)
|
|
263
|
+
return `$${(num / 1_000).toFixed(1)}K`;
|
|
264
|
+
return `$${num.toFixed(0)}`;
|
|
265
|
+
}
|
|
266
|
+
// The published package.json sits one level above build/index.js.
|
|
267
|
+
const VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
268
|
+
// Create server
|
|
269
|
+
const server = new McpServer({
|
|
270
|
+
name: "chaindaddy",
|
|
271
|
+
version: VERSION,
|
|
272
|
+
});
|
|
273
|
+
// ============================================================================
|
|
274
|
+
// TOOLS
|
|
275
|
+
// ============================================================================
|
|
276
|
+
// Tool: get_auth_nonce
|
|
277
|
+
//
|
|
278
|
+
// Step 1 of the auth handshake. Returns a server-issued nonce the caller
|
|
279
|
+
// MUST embed in the SIWE/SIWS message before signing — the backend's
|
|
280
|
+
// /api/v2/auth/login/{siwe,siws} routes reject any message whose nonce
|
|
281
|
+
// wasn't issued (and consumed) by this endpoint. Nonces expire quickly
|
|
282
|
+
// (single-use, ~5 minute window per parseSIWSMessage freshness check in
|
|
283
|
+
// pkg/handler/v2_auth_handler.go), so fetch right before signing.
|
|
284
|
+
server.registerTool("get_auth_nonce", {
|
|
285
|
+
title: "Get sign-in nonce",
|
|
286
|
+
description: "Step 1 of authentication. Fetches a server-issued nonce the caller must embed in the SIWE/SIWS message before signing.\n\nParams: none\nReturns: {nonce: string}\nErrors: API_ERROR",
|
|
287
|
+
inputSchema: {},
|
|
288
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
289
|
+
}, async () => {
|
|
290
|
+
try {
|
|
291
|
+
const data = await apiCall("/api/v2/auth/nonce");
|
|
292
|
+
if (!data.nonce) {
|
|
293
|
+
return {
|
|
294
|
+
content: [{ type: "text", text: JSON.stringify({ error: "API_ERROR", message: "nonce missing from response" }) }],
|
|
295
|
+
isError: true,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
content: [{ type: "text", text: JSON.stringify({ nonce: data.nonce }) }],
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
return {
|
|
304
|
+
content: [{ type: "text", text: JSON.stringify({ error: "API_ERROR", message: error.message }) }],
|
|
305
|
+
isError: true,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
// Tool: authenticate
|
|
310
|
+
//
|
|
311
|
+
// Step 2 of the auth handshake. After local signature verification (fast
|
|
312
|
+
// fail before consuming a server nonce), POSTs the signed message to
|
|
313
|
+
// /api/v2/auth/login/{siwe,siws} to exchange for a backend JWT. The JWT
|
|
314
|
+
// is stored alongside the session so JWT-gated tools (update_profile,
|
|
315
|
+
// etc.) can attach it as Authorization: Bearer.
|
|
316
|
+
server.registerTool("authenticate", {
|
|
317
|
+
title: "Sign in with a wallet signature",
|
|
318
|
+
description: "Step 2 of authentication. Exchanges a signed SIWE/SIWS message for a session bound to a backend JWT. Call get_auth_nonce first and embed the nonce in the message.\n\nParams: wallet_address (string), chain_type ('evm'|'solana'), signed_message (string), signature (string)\nReturns: {session_token: string, expires_at: string}\nErrors: INVALID_SIGNATURE, INVALID_ADDRESS, UNSUPPORTED_CHAIN, JWT_EXCHANGE_FAILED",
|
|
319
|
+
inputSchema: {
|
|
320
|
+
wallet_address: z.string().describe("Wallet address (0x... for EVM, base58 for Solana)"),
|
|
321
|
+
chain_type: z.enum(["evm", "solana"]).describe("Chain type: 'evm' or 'solana'"),
|
|
322
|
+
signed_message: z.string().describe("The SIWE or SIWS message that was signed. Must include the server-issued nonce from get_auth_nonce."),
|
|
323
|
+
signature: z.string().describe("The signature of the message (hex for EVM, base58 for Solana)"),
|
|
324
|
+
},
|
|
325
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
326
|
+
}, async ({ wallet_address, chain_type, signed_message, signature }) => {
|
|
327
|
+
try {
|
|
328
|
+
// Local pre-flight signature check so a tampered message bails out
|
|
329
|
+
// before the backend consumes its nonce. Backend re-verifies.
|
|
330
|
+
if (chain_type === "evm") {
|
|
331
|
+
const siweMessage = new SiweMessage(signed_message);
|
|
332
|
+
const result = await siweMessage.verify({ signature });
|
|
333
|
+
if (!result.success) {
|
|
334
|
+
return {
|
|
335
|
+
content: [{ type: "text", text: JSON.stringify({ error: "INVALID_SIGNATURE", message: "EVM signature verification failed" }) }],
|
|
336
|
+
isError: true,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
if (siweMessage.address.toLowerCase() !== wallet_address.toLowerCase()) {
|
|
340
|
+
return {
|
|
341
|
+
content: [{ type: "text", text: JSON.stringify({ error: "INVALID_ADDRESS", message: "Message address does not match wallet_address" }) }],
|
|
342
|
+
isError: true,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
else if (chain_type === "solana") {
|
|
347
|
+
const publicKeyBytes = bs58.decode(wallet_address);
|
|
348
|
+
const messageBytes = new TextEncoder().encode(signed_message);
|
|
349
|
+
const signatureBytes = bs58.decode(signature);
|
|
350
|
+
const verified = nacl.sign.detached.verify(messageBytes, signatureBytes, publicKeyBytes);
|
|
351
|
+
if (!verified) {
|
|
352
|
+
return {
|
|
353
|
+
content: [{ type: "text", text: JSON.stringify({ error: "INVALID_SIGNATURE", message: "Solana signature verification failed" }) }],
|
|
354
|
+
isError: true,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
return {
|
|
360
|
+
content: [{ type: "text", text: JSON.stringify({ error: "UNSUPPORTED_CHAIN", message: `Unsupported chain type: ${chain_type}` }) }],
|
|
361
|
+
isError: true,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
// Exchange the signed message for a backend JWT.
|
|
365
|
+
let jwt;
|
|
366
|
+
try {
|
|
367
|
+
const loginPath = chain_type === "evm" ? "/api/v2/auth/login/siwe" : "/api/v2/auth/login/siws";
|
|
368
|
+
const loginBody = chain_type === "evm"
|
|
369
|
+
? { message: signed_message, signature }
|
|
370
|
+
: { message: signed_message, signature, publicKey: wallet_address };
|
|
371
|
+
const loginResp = await apiPost(loginPath, loginBody);
|
|
372
|
+
if (!loginResp.accessToken) {
|
|
373
|
+
throw new Error("login response missing accessToken");
|
|
374
|
+
}
|
|
375
|
+
jwt = loginResp.accessToken;
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
return {
|
|
379
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
380
|
+
error: "JWT_EXCHANGE_FAILED",
|
|
381
|
+
message: `Backend rejected SIWE/SIWS login: ${err.message}. Common cause: the message's nonce was not issued by get_auth_nonce, or was consumed already.`,
|
|
382
|
+
}) }],
|
|
383
|
+
isError: true,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
// Create session token bound to the backend JWT.
|
|
387
|
+
const session_token = randomUUID();
|
|
388
|
+
const expires_at = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
|
389
|
+
sessions.set(session_token, {
|
|
390
|
+
wallet: wallet_address,
|
|
391
|
+
chain_type,
|
|
392
|
+
expires_at,
|
|
393
|
+
jwt,
|
|
394
|
+
});
|
|
395
|
+
return {
|
|
396
|
+
content: [{ type: "text", text: JSON.stringify({ session_token, expires_at }) }],
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
return {
|
|
401
|
+
content: [{ type: "text", text: JSON.stringify({ error: "INVALID_SIGNATURE", message: error.message }) }],
|
|
402
|
+
isError: true,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
// Tool: get_session
|
|
407
|
+
server.registerTool("get_session", {
|
|
408
|
+
title: "Get session status",
|
|
409
|
+
description: "Get the current session status for a session token. Returns authentication state, wallet, chain type, and expiry.\n\nParams: session_token (string)\nReturns: {authenticated: true, wallet: string, chain_type: 'evm'|'solana', expires_at: string} or {authenticated: false}\nErrors: none (always returns a result)",
|
|
410
|
+
inputSchema: {
|
|
411
|
+
session_token: z.string().describe("Session token from authenticate"),
|
|
412
|
+
},
|
|
413
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
414
|
+
}, async ({ session_token }) => {
|
|
415
|
+
try {
|
|
416
|
+
const session = requireSession(session_token);
|
|
417
|
+
return {
|
|
418
|
+
content: [{ type: "text", text: JSON.stringify({ authenticated: true, wallet: session.wallet, chain_type: session.chain_type, expires_at: session.expires_at }) }],
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
return {
|
|
423
|
+
content: [{ type: "text", text: JSON.stringify({ authenticated: false }) }],
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
// Tool: search_symbol
|
|
428
|
+
server.registerTool("search_symbol", {
|
|
429
|
+
title: "Search a ticker",
|
|
430
|
+
description: "Search for a crypto ticker symbol across all supported chains. Returns availability status, existing tokens, market data, and crown ownership.",
|
|
431
|
+
inputSchema: {
|
|
432
|
+
symbol: z.string().describe("The ticker symbol to search (1-20 alphanumeric chars, e.g., 'PEPE', 'DOGE')"),
|
|
433
|
+
chain: z.enum(["eth", "arbitrum", "arbitrum-sepolia", "solana", "base", "polygon", "bnb"]).optional().describe("Optional: Filter to specific chain"),
|
|
434
|
+
},
|
|
435
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
436
|
+
}, async ({ symbol, chain }) => {
|
|
437
|
+
try {
|
|
438
|
+
const params = new URLSearchParams({ symbol });
|
|
439
|
+
if (chain)
|
|
440
|
+
params.set("chain", chain);
|
|
441
|
+
const endpoint = `/api/v2/search?${params.toString()}`;
|
|
442
|
+
const data = await apiCall(endpoint);
|
|
443
|
+
if (!data.success || !data.results) {
|
|
444
|
+
return {
|
|
445
|
+
content: [{ type: "text", text: `No results found for "${symbol}"` }],
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
// The API filters by chain itself.
|
|
449
|
+
const results = data.results;
|
|
450
|
+
// Build output table
|
|
451
|
+
const lines = [
|
|
452
|
+
`Search Results for ${symbol.toUpperCase()}`,
|
|
453
|
+
"",
|
|
454
|
+
"| Chain | Status | Token Address | Market Cap | Crown Owner |",
|
|
455
|
+
"|------------------|--------------|--------------------| ------------|-------------------|",
|
|
456
|
+
];
|
|
457
|
+
for (const r of results) {
|
|
458
|
+
const status = r.crown?.status || "available";
|
|
459
|
+
const statusDisplay = status.charAt(0).toUpperCase() + status.slice(1);
|
|
460
|
+
const tokenAddr = r.token?.address ? formatAddress(r.token.address) : "-";
|
|
461
|
+
const marketCap = formatNumber(r.token?.marketCap);
|
|
462
|
+
const owner = r.crown?.owner ? formatAddress(r.crown.owner) : "-";
|
|
463
|
+
lines.push(`| ${r.chainName.padEnd(16)} | ${statusDisplay.padEnd(12)} | ${tokenAddr.padEnd(18)} | ${marketCap.padEnd(11)} | ${owner.padEnd(17)} |`);
|
|
464
|
+
}
|
|
465
|
+
lines.push("");
|
|
466
|
+
lines.push('Status Guide:');
|
|
467
|
+
lines.push(' "available" = No token exists, you can create one');
|
|
468
|
+
lines.push(' "unclaimed" = Token exists but no crown claimed');
|
|
469
|
+
lines.push(' "claimed" = Crown claimed by another owner');
|
|
470
|
+
lines.push(' "owned" = You own this crown');
|
|
471
|
+
return {
|
|
472
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
return {
|
|
477
|
+
content: [{ type: "text", text: `Search failed: ${error.message}` }],
|
|
478
|
+
isError: true,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
// Tool: get_token
|
|
483
|
+
server.registerTool("get_token", {
|
|
484
|
+
title: "Get token details",
|
|
485
|
+
description: "Get detailed information about a token on a specific chain including crown status, market data, and metadata.",
|
|
486
|
+
inputSchema: {
|
|
487
|
+
symbol: z.string().describe("The ticker symbol (e.g., 'PEPE')"),
|
|
488
|
+
chain: z.enum(["eth", "arbitrum", "arbitrum-sepolia", "solana", "base", "polygon", "bnb"]).describe("The chain to query"),
|
|
489
|
+
},
|
|
490
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
491
|
+
}, async ({ symbol, chain }) => {
|
|
492
|
+
try {
|
|
493
|
+
const endpoint = `/api/v2/token/${encodeURIComponent(symbol)}/${chain}`;
|
|
494
|
+
const raw = await apiCall(endpoint);
|
|
495
|
+
const data = { token: raw.token, crown: raw.registration };
|
|
496
|
+
const lines = [
|
|
497
|
+
`${raw.token?.symbol || symbol.toUpperCase()} on ${raw.chainName || chain}`,
|
|
498
|
+
"",
|
|
499
|
+
];
|
|
500
|
+
if (data.token) {
|
|
501
|
+
lines.push("Token Details:");
|
|
502
|
+
lines.push(` Address: ${data.token.address}`);
|
|
503
|
+
lines.push(` Name: ${data.token.name}`);
|
|
504
|
+
lines.push(` Symbol: ${data.token.symbol}`);
|
|
505
|
+
if (data.token.decimals !== undefined) {
|
|
506
|
+
lines.push(` Decimals: ${data.token.decimals}`);
|
|
507
|
+
}
|
|
508
|
+
if (data.token.totalSupply) {
|
|
509
|
+
lines.push(` Total Supply: ${data.token.totalSupply}`);
|
|
510
|
+
}
|
|
511
|
+
lines.push("");
|
|
512
|
+
if (data.token.marketCap || data.token.volume24h || data.token.liquidity) {
|
|
513
|
+
lines.push("Market Data:");
|
|
514
|
+
if (data.token.marketCap)
|
|
515
|
+
lines.push(` Market Cap: ${formatNumber(data.token.marketCap)}`);
|
|
516
|
+
if (data.token.volume24h)
|
|
517
|
+
lines.push(` 24h Volume: ${formatNumber(data.token.volume24h)}`);
|
|
518
|
+
if (data.token.liquidity)
|
|
519
|
+
lines.push(` Liquidity: ${formatNumber(data.token.liquidity)}`);
|
|
520
|
+
if (data.token.holders)
|
|
521
|
+
lines.push(` Holders: ${data.token.holders.toLocaleString()}`);
|
|
522
|
+
lines.push("");
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
lines.push("No token found on this chain.");
|
|
527
|
+
lines.push("");
|
|
528
|
+
}
|
|
529
|
+
if (data.crown) {
|
|
530
|
+
const status = data.crown.status.toUpperCase();
|
|
531
|
+
lines.push(`Crown Status: ${status}`);
|
|
532
|
+
if (data.crown.owner) {
|
|
533
|
+
lines.push(` Owner: ${data.crown.owner}`);
|
|
534
|
+
lines.push(` Token ID: #${data.crown.tokenId}`);
|
|
535
|
+
}
|
|
536
|
+
if (data.crown.heartbeatScore !== undefined) {
|
|
537
|
+
lines.push(` Heartbeat: ${data.crown.heartbeatScore}%`);
|
|
538
|
+
}
|
|
539
|
+
if (data.crown.verificationMethod) {
|
|
540
|
+
lines.push(` Verified As: ${data.crown.verificationMethod}`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
lines.push("Crown Status: AVAILABLE");
|
|
545
|
+
lines.push(" No crown has been minted for this symbol on this chain.");
|
|
546
|
+
}
|
|
547
|
+
return {
|
|
548
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
return {
|
|
553
|
+
content: [{ type: "text", text: `Failed to get token: ${error.message}` }],
|
|
554
|
+
isError: true,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
// Tool: get_crown
|
|
559
|
+
server.registerTool("get_crown", {
|
|
560
|
+
title: "Get a token registration",
|
|
561
|
+
description: "Get detailed information about a specific crown by ID.\n\nParams: crown_id (string)\nReturns: {symbol, chain, owner, manager, heartbeat_score, status, metadata, days_in_grace, penalty_deadline}\nErrors: CROWN_NOT_FOUND, API_ERROR",
|
|
562
|
+
inputSchema: {
|
|
563
|
+
crown_id: z.string().describe("Crown ID (token ID or identifier)"),
|
|
564
|
+
},
|
|
565
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
566
|
+
}, async ({ crown_id }) => {
|
|
567
|
+
try {
|
|
568
|
+
const data = await apiCall(`/api/v2/registration/${encodeURIComponent(crown_id)}`);
|
|
569
|
+
return {
|
|
570
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
571
|
+
symbol: data.symbol || "",
|
|
572
|
+
chain: data.chain || data.chainName || (data.chainId ? String(data.chainId) : ""),
|
|
573
|
+
owner: data.owner || "",
|
|
574
|
+
manager: data.manager || null,
|
|
575
|
+
heartbeat_score: data.heartbeatScore ?? null,
|
|
576
|
+
status: data.status || "unknown",
|
|
577
|
+
metadata: data.metadata || {},
|
|
578
|
+
days_in_grace: data.gracePeriod?.daysRemaining ?? null,
|
|
579
|
+
penalty_deadline: data.penalty?.deadline ?? null,
|
|
580
|
+
}) }],
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
catch (error) {
|
|
584
|
+
const message = error.message;
|
|
585
|
+
const errorCode = message.includes("404") ? "CROWN_NOT_FOUND" : "API_ERROR";
|
|
586
|
+
return {
|
|
587
|
+
content: [{ type: "text", text: JSON.stringify({ error: errorCode, message }) }],
|
|
588
|
+
isError: true,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
// Tool: list_crowns
|
|
593
|
+
server.registerTool("list_crowns", {
|
|
594
|
+
title: "List token registrations",
|
|
595
|
+
description: "List crowns with optional filters. All params optional.\n\nParams: owner (string?), chain (string?), status (string?)\nReturns: {crowns: [{symbol, chain, owner, manager, heartbeat_score, status, metadata, days_in_grace, penalty_deadline}]}\nErrors: API_ERROR",
|
|
596
|
+
inputSchema: {
|
|
597
|
+
owner: z.string().optional().describe("Filter by owner wallet address"),
|
|
598
|
+
chain: z.string().optional().describe("Filter by chain (e.g., 'arb', 'sol', 'base')"),
|
|
599
|
+
status: z.string().optional().describe("Filter by status (e.g., 'claimed', 'inactive', 'grace_period')"),
|
|
600
|
+
},
|
|
601
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
602
|
+
}, async ({ owner, chain, status }) => {
|
|
603
|
+
try {
|
|
604
|
+
let data;
|
|
605
|
+
if (owner) {
|
|
606
|
+
// Public v3 endpoint. The v2 /crown/owned/{address} predecessor
|
|
607
|
+
// moved behind JWT auth that the MCP server doesn't issue today,
|
|
608
|
+
// and the legacy /crown/* prefix has no backing routes at all.
|
|
609
|
+
// v3 wraps results in {success, data: {crowns, total, limit, offset}}.
|
|
610
|
+
const rawData = await apiCall(`/api/v3/registration/by-owner/${encodeURIComponent(owner)}`);
|
|
611
|
+
// The v3 route names the list `tokens`.
|
|
612
|
+
const v3 = rawData.data;
|
|
613
|
+
data = { crowns: v3?.tokens || rawData.data?.crowns || [] };
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
// No public "list every crown" endpoint exists — the prior
|
|
617
|
+
// /registrations fallback URL was never a real route. Require an
|
|
618
|
+
// owner filter so the caller doesn't get a confused empty result.
|
|
619
|
+
return {
|
|
620
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
621
|
+
error: "OWNER_REQUIRED",
|
|
622
|
+
message: "list_crowns requires an `owner` parameter (no public endpoint lists every crown). Try search_symbols or get_crown_details with a known id instead.",
|
|
623
|
+
}) }],
|
|
624
|
+
isError: true,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
let crowns = data.crowns;
|
|
628
|
+
// Apply chain filter
|
|
629
|
+
if (chain) {
|
|
630
|
+
crowns = crowns.filter(c => c.chain === chain || c.chainName?.toLowerCase() === chain.toLowerCase() || String(c.chainId) === chain);
|
|
631
|
+
}
|
|
632
|
+
// Apply status filter
|
|
633
|
+
if (status) {
|
|
634
|
+
crowns = crowns.filter(c => {
|
|
635
|
+
if (c.status)
|
|
636
|
+
return c.status === status;
|
|
637
|
+
// Infer status from isActive field if status not explicitly set
|
|
638
|
+
if (status === "inactive")
|
|
639
|
+
return c.isActive === false;
|
|
640
|
+
if (status === "claimed")
|
|
641
|
+
return c.isActive !== false;
|
|
642
|
+
return true;
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
const result = crowns.map(c => ({
|
|
646
|
+
symbol: c.symbol || "",
|
|
647
|
+
chain: c.chain || c.chainName || (c.chainId ? String(c.chainId) : ""),
|
|
648
|
+
owner: c.owner || "",
|
|
649
|
+
manager: c.manager || null,
|
|
650
|
+
heartbeat_score: c.heartbeatScore ?? null,
|
|
651
|
+
status: c.status || (c.isActive === false ? "inactive" : "claimed"),
|
|
652
|
+
metadata: c.metadata || {},
|
|
653
|
+
days_in_grace: c.gracePeriod?.daysRemaining ?? null,
|
|
654
|
+
penalty_deadline: c.penalty?.deadline ?? null,
|
|
655
|
+
}));
|
|
656
|
+
return {
|
|
657
|
+
content: [{ type: "text", text: JSON.stringify({ crowns: result }) }],
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
catch (error) {
|
|
661
|
+
return {
|
|
662
|
+
content: [{ type: "text", text: JSON.stringify({ error: "API_ERROR", message: error.message }) }],
|
|
663
|
+
isError: true,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
// Tool: get_pricing
|
|
668
|
+
server.registerTool("get_pricing", {
|
|
669
|
+
title: "Get pricing",
|
|
670
|
+
description: "Get what an order costs, as the order will charge it. mode 'create' (default): a new token on each chain, its registration included. mode 'claim': registering a token that already exists on each chain (free for an OG token when symbol and address are given). All params optional. Two or more chains take the 10% multi-chain bundle discount, already subtracted from total_usd.\n\nParams: chains (string[]?), mode ('create'|'claim'?), symbol (string?), address (string?), bundle_type (string?), wallet_address (string?)\nReturns: {items: [{chain, type, fee_usd, gas_estimate}], discounts: [{type, description, amount_usd}], total_usd: number}\nErrors: API_ERROR",
|
|
671
|
+
inputSchema: {
|
|
672
|
+
chains: z.array(z.string()).optional().describe("Chains to get pricing for (e.g., ['arb', 'sol'])"),
|
|
673
|
+
mode: z.enum(["create", "claim"]).optional().describe("'create' (default) prices a new token; 'claim' prices registering a token that already exists"),
|
|
674
|
+
address: z.string().optional().describe("With mode 'claim': the token's contract or mint address, so an OG token shows as free"),
|
|
675
|
+
symbol: z.string().optional().describe("Token symbol for pricing context"),
|
|
676
|
+
bundle_type: z.string().optional().describe("Label for the bundle discount line only; the discount itself follows the number of chains"),
|
|
677
|
+
wallet_address: z.string().optional().describe("Accepted for compatibility; pricing does not depend on the wallet"),
|
|
678
|
+
},
|
|
679
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
680
|
+
}, async ({ chains, mode, address, symbol, bundle_type, wallet_address }) => {
|
|
681
|
+
try {
|
|
682
|
+
const params = new URLSearchParams();
|
|
683
|
+
if (chains && chains.length > 0)
|
|
684
|
+
params.set("chains", chains.join(","));
|
|
685
|
+
if (mode)
|
|
686
|
+
params.set("mode", mode);
|
|
687
|
+
if (address)
|
|
688
|
+
params.set("address", address);
|
|
689
|
+
if (symbol)
|
|
690
|
+
params.set("symbol", symbol);
|
|
691
|
+
if (bundle_type)
|
|
692
|
+
params.set("bundle_type", bundle_type);
|
|
693
|
+
if (wallet_address)
|
|
694
|
+
params.set("wallet_address", wallet_address);
|
|
695
|
+
const queryString = params.toString();
|
|
696
|
+
const endpoint = `/api/v2/pricing${queryString ? `?${queryString}` : ""}`;
|
|
697
|
+
const data = await apiCall(endpoint);
|
|
698
|
+
return {
|
|
699
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
700
|
+
items: data.items,
|
|
701
|
+
discounts: data.discounts,
|
|
702
|
+
total_usd: data.total_usd,
|
|
703
|
+
}) }],
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
catch (error) {
|
|
707
|
+
return {
|
|
708
|
+
content: [{ type: "text", text: JSON.stringify({ error: "API_ERROR", message: error.message }) }],
|
|
709
|
+
isError: true,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
// Tool: get_deployments
|
|
714
|
+
server.registerTool("get_deployments", {
|
|
715
|
+
title: "Find tokens a wallet can register",
|
|
716
|
+
description: "Find the tokens a wallet made on a chain (deployed, owns, or launched) that are not registered yet, and how it qualifies for each. Useful for finding tokens you can claim.",
|
|
717
|
+
inputSchema: {
|
|
718
|
+
address: z.string().describe("Wallet address (0x...)"),
|
|
719
|
+
chain: z.enum(["eth", "arbitrum", "arbitrum-sepolia", "base", "polygon", "bnb"]).describe("Chain to search"),
|
|
720
|
+
},
|
|
721
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
722
|
+
}, async ({ address, chain }) => {
|
|
723
|
+
try {
|
|
724
|
+
const chainConfig = SUPPORTED_CHAINS[chain];
|
|
725
|
+
if (!chainConfig) {
|
|
726
|
+
return {
|
|
727
|
+
content: [{ type: "text", text: `Invalid chain: ${chain}` }],
|
|
728
|
+
isError: true,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
// GET /api/v2/registration/claimable answers for the wallet in
|
|
732
|
+
// X-Wallet-Address (or the session's), across every chain.
|
|
733
|
+
const data = await apiCall("/api/v2/registration/claimable", {
|
|
734
|
+
headers: { "X-Wallet-Address": address },
|
|
735
|
+
});
|
|
736
|
+
const onChain = (data.tokens || []).filter((t) => t.chainId === `eip155:${chainConfig.id}` || t.chainId === String(chainConfig.id));
|
|
737
|
+
if (onChain.length === 0) {
|
|
738
|
+
return {
|
|
739
|
+
content: [{ type: "text", text: `No unregistered tokens found for ${formatAddress(address)} on ${chainConfig.name}` }],
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
const lines = [
|
|
743
|
+
`Tokens ${formatAddress(address)} can register on ${chainConfig.name}`,
|
|
744
|
+
"",
|
|
745
|
+
"| Symbol | Name | Address | Qualifies as |",
|
|
746
|
+
"|--------|----------------------|--------------------|------------------|",
|
|
747
|
+
];
|
|
748
|
+
for (const token of onChain) {
|
|
749
|
+
const symbol = (token.symbol || "?").padEnd(6);
|
|
750
|
+
const name = (token.name || "Unknown").slice(0, 20).padEnd(20);
|
|
751
|
+
const addr = formatAddress(token.address).padEnd(18);
|
|
752
|
+
const how = (token.qualification?.method || "-").padEnd(16);
|
|
753
|
+
lines.push(`| ${symbol} | ${name} | ${addr} | ${how} |`);
|
|
754
|
+
}
|
|
755
|
+
lines.push("");
|
|
756
|
+
lines.push(`Found ${onChain.length} token${onChain.length !== 1 ? "s" : ""}`);
|
|
757
|
+
lines.push("");
|
|
758
|
+
lines.push("Use check_eligibility to confirm the price and terms for any of these tokens.");
|
|
759
|
+
return {
|
|
760
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
catch (error) {
|
|
764
|
+
return {
|
|
765
|
+
content: [{ type: "text", text: `Failed to get deployments: ${error.message}` }],
|
|
766
|
+
isError: true,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
// Tool: check_eligibility
|
|
771
|
+
server.registerTool("check_eligibility", {
|
|
772
|
+
title: "Check registration eligibility",
|
|
773
|
+
description: "Check if a wallet is eligible to claim a crown for a specific token. Verifies deployer, owner, admin, or majority holder status.",
|
|
774
|
+
inputSchema: {
|
|
775
|
+
token_address: z.string().describe("Token contract address"),
|
|
776
|
+
wallet_address: z.string().describe("Wallet to check eligibility for"),
|
|
777
|
+
chain: z.enum(["eth", "arbitrum", "arbitrum-sepolia", "solana", "base", "polygon", "bnb"]).describe("Chain where token exists"),
|
|
778
|
+
},
|
|
779
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
780
|
+
}, async ({ token_address, wallet_address, chain }) => {
|
|
781
|
+
try {
|
|
782
|
+
const chainConfig = SUPPORTED_CHAINS[chain];
|
|
783
|
+
if (!chainConfig) {
|
|
784
|
+
return {
|
|
785
|
+
content: [{ type: "text", text: `Invalid chain: ${chain}` }],
|
|
786
|
+
isError: true,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
// Read-only: asks the same verifier that gates the claim.
|
|
790
|
+
const data = await apiPost("/api/v2/registration/check-qualify", {
|
|
791
|
+
wallet: wallet_address,
|
|
792
|
+
tokenAddress: token_address,
|
|
793
|
+
chainId: chainConfig.id,
|
|
794
|
+
});
|
|
795
|
+
const lines = [
|
|
796
|
+
"Eligibility Check",
|
|
797
|
+
"",
|
|
798
|
+
`Token: ${formatAddress(token_address)} on ${chainConfig.name}`,
|
|
799
|
+
`Wallet: ${formatAddress(wallet_address)}`,
|
|
800
|
+
"",
|
|
801
|
+
];
|
|
802
|
+
if (data.eligible) {
|
|
803
|
+
lines.push("ELIGIBLE");
|
|
804
|
+
lines.push("");
|
|
805
|
+
if (data.qualification?.message) {
|
|
806
|
+
lines.push(`Qualifies as: ${data.qualification.method} (${data.qualification.message})`);
|
|
807
|
+
}
|
|
808
|
+
if (typeof data.price === "number") {
|
|
809
|
+
lines.push(`Price: ${data.price === 0 ? "free" : `${data.price} ${data.currency || "USD"}`}`);
|
|
810
|
+
}
|
|
811
|
+
lines.push("");
|
|
812
|
+
lines.push("You can claim this crown. Use claim_crown to proceed.");
|
|
813
|
+
}
|
|
814
|
+
else {
|
|
815
|
+
lines.push("NOT ELIGIBLE");
|
|
816
|
+
lines.push("");
|
|
817
|
+
if (data.reason) {
|
|
818
|
+
lines.push(`Reason: ${data.reason}`);
|
|
819
|
+
}
|
|
820
|
+
if (data.claimability?.message) {
|
|
821
|
+
lines.push(data.claimability.message);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return {
|
|
825
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
catch (error) {
|
|
829
|
+
return {
|
|
830
|
+
content: [{ type: "text", text: `Eligibility check failed: ${error.message}` }],
|
|
831
|
+
isError: true,
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
// Tool: check_heartbeat
|
|
836
|
+
server.registerTool("check_heartbeat", {
|
|
837
|
+
title: "Check token health",
|
|
838
|
+
description: "Get heartbeat status and activity score for a crown. Score below 60% means at-risk of reclaim.",
|
|
839
|
+
inputSchema: {
|
|
840
|
+
crown_id: z.number().describe("Crown token ID"),
|
|
841
|
+
},
|
|
842
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
843
|
+
}, async ({ crown_id }) => {
|
|
844
|
+
try {
|
|
845
|
+
// The legacy by-id heartbeat route was retired — heartbeat is keyed
|
|
846
|
+
// by (symbol, chain) now. Resolve the crown first, then hit the
|
|
847
|
+
// symbol-keyed endpoint.
|
|
848
|
+
const crownLookup = await apiCall(`/api/v2/registration/${encodeURIComponent(String(crown_id))}`);
|
|
849
|
+
const symbol = crownLookup.symbol || "";
|
|
850
|
+
// The registration route answers with a numeric chainId; the heartbeat
|
|
851
|
+
// route takes that as readily as a chain key.
|
|
852
|
+
const chain = crownLookup.chain || crownLookup.chainName || (crownLookup.chainId ? String(crownLookup.chainId) : "");
|
|
853
|
+
if (!symbol || !chain) {
|
|
854
|
+
return {
|
|
855
|
+
content: [{ type: "text", text: JSON.stringify({ error: "CROWN_NOT_FOUND", message: `No crown resolved for id ${crown_id}` }) }],
|
|
856
|
+
isError: true,
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
const data = await apiCall(`/api/v2/heartbeat/${encodeURIComponent(symbol)}/${encodeURIComponent(chain)}`);
|
|
860
|
+
const lines = [
|
|
861
|
+
`Heartbeat Status: ${data.symbol} (#${crown_id})`,
|
|
862
|
+
"",
|
|
863
|
+
`Score: ${data.score}%`,
|
|
864
|
+
`Status: ${data.status.toUpperCase()}`,
|
|
865
|
+
"",
|
|
866
|
+
];
|
|
867
|
+
if (data.components) {
|
|
868
|
+
lines.push("Score Breakdown:");
|
|
869
|
+
lines.push(` Timestamp (50%): ${data.components.timestamp}%`);
|
|
870
|
+
lines.push(` Volume (30%): ${data.components.volume}%`);
|
|
871
|
+
lines.push(` Holders (20%): ${data.components.holders}%`);
|
|
872
|
+
lines.push("");
|
|
873
|
+
}
|
|
874
|
+
if (data.lastUpdated) {
|
|
875
|
+
lines.push(`Last Update: ${data.lastUpdated}`);
|
|
876
|
+
}
|
|
877
|
+
if (data.score < 60) {
|
|
878
|
+
lines.push("");
|
|
879
|
+
lines.push("WARNING: Score below 60%. Crown is at risk of becoming reclaimable.");
|
|
880
|
+
lines.push("Recommendations:");
|
|
881
|
+
lines.push(" - Update profile metadata to refresh timestamp");
|
|
882
|
+
lines.push(" - Encourage trading activity");
|
|
883
|
+
lines.push(" - Grow holder base");
|
|
884
|
+
}
|
|
885
|
+
return {
|
|
886
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
catch (error) {
|
|
890
|
+
return {
|
|
891
|
+
content: [{ type: "text", text: `Heartbeat check failed: ${error.message}` }],
|
|
892
|
+
isError: true,
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
// Tool: export_listing
|
|
897
|
+
server.registerTool("export_listing", {
|
|
898
|
+
title: "Export listing data",
|
|
899
|
+
description: "Generate listing data formatted for aggregator platforms (CMC, CoinGecko, DEX Screener, TokenList).",
|
|
900
|
+
inputSchema: {
|
|
901
|
+
crown_id: z.number().describe("Crown token ID"),
|
|
902
|
+
format: z.enum(["cmc", "coingecko", "dexscreener", "tokenlist", "all"]).describe("Export format for the listing data"),
|
|
903
|
+
},
|
|
904
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
905
|
+
}, async ({ crown_id, format }) => {
|
|
906
|
+
try {
|
|
907
|
+
// The by-id export route was retired in favour of /tokenlist/export
|
|
908
|
+
// which keys on (symbol, chain). Resolve the crown first.
|
|
909
|
+
const crownLookup = await apiCall(`/api/v2/registration/${encodeURIComponent(String(crown_id))}`);
|
|
910
|
+
const symbol = crownLookup.symbol || "";
|
|
911
|
+
const chain = crownLookup.chain || crownLookup.chainName || (crownLookup.chainId ? String(crownLookup.chainId) : "");
|
|
912
|
+
if (!symbol || !chain) {
|
|
913
|
+
return {
|
|
914
|
+
content: [{ type: "text", text: JSON.stringify({ error: "CROWN_NOT_FOUND", message: `No crown resolved for id ${crown_id}` }) }],
|
|
915
|
+
isError: true,
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
const data = await apiCall(`/api/v2/tokenlist/export/${encodeURIComponent(symbol)}/${encodeURIComponent(chain)}?format=${encodeURIComponent(format)}`);
|
|
919
|
+
const lines = [
|
|
920
|
+
`Listing Export: ${data.symbol} (#${crown_id})`,
|
|
921
|
+
`Format: ${data.format.toUpperCase()}`,
|
|
922
|
+
"",
|
|
923
|
+
];
|
|
924
|
+
if (data.completeness !== undefined) {
|
|
925
|
+
lines.push(`Profile Completeness: ${data.completeness}%`);
|
|
926
|
+
lines.push("");
|
|
927
|
+
}
|
|
928
|
+
if (data.missingFields && data.missingFields.length > 0) {
|
|
929
|
+
lines.push("Missing Fields (recommended for listing):");
|
|
930
|
+
for (const field of data.missingFields) {
|
|
931
|
+
lines.push(` - ${field}`);
|
|
932
|
+
}
|
|
933
|
+
lines.push("");
|
|
934
|
+
}
|
|
935
|
+
lines.push("Export Data:");
|
|
936
|
+
lines.push("```json");
|
|
937
|
+
lines.push(JSON.stringify(data.data, null, 2));
|
|
938
|
+
lines.push("```");
|
|
939
|
+
return {
|
|
940
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
catch (error) {
|
|
944
|
+
return {
|
|
945
|
+
content: [{ type: "text", text: `Export failed: ${error.message}` }],
|
|
946
|
+
isError: true,
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
// Tool: get_holders
|
|
951
|
+
//
|
|
952
|
+
// Public read against /api/v2/registration/{id}/holders. Returns the
|
|
953
|
+
// per-crown holder snapshot the heartbeat-publisher daemon refreshes
|
|
954
|
+
// daily from Mobula: aggregate concentration percentages (top10/50/
|
|
955
|
+
// 100/200), an interpolated "wallets needed to hold 50% of supply",
|
|
956
|
+
// and adversarial-label counts (snipers, bundlers, insiders, sandwich).
|
|
957
|
+
// Wallet addresses and per-rank balances are deliberately NOT exposed
|
|
958
|
+
// — the underlying type system (`pkg/types/holder.go`) bans address
|
|
959
|
+
// leaks even for top-holder lists, so this tool surfaces what we can
|
|
960
|
+
// publish without violating that invariant.
|
|
961
|
+
server.registerTool("get_holders", {
|
|
962
|
+
title: "Get holder stats",
|
|
963
|
+
description: "Get holder concentration and adversarial-label stats for a crown. Sourced from /api/v2/registration/{id}/holders; data refreshes daily.\n\nParams: crown_id (number)\nReturns: text report with totalHolders, top10/50/100/200 concentration, walletsForHalf, and Mobula label counts (snipers/bundlers/insiders/sandwich) plus suspiciousRatio.\nErrors: CROWN_NOT_FOUND, NO_DATA, API_ERROR",
|
|
964
|
+
inputSchema: {
|
|
965
|
+
crown_id: z.number().describe("Crown token ID"),
|
|
966
|
+
},
|
|
967
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
968
|
+
}, async ({ crown_id }) => {
|
|
969
|
+
try {
|
|
970
|
+
const data = await apiCall(`/api/v2/registration/${crown_id}/holders`);
|
|
971
|
+
const sym = data.symbol || `#${crown_id}`;
|
|
972
|
+
const total = data.totalHolders ?? 0;
|
|
973
|
+
const lines = [
|
|
974
|
+
`Holder Snapshot: ${sym}`,
|
|
975
|
+
"",
|
|
976
|
+
`Total Holders: ${total.toLocaleString()}`,
|
|
977
|
+
];
|
|
978
|
+
const c = data.concentration;
|
|
979
|
+
if (c) {
|
|
980
|
+
lines.push("");
|
|
981
|
+
lines.push("Concentration (% of supply):");
|
|
982
|
+
if (c.top10Pct !== undefined)
|
|
983
|
+
lines.push(` Top 10: ${c.top10Pct.toFixed(2)}%`);
|
|
984
|
+
if (c.top50Pct !== undefined)
|
|
985
|
+
lines.push(` Top 50: ${c.top50Pct.toFixed(2)}%`);
|
|
986
|
+
if (c.top100Pct !== undefined)
|
|
987
|
+
lines.push(` Top 100: ${c.top100Pct.toFixed(2)}%`);
|
|
988
|
+
if (c.top200Pct !== undefined)
|
|
989
|
+
lines.push(` Top 200: ${c.top200Pct.toFixed(2)}%`);
|
|
990
|
+
if (c.walletsForHalf !== undefined && c.walletsForHalf > 0) {
|
|
991
|
+
lines.push(` Wallets to hold 50%: ${c.walletsForHalf}`);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
const q = data.quality;
|
|
995
|
+
if (q) {
|
|
996
|
+
lines.push("");
|
|
997
|
+
lines.push("Adversarial Labels (Mobula):");
|
|
998
|
+
if (q.snipersCount !== undefined)
|
|
999
|
+
lines.push(` Snipers: ${q.snipersCount}`);
|
|
1000
|
+
if (q.bundlersCount !== undefined)
|
|
1001
|
+
lines.push(` Bundlers: ${q.bundlersCount}`);
|
|
1002
|
+
if (q.insidersCount !== undefined)
|
|
1003
|
+
lines.push(` Insiders: ${q.insidersCount}`);
|
|
1004
|
+
if (q.sandwichCount !== undefined)
|
|
1005
|
+
lines.push(` Sandwich: ${q.sandwichCount}`);
|
|
1006
|
+
if (q.suspiciousRatio !== undefined) {
|
|
1007
|
+
lines.push(` Suspicious Ratio: ${(q.suspiciousRatio * 100).toFixed(2)}%`);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
if (data.source || data.fetchedAt) {
|
|
1011
|
+
lines.push("");
|
|
1012
|
+
lines.push(`Source: ${data.source ?? "unknown"}${data.fetchedAt ? ` (fetched ${data.fetchedAt})` : ""}`);
|
|
1013
|
+
}
|
|
1014
|
+
lines.push("");
|
|
1015
|
+
lines.push("Note: per-rank wallet addresses are intentionally not exposed.");
|
|
1016
|
+
return {
|
|
1017
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
catch (error) {
|
|
1021
|
+
const message = error.message;
|
|
1022
|
+
const errorCode = message.includes("404")
|
|
1023
|
+
? (message.toLowerCase().includes("crown not found") ? "CROWN_NOT_FOUND" : "NO_DATA")
|
|
1024
|
+
: "API_ERROR";
|
|
1025
|
+
return {
|
|
1026
|
+
content: [{ type: "text", text: JSON.stringify({ error: errorCode, message }) }],
|
|
1027
|
+
isError: true,
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
});
|
|
1031
|
+
// Tool: get_recommendations
|
|
1032
|
+
server.registerTool("get_recommendations", {
|
|
1033
|
+
title: "Get page recommendations",
|
|
1034
|
+
description: "Get AI-powered recommendations for optimizing crown profile, heartbeat, and listing readiness.",
|
|
1035
|
+
inputSchema: {
|
|
1036
|
+
crown_id: z.number().describe("Crown token ID"),
|
|
1037
|
+
focus: z.enum(["heartbeat", "listing", "engagement", "all"]).optional().describe("Optional: Focus area for recommendations"),
|
|
1038
|
+
},
|
|
1039
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
1040
|
+
}, async ({ crown_id, focus }) => {
|
|
1041
|
+
if (!RECOMMENDER_URL) {
|
|
1042
|
+
return {
|
|
1043
|
+
content: [{
|
|
1044
|
+
type: "text",
|
|
1045
|
+
text: "Recommendations are unavailable: they come from a recommender service, and the Chain Daddy API does not host one. Set CHAINDADDY_RECOMMENDER_URL to a recommender you run to use this tool.",
|
|
1046
|
+
}],
|
|
1047
|
+
isError: true,
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
try {
|
|
1051
|
+
const focusParam = focus ? `?focus=${focus}` : "";
|
|
1052
|
+
const endpoint = `/recommendations/${crown_id}${focusParam}`;
|
|
1053
|
+
const data = await apiCall(endpoint, { baseUrl: RECOMMENDER_URL });
|
|
1054
|
+
const lines = [
|
|
1055
|
+
`Recommendations: ${data.symbol} (#${data.crownId})`,
|
|
1056
|
+
"",
|
|
1057
|
+
];
|
|
1058
|
+
if (data.scores) {
|
|
1059
|
+
lines.push("Current Scores:");
|
|
1060
|
+
lines.push(` Overall: ${data.scores.overall}%`);
|
|
1061
|
+
lines.push(` Heartbeat: ${data.scores.heartbeat}%`);
|
|
1062
|
+
lines.push(` Listing: ${data.scores.listing}%`);
|
|
1063
|
+
lines.push(` Engagement: ${data.scores.engagement}%`);
|
|
1064
|
+
lines.push("");
|
|
1065
|
+
}
|
|
1066
|
+
if (data.recommendations.length === 0) {
|
|
1067
|
+
lines.push("No recommendations at this time. Your crown is in good health!");
|
|
1068
|
+
}
|
|
1069
|
+
else {
|
|
1070
|
+
lines.push("Action Items:");
|
|
1071
|
+
lines.push("");
|
|
1072
|
+
for (const rec of data.recommendations) {
|
|
1073
|
+
const priorityIcon = rec.priority === "high" ? "[!]" : rec.priority === "medium" ? "[*]" : "[-]";
|
|
1074
|
+
lines.push(`${priorityIcon} ${rec.title}`);
|
|
1075
|
+
lines.push(` Category: ${rec.category}`);
|
|
1076
|
+
lines.push(` ${rec.description}`);
|
|
1077
|
+
if (rec.impact) {
|
|
1078
|
+
lines.push(` Impact: ${rec.impact}`);
|
|
1079
|
+
}
|
|
1080
|
+
lines.push("");
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
return {
|
|
1084
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
catch (error) {
|
|
1088
|
+
return {
|
|
1089
|
+
content: [{ type: "text", text: `Recommendations failed: ${error.message}` }],
|
|
1090
|
+
isError: true,
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
// Tool: update_profile
|
|
1095
|
+
//
|
|
1096
|
+
// Backend route: PUT /api/v2/registration/{id}/profile (crown_profile.go).
|
|
1097
|
+
// JWT-gated; the session JWT must belong to the crown owner or a manager.
|
|
1098
|
+
// `category` is intentionally NOT forwarded — the profile endpoint does
|
|
1099
|
+
// not expose it. Tools that need to set category should target the
|
|
1100
|
+
// extensions/metadata blob via a future tool once a backend route lands.
|
|
1101
|
+
//
|
|
1102
|
+
// Field mapping: socials.twitter → xdotcom (the X.com handle field in
|
|
1103
|
+
// crown_display); other socials pass through; logo_url → logoUrl. Pointer
|
|
1104
|
+
// semantics in the Go struct mean an omitted field keeps its existing
|
|
1105
|
+
// value, so we only include keys the caller actually passed.
|
|
1106
|
+
server.registerTool("update_profile", {
|
|
1107
|
+
title: "Update token page profile",
|
|
1108
|
+
description: "Update crown profile metadata via PUT /api/v2/registration/{id}/profile. Requires a session bound to a backend JWT (run authenticate first) and the session wallet must own or manage the crown.\n\nParams: crown_id (string), description (string?), tags (string[]?), category ('meme'|'gaming'|'defi'|'nft'|'social'|'utility'?) [NOT FORWARDED — no backend route exposes category on the profile endpoint], website (string?), socials ({twitter?, telegram?, discord?}?), logo_url (string?), session_token (string)\nReturns: {updated_fields: string[]}\nErrors: NO_SESSION, SESSION_EXPIRED, NO_JWT, NOT_OWNER, NO_FIELDS, API_ERROR",
|
|
1109
|
+
inputSchema: {
|
|
1110
|
+
crown_id: z.string().describe("Crown ID (token ID)"),
|
|
1111
|
+
description: z.string().optional().describe("Token/project description"),
|
|
1112
|
+
tags: z.array(z.string()).optional().describe("Descriptive tags (e.g., ['meme', 'dog', 'community'])"),
|
|
1113
|
+
category: z.enum(["meme", "gaming", "defi", "nft", "social", "utility"]).optional().describe("Project category. ACCEPTED but NOT forwarded — the profile endpoint does not expose this field today; the input is preserved here for API stability."),
|
|
1114
|
+
website: z.string().optional().describe("Project website URL"),
|
|
1115
|
+
socials: z.object({
|
|
1116
|
+
twitter: z.string().optional(),
|
|
1117
|
+
telegram: z.string().optional(),
|
|
1118
|
+
discord: z.string().optional(),
|
|
1119
|
+
}).optional().describe("Social links: {twitter, telegram, discord}. twitter maps to the backend `xdotcom` field."),
|
|
1120
|
+
logo_url: z.string().optional().describe("Logo image URL"),
|
|
1121
|
+
session_token: z.string().describe("Session token from authenticate (must have been issued post-JWT-bridge, i.e. after fix/mcp-siwe-jwt-bridge)"),
|
|
1122
|
+
},
|
|
1123
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
1124
|
+
}, async ({ crown_id, description, tags, category, website, socials, logo_url, session_token }) => {
|
|
1125
|
+
void category;
|
|
1126
|
+
try {
|
|
1127
|
+
const { jwt } = requireSessionJWT(session_token);
|
|
1128
|
+
// Map MCP-friendly inputs to the backend's updateCrownProfileRequest
|
|
1129
|
+
// shape. Only include keys the caller actually passed so the backend's
|
|
1130
|
+
// "nil → keep" pointer semantics are preserved.
|
|
1131
|
+
const payload = {};
|
|
1132
|
+
if (description !== undefined)
|
|
1133
|
+
payload.description = description;
|
|
1134
|
+
if (tags !== undefined)
|
|
1135
|
+
payload.tags = tags;
|
|
1136
|
+
if (website !== undefined)
|
|
1137
|
+
payload.website = website;
|
|
1138
|
+
if (logo_url !== undefined)
|
|
1139
|
+
payload.logoUrl = logo_url;
|
|
1140
|
+
if (socials !== undefined) {
|
|
1141
|
+
if (socials.twitter !== undefined)
|
|
1142
|
+
payload.xdotcom = socials.twitter;
|
|
1143
|
+
if (socials.telegram !== undefined)
|
|
1144
|
+
payload.telegram = socials.telegram;
|
|
1145
|
+
if (socials.discord !== undefined)
|
|
1146
|
+
payload.discord = socials.discord;
|
|
1147
|
+
}
|
|
1148
|
+
if (Object.keys(payload).length === 0) {
|
|
1149
|
+
return {
|
|
1150
|
+
content: [{ type: "text", text: JSON.stringify({ error: "NO_FIELDS", message: "No fields provided to update" }) }],
|
|
1151
|
+
isError: true,
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
await apiPut(`/api/v2/registration/${encodeURIComponent(crown_id)}/profile`, payload, { jwt });
|
|
1155
|
+
return {
|
|
1156
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
1157
|
+
updated_fields: Object.keys(payload),
|
|
1158
|
+
}) }],
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
catch (error) {
|
|
1162
|
+
const message = error.message;
|
|
1163
|
+
const errorCode = message.startsWith("NO_SESSION") ? "NO_SESSION"
|
|
1164
|
+
: message.startsWith("SESSION_EXPIRED") ? "SESSION_EXPIRED"
|
|
1165
|
+
: message.startsWith("NO_JWT") ? "NO_JWT"
|
|
1166
|
+
: message.includes("403") ? "NOT_OWNER"
|
|
1167
|
+
: "API_ERROR";
|
|
1168
|
+
return {
|
|
1169
|
+
content: [{ type: "text", text: JSON.stringify({ error: errorCode, message }) }],
|
|
1170
|
+
isError: true,
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
});
|
|
1174
|
+
// Tool: create_order
|
|
1175
|
+
server.registerTool("create_order", {
|
|
1176
|
+
title: "Create an order",
|
|
1177
|
+
description: "Create a pending order with one or more actions. Returns order details with pricing and gas estimates. Order expires after 5 minutes.\n\nParams: actions (array of {type, params}), session_token (string)\nReturns: {order_id, summary, total_cost, gas_estimates, expires_at}\nErrors: NO_SESSION, SESSION_EXPIRED, INVALID_ACTIONS, VALIDATION_ERROR",
|
|
1178
|
+
inputSchema: {
|
|
1179
|
+
actions: z.array(z.object({
|
|
1180
|
+
type: z.string().describe("Action type (e.g., 'deploy_token_evm', 'deploy_token_solana', 'claim_crown')"),
|
|
1181
|
+
params: z.record(z.string(), z.unknown()).describe("Action-specific parameters"),
|
|
1182
|
+
})).min(1).describe("Array of order actions to execute"),
|
|
1183
|
+
session_token: z.string().describe("Session token from authenticate"),
|
|
1184
|
+
},
|
|
1185
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
1186
|
+
}, async ({ actions, session_token }) => {
|
|
1187
|
+
try {
|
|
1188
|
+
const session = requireSession(session_token);
|
|
1189
|
+
const data = await apiPost("/api/v2/orders", {
|
|
1190
|
+
actions,
|
|
1191
|
+
wallet: session.wallet,
|
|
1192
|
+
});
|
|
1193
|
+
return {
|
|
1194
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
1195
|
+
order_id: data.order_id,
|
|
1196
|
+
summary: data.summary,
|
|
1197
|
+
total_cost: data.total_cost_usd,
|
|
1198
|
+
gas_estimates: data.gas_estimates,
|
|
1199
|
+
expires_at: data.expires_at,
|
|
1200
|
+
}) }],
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
catch (error) {
|
|
1204
|
+
const message = error.message;
|
|
1205
|
+
const errorCode = message.startsWith("NO_SESSION") ? "NO_SESSION"
|
|
1206
|
+
: message.startsWith("SESSION_EXPIRED") ? "SESSION_EXPIRED"
|
|
1207
|
+
: "ORDER_ERROR";
|
|
1208
|
+
return {
|
|
1209
|
+
content: [{ type: "text", text: JSON.stringify({ error: errorCode, message }) }],
|
|
1210
|
+
isError: true,
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
// Tool: confirm_order
|
|
1215
|
+
server.registerTool("confirm_order", {
|
|
1216
|
+
title: "Confirm and execute an order",
|
|
1217
|
+
description: "Confirm and execute a pending order. Triggers all actions in the order. Cannot be undone.\n\nParams: order_id (string), session_token (string)\nReturns: {results: [{action, status, tx_hash?, error?}]}\nErrors: NO_SESSION, SESSION_EXPIRED, ORDER_NOT_FOUND, ORDER_EXPIRED, ORDER_ALREADY_CONFIRMED",
|
|
1218
|
+
inputSchema: {
|
|
1219
|
+
order_id: z.string().describe("Order ID from create_order"),
|
|
1220
|
+
session_token: z.string().describe("Session token from authenticate"),
|
|
1221
|
+
},
|
|
1222
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
1223
|
+
}, async ({ order_id, session_token }) => {
|
|
1224
|
+
try {
|
|
1225
|
+
requireSession(session_token);
|
|
1226
|
+
const data = await apiPost(`/api/v2/orders/${order_id}/confirm`, {});
|
|
1227
|
+
return {
|
|
1228
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
1229
|
+
results: data.results,
|
|
1230
|
+
}) }],
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
catch (error) {
|
|
1234
|
+
const message = error.message;
|
|
1235
|
+
const errorCode = message.startsWith("NO_SESSION") ? "NO_SESSION"
|
|
1236
|
+
: message.startsWith("SESSION_EXPIRED") ? "SESSION_EXPIRED"
|
|
1237
|
+
: message.includes("410") ? "ORDER_EXPIRED"
|
|
1238
|
+
: message.includes("409") ? "ORDER_ALREADY_CONFIRMED"
|
|
1239
|
+
: message.includes("404") ? "ORDER_NOT_FOUND"
|
|
1240
|
+
: "CONFIRM_ERROR";
|
|
1241
|
+
return {
|
|
1242
|
+
content: [{ type: "text", text: JSON.stringify({ error: errorCode, message }) }],
|
|
1243
|
+
isError: true,
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
});
|
|
1247
|
+
// Tool: cancel_order
|
|
1248
|
+
server.registerTool("cancel_order", {
|
|
1249
|
+
title: "Cancel an order",
|
|
1250
|
+
description: "Cancel a pending order. Only works on orders that have not been confirmed.\n\nParams: order_id (string), session_token (string)\nReturns: {cancelled: true}\nErrors: NO_SESSION, SESSION_EXPIRED, ORDER_NOT_FOUND, ORDER_EXPIRED, ORDER_ALREADY_CONFIRMED",
|
|
1251
|
+
inputSchema: {
|
|
1252
|
+
order_id: z.string().describe("Order ID from create_order"),
|
|
1253
|
+
session_token: z.string().describe("Session token from authenticate"),
|
|
1254
|
+
},
|
|
1255
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
1256
|
+
}, async ({ order_id, session_token }) => {
|
|
1257
|
+
try {
|
|
1258
|
+
requireSession(session_token);
|
|
1259
|
+
await apiDelete(`/api/v2/orders/${order_id}`);
|
|
1260
|
+
return {
|
|
1261
|
+
content: [{ type: "text", text: JSON.stringify({ cancelled: true }) }],
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
catch (error) {
|
|
1265
|
+
const message = error.message;
|
|
1266
|
+
const errorCode = message.startsWith("NO_SESSION") ? "NO_SESSION"
|
|
1267
|
+
: message.startsWith("SESSION_EXPIRED") ? "SESSION_EXPIRED"
|
|
1268
|
+
: message.includes("410") ? "ORDER_EXPIRED"
|
|
1269
|
+
: message.includes("409") ? "ORDER_ALREADY_CONFIRMED"
|
|
1270
|
+
: message.includes("404") ? "ORDER_NOT_FOUND"
|
|
1271
|
+
: "CANCEL_ERROR";
|
|
1272
|
+
return {
|
|
1273
|
+
content: [{ type: "text", text: JSON.stringify({ error: errorCode, message }) }],
|
|
1274
|
+
isError: true,
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
// Tool: deploy_token
|
|
1279
|
+
server.registerTool("deploy_token", {
|
|
1280
|
+
title: "Build a token creation action",
|
|
1281
|
+
description: "Deploy a single token on a single chain. Validates parameters and returns an order action spec for inclusion in create_order.\n\nParams: name (string), symbol (string), chain (string), supply (number), decimals (number), mintable (boolean), burnable (boolean), session_token (string)\nReturns: {order_action_spec: {type, params}}\nErrors: NO_SESSION, SESSION_EXPIRED, VALIDATION_ERROR, RATE_LIMITED",
|
|
1282
|
+
inputSchema: {
|
|
1283
|
+
name: z.string().describe("Token display name"),
|
|
1284
|
+
symbol: z.string().describe("Token symbol (1-10 uppercase alphanumeric)"),
|
|
1285
|
+
chain: z.string().describe("Target chain (e.g., 'arb', 'sol', 'base', 'polygon', 'bsc', 'eth')"),
|
|
1286
|
+
supply: z.number().describe("Total token supply"),
|
|
1287
|
+
decimals: z.number().describe("Token decimals (0-18)"),
|
|
1288
|
+
mintable: z.boolean().describe("Whether additional tokens can be minted"),
|
|
1289
|
+
burnable: z.boolean().describe("Whether tokens can be burned"),
|
|
1290
|
+
session_token: z.string().describe("Session token from authenticate"),
|
|
1291
|
+
},
|
|
1292
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
1293
|
+
}, async ({ name, symbol, chain, supply, decimals, mintable, burnable, session_token }) => {
|
|
1294
|
+
try {
|
|
1295
|
+
const session = requireSession(session_token);
|
|
1296
|
+
const data = await apiPost("/api/v2/tokens/deploy", {
|
|
1297
|
+
name,
|
|
1298
|
+
symbol,
|
|
1299
|
+
chain,
|
|
1300
|
+
supply,
|
|
1301
|
+
decimals,
|
|
1302
|
+
mintable,
|
|
1303
|
+
burnable,
|
|
1304
|
+
wallet_address: session.wallet,
|
|
1305
|
+
});
|
|
1306
|
+
if (!data.valid || !data.action) {
|
|
1307
|
+
return {
|
|
1308
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
1309
|
+
error: "VALIDATION_ERROR",
|
|
1310
|
+
message: data.errors?.join(", ") || "Token deployment validation failed",
|
|
1311
|
+
}) }],
|
|
1312
|
+
isError: true,
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
return {
|
|
1316
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
1317
|
+
order_action_spec: data.action,
|
|
1318
|
+
}) }],
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
catch (error) {
|
|
1322
|
+
const message = error.message;
|
|
1323
|
+
const errorCode = message.startsWith("NO_SESSION") ? "NO_SESSION"
|
|
1324
|
+
: message.startsWith("SESSION_EXPIRED") ? "SESSION_EXPIRED"
|
|
1325
|
+
: message.includes("429") ? "RATE_LIMITED"
|
|
1326
|
+
: "DEPLOY_ERROR";
|
|
1327
|
+
return {
|
|
1328
|
+
content: [{ type: "text", text: JSON.stringify({ error: errorCode, message }) }],
|
|
1329
|
+
isError: true,
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
});
|
|
1333
|
+
// Tool: list_launch_venues
|
|
1334
|
+
//
|
|
1335
|
+
// Every contract a token can be launched through, with what a trade pays and
|
|
1336
|
+
// where it goes, per $100 traded. Rows with freeCrown=true launch through Chain
|
|
1337
|
+
// Daddy and come with a free crown; the others are comparison only. Present BOTH
|
|
1338
|
+
// numbers for every venue (trader fee and creator share) — ranking on the
|
|
1339
|
+
// creator's share alone favours the launchpad that charges traders most — and do
|
|
1340
|
+
// not call any venue "best" or "recommended".
|
|
1341
|
+
server.registerTool("list_launch_venues", {
|
|
1342
|
+
title: "List launch venues",
|
|
1343
|
+
description: "List the contracts a token can be launched through (ours and third parties'), ranked by what reaches the creator per $100 traded. Name each one by chains[].contract where present, else by the venue's name — a creator picks a contract, not a product tier. Every economics field is basis points of trade volume (100 = $1 per $100 traded). Show both traderFeeBps and creatorBps for each venue. Venues with freeCrown=true launch through Chain Daddy with a free crown; others are comparison-only.\n\nParams: chain_id (number?) — narrow to one chain (EVM chain id; Solana mainnet 900900), session_token (string?) — a signed-in session sees its own plan's rates\nReturns: {venues: [...], sort, generatedAt}\nErrors: API_ERROR",
|
|
1344
|
+
inputSchema: {
|
|
1345
|
+
chain_id: z.number().optional().describe("Only venues that launch on this chain"),
|
|
1346
|
+
session_token: z.string().optional().describe("Optional session token from authenticate"),
|
|
1347
|
+
},
|
|
1348
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
1349
|
+
}, async ({ chain_id, session_token }) => {
|
|
1350
|
+
try {
|
|
1351
|
+
const jwt = session_token ? sessions.get(session_token)?.jwt : undefined;
|
|
1352
|
+
const q = chain_id ? `?chainId=${chain_id}` : "";
|
|
1353
|
+
const data = await apiCall(`/api/v2/launch/venues${q}`, { jwt });
|
|
1354
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
1355
|
+
}
|
|
1356
|
+
catch (error) {
|
|
1357
|
+
return {
|
|
1358
|
+
content: [{ type: "text", text: JSON.stringify({ error: "API_ERROR", message: error.message }) }],
|
|
1359
|
+
isError: true,
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
});
|
|
1363
|
+
// Tool: build_venue_launch
|
|
1364
|
+
server.registerTool("build_venue_launch", {
|
|
1365
|
+
title: "Build a third-party launch",
|
|
1366
|
+
description: "Build a token launch on a third-party contract (e.g. 'clanker') through Chain Daddy. Returns an unsigned transaction: the caller's OWN wallet — the session wallet — must sign and send transactions[0] on its chainId, then call submit_venue_launch with the hash. Chain Daddy never sends it. Once proven, the token's crown is free.\n\nParams: venue (string), chain_id (number), name (string), symbol (string), image (string?) — ipfs:// or https:// URL, description (string?), session_token (string)\nReturns: {intent: {id, status, ...}, transactions: [{chainId, to, data, value}]}\nErrors: NO_SESSION, SESSION_EXPIRED, NO_JWT, VENUE_UNAVAILABLE, API_ERROR",
|
|
1367
|
+
inputSchema: {
|
|
1368
|
+
venue: z.string().describe("Venue id from list_launch_venues with freeCrown=true (not chaindaddy-launchpad or chaindaddy-classic)"),
|
|
1369
|
+
chain_id: z.number().describe("Chain to launch on — one the venue lists as launchable"),
|
|
1370
|
+
name: z.string().describe("Token name"),
|
|
1371
|
+
symbol: z.string().describe("Ticker"),
|
|
1372
|
+
image: z.string().optional().describe("Token image URL"),
|
|
1373
|
+
description: z.string().optional().describe("Token description"),
|
|
1374
|
+
session_token: z.string().describe("Session token from authenticate"),
|
|
1375
|
+
},
|
|
1376
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
1377
|
+
}, async ({ venue, chain_id, name, symbol, image, description, session_token }) => {
|
|
1378
|
+
try {
|
|
1379
|
+
const { session, jwt } = requireSessionJWT(session_token);
|
|
1380
|
+
const data = await apiPost(`/api/v2/launch/venues/${encodeURIComponent(venue)}/build`, {
|
|
1381
|
+
chainId: chain_id,
|
|
1382
|
+
wallet: session.wallet,
|
|
1383
|
+
name,
|
|
1384
|
+
symbol,
|
|
1385
|
+
...(image ? { image } : {}),
|
|
1386
|
+
...(description ? { description } : {}),
|
|
1387
|
+
}, {}, { jwt });
|
|
1388
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
1389
|
+
}
|
|
1390
|
+
catch (error) {
|
|
1391
|
+
const message = error.message;
|
|
1392
|
+
const code = message.startsWith("NO_SESSION") ? "NO_SESSION"
|
|
1393
|
+
: message.startsWith("SESSION_EXPIRED") ? "SESSION_EXPIRED"
|
|
1394
|
+
: message.startsWith("NO_JWT") ? "NO_JWT"
|
|
1395
|
+
: /not available|does not launch/.test(message) ? "VENUE_UNAVAILABLE"
|
|
1396
|
+
: "API_ERROR";
|
|
1397
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: code, message }) }], isError: true };
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
// Tool: submit_venue_launch
|
|
1401
|
+
server.registerTool("submit_venue_launch", {
|
|
1402
|
+
title: "Report a launch transaction",
|
|
1403
|
+
description: "Report the transaction hash of a launch built by build_venue_launch, after the session wallet sent it. The server proves the launch from the launchpad's own on-chain record; poll get_venue_launch until status is 'confirmed' (freeCrown=true, tokenAddress set) or 'failed' (failureReason says why).\n\nParams: intent_id (string), tx_hash (string), session_token (string)\nReturns: {id, status, tokenAddress?, freeCrown, failureReason?}\nErrors: NO_SESSION, SESSION_EXPIRED, NO_JWT, API_ERROR",
|
|
1404
|
+
inputSchema: {
|
|
1405
|
+
intent_id: z.string().describe("intent.id from build_venue_launch"),
|
|
1406
|
+
tx_hash: z.string().describe("Hash of the sent launch transaction"),
|
|
1407
|
+
session_token: z.string().describe("Session token from authenticate"),
|
|
1408
|
+
},
|
|
1409
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1410
|
+
}, async ({ intent_id, tx_hash, session_token }) => {
|
|
1411
|
+
try {
|
|
1412
|
+
const { jwt } = requireSessionJWT(session_token);
|
|
1413
|
+
const data = await apiPost(`/api/v2/launch/intents/${encodeURIComponent(intent_id)}/submit`, { txHash: tx_hash }, {}, { jwt });
|
|
1414
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
1415
|
+
}
|
|
1416
|
+
catch (error) {
|
|
1417
|
+
const message = error.message;
|
|
1418
|
+
const code = message.startsWith("NO_SESSION") ? "NO_SESSION"
|
|
1419
|
+
: message.startsWith("SESSION_EXPIRED") ? "SESSION_EXPIRED"
|
|
1420
|
+
: message.startsWith("NO_JWT") ? "NO_JWT"
|
|
1421
|
+
: "API_ERROR";
|
|
1422
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: code, message }) }], isError: true };
|
|
1423
|
+
}
|
|
1424
|
+
});
|
|
1425
|
+
// Tool: get_venue_launch
|
|
1426
|
+
server.registerTool("get_venue_launch", {
|
|
1427
|
+
title: "Get a third-party launch",
|
|
1428
|
+
description: "Read a third-party launch's state. A submitted launch is re-proven on each read.\n\nParams: intent_id (string), session_token (string)\nReturns: {id, status, tokenAddress?, freeCrown, failureReason?}\nErrors: NO_SESSION, SESSION_EXPIRED, NO_JWT, API_ERROR",
|
|
1429
|
+
inputSchema: {
|
|
1430
|
+
intent_id: z.string().describe("intent.id from build_venue_launch"),
|
|
1431
|
+
session_token: z.string().describe("Session token from authenticate"),
|
|
1432
|
+
},
|
|
1433
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
1434
|
+
}, async ({ intent_id, session_token }) => {
|
|
1435
|
+
try {
|
|
1436
|
+
const { jwt } = requireSessionJWT(session_token);
|
|
1437
|
+
const data = await apiCall(`/api/v2/launch/intents/${encodeURIComponent(intent_id)}`, { jwt });
|
|
1438
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
1439
|
+
}
|
|
1440
|
+
catch (error) {
|
|
1441
|
+
const message = error.message;
|
|
1442
|
+
const code = message.startsWith("NO_SESSION") ? "NO_SESSION"
|
|
1443
|
+
: message.startsWith("SESSION_EXPIRED") ? "SESSION_EXPIRED"
|
|
1444
|
+
: message.startsWith("NO_JWT") ? "NO_JWT"
|
|
1445
|
+
: "API_ERROR";
|
|
1446
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: code, message }) }], isError: true };
|
|
1447
|
+
}
|
|
1448
|
+
});
|
|
1449
|
+
// Tool: claim_crown
|
|
1450
|
+
server.registerTool("claim_crown", {
|
|
1451
|
+
title: "Build a registration claim action",
|
|
1452
|
+
description: "Claim a crown for a specific token on a specific chain. Requires verified ownership. Returns an order action spec for inclusion in create_order.\n\nParams: symbol (string), chain (string), token_address (string), verification_method ('deployer'|'mint_authority'|'owner'|'majority_holder'), session_token (string)\nReturns: {order_action_spec: {type: 'claim_crown', params: {symbol, chain, token_address, verification_method, wallet}}}\nErrors: NO_SESSION, SESSION_EXPIRED, INVALID_CHAIN, INVALID_VERIFICATION_METHOD",
|
|
1453
|
+
inputSchema: {
|
|
1454
|
+
symbol: z.string().describe("Token symbol to claim crown for (e.g., 'PEPE')"),
|
|
1455
|
+
chain: z.string().describe("Chain where the token exists (e.g., 'arb', 'sol', 'base', 'polygon', 'bsc', 'eth')"),
|
|
1456
|
+
token_address: z.string().describe("Token contract/mint address on the specified chain"),
|
|
1457
|
+
verification_method: z.enum(["deployer", "mint_authority", "owner", "majority_holder"]).describe("Method used to verify ownership: 'deployer', 'mint_authority', 'owner', or 'majority_holder'"),
|
|
1458
|
+
session_token: z.string().describe("Session token from authenticate"),
|
|
1459
|
+
},
|
|
1460
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
1461
|
+
}, async ({ symbol, chain, token_address, verification_method, session_token }) => {
|
|
1462
|
+
try {
|
|
1463
|
+
const session = requireSession(session_token);
|
|
1464
|
+
// Validate chain is supported
|
|
1465
|
+
if (!(chain in SUPPORTED_CHAINS)) {
|
|
1466
|
+
return {
|
|
1467
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
1468
|
+
error: "INVALID_CHAIN",
|
|
1469
|
+
message: `Unsupported chain: ${chain}. Supported: ${Object.keys(SUPPORTED_CHAINS).join(", ")}`,
|
|
1470
|
+
}) }],
|
|
1471
|
+
isError: true,
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
// Return order action spec for inclusion in create_order
|
|
1475
|
+
return {
|
|
1476
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
1477
|
+
order_action_spec: {
|
|
1478
|
+
type: "claim_crown",
|
|
1479
|
+
params: {
|
|
1480
|
+
symbol,
|
|
1481
|
+
chain,
|
|
1482
|
+
token_address,
|
|
1483
|
+
verification_method,
|
|
1484
|
+
wallet: session.wallet,
|
|
1485
|
+
},
|
|
1486
|
+
},
|
|
1487
|
+
}) }],
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
catch (error) {
|
|
1491
|
+
const message = error.message;
|
|
1492
|
+
const errorCode = message.startsWith("NO_SESSION") ? "NO_SESSION"
|
|
1493
|
+
: message.startsWith("SESSION_EXPIRED") ? "SESSION_EXPIRED"
|
|
1494
|
+
: "CLAIM_ERROR";
|
|
1495
|
+
return {
|
|
1496
|
+
content: [{ type: "text", text: JSON.stringify({ error: errorCode, message }) }],
|
|
1497
|
+
isError: true,
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
// Tool: generate_listing
|
|
1502
|
+
server.registerTool("generate_listing", {
|
|
1503
|
+
title: "Generate an aggregator listing",
|
|
1504
|
+
description: "Generate a listing for one aggregator format using crown data.\n\nParams: crown_id (string), format ('coingecko'|'coinmarketcap'|'dexscreener'|'tokenlist')\nReturns: {listing_data: object, missing_fields?: string[]}\nErrors: CROWN_NOT_FOUND, INVALID_FORMAT, API_ERROR",
|
|
1505
|
+
inputSchema: {
|
|
1506
|
+
crown_id: z.string().describe("Crown ID to generate listing for"),
|
|
1507
|
+
format: z.enum(["coingecko", "coinmarketcap", "dexscreener", "tokenlist"]).describe("Listing format: 'coingecko', 'coinmarketcap', 'dexscreener', or 'tokenlist'"),
|
|
1508
|
+
},
|
|
1509
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
1510
|
+
}, async ({ crown_id, format }) => {
|
|
1511
|
+
try {
|
|
1512
|
+
// First get crown data to know symbol and chain
|
|
1513
|
+
const crownData = await apiCall(`/api/v2/registration/${encodeURIComponent(crown_id)}`);
|
|
1514
|
+
const symbol = crownData.symbol || "";
|
|
1515
|
+
const chain = crownData.chain || crownData.chainName || "";
|
|
1516
|
+
if (!symbol) {
|
|
1517
|
+
return {
|
|
1518
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
1519
|
+
error: "CROWN_NOT_FOUND",
|
|
1520
|
+
message: "Crown data missing symbol",
|
|
1521
|
+
}) }],
|
|
1522
|
+
isError: true,
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
let listingData;
|
|
1526
|
+
let missingFields = [];
|
|
1527
|
+
if (format === "tokenlist") {
|
|
1528
|
+
// Uniswap Token List format
|
|
1529
|
+
const params = new URLSearchParams();
|
|
1530
|
+
params.set("symbol", symbol);
|
|
1531
|
+
listingData = await apiCall(`/api/v2/tokenlist?${params.toString()}`);
|
|
1532
|
+
// Check for missing fields in token list
|
|
1533
|
+
const tokenList = listingData;
|
|
1534
|
+
if (!tokenList.tokens || tokenList.tokens.length === 0) {
|
|
1535
|
+
missingFields.push("tokens (no tokens in list)");
|
|
1536
|
+
}
|
|
1537
|
+
else {
|
|
1538
|
+
const firstToken = tokenList.tokens[0];
|
|
1539
|
+
if (!firstToken.address)
|
|
1540
|
+
missingFields.push("address");
|
|
1541
|
+
if (!firstToken.name)
|
|
1542
|
+
missingFields.push("name");
|
|
1543
|
+
if (!firstToken.logoURI)
|
|
1544
|
+
missingFields.push("logoURI");
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
else if (format === "coinmarketcap") {
|
|
1548
|
+
const encodedSymbol = encodeURIComponent(symbol);
|
|
1549
|
+
const encodedChain = encodeURIComponent(chain || "arb");
|
|
1550
|
+
listingData = await apiCall(`/api/v2/tokenlist/cmc/${encodedSymbol}/${encodedChain}`);
|
|
1551
|
+
// Check required CoinMarketCap fields
|
|
1552
|
+
const cmc = listingData;
|
|
1553
|
+
if (!cmc.description)
|
|
1554
|
+
missingFields.push("description");
|
|
1555
|
+
if (!cmc.logo)
|
|
1556
|
+
missingFields.push("logo");
|
|
1557
|
+
if (!cmc.urls?.website?.length)
|
|
1558
|
+
missingFields.push("website");
|
|
1559
|
+
if (!cmc.urls?.twitter?.length)
|
|
1560
|
+
missingFields.push("twitter");
|
|
1561
|
+
if (!cmc.urls?.explorer?.length)
|
|
1562
|
+
missingFields.push("explorer");
|
|
1563
|
+
if (!cmc.platform?.contractAddress)
|
|
1564
|
+
missingFields.push("contractAddress");
|
|
1565
|
+
}
|
|
1566
|
+
else if (format === "coingecko") {
|
|
1567
|
+
const encodedSymbol = encodeURIComponent(symbol);
|
|
1568
|
+
const encodedChain = encodeURIComponent(chain || "arb");
|
|
1569
|
+
listingData = await apiCall(`/api/v2/tokenlist/gecko/${encodedSymbol}/${encodedChain}`);
|
|
1570
|
+
// Check required CoinGecko fields
|
|
1571
|
+
const gecko = listingData;
|
|
1572
|
+
if (!gecko.id)
|
|
1573
|
+
missingFields.push("id");
|
|
1574
|
+
if (!gecko.name || gecko.name === symbol + " Token")
|
|
1575
|
+
missingFields.push("name (using default)");
|
|
1576
|
+
if (!gecko.platforms || Object.keys(gecko.platforms).length === 0)
|
|
1577
|
+
missingFields.push("platforms");
|
|
1578
|
+
if (!gecko.links?.homepage?.length)
|
|
1579
|
+
missingFields.push("homepage");
|
|
1580
|
+
if (!gecko.links?.twitter_screen_name)
|
|
1581
|
+
missingFields.push("twitter_screen_name");
|
|
1582
|
+
}
|
|
1583
|
+
else {
|
|
1584
|
+
// dexscreener
|
|
1585
|
+
const encodedSymbol = encodeURIComponent(symbol);
|
|
1586
|
+
const encodedChain = encodeURIComponent(chain || "arb");
|
|
1587
|
+
listingData = await apiCall(`/api/v2/tokenlist/dexscreener/${encodedSymbol}/${encodedChain}`);
|
|
1588
|
+
// Check required DEX Screener fields
|
|
1589
|
+
const dex = listingData;
|
|
1590
|
+
if (!dex.tokenAddress)
|
|
1591
|
+
missingFields.push("tokenAddress");
|
|
1592
|
+
if (!dex.icon)
|
|
1593
|
+
missingFields.push("icon");
|
|
1594
|
+
if (!dex.description)
|
|
1595
|
+
missingFields.push("description");
|
|
1596
|
+
if (!dex.links || dex.links.length === 0)
|
|
1597
|
+
missingFields.push("links");
|
|
1598
|
+
}
|
|
1599
|
+
const result = {
|
|
1600
|
+
listing_data: listingData,
|
|
1601
|
+
};
|
|
1602
|
+
if (missingFields.length > 0) {
|
|
1603
|
+
result.missing_fields = missingFields;
|
|
1604
|
+
}
|
|
1605
|
+
return {
|
|
1606
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
catch (error) {
|
|
1610
|
+
const message = error.message;
|
|
1611
|
+
const errorCode = message.includes("404") ? "CROWN_NOT_FOUND" : "API_ERROR";
|
|
1612
|
+
return {
|
|
1613
|
+
content: [{ type: "text", text: JSON.stringify({ error: errorCode, message }) }],
|
|
1614
|
+
isError: true,
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
});
|
|
1618
|
+
// Tool: find_inactive
|
|
1619
|
+
server.registerTool("find_inactive", {
|
|
1620
|
+
title: "Find inactive registrations",
|
|
1621
|
+
description: "Find inactive crowns that are eligible for reclaim. All params optional.\n\nParams: symbol (string?), chain (string?), min_market_cap (number?)\nReturns: {inactive_crowns: [{symbol, chain, owner_address, last_activity, heartbeat_score, reclaim_cost}]}\nErrors: API_ERROR",
|
|
1622
|
+
inputSchema: {
|
|
1623
|
+
symbol: z.string().optional().describe("Filter by symbol (e.g., 'DOGE')"),
|
|
1624
|
+
chain: z.string().optional().describe("Filter by chain (e.g., 'arb', 'sol', 'base')"),
|
|
1625
|
+
min_market_cap: z.number().optional().describe("Minimum market cap filter in USD"),
|
|
1626
|
+
},
|
|
1627
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
1628
|
+
}, async ({ symbol, chain, min_market_cap }) => {
|
|
1629
|
+
try {
|
|
1630
|
+
const params = new URLSearchParams();
|
|
1631
|
+
if (symbol)
|
|
1632
|
+
params.set("symbol", symbol);
|
|
1633
|
+
if (chain)
|
|
1634
|
+
params.set("chain", chain);
|
|
1635
|
+
if (min_market_cap !== undefined)
|
|
1636
|
+
params.set("min_market_cap", min_market_cap.toString());
|
|
1637
|
+
const queryString = params.toString();
|
|
1638
|
+
const endpoint = `/api/v2/registration/inactive${queryString ? `?${queryString}` : ""}`;
|
|
1639
|
+
const data = await apiCall(endpoint);
|
|
1640
|
+
return {
|
|
1641
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
1642
|
+
inactive_crowns: data.crowns || [],
|
|
1643
|
+
}) }],
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
catch (error) {
|
|
1647
|
+
return {
|
|
1648
|
+
content: [{ type: "text", text: JSON.stringify({ error: "API_ERROR", message: error.message }) }],
|
|
1649
|
+
isError: true,
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
});
|
|
1653
|
+
// Tool: search_symbols
|
|
1654
|
+
server.registerTool("search_symbols", {
|
|
1655
|
+
title: "Search tickers with filters",
|
|
1656
|
+
description: "Search for symbols across all supported chains. Returns availability, market data, and crown status.\n\nParams: query (string), chain (string?), status (string?)\nReturns: {results: [{symbol, chain, chain_name, status, token_address, market_cap, volume_24h, liquidity, crown_owner}]}\nErrors: API_ERROR",
|
|
1657
|
+
inputSchema: {
|
|
1658
|
+
query: z.string().describe("Search query (symbol or partial match, e.g., 'DOGE', 'PEP')"),
|
|
1659
|
+
chain: z.string().optional().describe("Filter by chain (e.g., 'arb', 'sol', 'base')"),
|
|
1660
|
+
status: z.string().optional().describe("Filter by status (e.g., 'available', 'unclaimed', 'claimed', 'inactive')"),
|
|
1661
|
+
},
|
|
1662
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
1663
|
+
}, async ({ query, chain, status }) => {
|
|
1664
|
+
try {
|
|
1665
|
+
const params = new URLSearchParams();
|
|
1666
|
+
params.set("symbol", query);
|
|
1667
|
+
if (chain)
|
|
1668
|
+
params.set("chain", chain);
|
|
1669
|
+
if (status)
|
|
1670
|
+
params.set("status", status);
|
|
1671
|
+
const endpoint = `/api/v2/search?${params.toString()}`;
|
|
1672
|
+
const data = await apiCall(endpoint);
|
|
1673
|
+
const results = (data.results || []).map(r => ({
|
|
1674
|
+
symbol: r.token?.symbol || query.toUpperCase(),
|
|
1675
|
+
chain: r.chainKey || "",
|
|
1676
|
+
chain_name: r.chainName || "",
|
|
1677
|
+
status: r.crown?.status || "available",
|
|
1678
|
+
token_address: r.token?.address || null,
|
|
1679
|
+
market_cap: r.token?.marketCap ?? null,
|
|
1680
|
+
volume_24h: r.token?.volume24h ?? null,
|
|
1681
|
+
liquidity: r.token?.liquidity ?? null,
|
|
1682
|
+
crown_owner: r.crown?.owner || null,
|
|
1683
|
+
}));
|
|
1684
|
+
return {
|
|
1685
|
+
content: [{ type: "text", text: JSON.stringify({ results }) }],
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
catch (error) {
|
|
1689
|
+
return {
|
|
1690
|
+
content: [{ type: "text", text: JSON.stringify({ error: "API_ERROR", message: error.message }) }],
|
|
1691
|
+
isError: true,
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
});
|
|
1695
|
+
// ============================================================================
|
|
1696
|
+
// CREATOR STORE (in-app items sold for the creator's own token, rewards, unlocks)
|
|
1697
|
+
// ============================================================================
|
|
1698
|
+
//
|
|
1699
|
+
// Every tool calls /api/v2/iap exactly as the website does. Creator routes need
|
|
1700
|
+
// a Developer plan (or Developer Beta program membership) on the wallet that
|
|
1701
|
+
// opened the store; otherwise they answer 403 IAP_PLAN_REQUIRED. Writes are
|
|
1702
|
+
// idempotent: each returns the idempotency_key it sent, and a retry with the
|
|
1703
|
+
// same key never grants or sells twice.
|
|
1704
|
+
const iapSessionToken = z
|
|
1705
|
+
.string()
|
|
1706
|
+
.optional()
|
|
1707
|
+
.describe("Session token from authenticate. Optional when CHAINDADDY_API_KEY is set (or, for store-scoped tools, CHAINDADDY_IAP_KEY)");
|
|
1708
|
+
const iapIdempotencyKey = z
|
|
1709
|
+
.string()
|
|
1710
|
+
.max(255)
|
|
1711
|
+
.optional()
|
|
1712
|
+
.describe("Idempotency-Key for this write. Omit to generate one; reuse the returned idempotency_key to retry safely");
|
|
1713
|
+
const iapStoreId = z.string().describe("Store id (from iap_open_store or iap_list_stores)");
|
|
1714
|
+
const iapRule = z
|
|
1715
|
+
.record(z.string(), z.unknown())
|
|
1716
|
+
.describe("Rule: {all:[…]}|{any:[…]}|{not:…}|{hold:{min, token?, chainId?}}|{owns:{sku, min?}}. `min` for hold is base units");
|
|
1717
|
+
const iapGrant = z.object({ sku: z.string(), quantity: z.number().int().positive() });
|
|
1718
|
+
const iapSkuFields = {
|
|
1719
|
+
type: z.enum(["consumable", "non_consumable", "timed", "unlock"]),
|
|
1720
|
+
name: z.string(),
|
|
1721
|
+
description: z.string(),
|
|
1722
|
+
imageUrl: z.string(),
|
|
1723
|
+
metadata: z.record(z.string(), z.unknown()).describe("Free-form JSON your app reads"),
|
|
1724
|
+
currency: z
|
|
1725
|
+
.enum(["token", "native", "usdc"])
|
|
1726
|
+
.describe("What the buyer pays in. 'token' (the default, and the recommended choice): the store's own token, so every sale is demand for it; pair it with priceUsdMicros for a steady dollar price. 'native': the chain's coin (ETH, BNB, POL, SOL…). 'usdc': the chain's dollar stable (USDC; USDG on Robinhood Chain; not on Gnosis). The buyer pays the creator directly in every case. price/minPrice/maxPrice are base units of this currency; an update that changes it must set price or priceUsdMicros in the same patch (the bounds clear). Unlocks take no currency"),
|
|
1727
|
+
price: z.string().nullable().describe("Price in base units of the SKU's currency (decimal string); null = not for sale"),
|
|
1728
|
+
priceUsdMicros: z
|
|
1729
|
+
.number()
|
|
1730
|
+
.int()
|
|
1731
|
+
.positive()
|
|
1732
|
+
.nullable()
|
|
1733
|
+
.describe("Price in US micro-dollars per unit instead of `price` ($5 = 5000000). Recommended with currency 'token'. Each order converts it to an exact amount of the SKU's currency and locks it: at the token's or coin's live price (the mean of the last 2 minutes of 15-second readings, rounded up to 4 significant figures), or exactly with currency 'usdc'. Set price OR priceUsdMicros, not both"),
|
|
1734
|
+
minPrice: z.string().nullable().describe("With priceUsdMicros in 'token' or 'native': never charge less than this per unit, in base units (decimal string). Refused with 'usdc', where the price is exact"),
|
|
1735
|
+
maxPrice: z.string().nullable().describe("With priceUsdMicros in 'token' or 'native': never charge more than this per unit, in base units (decimal string). Refused with 'usdc'"),
|
|
1736
|
+
grants: z.array(iapGrant).nullable().describe("Bundle: buying grants these items instead of itself"),
|
|
1737
|
+
splits: z
|
|
1738
|
+
.array(z.object({ address: z.string().describe("Wallet on the store's chain"), bps: z.number().int().min(1).max(9999) }))
|
|
1739
|
+
.max(3)
|
|
1740
|
+
.nullable()
|
|
1741
|
+
.describe("Revenue splits: each sale also pays these wallets bps (1/100 of a percent) of the creator's share, the price after any platform fee, straight from the buyer's one payment. At most 2 (3 on a launchpad token, which pays no fee: one payment carries 4 payees), together at most 9999 so the store payout keeps a part. Not the payout, the fee recipient or the checkout. null clears"),
|
|
1742
|
+
durationSeconds: z.number().int().positive().nullable().describe("timed SKUs: seconds added per unit"),
|
|
1743
|
+
rule: iapRule.nullable(),
|
|
1744
|
+
maxSupply: z.number().int().positive().nullable(),
|
|
1745
|
+
maxPerWallet: z.number().int().positive().nullable(),
|
|
1746
|
+
saleStartsAt: z.string().nullable(),
|
|
1747
|
+
saleEndsAt: z.string().nullable(),
|
|
1748
|
+
status: z.enum(["draft", "active", "archived"]),
|
|
1749
|
+
sortOrder: z.number().int(),
|
|
1750
|
+
};
|
|
1751
|
+
const iapSkuInput = z
|
|
1752
|
+
.object({
|
|
1753
|
+
sku: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,63}$/).describe("SKU key, unique per store"),
|
|
1754
|
+
...iapSkuFields,
|
|
1755
|
+
})
|
|
1756
|
+
.partial()
|
|
1757
|
+
.required({ sku: true, type: true, name: true });
|
|
1758
|
+
const iapSkuPatch = z.object(iapSkuFields).partial();
|
|
1759
|
+
const iapDropInput = z.object({
|
|
1760
|
+
name: z.string(),
|
|
1761
|
+
mode: z.enum(["claim", "push", "award"]).describe("claim = a claim link; push = delivered to a recipient list; award = your server awards it"),
|
|
1762
|
+
items: z.array(iapGrant).min(1),
|
|
1763
|
+
description: z.string().optional(),
|
|
1764
|
+
imageUrl: z.string().optional(),
|
|
1765
|
+
audience: iapRule.nullable().optional(),
|
|
1766
|
+
allowlistOnly: z.boolean().optional(),
|
|
1767
|
+
maxClaims: z.number().int().positive().nullable().optional(),
|
|
1768
|
+
perWalletLimit: z.number().int().positive().optional(),
|
|
1769
|
+
startsAt: z.string().nullable().optional(),
|
|
1770
|
+
endsAt: z.string().nullable().optional(),
|
|
1771
|
+
status: z.enum(["draft", "active", "paused", "ended"]).optional(),
|
|
1772
|
+
});
|
|
1773
|
+
const iapScopes = z.enum([
|
|
1774
|
+
"catalog:write",
|
|
1775
|
+
"orders:read",
|
|
1776
|
+
"orders:write",
|
|
1777
|
+
"entitlements:read",
|
|
1778
|
+
"entitlements:write",
|
|
1779
|
+
"drops:write",
|
|
1780
|
+
"rewards:award",
|
|
1781
|
+
"events:read",
|
|
1782
|
+
"messages:send",
|
|
1783
|
+
]);
|
|
1784
|
+
const iapWebhookEvents = z.enum([
|
|
1785
|
+
"iap.order.created",
|
|
1786
|
+
"iap.order.paid",
|
|
1787
|
+
"iap.order.confirmed",
|
|
1788
|
+
"iap.order.expired",
|
|
1789
|
+
"iap.order.reverted",
|
|
1790
|
+
"iap.order.refunded",
|
|
1791
|
+
"iap.order.mismatch",
|
|
1792
|
+
"iap.order.duplicate_payment",
|
|
1793
|
+
"iap.entitlement.granted",
|
|
1794
|
+
"iap.entitlement.consumed",
|
|
1795
|
+
"iap.entitlement.revoked",
|
|
1796
|
+
"iap.entitlement.expiring",
|
|
1797
|
+
"iap.entitlement.expired",
|
|
1798
|
+
"iap.drop.claimed",
|
|
1799
|
+
"iap.drop.awarded",
|
|
1800
|
+
"iap.drop.exhausted",
|
|
1801
|
+
"iap.sku.sold_out",
|
|
1802
|
+
"iap.store.not_live",
|
|
1803
|
+
"iap.store.live",
|
|
1804
|
+
]);
|
|
1805
|
+
const enc = encodeURIComponent;
|
|
1806
|
+
async function iapRead(path, auth, session_token) {
|
|
1807
|
+
try {
|
|
1808
|
+
const { data } = await iapRequest("GET", path, { auth, session_token });
|
|
1809
|
+
return iapOk(data);
|
|
1810
|
+
}
|
|
1811
|
+
catch (error) {
|
|
1812
|
+
return iapFail(error);
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
// A write. POSTs carry an Idempotency-Key, echoed back in the result (and in an
|
|
1816
|
+
// error) so the caller can retry with it. `noun` names the returned object; with
|
|
1817
|
+
// null the response's own fields are spread into the result.
|
|
1818
|
+
async function iapWrite(noun, method, path, body, auth, session_token, idempotency_key) {
|
|
1819
|
+
const key = method === "POST" ? idempotency_key || randomUUID() : undefined;
|
|
1820
|
+
try {
|
|
1821
|
+
const { data } = await iapRequest(method, path, { body, idempotencyKey: key, auth, session_token });
|
|
1822
|
+
const head = { ok: true };
|
|
1823
|
+
if (key)
|
|
1824
|
+
head.idempotency_key = key;
|
|
1825
|
+
return iapOk(noun ? { ...head, [noun]: data } : { ...head, ...(data ?? {}) });
|
|
1826
|
+
}
|
|
1827
|
+
catch (error) {
|
|
1828
|
+
return iapFail(error, key);
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
server.registerTool("iap_open_store", {
|
|
1832
|
+
title: "Open a creator store",
|
|
1833
|
+
description: "Open (or re-open) the creator store for a token page. Idempotent per token page. Needs a Developer plan or Developer Beta membership, and the manager role on the token page.\n\nParams: chain_key (string, e.g. 'base'), crown_id (number, the token page's registration id), session_token?, idempotency_key?\nReturns: {ok, idempotency_key, store: {id, chainId, chainKey, crownId, symbol, tokenAddress, tokenDecimals, tokenOrigin, feeBps, payoutAddress, status, live, …}}\nErrors: NO_CREDENTIALS, NO_SESSION, NO_JWT, IAP_PLAN_REQUIRED, HTTP_403, HTTP_404",
|
|
1834
|
+
inputSchema: {
|
|
1835
|
+
chain_key: z.string().describe("Chain key of the token page, e.g. 'base', 'solana'"),
|
|
1836
|
+
crown_id: z.number().int().nonnegative().describe("Registration id of the token page"),
|
|
1837
|
+
session_token: iapSessionToken,
|
|
1838
|
+
idempotency_key: iapIdempotencyKey,
|
|
1839
|
+
},
|
|
1840
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1841
|
+
}, async ({ chain_key, crown_id, session_token, idempotency_key }) => iapWrite("store", "POST", "/stores", { chainKey: chain_key, crownId: crown_id }, "account", session_token, idempotency_key));
|
|
1842
|
+
server.registerTool("iap_list_stores", {
|
|
1843
|
+
title: "List creator stores",
|
|
1844
|
+
description: "List the creator stores you manage, optionally filtered to one token page.\n\nParams: chain_key?, crown_id?, session_token?\nReturns: {entries: Store[]}\nErrors: NO_CREDENTIALS, IAP_PLAN_REQUIRED",
|
|
1845
|
+
inputSchema: {
|
|
1846
|
+
chain_key: z.string().optional(),
|
|
1847
|
+
crown_id: z.number().int().nonnegative().optional(),
|
|
1848
|
+
session_token: iapSessionToken,
|
|
1849
|
+
},
|
|
1850
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
1851
|
+
}, async ({ chain_key, crown_id, session_token }) => iapRead(`/stores${iapQuery({ chainKey: chain_key, crownId: crown_id })}`, "account", session_token));
|
|
1852
|
+
server.registerTool("iap_list_skus", {
|
|
1853
|
+
title: "List store items",
|
|
1854
|
+
description: "List a store's SKUs, including drafts and archived ones.\n\nParams: store_id, session_token?\nReturns: {entries: Sku[]} (price is base units of the SKU's currency — currency, currencyAddress, currencySymbol, currencyDecimals; null = not for sale)\nErrors: NO_CREDENTIALS, IAP_PLAN_REQUIRED, HTTP_404",
|
|
1855
|
+
inputSchema: { store_id: iapStoreId, session_token: iapSessionToken },
|
|
1856
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
1857
|
+
}, async ({ store_id, session_token }) => iapRead(`/stores/${enc(store_id)}/skus`, "store", session_token));
|
|
1858
|
+
server.registerTool("iap_create_sku", {
|
|
1859
|
+
title: "Create a store item",
|
|
1860
|
+
description: "Create a SKU. Types: consumable (a balance: coins, boosts), non_consumable (owned once: skins, cars), timed (expiry extended by durationSeconds × quantity: season pass), unlock (never sold; held while `rule` holds). No price = reward-only. Price in base units (`price`) or in dollars (`priceUsdMicros`, with optional per-unit `minPrice`/`maxPrice` bounds in base units), never both. `currency` is what the buyer pays in: 'token' (default; recommended with priceUsdMicros — a steady dollar price, paid in the creator's token), 'native' or 'usdc'. GET /api/v2/iap/config lists each chain's currencies. `grants` makes a bundle. `splits` pays up to 2 more wallets (3 on a launchpad token) a share of each sale. New SKUs are drafts unless status is 'active'. Store key scope: catalog:write.\n\nParams: store_id, sku ({sku, type, name, currency?, price?, priceUsdMicros?, minPrice?, maxPrice?, grants?, splits?, durationSeconds?, rule?, maxSupply?, maxPerWallet?, saleStartsAt?, saleEndsAt?, status?, metadata?, description?, imageUrl?, sortOrder?}), session_token?, idempotency_key?\nReturns: {ok, idempotency_key, sku: Sku}\nErrors: NO_CREDENTIALS, IAP_PLAN_REQUIRED, HTTP_400 (validation), HTTP_409 (sku key taken)",
|
|
1861
|
+
inputSchema: { store_id: iapStoreId, sku: iapSkuInput, session_token: iapSessionToken, idempotency_key: iapIdempotencyKey },
|
|
1862
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
1863
|
+
}, async ({ store_id, sku, session_token, idempotency_key }) => iapWrite("sku", "POST", `/stores/${enc(store_id)}/skus`, sku, "store", session_token, idempotency_key));
|
|
1864
|
+
server.registerTool("iap_update_sku", {
|
|
1865
|
+
title: "Update a store item",
|
|
1866
|
+
description: "Update a SKU's fields (partial). Set status 'active' to put a draft on sale, 'archived' to take it off. Setting `price` clears a dollar price (and its bounds); setting `priceUsdMicros` clears a fixed price. Changing `currency` needs `price` or `priceUsdMicros` in the same patch when the SKU has a fixed price, and clears the bounds. Store key scope: catalog:write.\n\nParams: store_id, sku_key, patch (any SKU fields except the key), session_token?\nReturns: {ok, sku: Sku}\nErrors: NO_CREDENTIALS, IAP_PLAN_REQUIRED, HTTP_400, HTTP_404",
|
|
1867
|
+
inputSchema: { store_id: iapStoreId, sku_key: z.string(), patch: iapSkuPatch, session_token: iapSessionToken },
|
|
1868
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
1869
|
+
}, async ({ store_id, sku_key, patch, session_token }) => iapWrite("sku", "PATCH", `/stores/${enc(store_id)}/skus/${enc(sku_key)}`, patch, "store", session_token));
|
|
1870
|
+
server.registerTool("iap_list_orders", {
|
|
1871
|
+
title: "List store orders",
|
|
1872
|
+
description: "List a store's orders, newest first. Statuses: pending (awaiting payment), paid (seen on chain, confirming), confirmed (items granted, receipt issued), expired, refunded. A stray or duplicate payment never changes an order; it shows up as an order.mismatch / order.duplicate_payment event (iap_list_events). Store key scope: orders:read.\n\nParams: store_id, status?, wallet?, cursor?, limit?, session_token?\nReturns: {entries: Order[], nextCursor}\nErrors: NO_CREDENTIALS, IAP_PLAN_REQUIRED",
|
|
1873
|
+
inputSchema: {
|
|
1874
|
+
store_id: iapStoreId,
|
|
1875
|
+
status: z.enum(["pending", "paid", "confirmed", "expired", "refunded", "mismatch"]).optional(),
|
|
1876
|
+
wallet: z.string().optional(),
|
|
1877
|
+
cursor: z.string().optional(),
|
|
1878
|
+
limit: z.number().int().positive().max(200).optional(),
|
|
1879
|
+
session_token: iapSessionToken,
|
|
1880
|
+
},
|
|
1881
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
1882
|
+
}, async ({ store_id, status, wallet, cursor, limit, session_token }) => iapRead(`/stores/${enc(store_id)}/orders${iapQuery({ status, wallet, cursor, limit })}`, "store", session_token));
|
|
1883
|
+
server.registerTool("iap_create_order", {
|
|
1884
|
+
title: "Create a store order",
|
|
1885
|
+
description: "Create an order for a user — the white-label path: your server sells from your app's own store with a store key (scope orders:write). The order locks the price, payees and a deadline (about 20 minutes) and carries `payment`: EVM {kind:'evm', chainId, checkout, order:{orderId, token, payees, amounts, deadline}, total, permit, calls?: [{to, data, value}] to send in order, permitTypedData?} or Solana {kind:'solana', cluster, transaction (base64 unsigned), reference, staticKey, memo}. The user's wallet pays; your client then posts the tx hash to /api/v2/iap/orders/{id}/submit, and the order turns confirmed at confirmation depth (poll iap_get_order or take the iap.order.confirmed webhook).\n\nParams: store_id, sku, quantity?, wallet (the user's wallet; required with a store key), app_account_token? (your own account id, echoed in the receipt), session_token?, idempotency_key?\nReturns: {ok, idempotency_key, order: Order}\nErrors: NO_CREDENTIALS, IAP_PLAN_REQUIRED (store not live), HTTP_400, HTTP_404 (sku), HTTP_409 (sold out / per-wallet cap / rule not met), PRICE_UNAVAILABLE (a dollar-priced SKU paid in the token or the coin whose live price cannot be read reliably right now — stale, still warming up, or moving more than 15%; retry in a minute). The order carries currency (address), currencyKind ('token'|'native'|'usdc'), currencySymbol and currencyDecimals; `amount` is the exact locked amount in that currency. Paid in the chain's coin, `calls` holds only the pay call with value = total, and permit is null. A dollar-priced order also carries priceUsdMicros and usdPerToken (the dollar price of one whole unit of the currency; 1 for usdc).",
|
|
1886
|
+
inputSchema: {
|
|
1887
|
+
store_id: iapStoreId,
|
|
1888
|
+
sku: z.string(),
|
|
1889
|
+
quantity: z.number().int().positive().optional(),
|
|
1890
|
+
wallet: z.string().optional().describe("User wallet that receives the items; required with a store key"),
|
|
1891
|
+
app_account_token: z.string().optional().describe("Your app's account id for the user; echoed in the receipt"),
|
|
1892
|
+
session_token: iapSessionToken,
|
|
1893
|
+
idempotency_key: iapIdempotencyKey,
|
|
1894
|
+
},
|
|
1895
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
1896
|
+
}, async ({ store_id, sku, quantity, wallet, app_account_token, session_token, idempotency_key }) => iapWrite("order", "POST", "/orders", { storeId: store_id, sku, quantity, wallet, appAccountToken: app_account_token }, "store", session_token, idempotency_key));
|
|
1897
|
+
server.registerTool("iap_get_order", {
|
|
1898
|
+
title: "Get a store order",
|
|
1899
|
+
description: "Get one order: its locked quote, status, on-chain payment (txHash, payer, confirmations) and the signed receipt once confirmed. With store_id it reads the store's admin view (store key scope orders:read); without it, GET /api/v2/iap/orders/{id} (the buyer's own order, or any order of the store key's store) — what an app polls until status is 'confirmed'.\n\nParams: order_id (0x + 64 hex), store_id?, session_token?\nReturns: Order\nErrors: NO_CREDENTIALS, IAP_PLAN_REQUIRED, HTTP_404",
|
|
1900
|
+
inputSchema: { order_id: z.string(), store_id: iapStoreId.optional(), session_token: iapSessionToken },
|
|
1901
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
1902
|
+
}, async ({ store_id, order_id, session_token }) => iapRead(store_id ? `/stores/${enc(store_id)}/orders/${enc(order_id)}` : `/orders/${enc(order_id)}`, "store", session_token));
|
|
1903
|
+
server.registerTool("iap_open_test_store", {
|
|
1904
|
+
title: "Open a test store",
|
|
1905
|
+
description: "Get or create the TEST store behind one of your live stores: same token page, a copy of its catalog at creation, and orders that are paid with iap_test_pay instead of on chain (no money, no fee). Mint its keys with iap_create_store_key on the returned store id; they start cd_iap_test_. Needs account credentials (a session or CHAINDADDY_API_KEY) with manager role on the token page. Available where test mode is on (GET /api/v2/iap/config → testMode).\n\nParams: store_id (the live store), session_token?\nReturns: {ok, store: Store} with mode 'test'\nErrors: NO_CREDENTIALS, IAP_PLAN_REQUIRED, HTTP_404 (TEST_MODE_DISABLED where test mode is off)",
|
|
1906
|
+
inputSchema: { store_id: iapStoreId, session_token: iapSessionToken },
|
|
1907
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1908
|
+
}, async ({ store_id, session_token }) => {
|
|
1909
|
+
try {
|
|
1910
|
+
const { data } = await iapRequest("POST", `/stores/${enc(store_id)}/test-store`, { body: {}, auth: "account", session_token });
|
|
1911
|
+
return iapOk({ ok: true, store: data });
|
|
1912
|
+
}
|
|
1913
|
+
catch (error) {
|
|
1914
|
+
return iapFail(error);
|
|
1915
|
+
}
|
|
1916
|
+
});
|
|
1917
|
+
server.registerTool("iap_test_pay", {
|
|
1918
|
+
title: "Pay a test order",
|
|
1919
|
+
description: "Pay a TEST store's pending order with no chain and no money: it confirms at once through the same path a real payment takes, so the items are granted, the receipt is issued (claim livemode: false, the test store's storeId) and the iap.order.confirmed webhook goes to the test store's own webhook with livemode false. Repeating it returns the confirmed order. Use a test store key (cd_iap_test_…, scope orders:write, via CHAINDADDY_IAP_KEY) or a session that manages the store.\n\nParams: order_id (0x + 64 hex), session_token?\nReturns: {ok, order: Order} with status 'confirmed'\nErrors: NO_CREDENTIALS, HTTP_403 (scope), HTTP_404 (unknown order, or TEST_MODE_DISABLED), HTTP_409 (LIVE_ORDER: a live order is paid on chain; ORDER_EXPIRED)",
|
|
1920
|
+
inputSchema: { order_id: z.string(), session_token: iapSessionToken },
|
|
1921
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1922
|
+
}, async ({ order_id, session_token }) => {
|
|
1923
|
+
try {
|
|
1924
|
+
const { data } = await iapRequest("POST", `/orders/${enc(order_id)}/test-pay`, { body: {}, auth: "store", session_token });
|
|
1925
|
+
return iapOk({ ok: true, order: data });
|
|
1926
|
+
}
|
|
1927
|
+
catch (error) {
|
|
1928
|
+
return iapFail(error);
|
|
1929
|
+
}
|
|
1930
|
+
});
|
|
1931
|
+
server.registerTool("iap_refund_order", {
|
|
1932
|
+
title: "Record a refund",
|
|
1933
|
+
description: "Record a refund for a confirmed order: marks it refunded, revokes its items and its receipt. Chain Daddy never holds the buyer's tokens — send the refund yourself from the payout wallet, then pass its transaction hash here.\n\nParams: store_id, order_id, refund_tx_hash?, note?, session_token?, idempotency_key?\nReturns: {ok, idempotency_key, order: Order}\nErrors: NO_CREDENTIALS, IAP_PLAN_REQUIRED, HTTP_404, HTTP_409 (not refundable in its status)",
|
|
1934
|
+
inputSchema: {
|
|
1935
|
+
store_id: iapStoreId,
|
|
1936
|
+
order_id: z.string(),
|
|
1937
|
+
refund_tx_hash: z.string().optional().describe("Your refund transfer's transaction hash / signature"),
|
|
1938
|
+
note: z.string().optional(),
|
|
1939
|
+
session_token: iapSessionToken,
|
|
1940
|
+
idempotency_key: iapIdempotencyKey,
|
|
1941
|
+
},
|
|
1942
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
1943
|
+
}, async ({ store_id, order_id, refund_tx_hash, note, session_token, idempotency_key }) => iapWrite("order", "POST", `/stores/${enc(store_id)}/orders/${enc(order_id)}/refund`, { refundTxHash: refund_tx_hash, note }, "store", session_token, idempotency_key));
|
|
1944
|
+
server.registerTool("iap_get_entitlements", {
|
|
1945
|
+
title: "Get item holdings",
|
|
1946
|
+
description: "What a wallet holds in a store (owned items plus live unlocks), or — with sku — who owns a SKU. Pass exactly one of wallet or sku. Store key scope: entitlements:read.\n\nParams: store_id, wallet?, sku?, cursor? (owners list only), session_token?\nReturns: {storeId, wallet, entitlements: [{sku, type, name, quantity, expiresAt, source: 'ledger'|'unlock', firstGrantedAt}]} or {entries: [{wallet, quantity, expiresAt}], nextCursor}\nErrors: INVALID_PARAMS, NO_CREDENTIALS, IAP_PLAN_REQUIRED",
|
|
1947
|
+
inputSchema: {
|
|
1948
|
+
store_id: iapStoreId,
|
|
1949
|
+
wallet: z.string().optional(),
|
|
1950
|
+
sku: z.string().optional(),
|
|
1951
|
+
cursor: z.string().optional(),
|
|
1952
|
+
session_token: iapSessionToken,
|
|
1953
|
+
},
|
|
1954
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
1955
|
+
}, async ({ store_id, wallet, sku, cursor, session_token }) => {
|
|
1956
|
+
if (!wallet === !sku) {
|
|
1957
|
+
return iapFail(new IapError("INVALID_PARAMS", "Pass exactly one of wallet or sku", 400));
|
|
1958
|
+
}
|
|
1959
|
+
return iapRead(`/stores/${enc(store_id)}/entitlements${iapQuery(wallet ? { wallet } : { sku, cursor })}`, "store", session_token);
|
|
1960
|
+
});
|
|
1961
|
+
server.registerTool("iap_grant", {
|
|
1962
|
+
title: "Grant items",
|
|
1963
|
+
description: "Grant items to a wallet (a reward, a make-good, a server-side award). Items are off-chain and non-transferable. Store key scope: entitlements:write.\n\nParams: store_id, wallet, sku, quantity, reason?, session_token?, idempotency_key?\nReturns: {ok, idempotency_key, entitlement: Entitlement}\nErrors: NO_CREDENTIALS, IAP_PLAN_REQUIRED, HTTP_400, HTTP_404 (unknown sku)",
|
|
1964
|
+
inputSchema: {
|
|
1965
|
+
store_id: iapStoreId,
|
|
1966
|
+
wallet: z.string(),
|
|
1967
|
+
sku: z.string(),
|
|
1968
|
+
quantity: z.number().int().positive(),
|
|
1969
|
+
reason: z.string().optional(),
|
|
1970
|
+
session_token: iapSessionToken,
|
|
1971
|
+
idempotency_key: iapIdempotencyKey,
|
|
1972
|
+
},
|
|
1973
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
1974
|
+
}, async ({ store_id, wallet, sku, quantity, reason, session_token, idempotency_key }) => iapWrite("entitlement", "POST", `/stores/${enc(store_id)}/grants`, { wallet, sku, quantity, reason }, "store", session_token, idempotency_key));
|
|
1975
|
+
server.registerTool("iap_consume", {
|
|
1976
|
+
title: "Consume items",
|
|
1977
|
+
description: "Consume units of a consumable a wallet holds (the user spent a credit). Atomic: fails without change when the wallet holds fewer. Store key scope: entitlements:write.\n\nParams: store_id, wallet, sku, quantity, session_token?, idempotency_key?\nReturns: {ok, idempotency_key, entitlement: Entitlement}\nErrors: INSUFFICIENT_QUANTITY (409), NO_CREDENTIALS, IAP_PLAN_REQUIRED, HTTP_404",
|
|
1978
|
+
inputSchema: {
|
|
1979
|
+
store_id: iapStoreId,
|
|
1980
|
+
wallet: z.string(),
|
|
1981
|
+
sku: z.string(),
|
|
1982
|
+
quantity: z.number().int().positive(),
|
|
1983
|
+
session_token: iapSessionToken,
|
|
1984
|
+
idempotency_key: iapIdempotencyKey,
|
|
1985
|
+
},
|
|
1986
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
1987
|
+
}, async ({ store_id, wallet, sku, quantity, session_token, idempotency_key }) => iapWrite("entitlement", "POST", `/stores/${enc(store_id)}/consume`, { wallet, sku, quantity }, "store", session_token, idempotency_key));
|
|
1988
|
+
server.registerTool("iap_revoke", {
|
|
1989
|
+
title: "Revoke items",
|
|
1990
|
+
description: "Take items back from a wallet. Omit quantity to revoke all of that SKU. Store key scope: entitlements:write.\n\nParams: store_id, wallet, sku, quantity?, reason?, session_token?, idempotency_key?\nReturns: {ok, idempotency_key, entitlement: Entitlement}\nErrors: NO_CREDENTIALS, IAP_PLAN_REQUIRED, HTTP_404",
|
|
1991
|
+
inputSchema: {
|
|
1992
|
+
store_id: iapStoreId,
|
|
1993
|
+
wallet: z.string(),
|
|
1994
|
+
sku: z.string(),
|
|
1995
|
+
quantity: z.number().int().positive().optional(),
|
|
1996
|
+
reason: z.string().optional(),
|
|
1997
|
+
session_token: iapSessionToken,
|
|
1998
|
+
idempotency_key: iapIdempotencyKey,
|
|
1999
|
+
},
|
|
2000
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
2001
|
+
}, async ({ store_id, wallet, sku, quantity, reason, session_token, idempotency_key }) => iapWrite("entitlement", "POST", `/stores/${enc(store_id)}/revoke`, { wallet, sku, quantity, reason }, "store", session_token, idempotency_key));
|
|
2002
|
+
server.registerTool("iap_create_drop", {
|
|
2003
|
+
title: "Create a drop",
|
|
2004
|
+
description: "Create a drop (a reward of items). Modes: claim (a link holders claim from), push (delivered to a recipient list when activated), award (your server awards it with iap_award, caps enforced). Audience is a rule (e.g. holders of at least X tokens). Drops start as drafts; activate with iap_set_drop_status. Store key scope: drops:write.\n\nParams: store_id, drop ({name, mode, items: [{sku, quantity}], audience?, allowlistOnly?, maxClaims?, perWalletLimit?, startsAt?, endsAt?, description?, imageUrl?}), session_token?, idempotency_key?\nReturns: {ok, idempotency_key, drop: Drop} (drop.slug is the claim link id)\nErrors: NO_CREDENTIALS, IAP_PLAN_REQUIRED, HTTP_400",
|
|
2005
|
+
inputSchema: { store_id: iapStoreId, drop: iapDropInput, session_token: iapSessionToken, idempotency_key: iapIdempotencyKey },
|
|
2006
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
2007
|
+
}, async ({ store_id, drop, session_token, idempotency_key }) => iapWrite("drop", "POST", `/stores/${enc(store_id)}/drops`, drop, "store", session_token, idempotency_key));
|
|
2008
|
+
server.registerTool("iap_set_drop_status", {
|
|
2009
|
+
title: "Set drop status",
|
|
2010
|
+
description: "Set a drop's status. Activating a push drop starts delivery to its recipients.\n\nParams: store_id, drop_id, status ('draft'|'active'|'paused'|'ended'), session_token?, idempotency_key?\nReturns: {ok, idempotency_key, drop: Drop}\nErrors: NO_CREDENTIALS, IAP_PLAN_REQUIRED, HTTP_404, HTTP_409",
|
|
2011
|
+
inputSchema: {
|
|
2012
|
+
store_id: iapStoreId,
|
|
2013
|
+
drop_id: z.string(),
|
|
2014
|
+
status: z.enum(["draft", "active", "paused", "ended"]),
|
|
2015
|
+
session_token: iapSessionToken,
|
|
2016
|
+
idempotency_key: iapIdempotencyKey,
|
|
2017
|
+
},
|
|
2018
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
2019
|
+
}, async ({ store_id, drop_id, status, session_token, idempotency_key }) => iapWrite("drop", "POST", `/stores/${enc(store_id)}/drops/${enc(drop_id)}/status`, { status }, "store", session_token, idempotency_key));
|
|
2020
|
+
server.registerTool("iap_add_drop_recipients", {
|
|
2021
|
+
title: "Add drop recipients",
|
|
2022
|
+
description: "Add wallets to a drop's recipient list (push drops, or an allowlist). Pass wallets, or csv text with one wallet per line.\n\nParams: store_id, drop_id, wallets?, csv?, session_token?, idempotency_key?\nReturns: {ok, idempotency_key, added: number, invalid: string[]}\nErrors: INVALID_PARAMS, NO_CREDENTIALS, IAP_PLAN_REQUIRED, HTTP_404",
|
|
2023
|
+
inputSchema: {
|
|
2024
|
+
store_id: iapStoreId,
|
|
2025
|
+
drop_id: z.string(),
|
|
2026
|
+
wallets: z.array(z.string()).optional(),
|
|
2027
|
+
csv: z.string().optional(),
|
|
2028
|
+
session_token: iapSessionToken,
|
|
2029
|
+
idempotency_key: iapIdempotencyKey,
|
|
2030
|
+
},
|
|
2031
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
2032
|
+
}, async ({ store_id, drop_id, wallets, csv, session_token, idempotency_key }) => {
|
|
2033
|
+
if (!wallets?.length && !csv) {
|
|
2034
|
+
return iapFail(new IapError("INVALID_PARAMS", "Pass wallets or csv", 400));
|
|
2035
|
+
}
|
|
2036
|
+
return iapWrite(null, "POST", `/stores/${enc(store_id)}/drops/${enc(drop_id)}/recipients`, wallets?.length ? { wallets } : { csv }, "store", session_token, idempotency_key);
|
|
2037
|
+
});
|
|
2038
|
+
server.registerTool("iap_award", {
|
|
2039
|
+
title: "Award a drop",
|
|
2040
|
+
description: "Award an award-mode drop to a wallet (the user reached a milestone). The drop's caps and window are enforced. Store key scope: rewards:award.\n\nParams: store_id, drop_id, wallet, session_token?, idempotency_key? (use one per milestone so a retry never awards twice)\nReturns: {ok, idempotency_key, …award result}\nErrors: NO_CREDENTIALS, IAP_PLAN_REQUIRED, HTTP_404, HTTP_409 (cap reached / drop not active)",
|
|
2041
|
+
inputSchema: {
|
|
2042
|
+
store_id: iapStoreId,
|
|
2043
|
+
drop_id: z.string(),
|
|
2044
|
+
wallet: z.string(),
|
|
2045
|
+
session_token: iapSessionToken,
|
|
2046
|
+
idempotency_key: iapIdempotencyKey,
|
|
2047
|
+
},
|
|
2048
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
2049
|
+
}, async ({ store_id, drop_id, wallet, session_token, idempotency_key }) => iapWrite(null, "POST", `/stores/${enc(store_id)}/drops/${enc(drop_id)}/award`, { wallet }, "store", session_token, idempotency_key));
|
|
2050
|
+
server.registerTool("iap_create_store_key", {
|
|
2051
|
+
title: "Create a store key",
|
|
2052
|
+
description: "Create a store key (cd_iap_…) for your server: one store, only the scopes you list. The secret is returned ONCE — store it in the server's secrets, never in a client. Needs account credentials (a session or CHAINDADDY_API_KEY), never a store key.\n\nParams: store_id, name, scopes (catalog:write|orders:read|orders:write|entitlements:read|entitlements:write|drops:write|rewards:award|events:read|messages:send), session_token?, idempotency_key?\nReturns: {ok, idempotency_key, key: {id, name, prefix, scopes, createdAt, secret}}\nErrors: NO_CREDENTIALS, IAP_PLAN_REQUIRED, HTTP_400",
|
|
2053
|
+
inputSchema: {
|
|
2054
|
+
store_id: iapStoreId,
|
|
2055
|
+
name: z.string().describe("A label, e.g. 'server-prod'"),
|
|
2056
|
+
scopes: z.array(iapScopes).min(1),
|
|
2057
|
+
session_token: iapSessionToken,
|
|
2058
|
+
idempotency_key: iapIdempotencyKey,
|
|
2059
|
+
},
|
|
2060
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
2061
|
+
}, async ({ store_id, name, scopes, session_token, idempotency_key }) => iapWrite("key", "POST", `/stores/${enc(store_id)}/keys`, { name, scopes }, "account", session_token, idempotency_key));
|
|
2062
|
+
server.registerTool("iap_list_events", {
|
|
2063
|
+
title: "List store events",
|
|
2064
|
+
description: "Read a store's event feed after a cursor: order.created/paid/confirmed/expired/refunded, order.mismatch (a payment named the order id but not its quote; the order is unchanged), order.duplicate_payment (the order was paid twice; the creator refunds the second), order.reverted (a paid order undone by a chain reorg), entitlement.granted/consumed/revoked, entitlement.expiring (a timed item is about to run out) / entitlement.expired, drop.claimed/awarded, drop.exhausted (a drop gave out its last item), sku.sold_out (an item reached its max supply), store.not_live (the store stopped selling because its plan lapsed) / store.live (selling again). Keep `last` and pass it as `after` next time; the same events arrive as iap.<type> webhooks, and this feed is how you catch up on any you missed. Store key scope: events:read.\n\nParams: store_id, after? (event id), limit?, session_token?\nReturns: {entries: [{id, type, wallet, orderId, payload, createdAt}], last}\nErrors: NO_CREDENTIALS, IAP_PLAN_REQUIRED",
|
|
2065
|
+
inputSchema: {
|
|
2066
|
+
store_id: iapStoreId,
|
|
2067
|
+
after: z.number().int().nonnegative().optional(),
|
|
2068
|
+
limit: z.number().int().positive().max(500).optional(),
|
|
2069
|
+
session_token: iapSessionToken,
|
|
2070
|
+
},
|
|
2071
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
2072
|
+
}, async ({ store_id, after, limit, session_token }) => iapRead(`/stores/${enc(store_id)}/events${iapQuery({ after, limit })}`, "store", session_token));
|
|
2073
|
+
server.registerTool("iap_set_webhook", {
|
|
2074
|
+
title: "Set store webhook",
|
|
2075
|
+
description: "Create or replace the store's webhook: one HTTPS endpoint that receives the store's events as they happen. Each delivery is POST {id, type, created_at, data} with header X-Webhook-Signature = hex HMAC-SHA256 of the raw body keyed with the webhook's whsec_ secret. Creating returns the secret ONCE — store it where the endpoint verifies signatures. Replacing keeps the secret (no secret in the result) and turns back on a webhook that was disabled after repeated failures. Needs account credentials (a session or CHAINDADDY_API_KEY), never a store key.\n\nParams: store_id, url (https://…), events? (subset of iap.order.created|paid|confirmed|expired|reverted|refunded|mismatch|duplicate_payment, iap.entitlement.granted|consumed|revoked|expiring|expired, iap.drop.claimed|awarded|exhausted, iap.sku.sold_out, iap.store.not_live|live; omit for all), session_token?\nReturns: {ok, webhook: {id, url, events, active, createdAt, lastTriggeredAt, secret?}} (secret only when created)\nErrors: NO_CREDENTIALS, IAP_PLAN_REQUIRED, SESSION_REQUIRED (a store key), HTTP_400 (bad url or event), HTTP_404",
|
|
2076
|
+
inputSchema: {
|
|
2077
|
+
store_id: iapStoreId,
|
|
2078
|
+
url: z.string().startsWith("https://").describe("Your endpoint, https://…"),
|
|
2079
|
+
events: z.array(iapWebhookEvents).min(1).optional().describe("Only these events; omit for all"),
|
|
2080
|
+
session_token: iapSessionToken,
|
|
2081
|
+
},
|
|
2082
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
2083
|
+
}, async ({ store_id, url, events, session_token }) => iapWrite("webhook", "PUT", `/stores/${enc(store_id)}/webhook`, events ? { url, events } : { url }, "account", session_token));
|
|
2084
|
+
server.registerTool("iap_get_webhook", {
|
|
2085
|
+
title: "Get store webhook",
|
|
2086
|
+
description: "Get the store's webhook and its last 20 deliveries, newest first. A delivery's status is pending, delivered, failed or exhausted (no more retries); anything missed can be caught up from iap_list_events. Store key scope: events:read.\n\nParams: store_id, session_token?\nReturns: {webhook: {id, url, events, active, createdAt, lastTriggeredAt} | null, deliveries: [{id, eventId, eventType, status, attempt, statusCode, error, createdAt, deliveredAt}]}\nErrors: NO_CREDENTIALS, IAP_PLAN_REQUIRED, HTTP_404",
|
|
2087
|
+
inputSchema: { store_id: iapStoreId, session_token: iapSessionToken },
|
|
2088
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
2089
|
+
}, async ({ store_id, session_token }) => iapRead(`/stores/${enc(store_id)}/webhook`, "store", session_token));
|
|
2090
|
+
server.registerTool("iap_test_webhook", {
|
|
2091
|
+
title: "Send a test webhook",
|
|
2092
|
+
description: "Queue an iap.test event to the store's webhook, to check the endpoint receives and verifies deliveries. See the result with iap_get_webhook. Store key scope: events:read.\n\nParams: store_id, session_token?, idempotency_key?\nReturns: {ok, idempotency_key, queued: true, event: {id, type: 'iap.test', createdAt, data}}\nErrors: NO_CREDENTIALS, IAP_PLAN_REQUIRED, NOT_FOUND (no webhook set)",
|
|
2093
|
+
inputSchema: { store_id: iapStoreId, session_token: iapSessionToken, idempotency_key: iapIdempotencyKey },
|
|
2094
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
2095
|
+
}, async ({ store_id, session_token, idempotency_key }) => iapWrite(null, "POST", `/stores/${enc(store_id)}/webhook/test`, undefined, "store", session_token, idempotency_key));
|
|
2096
|
+
// ============================================================================
|
|
2097
|
+
// RESOURCES
|
|
2098
|
+
// ============================================================================
|
|
2099
|
+
server.resource("chaindaddy://config", "Current Chain Daddy MCP configuration", async () => ({
|
|
2100
|
+
contents: [{
|
|
2101
|
+
uri: "chaindaddy://config",
|
|
2102
|
+
mimeType: "application/json",
|
|
2103
|
+
text: JSON.stringify({
|
|
2104
|
+
apiUrl: API_URL,
|
|
2105
|
+
network: NETWORK,
|
|
2106
|
+
version: VERSION,
|
|
2107
|
+
}, null, 2),
|
|
2108
|
+
}],
|
|
2109
|
+
}));
|
|
2110
|
+
server.resource("chaindaddy://chains", "List of all blockchain networks supported by Chain Daddy", async () => ({
|
|
2111
|
+
contents: [{
|
|
2112
|
+
uri: "chaindaddy://chains",
|
|
2113
|
+
mimeType: "application/json",
|
|
2114
|
+
text: JSON.stringify(SUPPORTED_CHAINS, null, 2),
|
|
2115
|
+
}],
|
|
2116
|
+
}));
|
|
2117
|
+
server.resource("chaindaddy://tools", "How the tools fit together: sign-in, crown lookup and the creator store flows", async () => ({
|
|
2118
|
+
contents: [{
|
|
2119
|
+
uri: "chaindaddy://tools",
|
|
2120
|
+
mimeType: "application/json",
|
|
2121
|
+
text: JSON.stringify({
|
|
2122
|
+
// The tool catalog is tools/list, which clients already read; a second
|
|
2123
|
+
// hand-kept copy here drifted from it, so this resource carries only the
|
|
2124
|
+
// flows that tie the tools together.
|
|
2125
|
+
tools: "Call tools/list: every tool has a title, a description and readOnlyHint/destructiveHint annotations",
|
|
2126
|
+
usage: {
|
|
2127
|
+
crown_lookup: "Use search_symbol with symbol and chain to find crown details",
|
|
2128
|
+
heartbeat_check: "Use check_heartbeat with crown_id to monitor activity score",
|
|
2129
|
+
profile_update: "Use update_profile with wallet signature to modify metadata",
|
|
2130
|
+
creator_store_auth: "Set CHAINDADDY_API_KEY (cd_live_) for account calls, CHAINDADDY_IAP_KEY (cd_iap_) for your server's store-scoped calls, or pass session_token. Needs a Developer plan or Developer Beta membership (else 403 IAP_PLAN_REQUIRED)",
|
|
2131
|
+
creator_store_setup: "iap_open_store → iap_create_sku (status 'active') → iap_create_store_key for your server",
|
|
2132
|
+
creator_store_sale: "iap_create_order (wallet = the user) → user pays payment.calls or the permit → POST /api/v2/iap/orders/{id}/submit → iap_get_order until 'confirmed' → verify the receipt → iap_get_entitlements / iap_consume",
|
|
2133
|
+
creator_store_writes: "Every write returns idempotency_key; retry with the same key and it never applies twice",
|
|
2134
|
+
creator_store_events: "Keep `last` from iap_list_events and pass it as `after`; webhooks arrive as iap.<type>",
|
|
2135
|
+
creator_store_test_mode: "iap_open_test_store → iap_create_store_key on the test store (cd_iap_test_…) → iap_create_order → iap_test_pay → iap_get_entitlements / iap_consume; receipts and webhooks carry livemode: false. Go live by switching to the live store's key and id",
|
|
2136
|
+
creator_store_webhooks: "iap_set_webhook (store the returned secret) → iap_test_webhook → iap_get_webhook for delivery status; verify X-Webhook-Signature = hex HMAC-SHA256(raw body, secret)",
|
|
2137
|
+
},
|
|
2138
|
+
}, null, 2),
|
|
2139
|
+
}],
|
|
2140
|
+
}));
|
|
2141
|
+
// ============================================================================
|
|
2142
|
+
// START SERVER
|
|
2143
|
+
// ============================================================================
|
|
2144
|
+
async function main() {
|
|
2145
|
+
const transport = new StdioServerTransport();
|
|
2146
|
+
await server.connect(transport);
|
|
2147
|
+
console.error("Chain Daddy MCP server running on stdio");
|
|
2148
|
+
}
|
|
2149
|
+
main().catch((error) => {
|
|
2150
|
+
console.error("Failed to start server:", error);
|
|
2151
|
+
process.exit(1);
|
|
2152
|
+
});
|
|
2153
|
+
//# sourceMappingURL=index.js.map
|