nansen-cli 1.41.1 → 1.43.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.
package/src/cli.js CHANGED
@@ -11,6 +11,7 @@ import { buildTradingCommands } from './trading.js';
11
11
  import { buildLimitOrderCommands } from './limit-order.js';
12
12
  import { formatAlertsTable, buildAlertsCommands } from './commands/alerts.js';
13
13
  import { buildAgentCommands } from './commands/agent.js';
14
+ import { buildMcpCommands } from './commands/mcp.js';
14
15
  import { buildResearchCommands, RESEARCH_HISTORICAL_SUBCOMMANDS } from './commands/research.js';
15
16
  import { resolveAddress, isEnsName } from './ens.js';
16
17
  import fs from 'fs';
@@ -190,7 +191,7 @@ export function parseArgs(args) {
190
191
  const key = arg.slice(2);
191
192
  const next = args[i + 1];
192
193
 
193
- if (key === 'pretty' || key === 'help' || key === 'version' || key === 'table' || key === 'no-retry' || key === 'cache' || key === 'no-cache' || key === 'stream' || key === 'enrich' || key === 'full' || key === 'human' || key === 'enabled' || key === 'disabled' || key === 'expert' || key === 'json' || key === 'offline' || key === 'no-simulate' || key === 'no-verify-outcome' || key === 'no-revoke-excessive-allowance') {
194
+ if (key === 'pretty' || key === 'help' || key === 'version' || key === 'table' || key === 'no-retry' || key === 'cache' || key === 'no-cache' || key === 'stream' || key === 'enrich' || key === 'full' || key === 'human' || key === 'enabled' || key === 'disabled' || key === 'expert' || key === 'json' || key === 'offline' || key === 'no-simulate' || key === 'no-verify-outcome' || key === 'no-revoke-excessive-allowance' || key === 'dry-run') {
194
195
  result.flags[key] = true;
195
196
  } else if (next && (!next.startsWith('-') || /^-\d/.test(next))) {
196
197
  // Try to parse as JSON first (for objects/arrays/booleans),
@@ -734,9 +735,10 @@ COMMANDS:
734
735
  agent Ask the Nansen AI research agent (fast/expert modes)
735
736
  alerts list, create, update, toggle, delete
736
737
  web search, fetch
738
+ mcp install/uninstall/verify the Nansen MCP server
737
739
  account Show API key status, plan, and remaining credits
738
740
  auth status — offline auth status: key source, wallets (no network)
739
- login Save API key (--api-key <key>, --human, or NANSEN_API_KEY env var)
741
+ login Save API key (--human, NANSEN_API_KEY, or --api-key <key>)
740
742
  logout Remove saved API key
741
743
  doctor Diagnostics: auth, wallets, caches, connectivity (--offline --json)
742
744
  schema JSON schema for all commands (use "nansen schema <cmd>" for one)
@@ -827,43 +829,45 @@ CROSS-CHAIN NOTES (when using --to-chain):
827
829
  Bridge providers: Li.Fi or Relay (selected automatically based on best price)
828
830
  Typical bridge time: 1-5 minutes`;
829
831
 
830
- // Helper to prompt for input (exported for mocking)
831
- export async function prompt(question, hidden = false) {
832
+ // Helper to prompt for input (exported for mocking). Output defaults to stderr
833
+ // so the prompt and masked `*` characters stay on the terminal and never land
834
+ // in a redirected stdout (matching wallet.js promptPassword).
835
+ export async function prompt(question, hidden = false, { input = process.stdin, output = process.stderr } = {}) {
832
836
  return new Promise((resolve) => {
833
- if (hidden && process.stdout.isTTY) {
834
- process.stdout.write(question);
835
- let input = '';
836
- process.stdin.setRawMode(true);
837
- process.stdin.resume();
838
- process.stdin.setEncoding('utf8');
837
+ if (hidden && input.isTTY) {
838
+ output.write(question);
839
+ let value = '';
840
+ input.setRawMode(true);
841
+ input.resume();
842
+ input.setEncoding('utf8');
839
843
 
840
844
  const onData = (char) => {
841
845
  if (char === '\n' || char === '\r') {
842
- process.stdin.setRawMode(false);
843
- process.stdin.pause();
844
- process.stdin.removeListener('data', onData);
845
- process.stdout.write('\n');
846
- resolve(input);
846
+ input.setRawMode(false);
847
+ input.pause();
848
+ input.removeListener('data', onData);
849
+ output.write('\n');
850
+ resolve(value);
847
851
  } else if (char === '\u0003') {
848
852
  // Ctrl+C
849
853
  process.exit();
850
854
  } else if (char === '\u007F' || char === '\b') {
851
855
  // Backspace
852
- if (input.length > 0) {
853
- input = input.slice(0, -1);
854
- process.stdout.write('\b \b');
856
+ if (value.length > 0) {
857
+ value = value.slice(0, -1);
858
+ output.write('\b \b');
855
859
  }
856
860
  } else {
857
- input += char;
858
- process.stdout.write('*');
861
+ value += char;
862
+ output.write('*');
859
863
  }
860
864
  };
861
865
 
862
- process.stdin.on('data', onData);
866
+ input.on('data', onData);
863
867
  } else {
864
868
  const rl = readline.createInterface({
865
- input: process.stdin,
866
- output: process.stdout
869
+ input,
870
+ output
867
871
  });
868
872
  rl.question(question, (answer) => {
869
873
  rl.close();
@@ -919,6 +923,7 @@ export function buildCommands(deps = {}) {
919
923
  log(formatDoctorReport(checks, { cliVersion: VERSION, offline: Boolean(flags.offline) }));
920
924
  },
921
925
 
926
+
922
927
  'web': async (args, apiInstance, flags, options) => {
923
928
  const subcommand = args[0] || 'help';
924
929
  const subArgs = args.slice(1);
@@ -993,13 +998,14 @@ export function buildCommands(deps = {}) {
993
998
  if (flags.help || flags.h) {
994
999
  log('nansen login - Save your Nansen API key\n');
995
1000
  log('USAGE:');
996
- log(' nansen login --api-key <key>');
997
- log(' NANSEN_API_KEY=<key> nansen login');
998
- log(' nansen login --human (interactive prompt)\n');
1001
+ log(' nansen login --human (interactive prompt; key never enters shell history)');
1002
+ log(' nansen login (uses NANSEN_API_KEY when already set)');
1003
+ log(' nansen login --api-key <key> (literal key IS recorded in shell history)\n');
999
1004
  log('OPTIONS:');
1000
- log(' --api-key <key> Your Nansen API key');
1005
+ log(' --api-key <key> Your Nansen API key (recorded in shell history — prefer --human)');
1001
1006
  log(' --human Enable interactive prompt');
1002
1007
  log(' --help Show this help\n');
1008
+ log('Setting a literal key in a command may record it in shell history.');
1003
1009
  log('Get your API key at: https://app.nansen.ai/auth/agent-setup');
1004
1010
  return;
1005
1011
  }
@@ -1012,9 +1018,9 @@ export function buildCommands(deps = {}) {
1012
1018
 
1013
1019
  if (!apiKey && flags.human) {
1014
1020
  if (!isTTY) {
1015
- throw new CommandError('--human requires an interactive terminal. Use --api-key or NANSEN_API_KEY env var instead.', 'NOT_A_TTY', {
1021
+ throw new CommandError('--human requires an interactive terminal. Set NANSEN_API_KEY in the environment (or pass --api-key <key>, which is recorded in shell history).', 'NOT_A_TTY', {
1016
1022
  error: 'NOT_A_TTY',
1017
- message: '--human requires an interactive terminal. Use --api-key or NANSEN_API_KEY env var instead.',
1023
+ message: '--human requires an interactive terminal. Set NANSEN_API_KEY in the environment (or pass --api-key <key>, which is recorded in shell history).',
1018
1024
  });
1019
1025
  }
1020
1026
  log('Nansen CLI Login\n');
@@ -1027,8 +1033,8 @@ export function buildCommands(deps = {}) {
1027
1033
  error: 'API_KEY_REQUIRED',
1028
1034
  message: 'No API key provided.',
1029
1035
  resolution: [
1030
- 'Run: nansen login --api-key <key>',
1031
- 'Or set NANSEN_API_KEY environment variable',
1036
+ 'Run in an interactive terminal: nansen login --human',
1037
+ 'Or set NANSEN_API_KEY in the environment',
1032
1038
  'Get your API key at: https://app.nansen.ai/auth/agent-setup',
1033
1039
  ],
1034
1040
  });
@@ -1049,13 +1055,28 @@ export function buildCommands(deps = {}) {
1049
1055
  throw new CommandError('The API key is not valid.', 'INVALID_API_KEY', {
1050
1056
  error: 'INVALID_API_KEY',
1051
1057
  message: 'The API key is not valid.',
1052
- resolution: ['Check your key at https://app.nansen.ai/auth/agent-setup'],
1058
+ resolution: ['Check or rotate your key at https://app.nansen.ai/api?tab=api'],
1053
1059
  });
1054
1060
  }
1055
- throw new CommandError(`Could not verify API key: ${error.message}`, 'VERIFICATION_FAILED', {
1061
+ // Restore signal from the STRUCTURED error code only — never from
1062
+ // error.message, which can echo the upstream response body (and the key
1063
+ // with it). A transient failure shouldn't read as "check your key".
1064
+ let message = 'Could not verify API key.';
1065
+ let resolution = ['Check your internet connection', 'Try again'];
1066
+ if (error.code === ErrorCode.RATE_LIMITED) {
1067
+ message = 'Rate limited while verifying the API key.';
1068
+ resolution = ['Wait a moment, then run nansen login again'];
1069
+ } else if (error.code === ErrorCode.SERVER_ERROR || error.code === ErrorCode.SERVICE_UNAVAILABLE) {
1070
+ message = 'The Nansen API is unavailable right now, so the key could not be verified.';
1071
+ resolution = ['Try again shortly'];
1072
+ } else if (error.code === ErrorCode.TIMEOUT) {
1073
+ message = 'Timed out verifying the API key.';
1074
+ resolution = ['Check your connection', 'Try again'];
1075
+ }
1076
+ throw new CommandError(message, 'VERIFICATION_FAILED', {
1056
1077
  error: 'VERIFICATION_FAILED',
1057
- message: `Could not verify API key: ${error.message}`,
1058
- resolution: ['Check your internet connection', 'Try again'],
1078
+ message,
1079
+ resolution,
1059
1080
  });
1060
1081
  }
1061
1082
 
@@ -1806,13 +1827,13 @@ export function generateSubcommandHelp(command, subcommand, prefix = null) {
1806
1827
  const exampleValues = { address: '0x...', token: '0x...', query: '"term"', symbol: 'BTC', date: '2024-01-01' };
1807
1828
  const chain = subSchema.options?.chain?.default || 'solana';
1808
1829
  const cmdPrefix = prefix || (DEPRECATED_TO_RESEARCH.has(command) ? `research ${command}` : command);
1809
- let example = `nansen ${cmdPrefix} ${subcommand}`;
1810
- if (subSchema.options) {
1830
+ let example = subSchema.examples?.[0] || `nansen ${cmdPrefix} ${subcommand}`;
1831
+ if (!subSchema.examples?.length && subSchema.options) {
1811
1832
  for (const [name, opt] of Object.entries(subSchema.options)) {
1812
1833
  if (opt.required) example += ` --${name} ${exampleValues[name] || '<val>'}`;
1813
1834
  }
1814
1835
  }
1815
- if (subSchema.options?.chain && !subSchema.options.chain.required) {
1836
+ if (!subSchema.examples?.length && subSchema.options?.chain && !subSchema.options.chain.required) {
1816
1837
  example += ` --chain ${chain}`;
1817
1838
  }
1818
1839
  lines.push(`Example: ${example}`);
@@ -1851,7 +1872,8 @@ export async function runCLI(rawArgs, deps = {}) {
1851
1872
  // `auth` and `doctor --offline` promise zero network activity — that
1852
1873
  // contract covers the background update-check fetch and telemetry too,
1853
1874
  // not just the command's own requests.
1854
- const isOfflineCommand = command === 'auth' || (command === 'doctor' && flags.offline);
1875
+ const isMcpUsage = command === 'mcp' && (subcommand !== 'verify' || flags.help || flags.h);
1876
+ const isOfflineCommand = command === 'auth' || (command === 'doctor' && flags.offline) || isMcpUsage;
1855
1877
  const trackSucceeded = isOfflineCommand ? async () => {} : trackCommandSucceeded;
1856
1878
  const trackFailed = isOfflineCommand ? async () => {} : trackCommandFailed;
1857
1879
 
@@ -1871,7 +1893,9 @@ export async function runCLI(rawArgs, deps = {}) {
1871
1893
  return '';
1872
1894
  };
1873
1895
 
1874
- const commands = { ...buildCommands(deps), ...buildWalletCommands(deps), ...buildTradingCommands(deps), ...buildAlertsCommands(deps), ...buildAgentCommands(deps), ...commandOverrides };
1896
+ // mcp prints its own output via `log`; runCLI callers inject their stdout
1897
+ // sink as `output`, so map it across (an explicit `log` dep still wins).
1898
+ const commands = { ...buildCommands(deps), ...buildWalletCommands(deps), ...buildTradingCommands(deps), ...buildAlertsCommands(deps), ...buildAgentCommands(deps), ...buildMcpCommands({ ...deps, log: deps.log ?? output }), ...commandOverrides };
1875
1899
 
1876
1900
  if (flags.version || flags.v) {
1877
1901
  output(VERSION);
@@ -2115,7 +2139,10 @@ export async function runCLI(rawArgs, deps = {}) {
2115
2139
  // data (e.g. PASSWORD_REQUIRED resolution steps) is preserved under `details`,
2116
2140
  // so agents get one consistent shape to branch on regardless of command.
2117
2141
  const errorData = formatError(error);
2118
- if (isUsageError(errorData, { pretty, table, csv, stream, isTTY })) {
2142
+ if (error.reported) {
2143
+ // The command already printed its full human-readable failure output;
2144
+ // emitting the envelope too would produce two output shapes on stdout.
2145
+ } else if (isUsageError(errorData, { pretty, table, csv, stream, isTTY })) {
2119
2146
  output(errorData.error);
2120
2147
  } else {
2121
2148
  const formatted = formatOutput(errorData, { pretty, table, csv });
@@ -254,6 +254,10 @@ EXAMPLES:
254
254
  try {
255
255
  response = await fetch(url, {
256
256
  method: 'POST',
257
+ // Never follow a redirect on a request carrying the API key — undici
258
+ // forwards custom credential headers (apikey) across a cross-origin
259
+ // redirect, handing the key to whatever host the response points at.
260
+ redirect: 'error',
257
261
  headers: buildHeaders(apiInstance),
258
262
  body: JSON.stringify(body),
259
263
  signal: controller.signal,
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Nansen CLI - MCP install command
3
+ * One-step install of the hosted Nansen MCP server into local MCP clients.
4
+ *
5
+ * Writes a `nansen` entry into the client's own config file (merge-only,
6
+ * atomic, backed up). No network calls, no shelling out.
7
+ */
8
+
9
+ import { CommandError } from '../api.js';
10
+ import { DEFAULT_MCP_URL, formatMcpVerifyReport, runMcpVerifyChecks } from '../mcp-verify.js';
11
+ import fs from 'fs';
12
+ import os from 'os';
13
+ import path from 'path';
14
+
15
+ // Hosted Nansen MCP server (streamable HTTP, auth via NANSEN-API-KEY header).
16
+ // Deliberately a constant: a user-supplied URL would let `install` write the
17
+ // API key into a config that sends it to an arbitrary host. NANSEN_BASE_URL
18
+ // (REST dev override) intentionally does not affect this.
19
+ export const NANSEN_MCP_URL = 'https://mcp.nansen.ai/ra/mcp';
20
+
21
+ // Claude Desktop's config only supports stdio servers, so it bridges through
22
+ // mcp-remote. Pinned exact so `npx -y` never auto-pulls a compromised future
23
+ // release; bump deliberately.
24
+ export const MCP_REMOTE_PIN = 'mcp-remote@0.2.1';
25
+
26
+ const SERVER_KEY = 'nansen';
27
+
28
+ // House idiom (see src/api.js CONFIG_DIR): env first so tests can point HOME
29
+ // at a temp dir; os.homedir() as last resort.
30
+ const houseHomedir = () => process.env.HOME || process.env.USERPROFILE || os.homedir();
31
+
32
+ export const SUPPORTED_CLIENTS = ['claude-code', 'claude-desktop', 'cursor'];
33
+
34
+ const MCP_USAGE = `nansen mcp — Install the Nansen MCP server into a local MCP client
35
+
36
+ USAGE:
37
+ nansen mcp install <client> Add the Nansen MCP server to the client's config
38
+ nansen mcp uninstall <client> Remove the Nansen MCP server from the client's config
39
+ nansen mcp verify [--api-key <key>] [--url <url>] [--json]
40
+ Verify the hosted MCP server and API key
41
+
42
+ CLIENTS:
43
+ claude-code ~/.claude.json (user scope)
44
+ claude-desktop Claude Desktop config (macOS/Windows only)
45
+ cursor ~/.cursor/mcp.json
46
+
47
+ OPTIONS:
48
+ --dry-run Preview the change (key redacted) without writing
49
+
50
+ The API key is taken from \`nansen login\` / NANSEN_API_KEY. Re-run install after
51
+ rotating your key to update the entry. Other clients: https://docs.nansen.ai/mcp/connecting`;
52
+
53
+ /**
54
+ * Resolve the client's config file path for this platform.
55
+ * Throws CommandError for unsupported client/platform combos.
56
+ */
57
+ export function resolveClientConfigPath(client, { platform = process.platform, homedir = houseHomedir(), env = process.env } = {}) {
58
+ switch (client) {
59
+ case 'claude-code':
60
+ return path.join(env.CLAUDE_CONFIG_DIR || homedir, '.claude.json');
61
+ case 'cursor':
62
+ return path.join(homedir, '.cursor', 'mcp.json');
63
+ case 'claude-desktop':
64
+ if (platform === 'darwin') {
65
+ return path.join(homedir, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
66
+ }
67
+ if (platform === 'win32') {
68
+ return path.join(env.APPDATA || path.join(homedir, 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json');
69
+ }
70
+ throw new CommandError('Claude Desktop is not available on Linux. Use: nansen mcp install claude-code', 'UNSUPPORTED_PLATFORM');
71
+ default:
72
+ throw new CommandError(`Unknown client: ${client}. Supported: ${SUPPORTED_CLIENTS.join(', ')}`, 'INVALID_PARAMS');
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Build the mcpServers entry for a client.
78
+ * claude-code/cursor use native remote HTTP; claude-desktop bridges via mcp-remote.
79
+ */
80
+ export function buildServerEntry(client, apiKey) {
81
+ switch (client) {
82
+ case 'claude-code':
83
+ // "type" is required — a url without type is treated as broken stdio and skipped
84
+ return { type: 'http', url: NANSEN_MCP_URL, headers: { 'NANSEN-API-KEY': apiKey } };
85
+ case 'cursor':
86
+ return { url: NANSEN_MCP_URL, headers: { 'NANSEN-API-KEY': apiKey } };
87
+ case 'claude-desktop':
88
+ // Header name and value must be one arg. mcp-remote parses with
89
+ // /^([A-Za-z0-9_-]+):\s*(.*)$/, so whitespace after the colon is trimmed;
90
+ // what breaks it is an empty value.
91
+ // The ${NANSEN_API_KEY} placeholder is NOT shell syntax: mcp-remote itself
92
+ // substitutes ${VAR} in header values from its process env — see
93
+ // mcp-remote@0.2.1 dist/chunk-KIPEEEAF.js:29573-29576
94
+ // (`value.replace(/\$\{([^}]+)}/g, ...)`, logging "Replacing ${...} with
95
+ // environment value in header"). Claude Desktop injects the `env` block
96
+ // into the spawned npx process, mcp-remote expands the reference, and the
97
+ // key never appears in argv (visible in process listings). Verified live:
98
+ // the exact written config connects to prod with the substitution logged.
99
+ // Do NOT "fix" this by inlining the key into args.
100
+ // No --allow-http: the URL is HTTPS.
101
+ return {
102
+ command: 'npx',
103
+ args: ['-y', MCP_REMOTE_PIN, NANSEN_MCP_URL, '--header', 'NANSEN-API-KEY:${NANSEN_API_KEY}'],
104
+ env: { NANSEN_API_KEY: apiKey },
105
+ };
106
+ default:
107
+ throw new CommandError(`Unknown client: ${client}. Supported: ${SUPPORTED_CLIENTS.join(', ')}`, 'INVALID_PARAMS');
108
+ }
109
+ }
110
+
111
+ function assertMergeableServers(config, configPath) {
112
+ if (typeof config !== 'object' || config === null || Array.isArray(config)) {
113
+ throw new CommandError(`${configPath} must contain a JSON object. Fix or move the file, then re-run.`, 'INVALID_CONFIG');
114
+ }
115
+ if (config.mcpServers !== undefined && (typeof config.mcpServers !== 'object' || config.mcpServers === null || Array.isArray(config.mcpServers))) {
116
+ throw new CommandError(`"mcpServers" in ${configPath} is not an object. Fix or move the file, then re-run.`, 'INVALID_CONFIG');
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Return a new config object with only mcpServers.nansen set/updated.
122
+ * Every other key and server entry is preserved.
123
+ */
124
+ export function mergeNansenEntry(config, entry, configPath = 'config') {
125
+ assertMergeableServers(config, configPath);
126
+ return { ...config, mcpServers: { ...config.mcpServers, [SERVER_KEY]: entry } };
127
+ }
128
+
129
+ /**
130
+ * Return { config, removed } with mcpServers.nansen deleted.
131
+ */
132
+ export function removeNansenEntry(config, configPath = 'config') {
133
+ assertMergeableServers(config, configPath);
134
+ if (!config.mcpServers || !(SERVER_KEY in config.mcpServers)) {
135
+ return { config, removed: false };
136
+ }
137
+ const { [SERVER_KEY]: _removed, ...rest } = config.mcpServers;
138
+ return { config: { ...config, mcpServers: rest }, removed: true };
139
+ }
140
+
141
+ export function buildMcpCommands(deps = {}) {
142
+ const {
143
+ log = console.log,
144
+ fsOverride: fsx = fs,
145
+ platform = process.platform,
146
+ homedirFn = houseHomedir,
147
+ env = process.env,
148
+ fetchFn = fetch,
149
+ devConfigPath,
150
+ } = deps;
151
+
152
+ // Follow symlinks so dotfile-managed configs are edited in place instead of
153
+ // having the link replaced by the atomic rename.
154
+ const resolveReal = (targetPath) => {
155
+ let ancestor = targetPath;
156
+ const missing = [];
157
+ while (!fsx.existsSync(ancestor)) {
158
+ const parent = path.dirname(ancestor);
159
+ if (parent === ancestor) return targetPath;
160
+ missing.unshift(path.basename(ancestor));
161
+ ancestor = parent;
162
+ }
163
+ try { return path.join(fsx.realpathSync(ancestor), ...missing); } catch { return targetPath; }
164
+ };
165
+
166
+ const readConfig = (configPath) => {
167
+ if (!fsx.existsSync(configPath)) return { config: {}, existed: false };
168
+ let raw;
169
+ try {
170
+ raw = fsx.readFileSync(configPath, 'utf8');
171
+ } catch (err) {
172
+ throw new CommandError(`Could not read ${configPath}: ${err.message}`, 'INVALID_CONFIG');
173
+ }
174
+ try {
175
+ return { config: JSON.parse(raw), existed: true };
176
+ } catch {
177
+ throw new CommandError(`Could not parse ${configPath} as JSON. Fix or move the file, then re-run.`, 'INVALID_CONFIG');
178
+ }
179
+ };
180
+
181
+ // Atomic: temp file in the same dir, then rename over the target.
182
+ // A crash mid-write can't leave a truncated config. chmod after rename is
183
+ // best-effort (no-op semantics on Windows) — the file now holds a secret.
184
+ const writeConfig = (configPath, config) => {
185
+ const dir = path.dirname(configPath);
186
+ if (!fsx.existsSync(dir)) fsx.mkdirSync(dir, { recursive: true, mode: 0o700 });
187
+ const tmp = path.join(dir, `.${path.basename(configPath)}.tmp-${process.pid}`);
188
+ try {
189
+ fsx.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
190
+ fsx.renameSync(tmp, configPath);
191
+ } catch (err) {
192
+ try { fsx.unlinkSync(tmp); } catch { /* temp file may not exist */ }
193
+ throw err;
194
+ }
195
+ try { fsx.chmodSync(configPath, 0o600); } catch { /* Windows / exotic fs */ }
196
+ };
197
+
198
+ const backupConfig = (configPath) => {
199
+ const backupPath = `${configPath}.bak`;
200
+ const overwritten = fsx.existsSync(backupPath);
201
+ fsx.copyFileSync(configPath, backupPath);
202
+ try { fsx.chmodSync(backupPath, 0o600); } catch { /* best-effort */ }
203
+ log(overwritten
204
+ ? `Overwrote existing backup at ${backupPath}`
205
+ : `Backed up existing config to ${backupPath}`);
206
+ return backupPath;
207
+ };
208
+
209
+ const requireClient = (operation, client) => {
210
+ if (!client || !SUPPORTED_CLIENTS.includes(client)) {
211
+ throw new CommandError(`Usage: nansen mcp ${operation} <client>. Supported: ${SUPPORTED_CLIENTS.join(', ')}`, 'INVALID_PARAMS');
212
+ }
213
+ };
214
+
215
+ const verify = async (flags, options) => {
216
+ // A valueless --api-key parses as a flag and would silently fall back to
217
+ // the saved key - the exact false positive this command exists to catch.
218
+ if (flags['api-key']) {
219
+ throw new CommandError('--api-key requires a value. Usage: nansen mcp verify --api-key <key>', 'MISSING_PARAM');
220
+ }
221
+ // parseArgs JSON-parses option values, so `--api-key null` arrives as
222
+ // null and a repeated flag as an array - both must fail, not fall back.
223
+ if ('api-key' in options && typeof options['api-key'] !== 'string') {
224
+ throw new CommandError('--api-key must be a single key string. Usage: nansen mcp verify --api-key <key>', 'INVALID_PARAMS');
225
+ }
226
+ // Same guards for --url: a valueless flag or a repeated/JSON-parsed value
227
+ // must fail loudly, not flow an array into fetch or silently fall back.
228
+ if (flags.url) {
229
+ throw new CommandError('--url requires a value. Usage: nansen mcp verify --url <url>', 'MISSING_PARAM');
230
+ }
231
+ if ('url' in options && typeof options.url !== 'string') {
232
+ throw new CommandError('--url must be a single URL string. Usage: nansen mcp verify --url <url>', 'INVALID_PARAMS');
233
+ }
234
+
235
+ const url = options.url || DEFAULT_MCP_URL;
236
+ const checks = await runMcpVerifyChecks({
237
+ apiKey: options['api-key'],
238
+ url,
239
+ env,
240
+ fetchFn,
241
+ devConfigPath,
242
+ });
243
+ const verified = checks.some(checkItem => checkItem.id === 'mcp-auth' && checkItem.status === 'ok');
244
+ const result = {
245
+ verified,
246
+ url,
247
+ checks,
248
+ errors: checks.filter(checkItem => checkItem.status === 'error').length,
249
+ warnings: checks.filter(checkItem => checkItem.status === 'warn').length,
250
+ };
251
+
252
+ if (verified) {
253
+ if (flags.json) return result;
254
+ log(formatMcpVerifyReport(checks, url, true));
255
+ return undefined;
256
+ }
257
+
258
+ const reason = (checks.find(checkItem => checkItem.status === 'error')
259
+ || checks.find(checkItem => checkItem.id === 'mcp-auth' && checkItem.status !== 'ok'))?.message
260
+ || 'the paid data path did not complete';
261
+ const message = `MCP setup verification failed - ${reason}`;
262
+ if (flags.json) throw new CommandError(message, 'MCP_VERIFY_FAILED', result);
263
+ log(formatMcpVerifyReport(checks, url, false));
264
+ // The human report above is the complete failure output; mark the error
265
+ // so runCLI exits non-zero without also emitting the JSON envelope.
266
+ const reportedError = new CommandError(message, 'MCP_VERIFY_FAILED');
267
+ reportedError.reported = true;
268
+ throw reportedError;
269
+ };
270
+
271
+ return {
272
+ 'mcp': async (args, apiInstance, flags, options) => {
273
+ const sub = args[0];
274
+ const client = args[1];
275
+
276
+ if (!sub || flags.help || flags.h) {
277
+ log(MCP_USAGE);
278
+ return undefined;
279
+ }
280
+
281
+ if (sub === 'verify') {
282
+ return verify(flags, options);
283
+ }
284
+
285
+ if (sub !== 'install' && sub !== 'uninstall') {
286
+ throw new CommandError(`Unknown subcommand: ${sub}\n\n${MCP_USAGE}`, 'INVALID_PARAMS');
287
+ }
288
+
289
+ requireClient(sub, client);
290
+ const configPath = resolveReal(resolveClientConfigPath(client, { platform, homedir: homedirFn(), env }));
291
+
292
+ if (sub === 'uninstall') {
293
+ // Single state object, assigned only when the read+remove pair
294
+ // succeeds. The !state guard below treats any escape from the try as
295
+ // "nothing to do", so a future early-return added to the catch cannot
296
+ // leak partial state into the backup/write path. Deliberately no
297
+ // initializer: no-useless-assignment proves every current path assigns
298
+ // or exits, and undefined already reads as "nothing to do".
299
+ let state;
300
+ try {
301
+ const { config, existed } = readConfig(configPath);
302
+ const { config: updated, removed } = removeNansenEntry(config, configPath);
303
+ state = { existed, updated, removed };
304
+ } catch (err) {
305
+ // --dry-run must not throw on an unparseable config: users reach for it
306
+ // precisely when unsure of the file's state. A real uninstall still
307
+ // fails before writing.
308
+ if (flags['dry-run']) {
309
+ log(`Cannot preview ${configPath}: ${err.message} No changes made.`);
310
+ return undefined;
311
+ }
312
+ throw err;
313
+ }
314
+ if (!state || !state.existed || !state.removed) {
315
+ log(`No Nansen MCP entry found in ${configPath}. Nothing to do.`);
316
+ return undefined;
317
+ }
318
+ const { updated } = state;
319
+ if (flags['dry-run']) {
320
+ log(`Would remove "${SERVER_KEY}" entry from ${configPath} (no changes made).`);
321
+ return undefined;
322
+ }
323
+ // Same backup contract as install: an accidental uninstall of the wrong
324
+ // client is recoverable. Backup failures surface before the config write.
325
+ const backupPath = backupConfig(configPath);
326
+ writeConfig(configPath, updated);
327
+ log(`Removed Nansen MCP server from ${configPath}`);
328
+ // The backup still holds the entry we just removed, key included.
329
+ log(`Note: ${backupPath} still contains your API key (mode 0600). Delete it once you've confirmed the change.`);
330
+ log(`Restart ${client} to pick up the change.`);
331
+ return undefined;
332
+ }
333
+
334
+ // install
335
+ const apiKey = apiInstance?.apiKey;
336
+ if (!apiKey) {
337
+ throw new CommandError('Not logged in. Run: nansen login', 'NOT_LOGGED_IN');
338
+ }
339
+
340
+ // Deliberate asymmetry with uninstall --dry-run (which previews even an
341
+ // unparseable config): for install, a corrupt existing config is a
342
+ // blocking error even under --dry-run, because the eventual write would
343
+ // refuse it too — previewing a merge into a file we cannot parse would
344
+ // promise something install cannot deliver.
345
+ const { config, existed } = readConfig(configPath);
346
+
347
+ if (flags['dry-run']) {
348
+ // The key is never printed — dry-run shows a redacted entry.
349
+ const redacted = buildServerEntry(client, '<redacted>');
350
+ mergeNansenEntry(config, redacted, configPath);
351
+ log(`Would write "${SERVER_KEY}" entry to ${configPath}:`);
352
+ log(JSON.stringify({ mcpServers: { [SERVER_KEY]: redacted } }, null, 2));
353
+ return undefined;
354
+ }
355
+
356
+ const hadEntry = !!config.mcpServers?.[SERVER_KEY];
357
+ const merged = mergeNansenEntry(config, buildServerEntry(client, apiKey), configPath);
358
+
359
+ if (existed) {
360
+ backupConfig(configPath);
361
+ }
362
+ writeConfig(configPath, merged);
363
+
364
+ log(hadEntry
365
+ ? `Updated existing Nansen MCP entry in ${configPath}`
366
+ : `Installed Nansen MCP server to ${configPath}`);
367
+ log(`Note: your Nansen API key is stored in plaintext in ${configPath}.`);
368
+ log('If this file is synced or backed up (settings sync, dotfiles), your key travels with it.');
369
+ log(`Restart ${client} to pick up the change.`);
370
+ return undefined;
371
+ },
372
+ };
373
+ }
package/src/doctor.js CHANGED
@@ -85,7 +85,7 @@ const DEV_CONFIG_PATH = path.join(__dirname, '..', 'config.json');
85
85
  * ~/.nansen/config.json, then the repo-local dev config.json, then env
86
86
  * overrides — but lazily and without secrets leaving this function unmasked.
87
87
  */
88
- function resolveAuthConfig(env, devConfigPath = DEV_CONFIG_PATH) {
88
+ export function resolveAuthConfig(env, devConfigPath = DEV_CONFIG_PATH) {
89
89
  const userConfigPath = getConfigFilePath(env);
90
90
 
91
91
  let config = null;
@@ -249,7 +249,7 @@ export function getAuthStatus(deps = {}) {
249
249
 
250
250
  // ============= doctor =============
251
251
 
252
- function check(id, status, message, fix = null) {
252
+ export function check(id, status, message, fix = null) {
253
253
  const result = { id, status, message };
254
254
  if (fix) result.fix = fix;
255
255
  return result;
@@ -313,7 +313,7 @@ export function runDoctorChecks(deps = {}) {
313
313
  }
314
314
  }
315
315
  } else {
316
- checks.push(check('api-key', auth.configFileExists ? 'error' : 'warn', 'No API key configured', 'Run: nansen login --api-key <key> (or fund an x402 wallet for pay-per-call access)'));
316
+ checks.push(check('api-key', auth.configFileExists ? 'error' : 'warn', 'No API key configured', 'Run: nansen login --human or set NANSEN_API_KEY (or fund an x402 wallet for pay-per-call access)'));
317
317
  }
318
318
  // POSIX modes are meaningless on Windows — fs.stat reports 0o666 for every
319
319
  // file there, which would warn on all of them
@@ -454,6 +454,18 @@ export async function runConnectivityChecks(deps = {}) {
454
454
 
455
455
  const STATUS_ICONS = { ok: '✓', warn: '⚠️ ', error: '❌', info: 'ℹ' };
456
456
 
457
+ /**
458
+ * Render check lines without a header or summary.
459
+ */
460
+ export function formatChecks(checks) {
461
+ const lines = [];
462
+ for (const c of checks) {
463
+ lines.push(`${STATUS_ICONS[c.status] || ' '} ${c.message}`);
464
+ if (c.fix) lines.push(` ${c.fix}`);
465
+ }
466
+ return lines.join('\n');
467
+ }
468
+
457
469
  /**
458
470
  * Render doctor checks as human-readable lines with a summary tail.
459
471
  */
@@ -464,10 +476,7 @@ export function formatDoctorReport(checks, { cliVersion = null, offline = false
464
476
  : 'diagnostics (local checks + a credit-free connectivity probe; --offline to skip network)';
465
477
  lines.push(`Nansen CLI doctor${cliVersion ? ` v${cliVersion}` : ''} — ${mode}`);
466
478
  lines.push('');
467
- for (const c of checks) {
468
- lines.push(`${STATUS_ICONS[c.status] || ' '} ${c.message}`);
469
- if (c.fix) lines.push(` ${c.fix}`);
470
- }
479
+ lines.push(formatChecks(checks));
471
480
  const warnings = checks.filter(c => c.status === 'warn').length;
472
481
  const errors = checks.filter(c => c.status === 'error').length;
473
482
  lines.push('');