mcp-crypto-price 2.1.4 → 2.2.0

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.
@@ -1,7 +1,28 @@
1
+ import { readFileSync } from 'fs';
2
+ import { fileURLToPath } from 'url';
3
+ import { dirname, resolve } from 'path';
4
+ function readVersion() {
5
+ try {
6
+ // ESM context — import.meta.url is available
7
+ const dir = dirname(fileURLToPath(import.meta.url));
8
+ const pkg = JSON.parse(readFileSync(resolve(dir, '../../package.json'), 'utf-8'));
9
+ return pkg.version;
10
+ }
11
+ catch {
12
+ // CJS context (e.g. Smithery esbuild bundle) — import.meta is empty
13
+ try {
14
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8'));
15
+ return pkg.version;
16
+ }
17
+ catch {
18
+ return '0.0.0';
19
+ }
20
+ }
21
+ }
1
22
  export const COINCAP_API_V2_BASE = "https://api.coincap.io/v2";
2
23
  export const COINCAP_API_V3_BASE = "https://rest.coincap.io/v3";
3
24
  export const SERVER_CONFIG = {
4
25
  name: "mcp-crypto-price",
5
- version: "2.1.0",
26
+ version: readVersion(),
6
27
  };
7
28
  export const CACHE_TTL = 60000; // 1 minute cache
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { z } from "zod";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { SERVER_CONFIG } from './config/index.js';
8
- import { handleGetPrice, handleGetMarketAnalysis, handleGetHistoricalAnalysis, GetPriceArgumentsSchema, GetMarketAnalysisSchema, GetHistoricalAnalysisSchema, } from './tools/index.js';
8
+ import { handleGetPrice, handleGetMarketAnalysis, handleGetHistoricalAnalysis, handleGetTopAssets, GetPriceArgumentsSchema, GetMarketAnalysisSchema, GetHistoricalAnalysisSchema, GetTopAssetsSchema, } from './tools/index.js';
9
9
  export const configSchema = z.object({
10
10
  coincapApiKey: z
11
11
  .string()
@@ -44,6 +44,33 @@ export function createServer({ config, }) {
44
44
  const result = await handleGetHistoricalAnalysis(args);
45
45
  return result;
46
46
  });
47
+ server.registerTool("get-top-assets", {
48
+ title: "Get Top Assets",
49
+ description: "Get top cryptocurrencies ranked by market cap",
50
+ inputSchema: GetTopAssetsSchema.shape,
51
+ }, async (args, _extra) => {
52
+ const result = await handleGetTopAssets(args);
53
+ return result;
54
+ });
55
+ // Register a no-op resource so the server advertises resources capability
56
+ // during the MCP initialize handshake. Without this, clients may receive
57
+ // -32601 "Method not found" errors for resources/list.
58
+ server.registerResource("server-info", "info://server", {
59
+ title: "Server Info",
60
+ description: "Basic server information",
61
+ mimeType: "application/json",
62
+ }, async (uri) => ({
63
+ contents: [
64
+ {
65
+ uri: uri.href,
66
+ mimeType: "application/json",
67
+ text: JSON.stringify({
68
+ name: SERVER_CONFIG.name,
69
+ version: SERVER_CONFIG.version,
70
+ }),
71
+ },
72
+ ],
73
+ }));
47
74
  return server.server;
48
75
  }
49
76
  export default createServer;
Binary file
@@ -23,7 +23,13 @@ describe('CoinCap Service', () => {
23
23
  rank: '1',
24
24
  symbol: 'BTC',
25
25
  name: 'Bitcoin',
26
- priceUsd: '50000.00'
26
+ priceUsd: '50000.00',
27
+ changePercent24Hr: '2.50',
28
+ volumeUsd24Hr: '30000000000',
29
+ marketCapUsd: '950000000000',
30
+ supply: '19000000',
31
+ maxSupply: '21000000',
32
+ vwap24Hr: '49500.00',
27
33
  }
28
34
  ]
29
35
  };
@@ -59,7 +65,10 @@ describe('CoinCap Service', () => {
59
65
  {
60
66
  exchangeId: 'binance',
61
67
  baseSymbol: 'BTC',
62
- priceUsd: '50000.00'
68
+ quoteSymbol: 'USDT',
69
+ priceUsd: '50000.00',
70
+ volumeUsd24Hr: '5000000000',
71
+ percentExchangeVolume: '25.00',
63
72
  }
64
73
  ]
65
74
  };
