diff --git a/.env.example b/.env.example index cf3312a..c6e8852 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,5 @@ ALCHEMY_API_KEY=my_alchemy_key # Caching (optional, strongly recommended for production) REDIS_URL=redis_url + +RARIBLE_API_KEY=my_rarible_key diff --git a/package.json b/package.json index faf6940..ac3b23f 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "compile": "npx hardhat compile" }, "dependencies": { + "@rarible/protocol-mcp": "^0.0.4", "alchemy-sdk": "^3.6.1", "express-rate-limit": "^8.0.1", "ioredis": "^5.6.1", diff --git a/src/config.ts b/src/config.ts index 3aaee56..9aee175 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,6 +3,7 @@ import { shape } from 'viem/chains'; export const config = { chainId: Number(process.env.CHAIN_ID), alchemyApiKey: process.env.ALCHEMY_API_KEY as string, + raribleApiKey: process.env.RARIBLE_API_KEY as string, isMainnet: Number(process.env.CHAIN_ID) === shape.id, redisUrl: process.env.REDIS_URL as string, defaultRpcUrl: diff --git a/src/tools/market/get-collection-market-stats.ts b/src/tools/market/get-collection-market-stats.ts new file mode 100644 index 0000000..93a2693 --- /dev/null +++ b/src/tools/market/get-collection-market-stats.ts @@ -0,0 +1,191 @@ +import { z } from 'zod'; +import { type InferSchema } from 'xmcp'; +import { RaribleProtocolMcp } from '@rarible/protocol-mcp'; +import { ToolErrorOutput, NormalizedMarketStats, MarketStatsOutput } from '../../types'; + +const amountSchema = z + .object({ + value: z.number().optional().nullable(), + valueUsd: z.number().optional().nullable(), + currency: z.string().optional(), + }) + .partial(); + +const statsLikeSchema = z + .object({ + id: z.string().optional().nullable(), + listed: z.number().optional(), + items: z.number().optional(), + owners: z.number().optional(), + floor: amountSchema.optional().nullable(), + volume: amountSchema.optional().nullable(), + }) + .strip(); + +function normalizeRaribleCollectionStats(input: unknown): NormalizedMarketStats | null { + const parsed = statsLikeSchema.safeParse(input); + if (!parsed.success) return null; + const s = parsed.data; + return { + floorPrice: s.floor?.value ?? null, + totalVolume: s.volume?.value ?? null, + totalItems: s.items ?? null, + owners: s.owners ?? null, + }; +} + +function extractRawValueFromSdkError(error: unknown): unknown | null { + if (!error || typeof error !== 'object') return null; + return 'rawValue' in (error as Record) + ? ((error as { rawValue?: unknown }).rawValue ?? null) + : null; +} +import { getCached, setCached } from '../../utils/cache'; +import { isAddress } from 'viem'; + +export const schema = { + collection: z + .string() + .refine((value) => isAddress(value), { + message: 'Invalid collection address', + }) + .describe('The collection contract address'), +}; + +export const metadata = { + name: 'getCollectionMarketStats', + description: 'Get market statistics from Rarible: floor price, volume, and trading activity', + annotations: { + title: 'Rarible Market Statistics', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + requiresWallet: false, + category: 'market-analysis', + educationalHint: true, + chainableWith: ['getCollectionAnalytics'], + cacheTTL: 60 * 5, // 5 minutes + }, +}; + +export default async function getCollectionMarketStats({ collection }: InferSchema) { + const cacheKey = `mcp:collectionMarketStats:${collection.toLowerCase()}`; + const cached = await getCached(cacheKey); + + if (cached) { + return JSON.parse(cached); + } + + try { + const raribleApiKey = process.env.RARIBLE_API_KEY; + if (!raribleApiKey) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: true, + message: + 'Please set RARIBLE_API_KEY environment variable to access Rarible market data', + timestamp: new Date().toISOString(), + } satisfies ToolErrorOutput), + }, + ], + }; + } + + const rarible = new RaribleProtocolMcp({ + apiKeyAuth: raribleApiKey, + }); + + const statsResponse = await rarible.collectionStatistics.getGlobalCollectionStatistics({ + id: `SHAPE:${collection}`, + }); + + if (!statsResponse) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + collection, + floorPrice: null, + totalVolume: null, + totalItems: null, + owners: null, + note: 'No market data found for this collection on Rarible', + }, + null, + 2 + ), + }, + ], + }; + } + + const normalized = normalizeRaribleCollectionStats(statsResponse); + const marketStats: MarketStatsOutput = { + collection, + floorPrice: normalized?.floorPrice ?? null, + totalVolume: normalized?.totalVolume ?? null, + totalItems: normalized?.totalItems ?? null, + owners: normalized?.owners ?? null, + }; + + const response = { + content: [ + { + type: 'text' as const, + text: JSON.stringify(marketStats, null, 2), + }, + ], + }; + + await setCached(cacheKey, JSON.stringify(response), metadata.annotations.cacheTTL); + + return response; + } catch (error) { + const rawValue = extractRawValueFromSdkError(error); + const normalizedFromError = normalizeRaribleCollectionStats(rawValue); + if (normalizedFromError) { + const marketStats: MarketStatsOutput = { + collection, + floorPrice: normalizedFromError.floorPrice, + totalVolume: normalizedFromError.totalVolume, + totalItems: normalizedFromError.totalItems, + owners: normalizedFromError.owners, + }; + + const response = { + content: [ + { + type: 'text' as const, + text: JSON.stringify(marketStats, null, 2), + }, + ], + }; + + await setCached(cacheKey, JSON.stringify(response), metadata.annotations.cacheTTL); + return response; + } + + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`Market stats error for collection ${collection}:`, error); + + const errorOutput: ToolErrorOutput = { + error: true, + message: `Failed to fetch market statistics: ${errorMessage}`, + timestamp: new Date().toISOString(), + }; + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(errorOutput, null, 2), + }, + ], + }; + } +} diff --git a/src/tools/nft/get-collection-analytics.ts b/src/tools/nft/get-collection-analytics.ts index b17ba72..68017c9 100644 --- a/src/tools/nft/get-collection-analytics.ts +++ b/src/tools/nft/get-collection-analytics.ts @@ -3,7 +3,7 @@ import { type InferSchema } from 'xmcp'; import { isAddress } from 'viem'; import { alchemy } from '../../clients'; import { config } from '../../config'; -import type { CollectionAnalyticsOutput, ToolErrorOutput } from '../../types'; +import type { ToolErrorOutput, CollectionAnalyticsOutput } from '../../types'; import { getCached, setCached } from '../../utils/cache'; export const schema = { @@ -18,7 +18,7 @@ export const schema = { export const metadata = { name: 'getCollectionAnalytics', description: - 'Get NFT collection analytics: name, symbol, total supply, owner count, sample NFTs, marketplace floor prices, etc.', + 'Get essential NFT collection analytics: name, symbol, total supply, owner count, and floor price.', annotations: { title: 'NFT Collection Analytics', readOnlyHint: true, @@ -27,8 +27,8 @@ export const metadata = { requiresWallet: false, category: 'nft-analysis', educationalHint: true, - chainableWith: ['getShapeNft', 'simulateGasbackRewards'], - cacheTTL: 60 * 15, // 15 minutes + chainableWith: ['getShapeNft', 'getCollectionMarketStats'], + cacheTTL: 60 * 5, // 5 minutes }, }; @@ -45,23 +45,18 @@ export default async function getCollectionAnalytics({ try { const analytics: CollectionAnalyticsOutput = { contractAddress, - timestamp: new Date().toISOString(), name: null, symbol: null, totalSupply: null, - ownerCount: null, - contractType: null, - sampleNfts: [], - floorPrice: null, + owners: null, }; - const [collectionResult, ownersResult, floorPriceResult] = await Promise.allSettled([ + const [collectionResult, ownersResult] = await Promise.allSettled([ alchemy.nft.getNftsForContract(contractAddress, { pageSize: 10, omitMetadata: false, }), alchemy.nft.getOwnersForContract(contractAddress), - alchemy.nft.getFloorPrice(contractAddress), ]); if (collectionResult.status === 'fulfilled' && collectionResult.value.nfts.length > 0) { @@ -71,21 +66,10 @@ export default async function getCollectionAnalytics({ analytics.totalSupply = sampleNft.contract.totalSupply ? parseInt(sampleNft.contract.totalSupply) : null; - analytics.contractType = sampleNft.contract.tokenType || null; - - analytics.sampleNfts = collectionResult.value.nfts.slice(0, 5).map((nft) => ({ - tokenId: nft.tokenId, - name: nft.name || null, - imageUrl: nft.image?.originalUrl || nft.image?.thumbnailUrl || null, - })); } if (ownersResult.status === 'fulfilled') { - analytics.ownerCount = ownersResult.value.owners.length; - } - - if (floorPriceResult.status === 'fulfilled') { - analytics.floorPrice = floorPriceResult.value; + analytics.owners = ownersResult.value.owners.length ?? null; } const response = { diff --git a/src/types.ts b/src/types.ts index 7c8614f..37dc8b0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,22 +1,5 @@ -import { GetFloorPriceResponse } from 'alchemy-sdk'; import { Address } from 'viem'; -export type CollectionAnalyticsOutput = { - contractAddress: Address; - timestamp: string; - name: string | null; - symbol: string | null; - totalSupply: number | null; - ownerCount: number | null; - contractType: string | null; - sampleNfts: Array<{ - tokenId: string; - name: string | null; - imageUrl: string | null; - }>; - floorPrice: GetFloorPriceResponse | null; -}; - export type ShapeCreatorAnalyticsOutput = CreatorAnalytics & { timestamp: string; }; @@ -130,3 +113,27 @@ export type PrepareMintSVGNFTOutput = { nextSteps: string[]; }; }; + +export type NormalizedMarketStats = { + floorPrice: number | null; + totalVolume: number | null; + totalItems: number | null; + owners: number | null; +}; + +export type CollectionAnalyticsOutput = { + contractAddress: Address; + name: string | null; + symbol: string | null; + totalSupply: number | null; + owners: number | null; +}; + +export type MarketStatsOutput = { + collection: Address; + floorPrice: number | null; + totalVolume: number | null; + totalItems: number | null; + owners: number | null; + note?: string; +}; diff --git a/yarn.lock b/yarn.lock index f044e85..80f04d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1385,6 +1385,11 @@ resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb" integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw== +"@rarible/protocol-mcp@^0.0.4": + version "0.0.4" + resolved "https://registry.yarnpkg.com/@rarible/protocol-mcp/-/protocol-mcp-0.0.4.tgz#d4a842c66652122e988f31dba10895940117d9c1" + integrity sha512-ocNXRZDt9ETR+OYXFiIvFJYFoJiboxe1dj6Ex113vJx0vMQRPLKtPeEOSXyuZnwl6qzgwB8qdBbW3VF33ZtTow== + "@redis/bloom@1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-1.2.0.tgz#d3fd6d3c0af3ef92f26767b56414a370c7b63b71"