Skip to content

Commit cc72dd2

Browse files
authored
feat: Add get_cmc_metadata_v2 tool for enhanced cryptocurrency metadata retrieval (#67)
- Introduced CMCMetadataV2Schema for detailed validation of cryptocurrency metadata. - Implemented GetMetadataV2ToolSchema to fetch comprehensive metadata including logos, descriptions, and social links. - Updated CMCBaseTool to register the new metadata retrieval tool. - Expanded unit tests to validate functionality and ensure robust error handling for the new tool.
1 parent 8f9e154 commit cc72dd2

File tree

2 files changed

+227
-2
lines changed

2 files changed

+227
-2
lines changed

src/tools/__tests__/cmc.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ describe("CMCBaseTool", () => {
104104
expect(cmcTool.description).toContain(
105105
"Fetches token mapping data from CoinMarketCap"
106106
);
107-
expect(cmcTool.schema).toHaveLength(1);
107+
expect(cmcTool.schema).toHaveLength(2);
108108
expect(cmcTool.schema[0].name).toBe("get_cmc_token_map");
109109
});
110110

@@ -305,4 +305,94 @@ describe("CMCBaseTool", () => {
305305
expect(result.tokens[0].platform).toBeNull();
306306
});
307307
});
308+
309+
describe("getMetadataV2", () => {
310+
const executionOptions = {
311+
toolCallId: "test-call-id",
312+
messages: [],
313+
llm: new LLMService(llmServiceParams),
314+
};
315+
const mockMetadataResponse = {
316+
status: {
317+
timestamp: "2024-03-20T12:00:00.000Z",
318+
error_code: 0,
319+
error_message: null,
320+
elapsed: 10,
321+
credit_count: 1,
322+
notice: null,
323+
},
324+
data: {
325+
BTC: {
326+
id: 1,
327+
name: "Bitcoin",
328+
symbol: "BTC",
329+
category: "coin",
330+
description: "Bitcoin description",
331+
slug: "bitcoin",
332+
logo: "https://example.com/btc.png",
333+
subreddit: "bitcoin",
334+
notice: "",
335+
tags: ["store-of-value"],
336+
"tag-names": ["Store of Value"],
337+
"tag-groups": ["CATEGORY"],
338+
urls: {
339+
website: ["https://bitcoin.org"],
340+
twitter: ["https://twitter.com/bitcoin"],
341+
},
342+
platform: null,
343+
date_added: "2013-04-28T00:00:00.000Z",
344+
twitter_username: "bitcoin",
345+
is_hidden: 0,
346+
},
347+
},
348+
};
349+
350+
it("should fetch and validate metadata v2 data", async () => {
351+
vi.mocked(fetch).mockResolvedValueOnce({
352+
ok: true,
353+
json: () => Promise.resolve(mockMetadataResponse),
354+
} as Response);
355+
356+
const result = await cmcTool.schema[1].tool.execute(
357+
{ id: "1" },
358+
executionOptions
359+
);
360+
361+
if (typeof result === "string") {
362+
throw new Error("Expected result to be an object");
363+
}
364+
365+
expect(result.metadata).toEqual({
366+
timestamp: "2024-03-20T12:00:00.000Z",
367+
errorCode: 0,
368+
errorMessage: null,
369+
elapsed: 10,
370+
creditCount: 1,
371+
notice: null,
372+
});
373+
374+
expect(result.tokens.BTC).toEqual({
375+
id: 1,
376+
name: "Bitcoin",
377+
symbol: "BTC",
378+
category: "coin",
379+
description: "Bitcoin description",
380+
slug: "bitcoin",
381+
logo: "https://example.com/btc.png",
382+
subreddit: "bitcoin",
383+
notice: "",
384+
tags: ["store-of-value"],
385+
"tag-names": ["Store of Value"],
386+
"tag-groups": ["CATEGORY"],
387+
urls: {
388+
website: ["https://bitcoin.org"],
389+
twitter: ["https://twitter.com/bitcoin"],
390+
},
391+
platform: null,
392+
date_added: "2013-04-28T00:00:00.000Z",
393+
twitter_username: "bitcoin",
394+
is_hidden: 0,
395+
});
396+
});
397+
});
308398
});

src/tools/cmc.ts

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { logger } from "../logger/winston";
77
export const CMC_BASE_URL = "https://pro-api.coinmarketcap.com/v1";
88

99
const CMCPlatformSchema = z.object({
10-
id: z.number().describe("Platform ID"),
10+
id: z.union([z.number(), z.string()]).describe("Platform ID"),
1111
name: z.string().describe("Platform name"),
1212
symbol: z.string().describe("Platform symbol"),
1313
slug: z.string().describe("Platform slug"),
@@ -47,6 +47,51 @@ const CMCResponseSchema = z.object({
4747
}),
4848
});
4949

