Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
191 changes: 191 additions & 0 deletions src/tools/market/get-collection-market-stats.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)
? ((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<typeof schema>) {
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),
},
],
};
}
}
30 changes: 7 additions & 23 deletions src/tools/nft/get-collection-analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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,
Expand All @@ -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
},
};

Expand All @@ -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) {
Expand All @@ -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 = {
Expand Down
41 changes: 24 additions & 17 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Expand Down Expand Up @@ -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;
};
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down