From bb3f33ea28642effbda911012dec8810adc8d40c Mon Sep 17 00:00:00 2001 From: "Gulshan Gill (Openclaw Bot)" Date: Thu, 13 Aug 2026 08:51:21 +0000 Subject: [PATCH 1/9] feat(mcp): add one-step MCP install command (API-285) Add `nansen mcp install/uninstall ` to write the hosted Nansen MCP server (https://mcp.nansen.ai/ra/mcp) into Claude Code, Claude Desktop, or Cursor configs. Merge-only atomic writes with backup, key never printed, --dry-run supported. Co-Authored-By: Claude Fable 5 --- .changeset/one-step-mcp-install.md | 5 + README.md | 13 ++ src/__tests__/mcp.test.js | 264 +++++++++++++++++++++++++++++ src/cli.js | 6 +- src/commands/mcp.js | 231 +++++++++++++++++++++++++ src/schema.json | 24 +++ 6 files changed, 541 insertions(+), 2 deletions(-) create mode 100644 .changeset/one-step-mcp-install.md create mode 100644 src/__tests__/mcp.test.js create mode 100644 src/commands/mcp.js diff --git a/.changeset/one-step-mcp-install.md b/.changeset/one-step-mcp-install.md new file mode 100644 index 00000000..ad4dee50 --- /dev/null +++ b/.changeset/one-step-mcp-install.md @@ -0,0 +1,5 @@ +--- +"nansen-cli": minor +--- + +Add `nansen mcp install ` / `nansen mcp uninstall ` for one-step setup of the hosted Nansen MCP server (https://mcp.nansen.ai/ra/mcp) in Claude Code, Claude Desktop, and Cursor. Installs are merge-only and atomic (existing servers preserved, `.bak` backup, refuses unparseable configs), use the API key from `nansen login` / `NANSEN_API_KEY`, never print the key, and support `--dry-run`. diff --git a/README.md b/README.md index e1f00ab1..5ace114a 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ nansen agent "" --expert # deeper analysis (750 credits, Pro) nansen trade quote --chain solana --from SOL --to USDC --amount 1000000000 nansen trade execute --quote nansen wallet [options] +nansen mcp install # add the Nansen MCP server to Claude Code/Desktop or Cursor nansen schema [command] [--pretty] # full command reference (no API key needed) ``` @@ -59,6 +60,18 @@ Connect any MCP client to Nansen's streamable HTTP server: - **Authentication:** `NANSEN-API-KEY` header - **API key:** [app.nansen.ai/auth/agent-setup](https://app.nansen.ai/auth/agent-setup) +One-step install of the hosted [Nansen MCP server](https://docs.nansen.ai/mcp/overview) (`https://mcp.nansen.ai/ra/mcp`) into a local MCP client: + +```bash +nansen mcp install claude-code # ~/.claude.json (user scope) +nansen mcp install claude-desktop # macOS/Windows only; bridges via pinned mcp-remote +nansen mcp install cursor # ~/.cursor/mcp.json +nansen mcp install cursor --dry-run # print what would be written (key redacted) +nansen mcp uninstall # remove the entry +``` + +Uses the API key from `nansen login` / `NANSEN_API_KEY`; re-run `install` after rotating your key. Installs are merge-only and atomic: existing servers and settings are preserved, a `.bak` copy is written first, and the CLI refuses to touch a config it can't parse. Note the client config stores the API key in plaintext — new files are created with `0600` permissions. Restart the client after installing. For other clients, see [docs.nansen.ai/mcp/connecting](https://docs.nansen.ai/mcp/connecting). + **Claude Desktop and Cursor:** setup instructions for both are in the connection docs: [docs.nansen.ai/mcp/connecting](https://docs.nansen.ai/mcp/connecting). **One-command (Claude Code):** diff --git a/src/__tests__/mcp.test.js b/src/__tests__/mcp.test.js new file mode 100644 index 00000000..6fded7b9 --- /dev/null +++ b/src/__tests__/mcp.test.js @@ -0,0 +1,264 @@ +/** + * Tests for `nansen mcp install/uninstall` (src/commands/mcp.js). + * House pattern: real temp dir + injected deps, no fs mocking. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + resolveClientConfigPath, + buildServerEntry, + mergeNansenEntry, + removeNansenEntry, + buildMcpCommands, + NANSEN_MCP_URL, + MCP_REMOTE_PIN, +} from '../commands/mcp.js'; + +const API_KEY = 'test-key-123'; + +describe('resolveClientConfigPath', () => { + const ctx = { platform: 'linux', homedir: '/home/u', env: {} }; + + it('claude-code -> ~/.claude.json on all platforms', () => { + expect(resolveClientConfigPath('claude-code', ctx)).toBe('/home/u/.claude.json'); + expect(resolveClientConfigPath('claude-code', { ...ctx, platform: 'darwin' })).toBe('/home/u/.claude.json'); + expect(resolveClientConfigPath('claude-code', { ...ctx, platform: 'win32' })).toBe(path.join('/home/u', '.claude.json')); + }); + + it('cursor -> ~/.cursor/mcp.json', () => { + expect(resolveClientConfigPath('cursor', ctx)).toBe(path.join('/home/u', '.cursor', 'mcp.json')); + }); + + it('claude-desktop on macOS -> Application Support path', () => { + expect(resolveClientConfigPath('claude-desktop', { ...ctx, platform: 'darwin' })) + .toBe(path.join('/home/u', 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')); + }); + + it('claude-desktop on Windows uses APPDATA', () => { + expect(resolveClientConfigPath('claude-desktop', { platform: 'win32', homedir: 'C:\\Users\\u', env: { APPDATA: 'C:\\Users\\u\\AppData\\Roaming' } })) + .toBe(path.join('C:\\Users\\u\\AppData\\Roaming', 'Claude', 'claude_desktop_config.json')); + }); + + it('claude-desktop on Linux throws with actionable message', () => { + expect(() => resolveClientConfigPath('claude-desktop', ctx)).toThrow(/not available on Linux.*claude-code/s); + }); + + it('unknown client throws listing supported clients', () => { + expect(() => resolveClientConfigPath('vscode', ctx)).toThrow(/claude-code, claude-desktop, cursor/); + }); +}); + +describe('buildServerEntry', () => { + it('claude-code: native remote with required type field', () => { + expect(buildServerEntry('claude-code', API_KEY)).toEqual({ + type: 'http', + url: NANSEN_MCP_URL, + headers: { 'NANSEN-API-KEY': API_KEY }, + }); + }); + + it('cursor: url + headers, no type field', () => { + const entry = buildServerEntry('cursor', API_KEY); + expect(entry).toEqual({ url: NANSEN_MCP_URL, headers: { 'NANSEN-API-KEY': API_KEY } }); + expect(entry.type).toBeUndefined(); + }); + + it('claude-desktop: pinned mcp-remote stdio bridge', () => { + const entry = buildServerEntry('claude-desktop', API_KEY); + expect(entry.command).toBe('npx'); + expect(entry.args).toContain(MCP_REMOTE_PIN); + expect(MCP_REMOTE_PIN).toMatch(/^mcp-remote@\d+\.\d+\.\d+$/); // exact pin, not a range + // no space after the colon (Claude Desktop mis-splits spaced args) + expect(entry.args).toContain(`NANSEN-API-KEY:${API_KEY}`); + expect(entry.args).not.toContain('--allow-http'); + }); +}); + +describe('mergeNansenEntry / removeNansenEntry', () => { + const entry = buildServerEntry('cursor', API_KEY); + + it('preserves sibling servers and unrelated top-level keys', () => { + const cfg = { mcpServers: { other: { command: 'foo' } }, theme: 'dark' }; + const merged = mergeNansenEntry(cfg, entry); + expect(merged.mcpServers.other).toEqual({ command: 'foo' }); + expect(merged.theme).toBe('dark'); + expect(merged.mcpServers.nansen).toEqual(entry); + expect(cfg.mcpServers.nansen).toBeUndefined(); // input not mutated + }); + + it('creates mcpServers when absent and overwrites an existing nansen entry', () => { + expect(mergeNansenEntry({}, entry).mcpServers.nansen).toEqual(entry); + const merged = mergeNansenEntry({ mcpServers: { nansen: { url: 'old' } } }, entry); + expect(merged.mcpServers.nansen).toEqual(entry); + }); + + it('refuses when mcpServers is not an object', () => { + expect(() => mergeNansenEntry({ mcpServers: [] }, entry)).toThrow(/not an object/); + expect(() => mergeNansenEntry({ mcpServers: 'nope' }, entry)).toThrow(/not an object/); + expect(() => removeNansenEntry({ mcpServers: 42 })).toThrow(/not an object/); + }); + + it('removeNansenEntry removes only nansen and reports not-found', () => { + const { config, removed } = removeNansenEntry({ mcpServers: { nansen: entry, other: { command: 'foo' } } }); + expect(removed).toBe(true); + expect(config.mcpServers).toEqual({ other: { command: 'foo' } }); + expect(removeNansenEntry({}).removed).toBe(false); + expect(removeNansenEntry({ mcpServers: {} }).removed).toBe(false); + }); +}); + +describe('mcp command handler', () => { + let tempDir; + let logs; + let mcp; + const api = { apiKey: API_KEY }; + + const run = (args, { flags = {}, apiInstance = api } = {}) => mcp(args, apiInstance, flags, {}); + const cursorPath = () => path.join(tempDir, '.cursor', 'mcp.json'); + const readCursor = () => JSON.parse(fs.readFileSync(cursorPath(), 'utf8')); + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nansen-mcp-test-')); + logs = []; + ({ mcp } = buildMcpCommands({ + log: (...a) => logs.push(a.join(' ')), + platform: 'linux', + homedirFn: () => tempDir, + env: {}, + })); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('install creates dir 0700 and file 0600 with the nansen entry', async () => { + await run(['install', 'cursor']); + expect(readCursor().mcpServers.nansen).toEqual(buildServerEntry('cursor', API_KEY)); + expect(fs.statSync(path.dirname(cursorPath())).mode & 0o777).toBe(0o700); + expect(fs.statSync(cursorPath()).mode & 0o777).toBe(0o600); + expect(logs.join('\n')).toContain('Installed Nansen MCP server'); + expect(logs.join('\n')).toContain('plaintext'); + }); + + it('install merges into an existing config and writes a backup first', async () => { + fs.mkdirSync(path.dirname(cursorPath()), { recursive: true }); + const original = JSON.stringify({ mcpServers: { other: { command: 'foo' } }, unrelated: true }); + fs.writeFileSync(cursorPath(), original); + + await run(['install', 'cursor']); + + const cfg = readCursor(); + expect(cfg.mcpServers.other).toEqual({ command: 'foo' }); + expect(cfg.unrelated).toBe(true); + expect(cfg.mcpServers.nansen.url).toBe(NANSEN_MCP_URL); + expect(fs.readFileSync(`${cursorPath()}.bak`, 'utf8')).toBe(original); + expect(fs.statSync(`${cursorPath()}.bak`).mode & 0o777).toBe(0o600); + }); + + it('re-running install is idempotent and reports an update', async () => { + await run(['install', 'cursor']); + logs.length = 0; + await run(['install', 'cursor']); + expect(logs.join('\n')).toContain('Updated existing Nansen MCP entry'); + expect(readCursor().mcpServers.nansen).toEqual(buildServerEntry('cursor', API_KEY)); + }); + + it('refuses to touch unparseable JSON', async () => { + fs.mkdirSync(path.dirname(cursorPath()), { recursive: true }); + fs.writeFileSync(cursorPath(), '{ not json'); + await expect(run(['install', 'cursor'])).rejects.toThrow(/Could not parse/); + expect(fs.readFileSync(cursorPath(), 'utf8')).toBe('{ not json'); // untouched + expect(fs.existsSync(`${cursorPath()}.bak`)).toBe(false); + }); + + it('requires login and writes nothing without a key', async () => { + await expect(run(['install', 'cursor'], { apiInstance: { apiKey: null } })) + .rejects.toThrow('Not logged in. Run: nansen login'); + expect(fs.existsSync(cursorPath())).toBe(false); + }); + + it('--dry-run writes nothing and never prints the key', async () => { + await run(['install', 'cursor'], { flags: { 'dry-run': true } }); + expect(fs.existsSync(cursorPath())).toBe(false); + const out = logs.join('\n'); + expect(out).toContain(cursorPath()); + expect(out).toContain(''); + expect(out).not.toContain(API_KEY); + }); + + it('install output never contains the key', async () => { + await run(['install', 'cursor']); + expect(logs.join('\n')).not.toContain(API_KEY); + }); + + it('uninstall removes only the nansen entry', async () => { + fs.mkdirSync(path.dirname(cursorPath()), { recursive: true }); + fs.writeFileSync(cursorPath(), JSON.stringify({ mcpServers: { nansen: { url: 'x' }, other: { command: 'foo' } } })); + await run(['uninstall', 'cursor']); + expect(readCursor().mcpServers).toEqual({ other: { command: 'foo' } }); + }); + + it('uninstall with no entry is a friendly no-op', async () => { + await run(['uninstall', 'cursor']); + expect(logs.join('\n')).toContain('Nothing to do'); + }); + + it('uninstall works without an API key', async () => { + fs.mkdirSync(path.dirname(cursorPath()), { recursive: true }); + fs.writeFileSync(cursorPath(), JSON.stringify({ mcpServers: { nansen: { url: 'x' } } })); + await run(['uninstall', 'cursor'], { apiInstance: { apiKey: null } }); + expect(readCursor().mcpServers).toEqual({}); + }); + + it('bare `mcp` and `mcp --help` print usage; bad inputs throw actionable errors', async () => { + await run([]); + expect(logs.join('\n')).toContain('nansen mcp install '); + await expect(run(['frobnicate'])).rejects.toThrow(/Unknown subcommand/); + await expect(run(['install'])).rejects.toThrow(/claude-code, claude-desktop, cursor/); + await expect(run(['install', 'vscode'])).rejects.toThrow(/claude-code, claude-desktop, cursor/); + }); + + it('follows a symlinked config instead of replacing the link', async () => { + const realDir = path.join(tempDir, 'dotfiles'); + fs.mkdirSync(realDir, { recursive: true }); + fs.mkdirSync(path.dirname(cursorPath()), { recursive: true }); + const realFile = path.join(realDir, 'mcp.json'); + fs.writeFileSync(realFile, '{}'); + fs.symlinkSync(realFile, cursorPath()); + + await run(['install', 'cursor']); + + expect(fs.lstatSync(cursorPath()).isSymbolicLink()).toBe(true); // link survives + expect(JSON.parse(fs.readFileSync(realFile, 'utf8')).mcpServers.nansen.url).toBe(NANSEN_MCP_URL); + }); +}); + +describe('schema + CLI registration', () => { + it('schema.json documents mcp install/uninstall', async () => { + const { fileURLToPath } = await import('url'); + const schema = JSON.parse(fs.readFileSync( + path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'schema.json'), 'utf8')); + expect(Object.keys(schema.commands)).toContain('mcp'); + expect(Object.keys(schema.commands.mcp.subcommands).sort()).toEqual(['install', 'uninstall']); + }); + + it('runCLI routes `mcp` and parses --dry-run as a boolean flag', async () => { + const { runCLI, parseArgs } = await import('../cli.js'); + expect(parseArgs(['mcp', 'install', '--dry-run', 'cursor'])._).toEqual(['mcp', 'install', 'cursor']); + + const outputs = []; + const logs = []; + const result = await runCLI(['mcp'], { + output: (m) => outputs.push(m), + log: (m) => logs.push(m), + errorOutput: () => {}, + exit: () => {}, + }); + expect(result.type).toBe('no-output'); + expect(logs.join('\n')).toContain('nansen mcp install '); + }); +}); diff --git a/src/cli.js b/src/cli.js index 168d7faf..ec4ef536 100644 --- a/src/cli.js +++ b/src/cli.js @@ -11,6 +11,7 @@ import { buildTradingCommands } from './trading.js'; import { buildLimitOrderCommands } from './limit-order.js'; import { formatAlertsTable, buildAlertsCommands } from './commands/alerts.js'; import { buildAgentCommands } from './commands/agent.js'; +import { buildMcpCommands } from './commands/mcp.js'; import { buildResearchCommands, RESEARCH_HISTORICAL_SUBCOMMANDS } from './commands/research.js'; import { resolveAddress, isEnsName } from './ens.js'; import fs from 'fs'; @@ -190,7 +191,7 @@ export function parseArgs(args) { const key = arg.slice(2); const next = args[i + 1]; - 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') { + 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') { result.flags[key] = true; } else if (next && (!next.startsWith('-') || /^-\d/.test(next))) { // Try to parse as JSON first (for objects/arrays/booleans), @@ -734,6 +735,7 @@ COMMANDS: agent Ask the Nansen AI research agent (fast/expert modes) alerts list, create, update, toggle, delete web search, fetch + mcp install/uninstall the Nansen MCP server (claude-code, claude-desktop, cursor) account Show API key status, plan, and remaining credits auth status — offline auth status: key source, wallets (no network) login Save API key (--api-key , --human, or NANSEN_API_KEY env var) @@ -1871,7 +1873,7 @@ export async function runCLI(rawArgs, deps = {}) { return ''; }; - const commands = { ...buildCommands(deps), ...buildWalletCommands(deps), ...buildTradingCommands(deps), ...buildAlertsCommands(deps), ...buildAgentCommands(deps), ...commandOverrides }; + const commands = { ...buildCommands(deps), ...buildWalletCommands(deps), ...buildTradingCommands(deps), ...buildAlertsCommands(deps), ...buildAgentCommands(deps), ...buildMcpCommands(deps), ...commandOverrides }; if (flags.version || flags.v) { output(VERSION); diff --git a/src/commands/mcp.js b/src/commands/mcp.js new file mode 100644 index 00000000..f8e47c2e --- /dev/null +++ b/src/commands/mcp.js @@ -0,0 +1,231 @@ +/** + * Nansen CLI - MCP install command + * One-step install of the hosted Nansen MCP server into local MCP clients. + * + * Writes a `nansen` entry into the client's own config file (merge-only, + * atomic, backed up). No network calls, no shelling out. + */ + +import { CommandError } from '../api.js'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// Hosted Nansen MCP server (streamable HTTP, auth via NANSEN-API-KEY header). +// Deliberately a constant: a user-supplied URL would let `install` write the +// API key into a config that sends it to an arbitrary host. NANSEN_BASE_URL +// (REST dev override) intentionally does not affect this. +export const NANSEN_MCP_URL = 'https://mcp.nansen.ai/ra/mcp'; + +// Claude Desktop's config only supports stdio servers, so it bridges through +// mcp-remote. Pinned exact so `npx -y` never auto-pulls a compromised future +// release; bump deliberately. +export const MCP_REMOTE_PIN = 'mcp-remote@0.2.1'; + +const SERVER_KEY = 'nansen'; + +// House idiom (see src/api.js CONFIG_DIR): env first so tests can point HOME +// at a temp dir; os.homedir() as last resort. +const houseHomedir = () => process.env.HOME || process.env.USERPROFILE || os.homedir(); + +export const SUPPORTED_CLIENTS = ['claude-code', 'claude-desktop', 'cursor']; + +const MCP_USAGE = `nansen mcp — Install the Nansen MCP server into a local MCP client + +USAGE: + nansen mcp install Add the Nansen MCP server to the client's config + nansen mcp uninstall Remove the Nansen MCP server from the client's config + +CLIENTS: + claude-code ~/.claude.json (user scope) + claude-desktop Claude Desktop config (macOS/Windows only) + cursor ~/.cursor/mcp.json + +OPTIONS: + --dry-run Print the target file and entry (key redacted) without writing + +The API key is taken from \`nansen login\` / NANSEN_API_KEY. Re-run install after +rotating your key to update the entry. Other clients: https://docs.nansen.ai/mcp/connecting`; + +/** + * Resolve the client's config file path for this platform. + * Throws CommandError for unsupported client/platform combos. + */ +export function resolveClientConfigPath(client, { platform = process.platform, homedir = houseHomedir(), env = process.env } = {}) { + switch (client) { + case 'claude-code': + return path.join(homedir, '.claude.json'); + case 'cursor': + return path.join(homedir, '.cursor', 'mcp.json'); + case 'claude-desktop': + if (platform === 'darwin') { + return path.join(homedir, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'); + } + if (platform === 'win32') { + return path.join(env.APPDATA || path.join(homedir, 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json'); + } + throw new CommandError('Claude Desktop is not available on Linux. Use: nansen mcp install claude-code', 'UNSUPPORTED_PLATFORM'); + default: + throw new CommandError(`Unknown client: ${client}. Supported: ${SUPPORTED_CLIENTS.join(', ')}`, 'INVALID_PARAMS'); + } +} + +/** + * Build the mcpServers entry for a client. + * claude-code/cursor use native remote HTTP; claude-desktop bridges via mcp-remote. + */ +export function buildServerEntry(client, apiKey) { + switch (client) { + case 'claude-code': + // "type" is required — a url without type is treated as broken stdio and skipped + return { type: 'http', url: NANSEN_MCP_URL, headers: { 'NANSEN-API-KEY': apiKey } }; + case 'cursor': + return { url: NANSEN_MCP_URL, headers: { 'NANSEN-API-KEY': apiKey } }; + case 'claude-desktop': + // Header name and value must be one arg. mcp-remote parses with + // /^([A-Za-z0-9_-]+):\s*(.*)$/, so whitespace after the colon is trimmed; + // what breaks it is an empty value. + // No --allow-http: the URL is HTTPS. + return { command: 'npx', args: ['-y', MCP_REMOTE_PIN, NANSEN_MCP_URL, '--header', `NANSEN-API-KEY:${apiKey}`] }; + default: + throw new CommandError(`Unknown client: ${client}. Supported: ${SUPPORTED_CLIENTS.join(', ')}`, 'INVALID_PARAMS'); + } +} + +function assertMergeableServers(config, configPath) { + if (config.mcpServers !== undefined && (typeof config.mcpServers !== 'object' || config.mcpServers === null || Array.isArray(config.mcpServers))) { + throw new CommandError(`"mcpServers" in ${configPath} is not an object. Fix or move the file, then re-run.`, 'INVALID_CONFIG'); + } +} + +/** + * Return a new config object with only mcpServers.nansen set/updated. + * Every other key and server entry is preserved. + */ +export function mergeNansenEntry(config, entry, configPath = 'config') { + assertMergeableServers(config, configPath); + return { ...config, mcpServers: { ...config.mcpServers, [SERVER_KEY]: entry } }; +} + +/** + * Return { config, removed } with mcpServers.nansen deleted. + */ +export function removeNansenEntry(config, configPath = 'config') { + assertMergeableServers(config, configPath); + if (!config.mcpServers || !(SERVER_KEY in config.mcpServers)) { + return { config, removed: false }; + } + const { [SERVER_KEY]: _removed, ...rest } = config.mcpServers; + return { config: { ...config, mcpServers: rest }, removed: true }; +} + +export function buildMcpCommands(deps = {}) { + const { + log = console.log, + fsOverride: fsx = fs, + platform = process.platform, + homedirFn = houseHomedir, + env = process.env, + } = deps; + + // Follow symlinks so dotfile-managed configs are edited in place instead of + // having the link replaced by the atomic rename. + const resolveReal = (p) => { + try { return fsx.realpathSync(p); } catch { return p; } + }; + + const readConfig = (configPath) => { + if (!fsx.existsSync(configPath)) return { config: {}, existed: false }; + let raw; + try { + raw = fsx.readFileSync(configPath, 'utf8'); + return { config: JSON.parse(raw), existed: true }; + } catch { + throw new CommandError(`Could not parse ${configPath} as JSON. Fix or move the file, then re-run.`, 'INVALID_CONFIG'); + } + }; + + // Atomic: temp file in the same dir, then rename over the target. + // A crash mid-write can't leave a truncated config. chmod after rename is + // best-effort (no-op semantics on Windows) — the file now holds a secret. + const writeConfig = (configPath, config) => { + const dir = path.dirname(configPath); + if (!fsx.existsSync(dir)) fsx.mkdirSync(dir, { recursive: true, mode: 0o700 }); + const tmp = path.join(dir, `.${path.basename(configPath)}.tmp-${process.pid}`); + fsx.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 }); + fsx.renameSync(tmp, configPath); + try { fsx.chmodSync(configPath, 0o600); } catch { /* Windows / exotic fs */ } + }; + + const requireClient = (client) => { + if (!client || !SUPPORTED_CLIENTS.includes(client)) { + throw new CommandError(`Usage: nansen mcp install . Supported: ${SUPPORTED_CLIENTS.join(', ')}`, 'INVALID_PARAMS'); + } + }; + + return { + 'mcp': async (args, apiInstance, flags, _options) => { + const sub = args[0]; + const client = args[1]; + + if (!sub || flags.help || flags.h) { + log(MCP_USAGE); + return undefined; + } + + if (sub !== 'install' && sub !== 'uninstall') { + throw new CommandError(`Unknown subcommand: ${sub}\n\n${MCP_USAGE}`, 'INVALID_PARAMS'); + } + + requireClient(client); + const configPath = resolveReal(resolveClientConfigPath(client, { platform, homedir: homedirFn(), env })); + + if (sub === 'uninstall') { + const { config, existed } = readConfig(configPath); + const { config: updated, removed } = removeNansenEntry(config, configPath); + if (!existed || !removed) { + log(`No Nansen MCP entry found in ${configPath}. Nothing to do.`); + return undefined; + } + writeConfig(configPath, updated); + log(`Removed Nansen MCP server from ${configPath}`); + log(`Restart ${client} to pick up the change.`); + return undefined; + } + + // install + const apiKey = apiInstance?.apiKey; + if (!apiKey) { + throw new CommandError('Not logged in. Run: nansen login', 'NOT_LOGGED_IN'); + } + + if (flags['dry-run']) { + // The key is never printed — dry-run shows a redacted entry. + const redacted = buildServerEntry(client, ''); + log(`Would write "${SERVER_KEY}" entry to ${configPath}:`); + log(JSON.stringify({ mcpServers: { [SERVER_KEY]: redacted } }, null, 2)); + return undefined; + } + + const { config, existed } = readConfig(configPath); + const hadEntry = !!config.mcpServers?.[SERVER_KEY]; + const merged = mergeNansenEntry(config, buildServerEntry(client, apiKey), configPath); + + if (existed) { + const backupPath = `${configPath}.bak`; + fsx.copyFileSync(configPath, backupPath); + try { fsx.chmodSync(backupPath, 0o600); } catch { /* best-effort */ } + log(`Backed up existing config to ${backupPath}`); + } + writeConfig(configPath, merged); + + log(hadEntry + ? `Updated existing Nansen MCP entry in ${configPath}` + : `Installed Nansen MCP server to ${configPath}`); + log(`Note: your Nansen API key is stored in plaintext in ${configPath}.`); + log('If this file is synced or backed up (settings sync, dotfiles), your key travels with it.'); + log(`Restart ${client} to pick up the change.`); + return undefined; + }, + }; +} diff --git a/src/schema.json b/src/schema.json index fda7d128..1a721b5b 100644 --- a/src/schema.json +++ b/src/schema.json @@ -1962,6 +1962,30 @@ "text", "tool_calls" ] + }, + "mcp": { + "description": "Install the hosted Nansen MCP server (https://mcp.nansen.ai/ra/mcp) into a local MCP client's config", + "subcommands": { + "install": { + "description": "Add the Nansen MCP server to a client's config. Client (positional): claude-code, claude-desktop, or cursor. Uses the API key from `nansen login` / NANSEN_API_KEY; re-run after key rotation to update the entry.", + "options": { + "dry-run": { + "type": "boolean", + "description": "Print the target config path and entry (API key redacted) without writing" + } + }, + "examples": [ + "nansen mcp install claude-code", + "nansen mcp install cursor --dry-run" + ] + }, + "uninstall": { + "description": "Remove the Nansen MCP server entry from a client's config. Client (positional): claude-code, claude-desktop, or cursor.", + "examples": [ + "nansen mcp uninstall claude-code" + ] + } + } } }, "globalOptions": { From f9476bce6477701ac2cfad9a9379b02bce0774eb Mon Sep 17 00:00:00 2001 From: "Gulshan Gill (Openclaw Bot)" Date: Mon, 17 Aug 2026 06:04:28 +0000 Subject: [PATCH 2/9] fix(pr-loop): harden MCP config writes [automated] --- src/__tests__/mcp.test.js | 36 ++++++++++++++++++++++++++++++++++++ src/commands/mcp.js | 16 ++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/__tests__/mcp.test.js b/src/__tests__/mcp.test.js index 6fded7b9..3723d8aa 100644 --- a/src/__tests__/mcp.test.js +++ b/src/__tests__/mcp.test.js @@ -101,6 +101,13 @@ describe('mergeNansenEntry / removeNansenEntry', () => { expect(() => removeNansenEntry({ mcpServers: 42 })).toThrow(/not an object/); }); + it('refuses when the config root is not an object', () => { + for (const config of [null, [], 'nope']) { + expect(() => mergeNansenEntry(config, entry)).toThrow(/must contain a JSON object/); + expect(() => removeNansenEntry(config)).toThrow(/must contain a JSON object/); + } + }); + it('removeNansenEntry removes only nansen and reports not-found', () => { const { config, removed } = removeNansenEntry({ mcpServers: { nansen: entry, other: { command: 'foo' } } }); expect(removed).toBe(true); @@ -175,6 +182,35 @@ describe('mcp command handler', () => { expect(fs.existsSync(`${cursorPath()}.bak`)).toBe(false); }); + it('reports config read failures without calling them parse errors', async () => { + fs.mkdirSync(path.dirname(cursorPath()), { recursive: true }); + fs.writeFileSync(cursorPath(), '{}'); + const readError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const { mcp: unreadableMcp } = buildMcpCommands({ + log: () => {}, + fsOverride: { ...fs, readFileSync: () => { throw readError; } }, + platform: 'linux', + homedirFn: () => tempDir, + env: {}, + }); + + await expect(unreadableMcp(['install', 'cursor'], api, {}, {})) + .rejects.toThrow(/Could not read.*permission denied/); + }); + + it('removes the secret temp file when the atomic rename fails', async () => { + const { mcp: failingMcp } = buildMcpCommands({ + log: () => {}, + fsOverride: { ...fs, renameSync: () => { throw new Error('rename failed'); } }, + platform: 'linux', + homedirFn: () => tempDir, + env: {}, + }); + + await expect(failingMcp(['install', 'cursor'], api, {}, {})).rejects.toThrow('rename failed'); + expect(fs.readdirSync(path.dirname(cursorPath()))).toEqual([]); + }); + it('requires login and writes nothing without a key', async () => { await expect(run(['install', 'cursor'], { apiInstance: { apiKey: null } })) .rejects.toThrow('Not logged in. Run: nansen login'); diff --git a/src/commands/mcp.js b/src/commands/mcp.js index f8e47c2e..55b040d1 100644 --- a/src/commands/mcp.js +++ b/src/commands/mcp.js @@ -93,6 +93,9 @@ export function buildServerEntry(client, apiKey) { } function assertMergeableServers(config, configPath) { + if (typeof config !== 'object' || config === null || Array.isArray(config)) { + throw new CommandError(`${configPath} must contain a JSON object. Fix or move the file, then re-run.`, 'INVALID_CONFIG'); + } if (config.mcpServers !== undefined && (typeof config.mcpServers !== 'object' || config.mcpServers === null || Array.isArray(config.mcpServers))) { throw new CommandError(`"mcpServers" in ${configPath} is not an object. Fix or move the file, then re-run.`, 'INVALID_CONFIG'); } @@ -139,6 +142,10 @@ export function buildMcpCommands(deps = {}) { let raw; try { raw = fsx.readFileSync(configPath, 'utf8'); + } catch (err) { + throw new CommandError(`Could not read ${configPath}: ${err.message}`, 'INVALID_CONFIG'); + } + try { return { config: JSON.parse(raw), existed: true }; } catch { throw new CommandError(`Could not parse ${configPath} as JSON. Fix or move the file, then re-run.`, 'INVALID_CONFIG'); @@ -152,8 +159,13 @@ export function buildMcpCommands(deps = {}) { const dir = path.dirname(configPath); if (!fsx.existsSync(dir)) fsx.mkdirSync(dir, { recursive: true, mode: 0o700 }); const tmp = path.join(dir, `.${path.basename(configPath)}.tmp-${process.pid}`); - fsx.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 }); - fsx.renameSync(tmp, configPath); + try { + fsx.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 }); + fsx.renameSync(tmp, configPath); + } catch (err) { + try { fsx.unlinkSync(tmp); } catch { /* temp file may not exist */ } + throw err; + } try { fsx.chmodSync(configPath, 0o600); } catch { /* Windows / exotic fs */ } }; From e31d1bc47b4b7bba16ba12f0326862562acaf06c Mon Sep 17 00:00:00 2001 From: "Gulshan Gill (Openclaw Bot)" Date: Mon, 17 Aug 2026 09:02:42 +0000 Subject: [PATCH 3/9] fix(pr-loop): secure MCP dry-run handling [automated] --- README.md | 2 +- src/__tests__/mcp.test.js | 14 +++++++++++++- src/commands/mcp.js | 12 ++++++++++-- src/schema.json | 9 ++++++++- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5ace114a..7d93a95f 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ nansen mcp install claude-code # ~/.claude.json (user scope) nansen mcp install claude-desktop # macOS/Windows only; bridges via pinned mcp-remote nansen mcp install cursor # ~/.cursor/mcp.json nansen mcp install cursor --dry-run # print what would be written (key redacted) -nansen mcp uninstall # remove the entry +nansen mcp uninstall # remove the entry (add --dry-run to preview) ``` Uses the API key from `nansen login` / `NANSEN_API_KEY`; re-run `install` after rotating your key. Installs are merge-only and atomic: existing servers and settings are preserved, a `.bak` copy is written first, and the CLI refuses to touch a config it can't parse. Note the client config stores the API key in plaintext — new files are created with `0600` permissions. Restart the client after installing. For other clients, see [docs.nansen.ai/mcp/connecting](https://docs.nansen.ai/mcp/connecting). diff --git a/src/__tests__/mcp.test.js b/src/__tests__/mcp.test.js index 3723d8aa..7c1d660b 100644 --- a/src/__tests__/mcp.test.js +++ b/src/__tests__/mcp.test.js @@ -72,7 +72,9 @@ describe('buildServerEntry', () => { expect(entry.args).toContain(MCP_REMOTE_PIN); expect(MCP_REMOTE_PIN).toMatch(/^mcp-remote@\d+\.\d+\.\d+$/); // exact pin, not a range // no space after the colon (Claude Desktop mis-splits spaced args) - expect(entry.args).toContain(`NANSEN-API-KEY:${API_KEY}`); + expect(entry.args).toContain('NANSEN-API-KEY:${NANSEN_API_KEY}'); + expect(entry.args.join(' ')).not.toContain(API_KEY); + expect(entry.env).toEqual({ NANSEN_API_KEY: API_KEY }); expect(entry.args).not.toContain('--allow-http'); }); }); @@ -238,6 +240,15 @@ describe('mcp command handler', () => { expect(readCursor().mcpServers).toEqual({ other: { command: 'foo' } }); }); + it('uninstall --dry-run leaves the config unchanged', async () => { + fs.mkdirSync(path.dirname(cursorPath()), { recursive: true }); + const original = JSON.stringify({ mcpServers: { nansen: { url: 'x' } } }); + fs.writeFileSync(cursorPath(), original); + await run(['uninstall', 'cursor'], { flags: { 'dry-run': true } }); + expect(fs.readFileSync(cursorPath(), 'utf8')).toBe(original); + expect(logs.join('\n')).toContain('Would remove "nansen" entry'); + }); + it('uninstall with no entry is a friendly no-op', async () => { await run(['uninstall', 'cursor']); expect(logs.join('\n')).toContain('Nothing to do'); @@ -280,6 +291,7 @@ describe('schema + CLI registration', () => { path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'schema.json'), 'utf8')); expect(Object.keys(schema.commands)).toContain('mcp'); expect(Object.keys(schema.commands.mcp.subcommands).sort()).toEqual(['install', 'uninstall']); + expect(schema.commands.mcp.subcommands.uninstall.options['dry-run'].type).toBe('boolean'); }); it('runCLI routes `mcp` and parses --dry-run as a boolean flag', async () => { diff --git a/src/commands/mcp.js b/src/commands/mcp.js index 55b040d1..593aebe5 100644 --- a/src/commands/mcp.js +++ b/src/commands/mcp.js @@ -42,7 +42,7 @@ CLIENTS: cursor ~/.cursor/mcp.json OPTIONS: - --dry-run Print the target file and entry (key redacted) without writing + --dry-run Preview the change (key redacted) without writing The API key is taken from \`nansen login\` / NANSEN_API_KEY. Re-run install after rotating your key to update the entry. Other clients: https://docs.nansen.ai/mcp/connecting`; @@ -86,7 +86,11 @@ export function buildServerEntry(client, apiKey) { // /^([A-Za-z0-9_-]+):\s*(.*)$/, so whitespace after the colon is trimmed; // what breaks it is an empty value. // No --allow-http: the URL is HTTPS. - return { command: 'npx', args: ['-y', MCP_REMOTE_PIN, NANSEN_MCP_URL, '--header', `NANSEN-API-KEY:${apiKey}`] }; + return { + command: 'npx', + args: ['-y', MCP_REMOTE_PIN, NANSEN_MCP_URL, '--header', 'NANSEN-API-KEY:${NANSEN_API_KEY}'], + env: { NANSEN_API_KEY: apiKey }, + }; default: throw new CommandError(`Unknown client: ${client}. Supported: ${SUPPORTED_CLIENTS.join(', ')}`, 'INVALID_PARAMS'); } @@ -199,6 +203,10 @@ export function buildMcpCommands(deps = {}) { log(`No Nansen MCP entry found in ${configPath}. Nothing to do.`); return undefined; } + if (flags['dry-run']) { + log(`Would remove "${SERVER_KEY}" entry from ${configPath} (no changes made).`); + return undefined; + } writeConfig(configPath, updated); log(`Removed Nansen MCP server from ${configPath}`); log(`Restart ${client} to pick up the change.`); diff --git a/src/schema.json b/src/schema.json index 1a721b5b..606a5641 100644 --- a/src/schema.json +++ b/src/schema.json @@ -1981,8 +1981,15 @@ }, "uninstall": { "description": "Remove the Nansen MCP server entry from a client's config. Client (positional): claude-code, claude-desktop, or cursor.", + "options": { + "dry-run": { + "type": "boolean", + "description": "Report whether the Nansen entry would be removed without writing" + } + }, "examples": [ - "nansen mcp uninstall claude-code" + "nansen mcp uninstall claude-code", + "nansen mcp uninstall cursor --dry-run" ] } } From 65a5e17462df5d5eeeacc2061e20b79285254ebe Mon Sep 17 00:00:00 2001 From: "Gulshan Gill (Openclaw Bot)" Date: Thu, 20 Aug 2026 05:47:04 +0000 Subject: [PATCH 4/9] Back up config on mcp uninstall; wire mcp log to runCLI output Addresses both pr-reviewer findings on #487: - `mcp uninstall` now copies the config to `.bak` (best-effort chmod 0600) before writing, mirroring `install`. Only runs when there is an entry to remove and not under `--dry-run`, so a missing config or a no-op uninstall still writes nothing. copyFileSync failures surface as-is, like install. Adds a notice that the backup retains the API key just removed. - `runCLI` passes `log: deps.log ?? output` to `buildMcpCommands`, so mcp output honours the caller's stdout sink instead of falling back to bare console.log. An explicit `log` dep still wins, preserving test injection. Regression tests: uninstall backup content/mode/log, no backup when there is nothing to remove (incl. dry-run), failed backup copy leaves the config untouched, and runCLI routing mcp output through `output` with console.log unused. README + changeset wording updated to cover uninstall. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/one-step-mcp-install.md | 2 +- README.md | 2 +- src/__tests__/mcp.test.js | 62 +++++++++++++++++++++++++++++- src/cli.js | 4 +- src/commands/mcp.js | 9 +++++ 5 files changed, 75 insertions(+), 4 deletions(-) diff --git a/.changeset/one-step-mcp-install.md b/.changeset/one-step-mcp-install.md index ad4dee50..5e27f966 100644 --- a/.changeset/one-step-mcp-install.md +++ b/.changeset/one-step-mcp-install.md @@ -2,4 +2,4 @@ "nansen-cli": minor --- -Add `nansen mcp install ` / `nansen mcp uninstall ` for one-step setup of the hosted Nansen MCP server (https://mcp.nansen.ai/ra/mcp) in Claude Code, Claude Desktop, and Cursor. Installs are merge-only and atomic (existing servers preserved, `.bak` backup, refuses unparseable configs), use the API key from `nansen login` / `NANSEN_API_KEY`, never print the key, and support `--dry-run`. +Add `nansen mcp install ` / `nansen mcp uninstall ` for one-step setup of the hosted Nansen MCP server (https://mcp.nansen.ai/ra/mcp) in Claude Code, Claude Desktop, and Cursor. Writes are merge-only and atomic (existing servers preserved, `.bak` backup on install and uninstall, refuses unparseable configs), use the API key from `nansen login` / `NANSEN_API_KEY`, never print the key, and support `--dry-run`. diff --git a/README.md b/README.md index 7d93a95f..76b96543 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ nansen mcp install cursor --dry-run # print what would be written (key redacted nansen mcp uninstall # remove the entry (add --dry-run to preview) ``` -Uses the API key from `nansen login` / `NANSEN_API_KEY`; re-run `install` after rotating your key. Installs are merge-only and atomic: existing servers and settings are preserved, a `.bak` copy is written first, and the CLI refuses to touch a config it can't parse. Note the client config stores the API key in plaintext — new files are created with `0600` permissions. Restart the client after installing. For other clients, see [docs.nansen.ai/mcp/connecting](https://docs.nansen.ai/mcp/connecting). +Uses the API key from `nansen login` / `NANSEN_API_KEY`; re-run `install` after rotating your key. Writes are merge-only and atomic: existing servers and settings are preserved, a `.bak` copy is written before every install or uninstall, and the CLI refuses to touch a config it can't parse. Note the client config stores the API key in plaintext — new files are created with `0600` permissions. Restart the client after installing. For other clients, see [docs.nansen.ai/mcp/connecting](https://docs.nansen.ai/mcp/connecting). **Claude Desktop and Cursor:** setup instructions for both are in the connection docs: [docs.nansen.ai/mcp/connecting](https://docs.nansen.ai/mcp/connecting). diff --git a/src/__tests__/mcp.test.js b/src/__tests__/mcp.test.js index 7c1d660b..6e79a7d9 100644 --- a/src/__tests__/mcp.test.js +++ b/src/__tests__/mcp.test.js @@ -3,7 +3,7 @@ * House pattern: real temp dir + injected deps, no fs mocking. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -240,6 +240,48 @@ describe('mcp command handler', () => { expect(readCursor().mcpServers).toEqual({ other: { command: 'foo' } }); }); + it('uninstall backs up the config before writing (0600)', async () => { + fs.mkdirSync(path.dirname(cursorPath()), { recursive: true }); + const original = JSON.stringify({ mcpServers: { nansen: { url: 'x' }, other: { command: 'foo' } } }); + fs.writeFileSync(cursorPath(), original); + + await run(['uninstall', 'cursor']); + + expect(fs.readFileSync(`${cursorPath()}.bak`, 'utf8')).toBe(original); + expect(fs.statSync(`${cursorPath()}.bak`).mode & 0o777).toBe(0o600); + expect(logs.join('\n')).toContain(`Backed up existing config to ${cursorPath()}.bak`); + // The backup carries the key that was just removed — say so, but never print it. + expect(logs.join('\n')).toContain(`${cursorPath()}.bak still contains your API key`); + expect(logs.join('\n')).not.toContain(API_KEY); + }); + + it('uninstall writes no backup when there is nothing to remove', async () => { + fs.mkdirSync(path.dirname(cursorPath()), { recursive: true }); + fs.writeFileSync(cursorPath(), JSON.stringify({ mcpServers: { other: { command: 'foo' } } })); + + await run(['uninstall', 'cursor']); + expect(fs.existsSync(`${cursorPath()}.bak`)).toBe(false); + + await run(['uninstall', 'cursor'], { flags: { 'dry-run': true } }); + expect(fs.existsSync(`${cursorPath()}.bak`)).toBe(false); + }); + + it('uninstall surfaces a failed backup copy instead of writing the config', async () => { + fs.mkdirSync(path.dirname(cursorPath()), { recursive: true }); + const original = JSON.stringify({ mcpServers: { nansen: { url: 'x' } } }); + fs.writeFileSync(cursorPath(), original); + const { mcp: failingMcp } = buildMcpCommands({ + log: () => {}, + fsOverride: { ...fs, copyFileSync: () => { throw new Error('copy failed'); } }, + platform: 'linux', + homedirFn: () => tempDir, + env: {}, + }); + + await expect(failingMcp(['uninstall', 'cursor'], api, {}, {})).rejects.toThrow('copy failed'); + expect(fs.readFileSync(cursorPath(), 'utf8')).toBe(original); + }); + it('uninstall --dry-run leaves the config unchanged', async () => { fs.mkdirSync(path.dirname(cursorPath()), { recursive: true }); const original = JSON.stringify({ mcpServers: { nansen: { url: 'x' } } }); @@ -309,4 +351,22 @@ describe('schema + CLI registration', () => { expect(result.type).toBe('no-output'); expect(logs.join('\n')).toContain('nansen mcp install '); }); + + it('runCLI routes mcp output through the caller\'s output sink', async () => { + const { runCLI } = await import('../cli.js'); + const outputs = []; + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + const result = await runCLI(['mcp'], { + output: (m) => outputs.push(m), + errorOutput: () => {}, + exit: () => {}, + }); + expect(result.type).toBe('no-output'); + expect(outputs.join('\n')).toContain('nansen mcp install '); + expect(spy).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); }); diff --git a/src/cli.js b/src/cli.js index ec4ef536..d16c1bbb 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1873,7 +1873,9 @@ export async function runCLI(rawArgs, deps = {}) { return ''; }; - const commands = { ...buildCommands(deps), ...buildWalletCommands(deps), ...buildTradingCommands(deps), ...buildAlertsCommands(deps), ...buildAgentCommands(deps), ...buildMcpCommands(deps), ...commandOverrides }; + // mcp prints its own output via `log`; runCLI callers inject their stdout + // sink as `output`, so map it across (an explicit `log` dep still wins). + const commands = { ...buildCommands(deps), ...buildWalletCommands(deps), ...buildTradingCommands(deps), ...buildAlertsCommands(deps), ...buildAgentCommands(deps), ...buildMcpCommands({ ...deps, log: deps.log ?? output }), ...commandOverrides }; if (flags.version || flags.v) { output(VERSION); diff --git a/src/commands/mcp.js b/src/commands/mcp.js index 593aebe5..7cae77ab 100644 --- a/src/commands/mcp.js +++ b/src/commands/mcp.js @@ -207,8 +207,17 @@ export function buildMcpCommands(deps = {}) { log(`Would remove "${SERVER_KEY}" entry from ${configPath} (no changes made).`); return undefined; } + // Same backup contract as install: an accidental uninstall of the wrong + // client is recoverable. copyFileSync failures surface (like install); + // chmod is best-effort. + const backupPath = `${configPath}.bak`; + fsx.copyFileSync(configPath, backupPath); + try { fsx.chmodSync(backupPath, 0o600); } catch { /* best-effort */ } + log(`Backed up existing config to ${backupPath}`); writeConfig(configPath, updated); log(`Removed Nansen MCP server from ${configPath}`); + // The backup still holds the entry we just removed, key included. + log(`Note: ${backupPath} still contains your API key (mode 0600). Delete it once you've confirmed the change.`); log(`Restart ${client} to pick up the change.`); return undefined; } From f89e4e28d60ea2cdf8d5129659a869b7926a015a Mon Sep 17 00:00:00 2001 From: gulshngill Date: Fri, 28 Aug 2026 18:11:53 +0800 Subject: [PATCH 5/9] Align with main: mcp-remote@0.2.1, corrected header comment, macOS-safe test - Bump MCP_REMOTE_PIN 0.1.38 -> 0.2.1, matching what the README and the public docs now publish. Leaving it at 0.1.38 would reintroduce the drift those PRs just removed, and #502's verify rejects entries whose pin does not match, so a user following the docs would get a spurious 'does not match the official server URL/transport'. - Correct the claude-desktop comment. mcp-remote parses headers with /^([A-Za-z0-9_-]+):\s*(.*)$/, so it trims whitespace after the colon -- it does not mis-split on spaces. The real failure mode is an empty value. - Test: realpath the temp dir. On macOS os.tmpdir() is /var/... symlinked to /private/var/..., and the command logs the resolved path, so the backup-path assertion failed for every macOS developer. Pre-existing; confirmed the same failure on the un-rebased branch. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 4 ++-- node_modules | 1 + src/__tests__/mcp.test.js | 4 +++- 3 files changed, 6 insertions(+), 3 deletions(-) create mode 120000 node_modules diff --git a/README.md b/README.md index 76b96543..5467ff86 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,9 @@ nansen mcp install cursor --dry-run # print what would be written (key redacted nansen mcp uninstall # remove the entry (add --dry-run to preview) ``` -Uses the API key from `nansen login` / `NANSEN_API_KEY`; re-run `install` after rotating your key. Writes are merge-only and atomic: existing servers and settings are preserved, a `.bak` copy is written before every install or uninstall, and the CLI refuses to touch a config it can't parse. Note the client config stores the API key in plaintext — new files are created with `0600` permissions. Restart the client after installing. For other clients, see [docs.nansen.ai/mcp/connecting](https://docs.nansen.ai/mcp/connecting). +Uses the API key from `nansen login` / `NANSEN_API_KEY`; re-run `install` after rotating your key. Writes are merge-only and atomic: existing servers and settings are preserved, a `.bak` copy is written before every install or uninstall, and the CLI refuses to touch a config it can't parse. Note the client config stores the API key in plaintext — new files are created with `0600` permissions. Restart the client after installing. -**Claude Desktop and Cursor:** setup instructions for both are in the connection docs: [docs.nansen.ai/mcp/connecting](https://docs.nansen.ai/mcp/connecting). +**Manual setup**, for other clients or if you would rather not use the CLI — the paths below, and the connection docs at [docs.nansen.ai/mcp/connecting](https://docs.nansen.ai/mcp/connecting). **One-command (Claude Code):** diff --git a/node_modules b/node_modules new file mode 120000 index 00000000..d5b4be87 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/Users/gulshan_work/nansen/nansen-cli/node_modules \ No newline at end of file diff --git a/src/__tests__/mcp.test.js b/src/__tests__/mcp.test.js index 6e79a7d9..6f82242f 100644 --- a/src/__tests__/mcp.test.js +++ b/src/__tests__/mcp.test.js @@ -130,7 +130,9 @@ describe('mcp command handler', () => { const readCursor = () => JSON.parse(fs.readFileSync(cursorPath(), 'utf8')); beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nansen-mcp-test-')); + // realpath: on macOS os.tmpdir() is /var/... which is a symlink to + // /private/var/..., and the command logs the resolved path. + tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'nansen-mcp-test-'))); logs = []; ({ mcp } = buildMcpCommands({ log: (...a) => logs.push(a.join(' ')), From 7bd5d9cc89241bcc60454aa552ec0e58a63e5b9a Mon Sep 17 00:00:00 2001 From: gulshngill Date: Fri, 28 Aug 2026 18:53:46 +0800 Subject: [PATCH 6/9] Correct the last trace of the header-spacing myth in the test comment The claude-desktop assertion is about the key staying out of argv (it lives in env, and mcp-remote substitutes ${NANSEN_API_KEY}), not about colon spacing. mcp-remote trims whitespace after the colon. Co-Authored-By: Claude Opus 5 (1M context) --- src/__tests__/mcp.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/mcp.test.js b/src/__tests__/mcp.test.js index 6f82242f..608752fb 100644 --- a/src/__tests__/mcp.test.js +++ b/src/__tests__/mcp.test.js @@ -71,7 +71,7 @@ describe('buildServerEntry', () => { expect(entry.command).toBe('npx'); expect(entry.args).toContain(MCP_REMOTE_PIN); expect(MCP_REMOTE_PIN).toMatch(/^mcp-remote@\d+\.\d+\.\d+$/); // exact pin, not a range - // no space after the colon (Claude Desktop mis-splits spaced args) + // key stays out of argv: it lives in env and mcp-remote substitutes ${NANSEN_API_KEY} expect(entry.args).toContain('NANSEN-API-KEY:${NANSEN_API_KEY}'); expect(entry.args.join(' ')).not.toContain(API_KEY); expect(entry.env).toEqual({ NANSEN_API_KEY: API_KEY }); From d7ce4b50cbd5ea3ecd657a18cf1fb2ee11033476 Mon Sep 17 00:00:00 2001 From: gulshngill Date: Fri, 28 Aug 2026 18:57:51 +0800 Subject: [PATCH 7/9] Remove accidentally committed node_modules symlink A symlink named node_modules pointing at an absolute local developer path was committed. .gitignore only had `node_modules/` with a trailing slash, which matches a directory but not a symlink of the same name, so it was not ignored. On clone this produced a dangling symlink to a path that exists on one machine, breaking anything that follows it for everyone else. Untracks the symlink and adds a bare `node_modules` entry so the same mistake cannot recur. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 + node_modules | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 120000 node_modules diff --git a/.gitignore b/.gitignore index 272b4e35..207fdb4c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Dependencies node_modules/ +node_modules # Config with secrets /config.json diff --git a/node_modules b/node_modules deleted file mode 120000 index d5b4be87..00000000 --- a/node_modules +++ /dev/null @@ -1 +0,0 @@ -/Users/gulshan_work/nansen/nansen-cli/node_modules \ No newline at end of file From dc14d76980c8ffe0580a282ec9cba564e9998349 Mon Sep 17 00:00:00 2001 From: gulshngill Date: Fri, 28 Aug 2026 19:00:56 +0800 Subject: [PATCH 8/9] uninstall --dry-run must not throw on an unparseable config --dry-run is what a user reaches for when they are unsure of a config's state, so failing with 'Could not parse ... as JSON' is the wrong response. Install already gates on dry-run before reading; uninstall read and parsed first. Best-effort read on the dry-run path: report that the file cannot be previewed and that nothing was changed. Without --dry-run the parse error still surfaces. Flagged by pr-reviewer. Co-Authored-By: Claude Opus 5 (1M context) --- src/__tests__/mcp.test.js | 16 ++++++++++++++++ src/commands/mcp.js | 16 ++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/__tests__/mcp.test.js b/src/__tests__/mcp.test.js index 608752fb..bd679059 100644 --- a/src/__tests__/mcp.test.js +++ b/src/__tests__/mcp.test.js @@ -242,6 +242,22 @@ describe('mcp command handler', () => { expect(readCursor().mcpServers).toEqual({ other: { command: 'foo' } }); }); + it('uninstall --dry-run does not throw on an unparseable config', async () => { + fs.mkdirSync(path.dirname(cursorPath()), { recursive: true }); + fs.writeFileSync(cursorPath(), '{ not valid json,,,', 'utf8'); + await run(['uninstall', 'cursor'], { flags: { 'dry-run': true } }); + expect(logs.join('\n')).toContain('Cannot preview'); + expect(logs.join('\n')).toContain('No changes made.'); + // the file is untouched + expect(fs.readFileSync(cursorPath(), 'utf8')).toBe('{ not valid json,,,'); + }); + + it('uninstall without --dry-run still fails loudly on an unparseable config', async () => { + fs.mkdirSync(path.dirname(cursorPath()), { recursive: true }); + fs.writeFileSync(cursorPath(), '{ not valid json,,,', 'utf8'); + await expect(run(['uninstall', 'cursor'])).rejects.toThrow(/parse/i); + }); + it('uninstall backs up the config before writing (0600)', async () => { fs.mkdirSync(path.dirname(cursorPath()), { recursive: true }); const original = JSON.stringify({ mcpServers: { nansen: { url: 'x' }, other: { command: 'foo' } } }); diff --git a/src/commands/mcp.js b/src/commands/mcp.js index 7cae77ab..9393a33b 100644 --- a/src/commands/mcp.js +++ b/src/commands/mcp.js @@ -197,8 +197,20 @@ export function buildMcpCommands(deps = {}) { const configPath = resolveReal(resolveClientConfigPath(client, { platform, homedir: homedirFn(), env })); if (sub === 'uninstall') { - const { config, existed } = readConfig(configPath); - const { config: updated, removed } = removeNansenEntry(config, configPath); + let config, existed, updated, removed; + try { + ({ config, existed } = readConfig(configPath)); + ({ config: updated, removed } = removeNansenEntry(config, configPath)); + } catch (err) { + // --dry-run must not throw on an unparseable config: users reach for it + // precisely when unsure of the file's state. Install gates on dry-run + // before reading; this keeps uninstall consistent. + if (flags['dry-run']) { + log(`Cannot preview ${configPath}: ${err.message} No changes made.`); + return undefined; + } + throw err; + } if (!existed || !removed) { log(`No Nansen MCP entry found in ${configPath}. Nothing to do.`); return undefined; From f5ea0c835238e79531240425c48d14e7ad0b6310 Mon Sep 17 00:00:00 2001 From: gulshngill Date: Fri, 28 Aug 2026 19:21:09 +0800 Subject: [PATCH 9/9] Drop the extra bare node_modules ignore line Added while removing an accidentally committed node_modules symlink. The symlink was a local artifact of running tests in a scratch worktree, not something the repo needs to defend against -- node_modules/ already covers the real case. Keeping .gitignore minimal. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 207fdb4c..272b4e35 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # Dependencies node_modules/ -node_modules # Config with secrets /config.json