diff --git a/.changeset/one-step-mcp-install.md b/.changeset/one-step-mcp-install.md new file mode 100644 index 00000000..5e27f966 --- /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. 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 d0fbdd6d..349392af 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) ``` @@ -51,6 +52,20 @@ nansen schema [command] [--pretty] # full command reference (no API key neede Run `nansen schema --pretty` for the full subcommand and field reference. +## MCP + +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 (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). + ## Trading DEX swaps on `solana` and `base`. Two-step: quote then execute. diff --git a/src/__tests__/mcp.test.js b/src/__tests__/mcp.test.js new file mode 100644 index 00000000..6e79a7d9 --- /dev/null +++ b/src/__tests__/mcp.test.js @@ -0,0 +1,372 @@ +/** + * 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, vi } 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:${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'); + }); +}); + +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('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); + 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('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'); + 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 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' } } }); + 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'); + }); + + 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']); + 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 () => { + 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 '); + }); + + 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 7f00cfb5..f1f0e19a 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') { + 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 === 'dry-run') { result.flags[key] = true; } else if (next && (!next.startsWith('-') || /^-\d/.test(next))) { // Try to parse as JSON first (for objects/arrays/booleans), @@ -729,6 +730,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) @@ -1866,7 +1868,9 @@ export async function runCLI(rawArgs, deps = {}) { return ''; }; - const commands = { ...buildCommands(deps), ...buildWalletCommands(deps), ...buildTradingCommands(deps), ...buildAlertsCommands(deps), ...buildAgentCommands(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 new file mode 100644 index 00000000..8cc4f485 --- /dev/null +++ b/src/commands/mcp.js @@ -0,0 +1,258 @@ +/** + * 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.1.38'; + +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 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`; + +/** + * 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': + // No space after the colon: Claude Desktop mis-splits args containing spaces. + // No --allow-http: the URL is HTTPS. + 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'); + } +} + +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'); + } +} + +/** + * 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'); + } 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'); + } + }; + + // 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}`); + 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 */ } + }; + + 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; + } + if (flags['dry-run']) { + 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; + } + + // 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 397c277b..ddacb888 100644 --- a/src/schema.json +++ b/src/schema.json @@ -1949,6 +1949,37 @@ "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.", + "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 cursor --dry-run" + ] + } + } } }, "globalOptions": {