@coinpaprika/mcp 1.4.1 → 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 +8 -0
- package/dist/index.js +57 -4
- package/dist/key-info.js +43 -0
- package/dist/safe-path.js +78 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ 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
|
+
|
|
7
15
|
## [1.4.1] - 2026-10-21
|
|
8
16
|
|
|
9
17
|
### Security
|
package/dist/index.js
CHANGED
|
@@ -2,7 +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 } from './safe-path.js';
|
|
5
|
+
import { assertPathUnchanged, CONTRACT_ADDRESS, fetchUpstream, SLUG_ID, upstreamRoute } from './safe-path.js';
|
|
6
|
+
import { pickKeyInfo } from './key-info.js';
|
|
6
7
|
import { z } from 'zod';
|
|
7
8
|
|
|
8
9
|
// Server version, read from package.json so the banner, capabilities
|
|
@@ -29,6 +30,7 @@ const ErrorCodes = {
|
|
|
29
30
|
CP404_NOT_FOUND: "CP404_NOT_FOUND",
|
|
30
31
|
CP429_RATE_LIMIT: "CP429_RATE_LIMIT",
|
|
31
32
|
CP500_SERVER_ERROR: "CP500_SERVER_ERROR",
|
|
33
|
+
CP502_REDIRECT_REFUSED: "CP502_REDIRECT_REFUSED",
|
|
32
34
|
CP_UNKNOWN_ERROR: "CP_UNKNOWN_ERROR",
|
|
33
35
|
};
|
|
34
36
|
|
|
@@ -198,7 +200,23 @@ async function fetchFromAPI(endpoint) {
|
|
|
198
200
|
const startTime = Date.now();
|
|
199
201
|
const url = `${API_BASE_URL}${endpoint}`;
|
|
200
202
|
assertPathUnchanged(url); // before the key leaves the process
|
|
201
|
-
|
|
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
|
+
}
|
|
202
220
|
|
|
203
221
|
if (!response.ok) {
|
|
204
222
|
const structuredError = parseAPIError(response.status, response.statusText, endpoint);
|
|
@@ -747,13 +765,38 @@ const server = new McpServer({
|
|
|
747
765
|
// (coinId / baseCurrencyId / quoteCurrencyId) instead of repeating it in each
|
|
748
766
|
// handler, so a ticker symbol like "BTC" gets actionable guidance to call
|
|
749
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
|
+
|
|
750
792
|
const _registerTool = server.tool.bind(server);
|
|
751
793
|
server.tool = (...regArgs) => {
|
|
752
794
|
const handler = regArgs[regArgs.length - 1];
|
|
753
795
|
const schema = regArgs.length >= 4 && regArgs[2] && typeof regArgs[2] === 'object' ? regArgs[2] : null;
|
|
754
796
|
if (typeof handler === 'function' && schema) {
|
|
755
797
|
const idFields = ['coinId', 'baseCurrencyId', 'quoteCurrencyId'].filter((f) => f in schema);
|
|
756
|
-
|
|
798
|
+
const otherIdFields = Object.keys(OTHER_ID_FORMATS).filter((f) => f in schema);
|
|
799
|
+
if (idFields.length > 0 || otherIdFields.length > 0) {
|
|
757
800
|
regArgs[regArgs.length - 1] = async (args, ...rest) => {
|
|
758
801
|
for (const f of idFields) {
|
|
759
802
|
const v = args && args[f];
|
|
@@ -762,6 +805,12 @@ server.tool = (...regArgs) => {
|
|
|
762
805
|
if (err) return formatMcpError(err);
|
|
763
806
|
}
|
|
764
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
|
+
}
|
|
765
814
|
return handler(args, ...rest);
|
|
766
815
|
};
|
|
767
816
|
}
|
|
@@ -1308,7 +1357,11 @@ server.tool(
|
|
|
1308
1357
|
});
|
|
1309
1358
|
}
|
|
1310
1359
|
return withGuidance(
|
|
1311
|
-
() =>
|
|
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
|
+
},
|
|
1312
1365
|
'keyInfo requires a Pro plan API key.'
|
|
1313
1366
|
);
|
|
1314
1367
|
}
|
package/dist/key-info.js
ADDED
|
@@ -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
|
+
}
|
package/dist/safe-path.js
CHANGED
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
// Ids reach the upstream path through encodeURIComponent, which encodes "/", "?" and "#" but
|
|
2
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
+
|
|
8
28
|
export function assertPathUnchanged(url) {
|
|
9
29
|
const afterScheme = url.indexOf('://');
|
|
10
30
|
const pathStart = afterScheme === -1 ? -1 : url.indexOf('/', afterScheme + 3);
|
|
@@ -21,8 +41,60 @@ export function assertPathUnchanged(url) {
|
|
|
21
41
|
} catch {
|
|
22
42
|
throw new Error('Invalid identifier.');
|
|
23
43
|
}
|
|
24
|
-
if (
|
|
44
|
+
if (!SEGMENT.test(decoded)) {
|
|
25
45
|
throw new Error('Invalid identifier.');
|
|
26
46
|
}
|
|
27
47
|
}
|
|
28
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