From f11545d9f7aee122a8b24f04bf3fa3a3528768b7 Mon Sep 17 00:00:00 2001 From: William H Date: Thu, 7 Aug 2025 12:29:29 +0200 Subject: [PATCH 1/8] add rarible mcp --- package.json | 1 + yarn.lock | 5 +++++ 2 files changed, 6 insertions(+) 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/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" From 5d8a17a5f46f7b92350cd0e3fec751e429402013 Mon Sep 17 00:00:00 2001 From: William H Date: Thu, 7 Aug 2025 13:03:30 +0200 Subject: [PATCH 2/8] rarible keys --- .env.example | 2 ++ src/config.ts | 1 + 2 files changed, 3 insertions(+) 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/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: From be4a24d21df596605896f447f2bf7f5b32664b77 Mon Sep 17 00:00:00 2001 From: William H Date: Fri, 8 Aug 2025 11:06:32 +0200 Subject: [PATCH 3/8] types --- src/types.ts | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/src/types.ts b/src/types.ts index 7c8614f..245579a 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,10 @@ export type PrepareMintSVGNFTOutput = { nextSteps: string[]; }; }; + +export type NormalizedMarketStats = { + floorPrice: number | null; + totalVolume: number | null; + totalItems: number | null; + owners: number | null; +}; From a043a8615a9825181a51a03b459dd10004baa187 Mon Sep 17 00:00:00 2001 From: William H Date: Fri, 8 Aug 2025 11:07:03 +0200 Subject: [PATCH 4/8] market data --- .../market/get-collection-market-stats.ts | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 src/tools/market/get-collection-market-stats.ts 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..cb8521e --- /dev/null +++ b/src/tools/market/get-collection-market-stats.ts @@ -0,0 +1,202 @@ +import { z } from 'zod'; +import { type InferSchema } from 'xmcp'; +import { RaribleProtocolMcp } from '@rarible/protocol-mcp'; +import { ToolErrorOutput, NormalizedMarketStats } from '../../types'; +// Helpers to adapt to Rarible's response shapes in a typed way +// NormalizedMarketStats type imported 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, + }); + + // Try with SHAPE: prefix for Shape blockchain collections + const collectionId = `SHAPE:${collection}`; + console.log(`Fetching Rarible stats for collection: ${collectionId}`); + + const statsResponse = await rarible.collectionStatistics.getGlobalCollectionStatistics({ + id: collectionId, + }); + + console.log('Rarible response structure:', JSON.stringify(statsResponse, null, 2)); + + // Handle case where collection might not exist or have no data + 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 = { + 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) { + // Validation errors may include a rawValue with valid stats; normalize if present. + const rawValue = extractRawValueFromSdkError(error); + const normalizedFromError = normalizeRaribleCollectionStats(rawValue); + if (normalizedFromError) { + const marketStats = { + 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; + } + + // Provide detailed error information for debugging when no fallback is available + 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), + }, + ], + }; + } +} From 0269ca7f20034099e483b0de4d2828addf95857a Mon Sep 17 00:00:00 2001 From: William H Date: Fri, 8 Aug 2025 11:23:52 +0200 Subject: [PATCH 5/8] d --- src/tools/market/get-collection-market-stats.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tools/market/get-collection-market-stats.ts b/src/tools/market/get-collection-market-stats.ts index cb8521e..acee97b 100644 --- a/src/tools/market/get-collection-market-stats.ts +++ b/src/tools/market/get-collection-market-stats.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { type InferSchema } from 'xmcp'; import { RaribleProtocolMcp } from '@rarible/protocol-mcp'; -import { ToolErrorOutput, NormalizedMarketStats } from '../../types'; +import { ToolErrorOutput, NormalizedMarketStats, MarketStatsOutput } from '../../types'; // Helpers to adapt to Rarible's response shapes in a typed way // NormalizedMarketStats type imported from ../../types @@ -134,7 +134,7 @@ export default async function getCollectionMarketStats({ collection }: InferSche } const normalized = normalizeRaribleCollectionStats(statsResponse); - const marketStats = { + const marketStats: MarketStatsOutput = { collection, floorPrice: normalized?.floorPrice ?? null, totalVolume: normalized?.totalVolume ?? null, @@ -159,7 +159,7 @@ export default async function getCollectionMarketStats({ collection }: InferSche const rawValue = extractRawValueFromSdkError(error); const normalizedFromError = normalizeRaribleCollectionStats(rawValue); if (normalizedFromError) { - const marketStats = { + const marketStats: MarketStatsOutput = { collection, floorPrice: normalizedFromError.floorPrice, totalVolume: normalizedFromError.totalVolume, From 70b4282879c2f085b17052b7f1ece710ef2553a5 Mon Sep 17 00:00:00 2001 From: William H Date: Fri, 8 Aug 2025 11:33:09 +0200 Subject: [PATCH 6/8] os --- src/tools/nft/get-collection-analytics.ts | 28 +++++++++-------------- src/types.ts | 18 +++++++++++++++ 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/tools/nft/get-collection-analytics.ts b/src/tools/nft/get-collection-analytics.ts index b17ba72..39337d1 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,13 +45,10 @@ 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: [], + owners: null, floorPrice: null, }; @@ -71,21 +68,18 @@ 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; + analytics.owners = ownersResult.value.owners.length ?? null; } if (floorPriceResult.status === 'fulfilled') { - analytics.floorPrice = floorPriceResult.value; + const openSea = floorPriceResult.value?.openSea as unknown; + if (openSea && typeof openSea === 'object' && 'floorPrice' in openSea) { + const fp = (openSea as { floorPrice?: number | null }).floorPrice; + analytics.floorPrice = fp ?? null; + } } const response = { diff --git a/src/types.ts b/src/types.ts index 245579a..5e6e33d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -120,3 +120,21 @@ export type NormalizedMarketStats = { totalItems: number | null; owners: number | null; }; + +export type CollectionAnalyticsOutput = { + contractAddress: Address; + name: string | null; + symbol: string | null; + totalSupply: number | null; + owners: number | null; + floorPrice: number | null; +}; + +export type MarketStatsOutput = { + collection: Address; + floorPrice: number | null; + totalVolume: number | null; + totalItems: number | null; + owners: number | null; + note?: string; +}; From 69cb5c0e8456d6f12117af063745fda85bf0d711 Mon Sep 17 00:00:00 2001 From: William H Date: Fri, 8 Aug 2025 16:17:23 +0200 Subject: [PATCH 7/8] c --- src/tools/market/get-collection-market-stats.ts | 8 -------- src/tools/nft/get-collection-analytics.ts | 12 +----------- src/types.ts | 1 - 3 files changed, 1 insertion(+), 20 deletions(-) diff --git a/src/tools/market/get-collection-market-stats.ts b/src/tools/market/get-collection-market-stats.ts index acee97b..9e47293 100644 --- a/src/tools/market/get-collection-market-stats.ts +++ b/src/tools/market/get-collection-market-stats.ts @@ -2,8 +2,6 @@ import { z } from 'zod'; import { type InferSchema } from 'xmcp'; import { RaribleProtocolMcp } from '@rarible/protocol-mcp'; import { ToolErrorOutput, NormalizedMarketStats, MarketStatsOutput } from '../../types'; -// Helpers to adapt to Rarible's response shapes in a typed way -// NormalizedMarketStats type imported from ../../types const amountSchema = z .object({ @@ -100,7 +98,6 @@ export default async function getCollectionMarketStats({ collection }: InferSche apiKeyAuth: raribleApiKey, }); - // Try with SHAPE: prefix for Shape blockchain collections const collectionId = `SHAPE:${collection}`; console.log(`Fetching Rarible stats for collection: ${collectionId}`); @@ -108,9 +105,6 @@ export default async function getCollectionMarketStats({ collection }: InferSche id: collectionId, }); - console.log('Rarible response structure:', JSON.stringify(statsResponse, null, 2)); - - // Handle case where collection might not exist or have no data if (!statsResponse) { return { content: [ @@ -155,7 +149,6 @@ export default async function getCollectionMarketStats({ collection }: InferSche return response; } catch (error) { - // Validation errors may include a rawValue with valid stats; normalize if present. const rawValue = extractRawValueFromSdkError(error); const normalizedFromError = normalizeRaribleCollectionStats(rawValue); if (normalizedFromError) { @@ -180,7 +173,6 @@ export default async function getCollectionMarketStats({ collection }: InferSche return response; } - // Provide detailed error information for debugging when no fallback is available const errorMessage = error instanceof Error ? error.message : String(error); console.error(`Market stats error for collection ${collection}:`, error); diff --git a/src/tools/nft/get-collection-analytics.ts b/src/tools/nft/get-collection-analytics.ts index 39337d1..68017c9 100644 --- a/src/tools/nft/get-collection-analytics.ts +++ b/src/tools/nft/get-collection-analytics.ts @@ -49,16 +49,14 @@ export default async function getCollectionAnalytics({ symbol: null, totalSupply: null, owners: null, - floorPrice: 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) { @@ -74,14 +72,6 @@ export default async function getCollectionAnalytics({ analytics.owners = ownersResult.value.owners.length ?? null; } - if (floorPriceResult.status === 'fulfilled') { - const openSea = floorPriceResult.value?.openSea as unknown; - if (openSea && typeof openSea === 'object' && 'floorPrice' in openSea) { - const fp = (openSea as { floorPrice?: number | null }).floorPrice; - analytics.floorPrice = fp ?? null; - } - } - const response = { content: [ { diff --git a/src/types.ts b/src/types.ts index 5e6e33d..37dc8b0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -127,7 +127,6 @@ export type CollectionAnalyticsOutput = { symbol: string | null; totalSupply: number | null; owners: number | null; - floorPrice: number | null; }; export type MarketStatsOutput = { From 2a6b0e0eac1483b0b7a53f93f91bd2a46272416d Mon Sep 17 00:00:00 2001 From: William H Date: Fri, 8 Aug 2025 16:18:38 +0200 Subject: [PATCH 8/8] f --- src/tools/market/get-collection-market-stats.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/tools/market/get-collection-market-stats.ts b/src/tools/market/get-collection-market-stats.ts index 9e47293..93a2693 100644 --- a/src/tools/market/get-collection-market-stats.ts +++ b/src/tools/market/get-collection-market-stats.ts @@ -98,11 +98,8 @@ export default async function getCollectionMarketStats({ collection }: InferSche apiKeyAuth: raribleApiKey, }); - const collectionId = `SHAPE:${collection}`; - console.log(`Fetching Rarible stats for collection: ${collectionId}`); - const statsResponse = await rarible.collectionStatistics.getGlobalCollectionStatistics({ - id: collectionId, + id: `SHAPE:${collection}`, }); if (!statsResponse) {