@@ -23,6 +23,24 @@ describe('Formatters', () => {
23
23
  expect(formatted).toContain('Market Cap: $1000.00B');
24
24
  expect(formatted).toContain('Rank: #1');
25
25
  });
26
+ it('should use adaptive precision for low-value coins', () => {
27
+ const asset = {
28
+ id: 'shiba-inu',
29
+ rank: '15',
30
+ symbol: 'SHIB',
31
+ name: 'Shiba Inu',
32
+ priceUsd: '0.00001234',
33
+ changePercent24Hr: '2.50',
34
+ volumeUsd24Hr: '500000000',
35
+ marketCapUsd: '7000000000',
36
+ supply: '589000000000000',
37
+ maxSupply: '',
38
+ vwap24Hr: '0.00001200'
39
+ };
40
+ const formatted = formatPriceInfo(asset);
41
+ expect(formatted).toContain('Price: $0.00001234');
42
+ expect(formatted).not.toContain('Price: $0.00\n');
43
+ });
26
44
  });
27
45
  describe('formatMarketAnalysis', () => {
28
46
  it('should format market analysis correctly', () => {
@@ -1,4 +1,5 @@
1
1
  import { COINCAP_API_V2_BASE, COINCAP_API_V3_BASE, CACHE_TTL } from '../config/index.js';
2
+ import { AssetsResponseSchema, HistoricalDataSchema, MarketsResponseSchema } from './schemas.js';
2
3
  const cache = new Map();
3
4
  // Expose cache clear function for testing
4
5
  export function clearCache() {
@@ -27,7 +28,7 @@ function setCacheData(key, data) {
27
28
  * 2. If v3 fails or no API key is provided, falls back to v2
28
29
  * 3. If both fail, throws an error
29
30
  */
30
- async function makeCoinCapRequest(endpoint) {
31
+ async function makeCoinCapRequest(endpoint, schema) {
31
32
  // Check cache first
32
33
  const cacheKey = endpoint;
33
34
  const cachedData = getCachedData(cacheKey);
@@ -45,7 +46,8 @@ async function makeCoinCapRequest(endpoint) {
45
46
  }
46
47
  });
47
48
  if (v3Response.ok) {
48
- const data = await v3Response.json();
49
+ const raw = await v3Response.json();
50
+ const data = schema ? schema.parse(raw) : raw;
49
51
  setCacheData(cacheKey, data);
50
52
  return data;
51
53
  }
@@ -67,7 +69,8 @@ async function makeCoinCapRequest(endpoint) {
67
69
  }
68
70
  const v2Response = await fetch(`${COINCAP_API_V2_BASE}${endpoint}`, { headers });
69
71
  if (v2Response.ok) {
70
- const data = await v2Response.json();
72
+ const raw = await v2Response.json();
73
+ const data = schema ? schema.parse(raw) : raw;
71
74
  setCacheData(cacheKey, data);
72
75
  return data;
73
76
  }
@@ -89,16 +92,29 @@ async function makeCoinCapRequest(endpoint) {
89
92
  }
90
93
  export async function getAssets() {
91
94
  try {
92
- return await makeCoinCapRequest('/assets');
95
+ return await makeCoinCapRequest('/assets', AssetsResponseSchema);
93
96
  }
94
97
  catch (error) {
95
98
  console.error("Failed to get assets:", error);
96
99
  return null;
97
100
  }
98
101
  }
102
+ export async function searchAsset(symbol) {
103
+ try {
104
+ const upperSymbol = symbol.toUpperCase();
105
+ const data = await makeCoinCapRequest(`/assets?search=${encodeURIComponent(symbol)}`, AssetsResponseSchema);
106
+ const asset = data.data.find((a) => a.symbol.toUpperCase() === upperSymbol) ??
107
+ data.data.find((a) => a.name.toUpperCase() === upperSymbol);
108
+ return asset ?? null;
109
+ }
110
+ catch (error) {
111
+ console.error(`Failed to search for asset ${symbol}:`, error);
112
+ return null;
113
+ }
114
+ }
99
115
  export async function getMarkets(assetId) {
100
116
  try {
101
- return await makeCoinCapRequest(`/assets/${assetId}/markets`);
117
+ return await makeCoinCapRequest(`/assets/${assetId}/markets`, MarketsResponseSchema);
102
118
  }
103
119
  catch (error) {
104
120
  console.error(`Failed to get markets for asset ${assetId}:`, error);
@@ -107,7 +123,7 @@ export async function getMarkets(assetId) {
107
123
  }
108
124
  export async function getHistoricalData(assetId, interval, start, end) {
109
125
  try {
110
- return await makeCoinCapRequest(`/assets/${assetId}/history?interval=${interval}&start=${start}&end=${end}`);
126
+ return await makeCoinCapRequest(`/assets/${assetId}/history?interval=${interval}&start=${start}&end=${end}`, HistoricalDataSchema);
111
127
  }
112
128
  catch (error) {
113
129
  console.error(`Failed to get historical data for asset ${assetId}:`, error);
@@ -1,8 +1,17 @@
1
+ function formatPrice(value) {
2
+ if (value >= 1)
3
+ return value.toFixed(2);
4
+ if (value >= 0.01)
5
+ return value.toFixed(4);
6
+ if (value >= 0.0001)
7
+ return value.toFixed(6);
8
+ return value.toFixed(8);
9
+ }
1
10
  export function formatPriceInfo(asset) {
2
- const price = parseFloat(asset.priceUsd).toFixed(2);
3
- const change = parseFloat(asset.changePercent24Hr).toFixed(2);
4
- const volume = (parseFloat(asset.volumeUsd24Hr) / 1000000).toFixed(2);
5
- const marketCap = (parseFloat(asset.marketCapUsd) / 1000000000).toFixed(2);
11
+ const price = formatPrice(parseFloat(asset.priceUsd));
12
+ const change = parseFloat(asset.changePercent24Hr || '0').toFixed(2);
13
+ const volume = (parseFloat(asset.volumeUsd24Hr || '0') / 1000000).toFixed(2);
14
+ const marketCap = (parseFloat(asset.marketCapUsd || '0') / 1000000000).toFixed(2);
6
15
  return [
7
16
  `${asset.name} (${asset.symbol})`,
8
17
  `Price: $${price}`,
@@ -20,17 +29,26 @@ export function formatMarketAnalysis(asset, markets) {
20
29
  const marketInfo = topMarkets.map(market => {
21
30
  const volumePercent = (parseFloat(market.volumeUsd24Hr) / totalVolume * 100).toFixed(2);
22
31
  const volume = (parseFloat(market.volumeUsd24Hr) / 1000000).toFixed(2);
23
- return `${market.exchangeId}: $${market.priceUsd} (Volume: $${volume}M, ${volumePercent}% of total)`;
32
+ return `${market.exchangeId}: $${formatPrice(parseFloat(market.priceUsd))} (Volume: $${volume}M, ${volumePercent}% of total)`;
24
33
  }).join('\n');
25
34
  return [
26
35
  `Market Analysis for ${asset.name} (${asset.symbol})`,
27
- `Current Price: $${parseFloat(asset.priceUsd).toFixed(2)}`,
28
- `24h Volume: $${(parseFloat(asset.volumeUsd24Hr) / 1000000).toFixed(2)}M`,
29
- `VWAP (24h): $${parseFloat(asset.vwap24Hr || '0').toFixed(2)}`,
36
+ `Current Price: $${formatPrice(parseFloat(asset.priceUsd))}`,
37
+ `24h Volume: $${(parseFloat(asset.volumeUsd24Hr || '0') / 1000000).toFixed(2)}M`,
38
+ `VWAP (24h): $${formatPrice(parseFloat(asset.vwap24Hr || '0'))}`,
30
39
  '\nTop 5 Markets by Volume:',
31
40
  marketInfo
32
41
  ].join('\n');
33
42
  }
43
+ export function formatTopAssets(assets) {
44
+ const lines = assets.map((asset) => {
45
+ const price = formatPrice(parseFloat(asset.priceUsd));
46
+ const change = parseFloat(asset.changePercent24Hr || '0').toFixed(2);
47
+ const marketCap = (parseFloat(asset.marketCapUsd || '0') / 1000000000).toFixed(2);
48
+ return `#${asset.rank} ${asset.name} (${asset.symbol}): $${price} (24h: ${change}%, MCap: $${marketCap}B)`;
49
+ });
50
+ return ['Top Cryptocurrencies by Market Cap', '', ...lines].join('\n');
51
+ }
34
52
  export function formatHistoricalAnalysis(asset, history) {
35
53
  const currentPrice = parseFloat(asset.priceUsd);
36
54
  const oldestPrice = parseFloat(history[0].priceUsd);
@@ -39,13 +57,13 @@ export function formatHistoricalAnalysis(asset, history) {
39
57
  const priceChange = ((currentPrice - oldestPrice) / oldestPrice * 100).toFixed(2);
40
58
  return [
41
59
  `Historical Analysis for ${asset.name} (${asset.symbol})`,
42
- `Period High: $${highestPrice.toFixed(2)}`,
43
- `Period Low: $${lowestPrice.toFixed(2)}`,
60
+ `Period High: $${formatPrice(highestPrice)}`,
61
+ `Period Low: $${formatPrice(lowestPrice)}`,
44
62
  `Price Change: ${priceChange}%`,
45
- `Current Price: $${currentPrice.toFixed(2)}`,
46
- `Starting Price: $${oldestPrice.toFixed(2)}`,
63
+ `Current Price: $${formatPrice(currentPrice)}`,
64
+ `Starting Price: $${formatPrice(oldestPrice)}`,
47
65
  '\nVolatility Analysis:',
48
- `Price Range: $${(highestPrice - lowestPrice).toFixed(2)}`,
66
+ `Price Range: $${formatPrice(highestPrice - lowestPrice)}`,
49
67
  `Range Percentage: ${((highestPrice - lowestPrice) / lowestPrice * 100).toFixed(2)}%`
50
68
  ].join('\n');
51
69
  }
@@ -0,0 +1,37 @@
1
+ import { z } from 'zod';
2
+ export const CryptoAssetSchema = z.object({
3
+ id: z.string(),
4
+ rank: z.string(),
5
+ symbol: z.string(),
6
+ name: z.string(),
7
+ priceUsd: z.string(),
8
+ changePercent24Hr: z.string().nullable(),
9
+ volumeUsd24Hr: z.string().nullable(),
10
+ marketCapUsd: z.string().nullable(),
11
+ supply: z.string().nullable(),
12
+ maxSupply: z.string().nullable(),
13
+ vwap24Hr: z.string().nullable(),
14
+ });
15
+ export const AssetsResponseSchema = z.object({
16
+ data: z.array(CryptoAssetSchema),
17
+ });
18
+ export const HistoryPointSchema = z.object({
19
+ time: z.number(),
20
+ priceUsd: z.string(),
21
+ circulatingSupply: z.string().optional(),
22
+ date: z.string(),
23
+ });
24
+ export const HistoricalDataSchema = z.object({
25
+ data: z.array(HistoryPointSchema),
26
+ });
27
+ export const MarketSchema = z.object({
28
+ exchangeId: z.string(),
29
+ baseSymbol: z.string(),
30
+ quoteSymbol: z.string(),
31
+ priceUsd: z.string(),
32
+ volumeUsd24Hr: z.string(),
33
+ percentExchangeVolume: z.string().nullable().optional(),
34
+ });
35
+ export const MarketsResponseSchema = z.object({
36
+ data: z.array(MarketSchema),
37
+ });
@@ -0,0 +1,61 @@
1
+ import { jest } from '@jest/globals';
2
+ jest.unstable_mockModule('../../services/coincap.js', () => ({
3
+ searchAsset: jest.fn(),
4
+ getAssets: jest.fn(),
5
+ getMarkets: jest.fn(),
6
+ getHistoricalData: jest.fn(),
7
+ clearCache: jest.fn(),
8
+ }));
9
+ const { searchAsset, getHistoricalData } = await import('../../services/coincap.js');
10
+ const { handleGetHistoricalAnalysis } = await import('../historical.js');
11
+ const mockSearchAsset = searchAsset;
12
+ const mockGetHistoricalData = getHistoricalData;
13
+ const mockAsset = {
14
+ id: 'bitcoin', rank: '1', symbol: 'BTC', name: 'Bitcoin',
15
+ priceUsd: '50000.00', changePercent24Hr: '2.50',
16
+ volumeUsd24Hr: '30000000000', marketCapUsd: '950000000000',
17
+ supply: '19000000', maxSupply: '21000000', vwap24Hr: '49500.00',
18
+ };
19
+ describe('handleGetHistoricalAnalysis', () => {
20
+ beforeEach(() => {
21
+ jest.clearAllMocks();
22
+ });
23
+ it('should return formatted historical analysis', async () => {
24
+ mockSearchAsset.mockResolvedValueOnce(mockAsset);
25
+ mockGetHistoricalData.mockResolvedValueOnce({
26
+ data: [
27
+ { time: 1609459200000, priceUsd: '45000.00', circulatingSupply: '18900000', date: '2021-01-01' },
28
+ { time: 1609545600000, priceUsd: '47000.00', circulatingSupply: '18900000', date: '2021-01-02' },
29
+ ],
30
+ });
31
+ const result = await handleGetHistoricalAnalysis({ symbol: 'BTC' });
32
+ expect(result.content[0].text).toContain('Historical Analysis for Bitcoin');
33
+ expect(result.content[0].text).toContain('Period High');
34
+ });
35
+ it('should return not-found message for unknown symbol', async () => {
36
+ mockSearchAsset.mockResolvedValueOnce(null);
37
+ const result = await handleGetHistoricalAnalysis({ symbol: 'ZZZZZ' });
38
+ expect(result.content[0].text).toContain('Could not find cryptocurrency with symbol ZZZZZ');
39
+ });
40
+ it('should return error when historical data is null', async () => {
41
+ mockSearchAsset.mockResolvedValueOnce(mockAsset);
42
+ mockGetHistoricalData.mockResolvedValueOnce(null);
43
+ const result = await handleGetHistoricalAnalysis({ symbol: 'BTC' });
44
+ expect(result.content[0].text).toBe('Failed to retrieve historical data');
45
+ });
46
+ it('should return message for empty history array', async () => {
47
+ mockSearchAsset.mockResolvedValueOnce(mockAsset);
48
+ mockGetHistoricalData.mockResolvedValueOnce({ data: [] });
49
+ const result = await handleGetHistoricalAnalysis({ symbol: 'BTC' });
50
+ expect(result.content[0].text).toBe('No historical data available for the selected time period');
51
+ });
52
+ it('should clamp days to schema bounds', async () => {
53
+ // days > 30 should fail schema validation
54
+ await expect(handleGetHistoricalAnalysis({ symbol: 'BTC', days: 100 })).rejects.toThrow();
55
+ // days < 1 should fail schema validation
56
+ await expect(handleGetHistoricalAnalysis({ symbol: 'BTC', days: 0 })).rejects.toThrow();
57
+ });
58
+ it('should throw on missing symbol', async () => {
59
+ await expect(handleGetHistoricalAnalysis({})).rejects.toThrow();
60
+ });
61
+ });
@@ -0,0 +1,50 @@
1
+ import { jest } from '@jest/globals';
2
+ jest.unstable_mockModule('../../services/coincap.js', () => ({
3
+ searchAsset: jest.fn(),
4
+ getAssets: jest.fn(),
5
+ getMarkets: jest.fn(),
6
+ getHistoricalData: jest.fn(),
7
+ clearCache: jest.fn(),
8
+ }));
9
+ const { searchAsset, getMarkets } = await import('../../services/coincap.js');
10
+ const { handleGetMarketAnalysis } = await import('../market.js');
11
+ const mockSearchAsset = searchAsset;
12
+ const mockGetMarkets = getMarkets;
13
+ const mockAsset = {
14
+ id: 'bitcoin', rank: '1', symbol: 'BTC', name: 'Bitcoin',
15
+ priceUsd: '50000.00', changePercent24Hr: '2.50',
16
+ volumeUsd24Hr: '30000000000', marketCapUsd: '950000000000',
17
+ supply: '19000000', maxSupply: '21000000', vwap24Hr: '49500.00',
18
+ };
19
+ describe('handleGetMarketAnalysis', () => {
20
+ beforeEach(() => {
21
+ jest.clearAllMocks();
22
+ });
23
+ it('should return formatted market analysis for a valid symbol', async () => {
24
+ mockSearchAsset.mockResolvedValueOnce(mockAsset);
25
+ mockGetMarkets.mockResolvedValueOnce({
26
+ data: [{
27
+ exchangeId: 'binance', baseSymbol: 'BTC', quoteSymbol: 'USDT',
28
+ priceUsd: '50000.00', volumeUsd24Hr: '5000000000',
29
+ percentExchangeVolume: '25.00',
30
+ }],
31
+ });
32
+ const result = await handleGetMarketAnalysis({ symbol: 'BTC' });
33
+ expect(result.content[0].text).toContain('Market Analysis for Bitcoin');
34
+ expect(result.content[0].text).toContain('binance');
35
+ });
36
+ it('should return not-found message for unknown symbol', async () => {
37
+ mockSearchAsset.mockResolvedValueOnce(null);
38
+ const result = await handleGetMarketAnalysis({ symbol: 'ZZZZZ' });
39
+ expect(result.content[0].text).toContain('Could not find cryptocurrency with symbol ZZZZZ');
40
+ });
41
+ it('should return error when markets data is null', async () => {
42
+ mockSearchAsset.mockResolvedValueOnce(mockAsset);
43
+ mockGetMarkets.mockResolvedValueOnce(null);
44
+ const result = await handleGetMarketAnalysis({ symbol: 'BTC' });
45
+ expect(result.content[0].text).toBe('Failed to retrieve market data');
46
+ });
47
+ it('should throw on missing symbol', async () => {
48
+ await expect(handleGetMarketAnalysis({})).rejects.toThrow();
49
+ });
50
+ });
@@ -0,0 +1,40 @@
1
+ import { jest } from '@jest/globals';
2
+ jest.unstable_mockModule('../../services/coincap.js', () => ({
3
+ searchAsset: jest.fn(),
4
+ getAssets: jest.fn(),
5
+ getMarkets: jest.fn(),
6
+ getHistoricalData: jest.fn(),
7
+ clearCache: jest.fn(),
8
+ }));
9
+ const { searchAsset } = await import('../../services/coincap.js');
10
+ const { handleGetPrice } = await import('../price.js');
11
+ const mockSearchAsset = searchAsset;
12
+ describe('handleGetPrice', () => {
13
+ beforeEach(() => {
14
+ jest.clearAllMocks();
15
+ });
16
+ it('should return formatted price for a valid symbol', async () => {
17
+ mockSearchAsset.mockResolvedValueOnce({
18
+ id: 'bitcoin', rank: '1', symbol: 'BTC', name: 'Bitcoin',
19
+ priceUsd: '50000.00', changePercent24Hr: '2.50',
20
+ volumeUsd24Hr: '30000000000', marketCapUsd: '950000000000',
21
+ supply: '19000000', maxSupply: '21000000', vwap24Hr: '49500.00',
22
+ });
23
+ const result = await handleGetPrice({ symbol: 'BTC' });
24
+ expect(result.content[0].text).toContain('Bitcoin (BTC)');
25
+ expect(result.content[0].text).toContain('50000.00');
26
+ });
27
+ it('should return not-found message for unknown symbol', async () => {
28
+ mockSearchAsset.mockResolvedValueOnce(null);
29
+ const result = await handleGetPrice({ symbol: 'ZZZZZ' });
30
+ expect(result.content[0].text).toContain('Could not find cryptocurrency with symbol ZZZZZ');
31
+ });
32
+ it('should return error message on exception', async () => {
33
+ mockSearchAsset.mockRejectedValueOnce(new Error('Network failure'));
34
+ const result = await handleGetPrice({ symbol: 'BTC' });
35
+ expect(result.content[0].text).toContain('Network failure');
36
+ });
37
+ it('should throw on missing symbol', async () => {
38
+ await expect(handleGetPrice({})).rejects.toThrow();
39
+ });
40
+ });
@@ -0,0 +1,54 @@
1
+ import { jest } from '@jest/globals';
2
+ jest.unstable_mockModule('../../services/coincap.js', () => ({
3
+ searchAsset: jest.fn(),
4
+ getAssets: jest.fn(),
5
+ getMarkets: jest.fn(),
6
+ getHistoricalData: jest.fn(),
7
+ clearCache: jest.fn(),
8
+ }));
9
+ const { getAssets } = await import('../../services/coincap.js');
10
+ const { handleGetTopAssets } = await import('../top-assets.js');
11
+ const mockGetAssets = getAssets;
12
+ const mockAssets = [
13
+ {
14
+ id: 'bitcoin', rank: '1', symbol: 'BTC', name: 'Bitcoin',
15
+ priceUsd: '50000.00', changePercent24Hr: '2.50',
16
+ volumeUsd24Hr: '30000000000', marketCapUsd: '950000000000',
17
+ supply: '19000000', maxSupply: '21000000', vwap24Hr: '49500.00',
18
+ },
19
+ {
20
+ id: 'ethereum', rank: '2', symbol: 'ETH', name: 'Ethereum',
21
+ priceUsd: '3000.00', changePercent24Hr: '1.20',
22
+ volumeUsd24Hr: '15000000000', marketCapUsd: '350000000000',
23
+ supply: '120000000', maxSupply: null, vwap24Hr: '2950.00',
24
+ },
25
+ ];
26
+ describe('handleGetTopAssets', () => {
27
+ beforeEach(() => {
28
+ jest.clearAllMocks();
29
+ });
30
+ it('should return formatted top assets', async () => {
31
+ mockGetAssets.mockResolvedValueOnce({ data: mockAssets });
32
+ const result = await handleGetTopAssets({});
33
+ expect(result.content[0].text).toContain('Top Cryptocurrencies by Market Cap');
34
+ expect(result.content[0].text).toContain('Bitcoin (BTC)');
35
+ expect(result.content[0].text).toContain('Ethereum (ETH)');
36
+ });
37
+ it('should respect limit parameter', async () => {
38
+ mockGetAssets.mockResolvedValueOnce({ data: mockAssets });
39
+ const result = await handleGetTopAssets({ limit: 1 });
40
+ expect(result.content[0].text).toContain('Bitcoin');
41
+ expect(result.content[0].text).not.toContain('Ethereum');
42
+ });
43
+ it('should return error when assets data is null', async () => {
44
+ mockGetAssets.mockResolvedValueOnce(null);
45
+ const result = await handleGetTopAssets({});
46
+ expect(result.content[0].text).toBe('Failed to retrieve assets data');
47
+ });
48
+ it('should reject limit > 50', async () => {
49
+ await expect(handleGetTopAssets({ limit: 100 })).rejects.toThrow();
50
+ });
51
+ it('should reject limit < 1', async () => {
52
+ await expect(handleGetTopAssets({ limit: 0 })).rejects.toThrow();
53
+ });
54
+ });
@@ -1,8 +1,8 @@
1
1
  import { z } from 'zod';
2
- import { getAssets, getHistoricalData } from '../services/coincap.js';
2
+ import { searchAsset, getHistoricalData } from '../services/coincap.js';
3
3
  import { formatHistoricalAnalysis } from '../services/formatters.js';
4
4
  export const GetHistoricalAnalysisSchema = z.object({
5
- symbol: z.string().min(1),
5
+ symbol: z.string().min(1).describe("Cryptocurrency symbol or name (e.g. BTC or Bitcoin)"),
6
6
  interval: z.enum(['m5', 'm15', 'm30', 'h1', 'h2', 'h6', 'h12', 'd1']).default('h1'),
7
7
  days: z.number().min(1).max(30).default(7),
8
8
  });
@@ -10,19 +10,16 @@ export async function handleGetHistoricalAnalysis(args) {
10
10
  const { symbol, interval, days } = GetHistoricalAnalysisSchema.parse(args);
11
11
  const upperSymbol = symbol.toUpperCase();
12
12
  try {
13
- const assetsData = await getAssets();
14
- if (!assetsData) {
15
- return {
16
- content: [{ type: "text", text: "Failed to retrieve cryptocurrency data" }],
17
- };
18
- }
19
- const asset = assetsData.data.find((a) => a.symbol.toUpperCase() === upperSymbol);
13
+ const asset = await searchAsset(upperSymbol);
20
14
  if (!asset) {
21
15
  return {
22
16
  content: [{ type: "text", text: `Could not find cryptocurrency with symbol ${upperSymbol}` }],
23
17
  };
24
18
  }
25
- const end = Date.now();
19
+ // Round timestamps to the nearest minute so the cache key stays stable
20
+ // across calls made within the same 60-second TTL window
21
+ const now = Date.now();
22
+ const end = now - (now % 60000);
26
23
  const start = end - (days * 24 * 60 * 60 * 1000);
27
24
  const historyData = await getHistoricalData(asset.id, interval, start, end);
28
25
  if (!historyData) {
@@ -1,3 +1,4 @@
1
1
  export * from './price.js';
2
2
  export * from './market.js';
3
3
  export * from './historical.js';
4
+ export * from './top-assets.js';
@@ -1,20 +1,14 @@
1
1
  import { z } from 'zod';
2
- import { getAssets, getMarkets } from '../services/coincap.js';
2
+ import { searchAsset, getMarkets } from '../services/coincap.js';
3
3
  import { formatMarketAnalysis } from '../services/formatters.js';
4
4
  export const GetMarketAnalysisSchema = z.object({
5
- symbol: z.string().min(1),
5
+ symbol: z.string().min(1).describe("Cryptocurrency symbol or name (e.g. BTC or Bitcoin)"),
6
6
  });
7
7
  export async function handleGetMarketAnalysis(args) {
8
8
  const { symbol } = GetMarketAnalysisSchema.parse(args);
9
9
  const upperSymbol = symbol.toUpperCase();
10
10
  try {
11
- const assetsData = await getAssets();
12
- if (!assetsData) {
13
- return {
14
- content: [{ type: "text", text: "Failed to retrieve cryptocurrency data" }],
15
- };
16
- }
17
- const asset = assetsData.data.find((a) => a.symbol.toUpperCase() === upperSymbol);
11
+ const asset = await searchAsset(upperSymbol);
18
12
  if (!asset) {
19
13
  return {
20
14
  content: [{ type: "text", text: `Could not find cryptocurrency with symbol ${upperSymbol}` }],
@@ -1,28 +1,17 @@
1
1
  import { z } from 'zod';
2
- import { getAssets } from '../services/coincap.js';
2
+ import { searchAsset } from '../services/coincap.js';
3
3
  import { formatPriceInfo } from '../services/formatters.js';
4
4
  export const GetPriceArgumentsSchema = z.object({
5
- symbol: z.string().min(1),
5
+ symbol: z.string().min(1).describe("Cryptocurrency symbol or name (e.g. BTC or Bitcoin)"),
6
6
  });
7
7
  export async function handleGetPrice(args) {
8
8
  const { symbol } = GetPriceArgumentsSchema.parse(args);
9
9
  const upperSymbol = symbol.toUpperCase();
10
10
  try {
11
- const assetsData = await getAssets();
12
- if (!assetsData) {
13
- return {
14
- content: [{ type: "text", text: "Failed to retrieve cryptocurrency data" }],
15
- };
16
- }
17
- const asset = assetsData.data.find((a) => a.symbol.toUpperCase() === upperSymbol);
11
+ const asset = await searchAsset(upperSymbol);
18
12
  if (!asset) {
19
13
  return {
20
- content: [
21
- {
22
- type: "text",
23
- text: `Could not find cryptocurrency with symbol ${upperSymbol}`,
24
- },
25
- ],
14
+ content: [{ type: "text", text: `Could not find cryptocurrency with symbol ${upperSymbol}` }],
26
15
  };
27
16
  }
28
17
  return {
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod';
2
+ import { getAssets } from '../services/coincap.js';
3
+ import { formatTopAssets } from '../services/formatters.js';
4
+ export const GetTopAssetsSchema = z.object({
5
+ limit: z.number().min(1).max(50).default(10),
6
+ });
7
+ export async function handleGetTopAssets(args) {
8
+ const { limit } = GetTopAssetsSchema.parse(args);
9
+ try {
10
+ const assetsData = await getAssets();
11
+ if (!assetsData) {
12
+ return {
13
+ content: [{ type: "text", text: "Failed to retrieve assets data" }],
14
+ };
15
+ }
16
+ const topAssets = assetsData.data.slice(0, limit);
17
+ if (!topAssets.length) {
18
+ return {
19
+ content: [{ type: "text", text: "No assets data available" }],
20
+ };
21
+ }
22
+ return {
23
+ content: [{ type: "text", text: formatTopAssets(topAssets) }],
24
+ };
25
+ }
26
+ catch (error) {
27
+ return {
28
+ content: [{
29
+ type: "text",
30
+ text: error instanceof Error ? error.message : `Failed to retrieve assets data: ${String(error)}`
31
+ }],
32
+ };
33
+ }
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-crypto-price",
3
- "version": "2.1.4",
3
+ "version": "2.2.0",
4
4
  "description": "A Model Context Protocol (MCP) server that provides real-time cryptocurrency data and analysis through CoinCap's API. Features include price tracking, market analysis, and historical trends.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -39,12 +39,13 @@
39
39
  "devDependencies": {
40
40
  "@semantic-release/changelog": "^6.0.3",
41
41
  "@semantic-release/git": "^10.0.1",
42
- "@semantic-release/github": "^12.0.2",
42
+ "@semantic-release/github": "^12.0.6",
43
+ "@semantic-release/npm": "^13.1.4",
43
44
  "@types/jest": "^30.0.0",
44
45
  "@types/node": "^24.10.1",
45
46
  "conventional-changelog-conventionalcommits": "^9.1.0",
46
47
  "jest": "^30.2.0",
47
- "semantic-release": "^25.0.2",
48
+ "semantic-release": "^25.0.3",
48
49
  "shx": "^0.3.4",
49
50
  "ts-jest": "^29.2.5",
50
51
  "typescript": "^5.7.3"
Binary file