@coinpaprika/mcp 1.4.0 → 1.4.2

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ All notable changes to the CoinPaprika MCP Server will be documented in this fil
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.4.2] - 2026-10-21
8
+
9
+ ### Security
10
+
11
+ - **Ids are checked against the characters real ids use.** 1.4.1 refused a short list of bad characters, so an id such as `..;`, `.. ` or a Unicode look-alike of `.` or `/` still reached the API, and whether it then meant `..` depended on how each server in front of the API parsed it. `exchangeId`, `tagId`, `personId` and `platformId` must now match `[a-z0-9-]`, and `contractAddress` `[A-Za-z0-9._~:-]` and not only dots (EVM, Solana, Sui and Aptos type tags such as `0x2::sui::SUI`, dotted NEAR and Hedera ids, TON addresses). Every path segment is checked against the same alphabet, and only the routes the tools call are sent. Anything else is refused with "Invalid identifier" before a request is made.
12
+ - **Redirects from the API are no longer followed blindly.** fetch followed them by default, repeating the request with your key without the path check seeing the new URL. A redirect is now followed only on the same host and base path, to the same route as the original request, and at most three times; anything else returns `CP502_REDIRECT_REFUSED`.
13
+ - **`keyInfo` passes only named fields to the model.** It returned the whole `/key/info` answer. It now returns `plan`, `plan_status`, `plan_started_at` and the `usage` message and monthly `requests_made` / `requests_left` counters, and drops every other field, including `portal_url`, and any value that contains the key.
14
+
15
+ ## [1.4.1] - 2026-10-21
16
+
17
+ ### Security
18
+
19
+ - **An id could change which API path a tool called.** Ids go into the request path through `encodeURIComponent`, which leaves `.` and `..` as they are, so an id of `..` moved the request, with your API key on it, to a different path. Every request now has its path checked before it is sent: a path that changes when parsed, or a segment that decodes to `.` or `..` or holds `/`, `\` or `%`, is refused with "Invalid identifier". No real coin, exchange, tag, person, platform or contract id is affected.
20
+
7
21
  ## [1.4.0] - 2026-08-19
8
22
 
9
23
  ### Fixed
package/dist/index.js CHANGED
@@ -2,6 +2,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { createRequire } from 'module';
4
4
  import fetch from 'node-fetch';
5
+ import { assertPathUnchanged, CONTRACT_ADDRESS, fetchUpstream, SLUG_ID, upstreamRoute } from './safe-path.js';
6
+ import { pickKeyInfo } from './key-info.js';
5
7
  import { z } from 'zod';
6
8
 
7
9
  // Server version, read from package.json so the banner, capabilities
@@ -28,6 +30,7 @@ const ErrorCodes = {
28
30
  CP404_NOT_FOUND: "CP404_NOT_FOUND",
29
31
  CP429_RATE_LIMIT: "CP429_RATE_LIMIT",
30
32
  CP500_SERVER_ERROR: "CP500_SERVER_ERROR",
33
+ CP502_REDIRECT_REFUSED: "CP502_REDIRECT_REFUSED",
31
34
  CP_UNKNOWN_ERROR: "CP_UNKNOWN_ERROR",
32
35
  };
33
36
 
@@ -195,7 +198,25 @@ function clamp(n, min, max) {
195
198
  // Helper function to fetch data from CoinPaprika API with structured error handling
196
199
  async function fetchFromAPI(endpoint) {
197
200
  const startTime = Date.now();
198
- const response = await fetch(`${API_BASE_URL}${endpoint}`, { headers: buildHeaders() });
201
+ const url = `${API_BASE_URL}${endpoint}`;
202
+ assertPathUnchanged(url); // before the key leaves the process
203
+ if (upstreamRoute(API_BASE_URL, url) === -1) throw new Error('Invalid identifier.');
204
+ let response;
205
+ try {
206
+ response = await fetchUpstream(fetch, API_BASE_URL, url, { headers: buildHeaders() });
207
+ } catch (e) {
208
+ if (e && e.status >= 300 && e.status <= 399) {
209
+ throw buildErrorResponse(
210
+ ErrorCodes.CP502_REDIRECT_REFUSED,
211
+ e.message,
212
+ false,
213
+ 'Try again later. If it persists, report it at https://github.com/coinpaprika/coinpaprika-mcp/issues',
214
+ undefined,
215
+ { endpoint, status: e.status }
216
+ );
217
+ }
218
+ throw e;
219
+ }
199
220
 
200
221
  if (!response.ok) {
201
222
  const structuredError = parseAPIError(response.status, response.statusText, endpoint);
@@ -744,13 +765,38 @@ const server = new McpServer({
744
765
  // (coinId / baseCurrencyId / quoteCurrencyId) instead of repeating it in each
745
766
  // handler, so a ticker symbol like "BTC" gets actionable guidance to call
746
767
  // search/resolveId instead of a bare upstream 404.
768
+ // The other ids that go into a request path are checked the same way: only the characters real
769
+ // ids use, so a value that is not an id never becomes part of a URL.
770
+ const OTHER_ID_FORMATS = {
771
+ exchangeId: SLUG_ID,
772
+ tagId: SLUG_ID,
773
+ personId: SLUG_ID,
774
+ platformId: SLUG_ID,
775
+ contractAddress: CONTRACT_ADDRESS,
776
+ };
777
+
778
+ function invalidIdError(field, value) {
779
+ const allowed = field === 'contractAddress'
780
+ ? 'letters, digits, ".", "_", "~", ":" and "-"'
781
+ : 'lowercase letters, digits and "-"';
782
+ return buildErrorResponse(
783
+ ErrorCodes.CP400_BAD_REQUEST,
784
+ `Invalid identifier: ${field} may contain only ${allowed}.`,
785
+ false,
786
+ 'Pass the id exactly as CoinPaprika returns it, e.g. from search, getExchanges, getTags, getPlatforms or getContracts.',
787
+ undefined,
788
+ { field, length: value.length }
789
+ );
790
+ }
791
+
747
792
  const _registerTool = server.tool.bind(server);
748
793
  server.tool = (...regArgs) => {
749
794
  const handler = regArgs[regArgs.length - 1];
750
795
  const schema = regArgs.length >= 4 && regArgs[2] && typeof regArgs[2] === 'object' ? regArgs[2] : null;
751
796
  if (typeof handler === 'function' && schema) {
752
797
  const idFields = ['coinId', 'baseCurrencyId', 'quoteCurrencyId'].filter((f) => f in schema);
753
- if (idFields.length > 0) {
798
+ const otherIdFields = Object.keys(OTHER_ID_FORMATS).filter((f) => f in schema);
799
+ if (idFields.length > 0 || otherIdFields.length > 0) {
754
800
  regArgs[regArgs.length - 1] = async (args, ...rest) => {
755
801
  for (const f of idFields) {
756
802
  const v = args && args[f];
@@ -759,6 +805,12 @@ server.tool = (...regArgs) => {
759
805
  if (err) return formatMcpError(err);
760
806
  }
761
807
  }
808
+ for (const f of otherIdFields) {
809
+ const v = args && args[f];
810
+ if (typeof v === 'string' && !OTHER_ID_FORMATS[f].test(v)) {
811
+ return formatMcpError(invalidIdError(f, v));
812
+ }
813
+ }
762
814
  return handler(args, ...rest);
763
815
  };
764
816
  }
@@ -1305,7 +1357,11 @@ server.tool(
1305
1357
  });
1306
1358
  }
1307
1359
  return withGuidance(
1308
- () => fetchFromAPI('/key/info'),
1360
+ async () => {
1361
+ // Only named, non-secret fields reach the model, whatever else the API sends.
1362
+ const { data, meta } = await fetchFromAPI('/key/info');
1363
+ return { data: pickKeyInfo(data, API_KEY), meta };
1364
+ },
1309
1365
  'keyInfo requires a Pro plan API key.'
1310
1366
  );
1311
1367
  }
@@ -0,0 +1,43 @@
1
+ // keyInfo hands the account's /key/info answer to the model. The model is not the key owner,
2
+ // so only fields named here pass, each with the type it is documented to have: the plan, its
3
+ // status and start date, and the monthly request counters. Anything else the API adds later,
4
+ // such as a key, a customer id or a per-account portal link, is dropped.
5
+
6
+ const STRING_FIELDS = ['plan', 'plan_status', 'plan_started_at'];
7
+ const MAX_STRING = 64;
8
+
9
+ function safeString(value, apiKey) {
10
+ if (typeof value !== 'string' || value.length > MAX_STRING) return undefined;
11
+ if (apiKey && value.includes(apiKey)) return undefined;
12
+ return value;
13
+ }
14
+
15
+ function safeNumber(value) {
16
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
17
+ }
18
+
19
+ export function pickKeyInfo(body, apiKey) {
20
+ const out = {};
21
+ if (!body || typeof body !== 'object') return out;
22
+ for (const field of STRING_FIELDS) {
23
+ const v = safeString(body[field], apiKey);
24
+ if (v !== undefined) out[field] = v;
25
+ }
26
+ const usage = body.usage;
27
+ if (usage && typeof usage === 'object') {
28
+ const picked = {};
29
+ const message = safeString(usage.message, apiKey);
30
+ if (message !== undefined) picked.message = message;
31
+ const month = usage.current_month;
32
+ if (month && typeof month === 'object') {
33
+ const counters = {};
34
+ for (const field of ['requests_made', 'requests_left']) {
35
+ const v = safeNumber(month[field]);
36
+ if (v !== undefined) counters[field] = v;
37
+ }
38
+ picked.current_month = counters;
39
+ }
40
+ out.usage = picked;
41
+ }
42
+ return out;
43
+ }
@@ -0,0 +1,100 @@
1
+ // Ids reach the upstream path through encodeURIComponent, which encodes "/", "?" and "#" but
2
+ // leaves "." and "..". An id of ".." would make the request, with your API key on it, go to a
3
+ // different path than the tool intended, because URL parsing resolves dot segments. And a
4
+ // character that URL parsing leaves alone can still mean "." or "/" to a server that strips
5
+ // ";" parameters, trims whitespace or folds Unicode before routing. So ids are checked against
6
+ // the characters real ids use (an allowlist, not a list of bad characters), every path segment
7
+ // is checked against the same alphabet, and a URL whose path changes when parsed is refused.
8
+
9
+ // Exchange, tag, person and platform ids: "binance", "defi", "vitalik-buterin", "eth-ethereum".
10
+ export const SLUG_ID = /^[a-z0-9-]+$/;
11
+ // Contract addresses: EVM 0x hex, Solana base58, Sui and Aptos type tags ("0x2::sui::SUI"),
12
+ // dotted forms such as NEAR or Hedera ids, TON base64url. Never only dots.
13
+ export const CONTRACT_ADDRESS = /^(?!\.+$)[A-Za-z0-9._~:-]+$/;
14
+ // Any path segment once decoded: the union of the id alphabets and the fixed route words.
15
+ const SEGMENT = CONTRACT_ADDRESS;
16
+
17
+ // The routes the tools call, relative to the base URL. "S" is one segment.
18
+ const S = '[^/]+';
19
+ const ROUTES = [
20
+ '/global', '/coins', '/tickers', '/tags', '/exchanges', '/contracts', '/search', '/price-converter',
21
+ '/key/info', '/changelog/ids',
22
+ `/coins/${S}`, `/coins/${S}/(events|exchanges|markets)`, `/coins/${S}/ohlcv/(latest|today|historical)`,
23
+ `/tickers/${S}`, `/tickers/${S}/historical`,
24
+ `/people/${S}`, `/tags/${S}`, `/exchanges/${S}`, `/exchanges/${S}/markets`,
25
+ `/contracts/${S}`, `/contracts/${S}/${S}`, `/contracts/${S}/${S}/historical`,
26
+ ].map((r) => new RegExp(`^${r}$`));
27
+
28
+ export function assertPathUnchanged(url) {
29
+ const afterScheme = url.indexOf('://');
30
+ const pathStart = afterScheme === -1 ? -1 : url.indexOf('/', afterScheme + 3);
31
+ if (pathStart === -1) return;
32
+ const end = url.slice(pathStart).search(/[?#]/);
33
+ const rawPath = end === -1 ? url.slice(pathStart) : url.slice(pathStart, pathStart + end);
34
+ if (new URL(url).pathname !== rawPath) {
35
+ throw new Error('Invalid identifier: an id may not be "." or "..".');
36
+ }
37
+ for (const seg of rawPath.split('/').slice(1)) {
38
+ let decoded;
39
+ try {
40
+ decoded = decodeURIComponent(seg);
41
+ } catch {
42
+ throw new Error('Invalid identifier.');
43
+ }
44
+ if (!SEGMENT.test(decoded)) {
45
+ throw new Error('Invalid identifier.');
46
+ }
47
+ }
48
+ }
49
+
50
+ // Index of the route a URL under baseUrl calls, or -1 if it is not one the tools use.
51
+ export function upstreamRoute(baseUrl, url) {
52
+ try {
53
+ const base = new URL(baseUrl);
54
+ const target = new URL(url);
55
+ const basePath = base.pathname.replace(/\/$/, '');
56
+ if (target.origin !== base.origin || !target.pathname.startsWith(`${basePath}/`)) return -1;
57
+ const path = target.pathname.slice(basePath.length);
58
+ return ROUTES.findIndex((re) => re.test(path));
59
+ } catch {
60
+ return -1;
61
+ }
62
+ }
63
+
64
+ // Where a redirect from the API may be followed, or null when it may not. The key is on every
65
+ // request, so a hop must stay on the same origin and base path, call the same route as the
66
+ // request it answers (a redirect may change an id, not which resource is read), and pass the
67
+ // same path check as the first URL.
68
+ export function upstreamRedirectTarget(baseUrl, currentUrl, location) {
69
+ if (!location) return null;
70
+ try {
71
+ const next = new URL(location, currentUrl);
72
+ next.hash = '';
73
+ const route = upstreamRoute(baseUrl, next.href);
74
+ if (route === -1 || route !== upstreamRoute(baseUrl, currentUrl)) return null;
75
+ assertPathUnchanged(next.href);
76
+ return next.href;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ // fetch with redirects handled here instead of by fetch, which would repeat the request, key
83
+ // included, to wherever Location points. Returns the final response; throws on a refused hop.
84
+ export async function fetchUpstream(fetchImpl, baseUrl, url, init, maxRedirects = 3) {
85
+ let current = url;
86
+ for (let hops = 0; ; hops += 1) {
87
+ const response = await fetchImpl(current, { ...init, redirect: 'manual' });
88
+ if (response.status < 300 || response.status > 399) return response;
89
+ const next = hops < maxRedirects
90
+ ? upstreamRedirectTarget(baseUrl, current, response.headers.get('location'))
91
+ : null;
92
+ try { await response.arrayBuffer(); } catch { /* body not needed */ }
93
+ if (!next) {
94
+ const err = new Error('The API answered with a redirect this server does not follow, so the request was not repeated.');
95
+ err.status = response.status;
96
+ throw err;
97
+ }
98
+ current = next;
99
+ }
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coinpaprika/mcp",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "A Model Context Protocol server for CoinPaprika cryptocurrency market data: prices, tickers, exchanges, and more",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -17,7 +17,7 @@
17
17
  "build": "mkdir -p dist && cp -r src/* dist/ && chmod +x dist/bin.js",
18
18
  "prepublishOnly": "npm run build",
19
19
  "start": "node src/index.js",
20
- "test": "node test/smoke.js"
20
+ "test": "node test/path_safety.js && node test/smoke.js"
21
21
  },
22
22
  "keywords": [
23
23
  "mcp",