nansen-cli 1.43.0 → 1.44.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,29 +1,33 @@
1
1
  /**
2
2
  * Nansen CLI - Research command
3
3
  *
4
- * Historical/point-in-time analytics. Each subcommand resolves labels and
5
- * metrics at the requested date rather than current state — useful for
6
- * backtesting and historical research.
4
+ * Direct API analytics, including historical/point-in-time research.
7
5
  */
8
6
 
9
7
  import { NansenError, ErrorCode } from '../api.js';
8
+ import { parseSort } from '../query-options.js';
10
9
 
11
- // Local copies of CLI helpers to avoid a circular import with src/cli.js.
10
+ // Research subcommands validate --page strictly. The shared helper in
11
+ // src/query-options.js clamps an invalid page to 1 for the category commands,
12
+ // so keep a local variant here that rejects it instead.
12
13
  function buildPagination(options) {
13
- if (!options.limit && !options.page) return undefined;
14
- return {
15
- page: Math.max(1, parseInt(options.page, 10) || 1),
16
- per_page: options.limit,
17
- };
18
- }
19
-
20
- function parseSort(sortOption, orderByOption) {
21
- if (orderByOption) return orderByOption;
22
- if (!sortOption) return undefined;
23
- const parts = String(sortOption).split(':');
24
- const field = parts[0];
25
- const direction = (parts[1] || 'desc').toUpperCase();
26
- return [{ field, direction }];
14
+ if (options.limit === undefined && options.page === undefined) return undefined;
15
+ const pagination = { page: 1 };
16
+ if (options.page !== undefined) {
17
+ const page = Number(options.page);
18
+ if (!Number.isInteger(page) || page < 1) {
19
+ throw new NansenError('--page must be a positive integer', ErrorCode.INVALID_PARAMS);
20
+ }
21
+ pagination.page = page;
22
+ }
23
+ if (options.limit !== undefined) {
24
+ const perPage = Number(options.limit);
25
+ if (!Number.isInteger(perPage) || perPage < 1) {
26
+ throw new NansenError('--limit must be a positive integer', ErrorCode.INVALID_PARAMS);
27
+ }
28
+ pagination.per_page = perPage;
29
+ }
30
+ return pagination;
27
31
  }
28
32
 