50+
const CMCMetadataV2Schema = z.object({
51+
data: z.record(
52+
z.object({
53+
id: z.number().describe("CoinMarketCap ID"),
54+
name: z.string().describe("Token name"),
55+
symbol: z.string().describe("Token symbol"),
56+
category: z.string().describe("Token category"),
57+
description: z.string().describe("Token description"),
58+
slug: z.string().describe("Token slug"),
59+
logo: z.string().optional().describe("Token logo URL"),
60+
subreddit: z.string().optional().describe("Token subreddit"),
61+
notice: z.string().optional().describe("Token notice"),
62+
tags: z.array(z.string()).optional().describe("Token tags"),
63+
"tag-names": z.array(z.string()).optional().describe("Token tag names"),
64+
"tag-groups": z.array(z.string()).optional().describe("Token tag groups"),
65+
urls: z
66+
.object({
67+
website: z.array(z.string()).optional(),
68+
twitter: z.array(z.string()).optional(),
69+
message_board: z.array(z.string()).optional(),
70+
chat: z.array(z.string()).optional(),
71+
facebook: z.array(z.string()).optional(),
72+
explorer: z.array(z.string()).optional(),
73+
reddit: z.array(z.string()).optional(),
74+
technical_doc: z.array(z.string()).optional(),
75+
source_code: z.array(z.string()).optional(),
76+
announcement: z.array(z.string()).optional(),
77+
})
78+
.optional(),
79+
platform: CMCPlatformSchema.nullable().optional(),
80+
date_added: z.string().optional(),
81+
twitter_username: z.string().optional(),
82+
is_hidden: z.number().optional(),
83+
})
84+
),
85+
status: z.object({
86+
timestamp: z.string(),
87+
error_code: z.number(),
88+
error_message: z.string().nullable(),
89+
elapsed: z.number(),
90+
credit_count: z.number(),
91+
notice: z.string().nullable(),
92+
}),
93+
});
94+
5095
const GetTokenMapToolSchema = {
5196
name: "get_cmc_token_map",
5297
description:
@@ -150,6 +195,62 @@ const GetTokenMapToolSchema = {
150195
},
151196
};
152197

198+
const GetMetadataV2ToolSchema = {
199+
name: "get_cmc_metadata_v2",
200+
description:
201+
"Fetches detailed metadata for cryptocurrencies including logo, description, website URLs, and social links.\n" +
202+
"If you don't know the token id, use get_cmc_token_map tool to get the token id first.",
203+
parameters: z.object({
204+
id: z
205+
.string()
206+
.optional()
207+
.describe("Comma-separated CoinMarketCap cryptocurrency IDs"),
208+
address: z.string().optional().describe("Contract address"),
209+
skip_invalid: z.boolean().optional().default(false),
210+
aux: z
211+
.string()
212+
.optional()
213+
.default("urls,logo,description,tags,platform,date_added,notice")
214+
.describe(
215+
"Only include necessary fields to reduce response size. Can be a single value or comma-separated list"
216+
),
217+
}),
218+
execute: async (args: {
219+
id?: string;
220+
slug?: string;
221+
symbol?: string;
222+
address?: string;
223+
skip_invalid?: boolean;
224+
aux?: string;
225+
}) => {
226+
try {
227+
const tool = new CMCBaseTool();
228+
const response = await tool.getMetadataV2(args);
229+
const parsedResponse = CMCMetadataV2Schema.parse(response);
230+
231+
return {
232+
tokens: Object.fromEntries(
233+
Object.entries(parsedResponse.data).map(([symbol, tokens]) => [
234+
symbol,
235+
tokens,
236+
])
237+
),
238+
metadata: {
239+
timestamp: parsedResponse.status.timestamp,
240+
errorCode: parsedResponse.status.error_code,
241+
errorMessage: parsedResponse.status.error_message,
242+
elapsed: parsedResponse.status.elapsed,
243+
creditCount: parsedResponse.status.credit_count,
244+
notice: parsedResponse.status.notice,
245+
},
246+
};
247+
} catch (error) {
248+
logger.error("Error executing get_cmc_metadata_v2 tool", error);
249+
return `Error executing get_cmc_metadata_v2 tool`;
250+
}
251+
},
252+
};
253+
153254
type CMCBaseParams = {
154255
start?: number;
155256
limit?: number;
@@ -162,6 +263,7 @@ type CMCBaseParams = {
162263
export class CMCBaseTool extends APITool<CMCBaseParams> {
163264
schema = [
164265
{ name: GetTokenMapToolSchema.name, tool: tool(GetTokenMapToolSchema) },
266+
{ name: GetMetadataV2ToolSchema.name, tool: tool(GetMetadataV2ToolSchema) },
165267
];
166268

167269
constructor() {
@@ -207,4 +309,37 @@ export class CMCBaseTool extends APITool<CMCBaseParams> {
207309

208310
return await res.json();
209311
}
312+
313+
async getMetadataV2(params: {
314+
id?: string;
315+
slug?: string;
316+
symbol?: string;
317+
address?: string;
318+
skip_invalid?: boolean;
319+
aux?: string;
320+
}) {
321+
const queryParams = new URLSearchParams();
322+
323+
if (params.id) queryParams.set("id", params.id);
324+
if (params.slug) queryParams.set("slug", params.slug);
325+
if (params.symbol) queryParams.set("symbol", params.symbol);
326+
if (params.address) queryParams.set("address", params.address);
327+
if (params.skip_invalid) queryParams.set("skip_invalid", "true");
328+
if (params.aux) queryParams.set("aux", params.aux);
329+
330+
const res = await fetch(
331+
`${CMC_BASE_URL}/cryptocurrency/info?${queryParams.toString()}`,
332+
{
333+
headers: {
334+
"X-CMC_PRO_API_KEY": process.env.CMC_API_KEY || "",
335+
},
336+
}
337+
);
338+
339+
if (!res.ok) {
340+
throw new Error(`API request failed with status: ${res.status}`);
341+
}
342+
343+
return await res.json();
344+
}
210345
}

0 commit comments

Comments
 (0)