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/CHANGELOG.md +49 -0
- package/README.md +33 -7
- package/package.json +1 -1
- package/scripts/postinstall.js +3 -3
- package/skills/nansen-wallet-manager/SKILL.md +5 -5
- package/src/api.js +11 -5
- package/src/bridge.js +791 -55
- package/src/cli.js +68 -41
- package/src/commands/agent.js +4 -0
- package/src/commands/mcp.js +373 -0
- package/src/doctor.js +16 -7
- package/src/limit-order.js +16 -2
- package/src/mcp-verify.js +292 -0
- package/src/privy.js +25 -4
- package/src/schema.json +55 -1
- package/src/swap-simulation.js +6 -0
- package/src/trade-validation.js +32 -5
- package/src/trading.js +92 -41
- package/src/wallet.js +22 -14
- package/src/walletconnect-x402.js +17 -10
- package/src/x402-evm.js +9 -6
- package/src/x402-policy.js +201 -0
- package/src/x402-svm.js +3 -2
- package/src/x402-tokens.js +29 -0
- package/src/x402.js +12 -25
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
|
|
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
|
-
|
|
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 &&
|
|
834
|
-
|
|
835
|
-
let
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
resolve(
|
|
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 (
|
|
853
|
-
|
|
854
|
-
|
|
856
|
+
if (value.length > 0) {
|
|
857
|
+
value = value.slice(0, -1);
|
|
858
|
+
output.write('\b \b');
|
|
855
859
|
}
|
|
856
860
|
} else {
|
|
857
|
-
|
|
858
|
-
|
|
861
|
+
value += char;
|
|
862
|
+
output.write('*');
|
|
859
863
|
}
|
|
860
864
|
};
|
|
861
865
|
|
|
862
|
-
|
|
866
|
+
input.on('data', onData);
|
|
863
867
|
} else {
|
|
864
868
|
const rl = readline.createInterface({
|
|
865
|
-
input
|
|
866
|
-
output
|
|
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 --
|
|
997
|
-
log(' NANSEN_API_KEY
|
|
998
|
-
log(' nansen login --
|
|
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.
|
|
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.
|
|
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 --
|
|
1031
|
-
'Or set NANSEN_API_KEY environment
|
|
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/
|
|
1058
|
+
resolution: ['Check or rotate your key at https://app.nansen.ai/api?tab=api'],
|
|
1053
1059
|
});
|
|
1054
1060
|
}
|
|
1055
|
-
|
|
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
|
|
1058
|
-
resolution
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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 });
|
package/src/commands/agent.js
CHANGED
|
@@ -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 --
|
|
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
|
-
|
|
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('');
|