29
33
  const SUBCOMMANDS = [
@@ -38,9 +42,16 @@ const SUBCOMMANDS = [
38
42
  'historical-wallet-balances',
39
43
  'historical-tx-lookup',
40
44
  'historical-wallet-transactions',
45
+ 'historical-token-ohlcv',
41
46
  ];
42
47
 
43
48
  export const RESEARCH_HISTORICAL_SUBCOMMANDS = new Set(SUBCOMMANDS);
49
+ export const RESEARCH_SUBCOMMANDS = new Set(['chain-rank', 'token-sectors', 'address-premium-labels', 'smart-money-pnl-leaderboard', 'position-intelligence', 'perp-pnl-summary', 'transaction-with-token-transfer-lookup', ...SUBCOMMANDS]);
50
+
51
+ const CHAIN_RANK_TIMEFRAMES = new Set([7, 30, 365]);
52
+ const CHAIN_RANK_CHAIN_TYPES = new Set(['all', 'evm']);
53
+
54
+ const SM_PNL_TIMEFRAME_DAYS = [1, 7, 30, 90, 180];
44
55
 
45
56
  function requireOptions(options, required) {
46
57
  const missing = required.filter(name => !options[name]);
@@ -58,11 +69,20 @@ function resolveDateRange(options) {
58
69
 
59
70
  function parseTimeframeDays(value) {
60
71
  if (value === undefined || value === null || value === '') return undefined;
61
- const n = parseInt(value, 10);
62
- if (Number.isNaN(n)) {
63
- throw new NansenError('--timeframe-days must be an integer', ErrorCode.INVALID_PARAMS);
72
+ const trimmed = String(value).trim();
73
+ if (!/^[1-9]\d*$/.test(trimmed)) {
74
+ throw new NansenError('--timeframe-days must be a positive integer', ErrorCode.INVALID_PARAMS);
64
75
  }
65
- return n;
76
+ return parseInt(trimmed, 10);
77
+ }
78
+
79
+ function parseBooleanOption(options, flags, key) {
80
+ const value = options[key] ?? flags[key];
81
+ if (value === undefined) return undefined;
82
+ if (typeof value === 'boolean') return value;
83
+ if (value === 'true' || value === '1') return true;
84
+ if (value === 'false' || value === '0') return false;
85
+ throw new NansenError(`--${key} must be true or false`, ErrorCode.INVALID_PARAMS);
66
86
  }
67
87
 
68
88
  function parseChains(options) {
@@ -75,9 +95,17 @@ function parseChains(options) {
75
95
  return undefined;
76
96
  }
77
97
 
78
- const HELP_TOP = `nansen research — Historical/point-in-time analytics
98
+ const HELP_TOP = `nansen research — Direct API analytics
79
99
 
80
100
  SUBCOMMANDS:
101
+ chain-rank Rank chains by growth metrics
102
+ token-sectors List token sectors available for filtering
103
+ address-premium-labels Get all labels for an address, including premium labels
104
+ smart-money-pnl-leaderboard Rank smart money wallets by PnL
105
+ position-intelligence Aggregate Hyperliquid positions by trader cohort
106
+ perp-pnl-summary Summarize realized Hyperliquid PnL for an address
107
+ transaction-with-token-transfer-lookup
108
+ Look up a transaction and its token/NFT transfers
81
109
  historical-dex-trades Historical DEX trades for a token
82
110
  historical-pnl-leaderboard Historical PnL leaderboard for a token
83
111
  historical-token-flow-summary Historical token flow summary
@@ -89,6 +117,7 @@ SUBCOMMANDS:
89
117
  historical-wallet-balances Historical token balances for a wallet
90
118
  historical-tx-lookup Lookup a historical transaction by hash
91
119
  historical-wallet-transactions Historical transactions for a wallet
120
+ historical-token-ohlcv Historical token OHLCV candles
92
121
 
93
122
  COMMON OPTIONS:
94
123
  --from-date <YYYY-MM-DD> Start of date range (for range-based subcommands)
@@ -102,6 +131,38 @@ COMMON OPTIONS:
102
131
  Run: nansen research <subcommand> --help`;
103
132
 
104
133
  const SUB_HELP = {
134
+ 'chain-rank': `nansen research chain-rank — Rank chains by growth metrics
135
+
136
+ USAGE:
137
+ nansen research chain-rank [--timeframe-days 7|30|365] [--chain-type all|evm]`,
138
+ 'token-sectors': `nansen research token-sectors — List token sectors available for filtering
139
+
140
+ USAGE:
141
+ nansen research token-sectors`,
142
+ 'address-premium-labels': `nansen research address-premium-labels — Get all labels for an address, including premium labels
143
+
144
+ USAGE:
145
+ nansen research address-premium-labels --address <addr> [--chain <chain>] [--page <n>] [--limit <n>]`,
146
+ 'smart-money-pnl-leaderboard': `nansen research smart-money-pnl-leaderboard — Rank smart money wallets by PnL
147
+
148
+ USAGE:
149
+ nansen research smart-money-pnl-leaderboard [--chains c1,c2] [--timeframe-days 1|7|30|90|180] [--filters '<json>'] [--sort <field[:asc|desc]>] [--page <n>] [--limit <n>]`,
150
+ 'position-intelligence': `nansen research position-intelligence — Aggregate Hyperliquid positions by trader cohort
151
+
152
+ USAGE:
153
+ nansen research position-intelligence --symbol <symbol>
154
+
155
+ NOTE: --token-address is accepted as an alias for --symbol (the API request field is token_address).`,
156
+ 'perp-pnl-summary': `nansen research perp-pnl-summary — Summarize realized Hyperliquid PnL for an address
157
+
158
+ USAGE:
159
+ nansen research perp-pnl-summary --address <addr> --from-date <date> --to-date <date>`,
160
+ 'transaction-with-token-transfer-lookup': `nansen research transaction-with-token-transfer-lookup — Look up a transaction and its token/NFT transfers
161
+
162
+ USAGE:
163
+ nansen research transaction-with-token-transfer-lookup --transaction-hash <hash> [--chain <chain>] [--block-timestamp "YYYY-MM-DD HH:MM:SS"]
164
+
165
+ NOTE: --block-timestamp is required for bitcoin, tron, ton, starknet, and sui.`,
105
166
  'historical-dex-trades': `nansen research historical-dex-trades — Historical DEX trades for a token
106
167
 
107
168
  USAGE:
@@ -152,6 +213,10 @@ NOTE: Providing --block-timestamp skips a slow hash-resolution step and returns
152
213
 
153
214
  USAGE:
154
215
  nansen research historical-wallet-transactions --address <addr> --as-of-date <YYYY-MM-DD> [--chain <chain>]`,
216
+ 'historical-token-ohlcv': `nansen research historical-token-ohlcv — Historical token OHLCV candles
217
+
218
+ USAGE:
219
+ nansen research historical-token-ohlcv --token-address <addr> --from-date <date> --timeframe <5m|15m|30m|1h|1d|1w> (--as-of-date <date> | --as-of-ts <timestamp>) [--chain <chain>] [--apply-blacklist-filter <true|false>]`,
155
220
  };
156
221
 
157
222
  export function buildResearchCommands(deps = {}) {
@@ -166,9 +231,9 @@ export function buildResearchCommands(deps = {}) {
166
231
  return;
167
232
  }
168
233
 
169
- if (!RESEARCH_HISTORICAL_SUBCOMMANDS.has(sub)) {
234
+ if (!RESEARCH_SUBCOMMANDS.has(sub)) {
170
235
  throw new NansenError(
171
- `Unknown research subcommand: ${sub}. Available: ${SUBCOMMANDS.join(', ')}`,
236
+ `Unknown research subcommand: ${sub}. Available: ${[...RESEARCH_SUBCOMMANDS].join(', ')}`,
172
237
  ErrorCode.UNKNOWN,
173
238
  );
174
239
  }
@@ -178,12 +243,121 @@ export function buildResearchCommands(deps = {}) {
178
243
  return;
179
244
  }
180
245
 
246
+ if (sub === 'token-sectors') return apiInstance.tokenSectors();
247
+
181
248
  const orderBy = parseSort(options.sort, options['order-by']);
182
249
  const pagination = buildPagination(options);
183
250
  const filters = options.filters || {};
184
251
  const { fromDate, toDate } = resolveDateRange(options);
185
252
  const asOfDate = options['as-of-date'];
186
253
 
254
+ if (sub === 'chain-rank') {
255
+ const timeFrame = parseTimeframeDays(options['timeframe-days']) ?? 7;
256
+ if (!CHAIN_RANK_TIMEFRAMES.has(timeFrame)) {
257
+ throw new NansenError(
258
+ `--timeframe-days must be one of: ${[...CHAIN_RANK_TIMEFRAMES].join(', ')}`,
259
+ ErrorCode.INVALID_PARAMS,
260
+ );
261
+ }
262
+ const chainType = options['chain-type'] || 'all';
263
+ if (!CHAIN_RANK_CHAIN_TYPES.has(chainType)) {
264
+ throw new NansenError(
265
+ `--chain-type must be one of: ${[...CHAIN_RANK_CHAIN_TYPES].join(', ')}`,
266
+ ErrorCode.INVALID_PARAMS,
267
+ );
268
+ }
269
+ return apiInstance.chainRank({ timeFrame, chainType });
270
+ }
271
+
272
+ if (sub === 'address-premium-labels') {
273
+ requireOptions({ address: options.address }, ['address']);
274
+ return apiInstance.addressPremiumLabels({
275
+ address: options.address,
276
+ chain: options.chain || 'all',
277
+ pagination,
278
+ });
279
+ }
280
+
281
+ if (sub === 'smart-money-pnl-leaderboard') {
282
+ const timeframe = parseTimeframeDays(options['timeframe-days']) ?? 7;
283
+ if (!SM_PNL_TIMEFRAME_DAYS.includes(timeframe)) {
284
+ throw new NansenError(
285
+ `--timeframe-days must be one of: ${SM_PNL_TIMEFRAME_DAYS.join(', ')}`,
286
+ ErrorCode.INVALID_PARAMS,
287
+ );
288
+ }
289
+ return apiInstance.smartMoneyPnlLeaderboard({
290
+ chains: parseChains(options) || ['solana'],
291
+ timeframe,
292
+ filters, orderBy, pagination,
293
+ });
294
+ }
295
+
296
+ if (sub === 'position-intelligence') {
297
+ const symbol = String(options.symbol || options['token-address'] || options.token || '').trim();
298
+ if (!symbol) {
299
+ throw new NansenError(
300
+ 'Required: --symbol (or --token-address)',
301
+ ErrorCode.MISSING_PARAM,
302
+ );
303
+ }
304
+ return apiInstance.tokenPositionIntelligence({ tokenAddress: symbol });
305
+ }
306
+
307
+ if (sub === 'perp-pnl-summary') {
308
+ requireOptions(
309
+ { address: options.address, 'from-date': fromDate, 'to-date': toDate },
310
+ ['address', 'from-date', 'to-date'],
311
+ );
312
+ return apiInstance.addressPerpPnlSummary({ address: options.address, fromDate, toDate });
313
+ }
314
+
315
+ if (sub === 'historical-token-ohlcv') {
316
+ const tokenAddress = options['token-address'] || options.token;
317
+ const asOfTs = options['as-of-ts'];
318
+ requireOptions(
319
+ { 'token-address': tokenAddress, 'from-date': fromDate, timeframe: options.timeframe },
320
+ ['token-address', 'from-date', 'timeframe'],
321
+ );
322
+ if (!asOfDate && !asOfTs) {
323
+ throw new NansenError(
324
+ 'Provide one of --as-of-date or --as-of-ts',
325
+ ErrorCode.MISSING_PARAM,
326
+ );
327
+ }
328
+ if (asOfDate && asOfTs) {
329
+ throw new NansenError(
330
+ '--as-of-date and --as-of-ts are mutually exclusive',
331
+ ErrorCode.INVALID_PARAMS,
332
+ );
333
+ }
334
+ return apiInstance.researchHistoricalTokenOhlcv({
335
+ tokenAddress,
336
+ chain: options.chain || 'solana',
337
+ fromDate,
338
+ asOfDate,
339
+ asOfTs,
340
+ timeframe: options.timeframe,
341
+ applyBlacklistFilter: parseBooleanOption(options, flags, 'apply-blacklist-filter'),
342
+ });
343
+ }
344
+
345
+ if (sub === 'transaction-with-token-transfer-lookup') {
346
+ const chain = options.chain || 'ethereum';
347
+ const blockTimestamp = options['block-timestamp'];
348
+ requireOptions({ 'transaction-hash': options['transaction-hash'] }, ['transaction-hash']);
349
+ // Chains the API rejects without block_timestamp. Every other enum value,
350
+ // including 'all', near, and injective, accepts a timestamp-less lookup.
351
+ if (['bitcoin', 'tron', 'ton', 'starknet', 'sui'].includes(chain)) {
352
+ requireOptions({ 'block-timestamp': blockTimestamp }, ['block-timestamp']);
353
+ }
354
+ return apiInstance.transactionWithTokenTransferLookup({
355
+ transactionHash: options['transaction-hash'],
356
+ chain,
357
+ blockTimestamp,
358
+ });
359
+ }
360
+
187
361
  // Range-based token endpoints (require --from-date + --to-date)
188
362
  const rangeTokenHandlers = {
189
363
  'historical-dex-trades': () => apiInstance.researchDexTrades({
package/src/hl-client.js CHANGED
@@ -30,6 +30,15 @@ export {
30
30
 
31
31
  import { hlApiUrl } from "./hl-env.js";
32
32
 
33
+ function exchangeError(message, code, exchangeResult) {
34
+ const error = new CommandError(message, code);
35
+ // Keep only a structured action response available to the caller so it can
36
+ // emit authoritative per-leg outcomes before rethrowing. Opaque HTTP bodies
37
+ // are not action results and must remain indeterminate.
38
+ if (exchangeResult !== undefined) error.exchangeResult = exchangeResult;
39
+ return error;
40
+ }
41
+
33
42
  // Port of perp_execute.py::extract_action_errors. HL returns top-level
34
43
  // status "ok" even when individual actions are rejected:
35
44
  // {"status":"ok","response":{"data":{"statuses":[{"error":"..."}]}}}
@@ -130,9 +139,9 @@ export async function submitExchange(
130
139
  typeof data === "string"
131
140
  ? data
132
141
  : data.response || data.error || JSON.stringify(data);
133
- throw new CommandError(
142
+ throw exchangeError(
134
143
  `Hyperliquid error (HTTP ${response.status}): ${detail}`,
135
- "HL_HTTP_ERROR"
144
+ "HL_HTTP_ERROR",
136
145
  );
137
146
  }
138
147
 
@@ -144,23 +153,26 @@ export async function submitExchange(
144
153
  typeof responseBody === "string"
145
154
  ? responseBody
146
155
  : "Hyperliquid rejected the action";
147
- throw new CommandError(
156
+ throw exchangeError(
148
157
  `Hyperliquid rejected the action: ${reason}`,
149
- "HL_ACTION_REJECTED"
158
+ "HL_ACTION_REJECTED",
159
+ data,
150
160
  );
151
161
  }
152
162
 
153
163
  const actionResults = extractActionErrors(responseBody, action);
154
164
  if (actionResults.failed.length > 0 && actionResults.succeeded.length > 0) {
155
- throw new CommandError(
165
+ throw exchangeError(
156
166
  `Hyperliquid partially filled the action: succeeded ${actionResults.succeeded.join(", ")}; failed ${actionResults.failed.map(({ leg, error }) => `${leg}: ${error}`).join("; ")}`,
157
- "PARTIAL_FILL"
167
+ "PARTIAL_FILL",
168
+ data,
158
169
  );
159
170
  }
160
171
  if (actionResults.failed.length > 0) {
161
- throw new CommandError(
172
+ throw exchangeError(
162
173
  `Hyperliquid rejected the action: ${actionResults.failed.map(({ error }) => error).join("; ")}`,
163
- "HL_ACTION_REJECTED"
174
+ "HL_ACTION_REJECTED",
175
+ data,
164
176
  );
165
177
  }
166
178
 
@@ -396,23 +396,34 @@ export function parseExpiry(expiryStr) {
396
396
  const match = expiryStr.match(/^(\d+)(h|d)$/i);
397
397
  if (match) {
398
398
  const value = parseInt(match[1], 10);
399
+ if (!Number.isFinite(value)) {
400
+ throw new Error(`Invalid expiry "${expiryStr}". Duration must be finite.`);
401
+ }
399
402
  if (value <= 0) {
400
403
  throw new Error(`Invalid expiry "${expiryStr}". Duration must be greater than 0.`);
401
404
  }
402
405
  const unit = match[2].toLowerCase();
403
406
  const ms = unit === 'h' ? value * 3600 * 1000 : value * 24 * 3600 * 1000;
404
- return Date.now() + ms;
407
+ const expiresAt = Date.now() + ms;
408
+ if (!Number.isFinite(expiresAt)) {
409
+ throw new Error(`Invalid expiry "${expiryStr}". Duration is too large.`);
410
+ }
411
+ return expiresAt;
405
412
  }
406
413
 
407
414
  // Try as raw epoch ms — must be in the future, or the order expires on arrival.
408
415
  const num = Number(expiryStr);
409
- if (!isNaN(num)) {
416
+ if (Number.isFinite(num)) {
410
417
  if (num <= Date.now()) {
411
418
  throw new Error(`Expiry "${expiryStr}" is in the past. Provide a future time (e.g. "24h", "7d", or a future epoch in ms).`);
412
419
  }
413
420
  return num;
414
421
  }
415
422
 
423
+ if (!Number.isNaN(num)) {
424
+ throw new Error(`Invalid expiry "${expiryStr}". Provide a finite future epoch in milliseconds or a duration such as "24h" or "7d".`);
425
+ }
426
+
416
427
  throw new Error(`Invalid expiry format: "${expiryStr}". Use "24h", "7d", "30d", or epoch ms.`);
417
428
  }
418
429
 
@@ -569,6 +580,22 @@ EXAMPLES:
569
580
  return;
570
581
  }
571
582
 
583
+ const price = Number(triggerPrice);
584
+ if (!Number.isFinite(price) || price <= 0) {
585
+ log('Error: --trigger-price must be a finite positive number.');
586
+ exit(1);
587
+ return;
588
+ }
589
+
590
+ let expiresAt;
591
+ try {
592
+ expiresAt = parseExpiry(expiresStr);
593
+ } catch (err) {
594
+ log(`Error: ${err.message}`);
595
+ exit(1);
596
+ return;
597
+ }
598
+
572
599
  // Amount is always in human-readable token units (e.g. 1.5 = 1.5 SOL)
573
600
  // Converted to base units (lamports) internally
574
601
  let amountBaseUnits;
@@ -594,13 +621,6 @@ EXAMPLES:
594
621
  return;
595
622
  }
596
623
 
597
- const price = Number(triggerPrice);
598
- if (isNaN(price) || price <= 0) {
599
- log('Error: --trigger-price must be a positive number (USD price).');
600
- exit(1);
601
- return;
602
- }
603
-
604
624
  if (triggerCondition !== 'above' && triggerCondition !== 'below') {
605
625
  log('Error: --trigger-condition must be "above" or "below".');
606
626
  exit(1);
@@ -619,15 +639,6 @@ EXAMPLES:
619
639
  }
620
640
  }
621
641
 
622
- let expiresAt;
623
- try {
624
- expiresAt = parseExpiry(expiresStr);
625
- } catch (err) {
626
- log(`Error: ${err.message}`);
627
- exit(1);
628
- return;
629
- }
630
-
631
642
  const triggerMint = resolveTokenAddress(triggerMintRaw, 'solana');
632
643
 
633
644
  const tmValidation = validateTokenAddress(triggerMint, 'solana');
@@ -899,8 +910,8 @@ EXAMPLES:
899
910
  const updateBody = { orderType: 'single' };
900
911
  if (triggerPrice != null) {
901
912
  const price = Number(triggerPrice);
902
- if (isNaN(price) || price <= 0) {
903
- log('Error: --trigger-price must be a positive number.');
913
+ if (!Number.isFinite(price) || price <= 0) {
914
+ log('Error: --trigger-price must be a finite positive number.');
904
915
  exit(1);
905
916
  return;
906
917
  }
package/src/mcp-verify.js CHANGED
@@ -12,6 +12,37 @@ class McpRequestError extends Error {
12
12
  }
13
13
  }
14
14
 
15
+ const IPV4_OCTET = '(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})';
16
+ const LOOPBACK_IPV4 = new RegExp(`^127\\.${IPV4_OCTET}\\.${IPV4_OCTET}\\.${IPV4_OCTET}$`);
17
+
18
+ // IPv4-mapped IPv6 loopback (127.0.0.0/8). Node's URL canonicalizes
19
+ // ::ffff:127.x.x.x to ::ffff:7fNN:NNNN (the 127 becomes the 7f high byte), so
20
+ // match that form; non-loopback mapped addresses fall outside the 7f prefix.
21
+ const LOOPBACK_IPV4_MAPPED = /^::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}$/i;
22
+
23
+ function isLoopbackHostname(hostname) {
24
+ if (!hostname) return false;
25
+ const host = hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
26
+ if (host === 'localhost' || host === '::1') return true;
27
+ if (LOOPBACK_IPV4_MAPPED.test(host)) return true;
28
+ return LOOPBACK_IPV4.test(host);
29
+ }
30
+
31
+ /**
32
+ * Classify a destination URL for the credential-disclosure guard: its origin
33
+ * (for messaging), protocol, and whether the host is loopback.
34
+ */
35
+ function classifyDestination(url) {
36
+ try {
37
+ const parsed = new URL(url);
38
+ return { origin: parsed.origin, protocol: parsed.protocol, loopback: isLoopbackHostname(parsed.hostname) };
39
+ } catch {
40
+ // An unparseable URL never reaches an authenticated fetch (tools/list fails
41
+ // first), so this is only for messaging; treat it as an unsafe destination.
42
+ return { origin: url, protocol: null, loopback: false };
43
+ }
44
+ }
45
+
15
46
  function responseContentType(response) {
16
47
  return (
17
48
  response.headers?.get?.('content-type')
@@ -185,6 +216,7 @@ export async function runMcpVerifyChecks({
185
216
  fetchFn = fetch,
186
217
  timeoutMs = 30_000,
187
218
  devConfigPath,
219
+ sendApiKey = false,
188
220
  } = {}) {
189
221
  const checks = [];
190
222
  const auth = resolveAuthConfig(env, devConfigPath);
@@ -205,8 +237,39 @@ export async function runMcpVerifyChecks({
205
237
  ));
206
238
  }
207
239
 
240
+ // A saved key is trusted for the default Nansen endpoint only. Forwarding it
241
+ // to a caller-supplied URL requires explicit per-invocation consent, and no
242
+ // key may travel in cleartext to a public host.
243
+ let keyWithheld = null;
208
244
  if (key && url !== DEFAULT_MCP_URL) {
209
- checks.push(check('mcp-url', 'warn', `Non-default MCP URL: ${url} — the API key will be sent to this host`));
245
+ const dest = classifyDestination(url);
246
+ if (dest.protocol === 'http:' && !dest.loopback) {
247
+ keyWithheld = check(
248
+ 'mcp-url',
249
+ 'error',
250
+ `Refusing to send the API key over plain HTTP to a non-loopback host (${dest.origin})`,
251
+ 'Use an https:// URL. Plain HTTP is only permitted for localhost/loopback development.',
252
+ );
253
+ } else if (dest.protocol !== 'https:' && !(dest.protocol === 'http:' && dest.loopback)) {
254
+ // Anything not provably secure — an unparseable URL or a non-http(s)
255
+ // scheme — is refused rather than relied on to fail later in fetch.
256
+ keyWithheld = check(
257
+ 'mcp-url',
258
+ 'error',
259
+ `Refusing to send the API key to an unsupported MCP URL (${dest.origin})`,
260
+ 'Use an https:// URL, or a loopback http:// URL for local development.',
261
+ );
262
+ } else if (!explicitKey && !sendApiKey) {
263
+ keyWithheld = check(
264
+ 'mcp-url',
265
+ 'error',
266
+ `Saved API key withheld from custom MCP URL (${dest.origin})`,
267
+ `Re-run with --send-api-key to authorize sending your saved key to ${dest.origin}, or pass --api-key <key> to supply one explicitly.`,
268
+ );
269
+ } else {
270
+ checks.push(check('mcp-url', 'warn', `Sending the API key to custom MCP host: ${dest.origin}`));
271
+ }
272
+ if (keyWithheld) checks.push(keyWithheld);
210
273
  }
211
274
 
212
275
  let serverReady = false;
@@ -239,6 +302,8 @@ export async function runMcpVerifyChecks({
239
302
 
240
303
  if (!key) {
241
304
  checks.push(skippedAuth('no API key was provided'));
305
+ } else if (keyWithheld) {
306
+ checks.push(skippedAuth('the API key was withheld from this custom URL (see above)'));
242
307
  } else if (!serverReady) {
243
308
  checks.push(skippedAuth('tools/list did not establish server reachability'));
244
309
  } else {