diff --git a/.changeset/mcp-env-ref.md b/.changeset/mcp-env-ref.md new file mode 100644 index 00000000..51ac3747 --- /dev/null +++ b/.changeset/mcp-env-ref.md @@ -0,0 +1,5 @@ +--- +"nansen-cli": minor +--- + +Add opt-in `nansen mcp install --env-ref` credential references and harden login guidance against shell-history and error-message credential leaks. diff --git a/README.md b/README.md index f74bc6b1..88863abc 100644 --- a/README.md +++ b/README.md @@ -61,13 +61,26 @@ 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 install cursor --env-ref # reference NANSEN_API_KEY instead of storing it nansen mcp uninstall # remove the entry (add --dry-run to preview) -nansen mcp verify [client] # prove the setup with one real authenticated data call +nansen mcp verify [client] # check setup with one real authenticated data call ``` -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. 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 by default (see `--env-ref`) — 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). -`install` writes the config; `verify` proves it works. The MCP server answers `tools/list` — and even some free tools — without a key, so a broken credential only surfaces on the first real data call. `nansen mcp verify` makes that call (a `token_info` lookup, consuming a small number of API credits) and maps each failure to a fix. With a client argument (`nansen mcp verify cursor`) it checks the key actually stored in that client's config — catching stale keys after rotation; hand-written entries are fine as long as they still target the official server URL/transport (other differences are warned about, not refused), and the key is only ever sent to the official server URL. Without one it checks the `nansen login` / `NANSEN_API_KEY` credential directly. +### MCP credential patterns + +By default, `install` writes the API key literal. With `--env-ref`, the config stores only a reference and `NANSEN_API_KEY` must be available in the client process when it launches: + +| Client | Default | `--env-ref` | Ceiling | +| --- | --- | --- | --- | +| Claude Code | `"NANSEN-API-KEY": ""` | `"NANSEN-API-KEY": "${NANSEN_API_KEY}"` | Some client versions send the literal reference in HTTP headers. | +| Cursor | `"NANSEN-API-KEY": ""` | `"NANSEN-API-KEY": "${env:NANSEN_API_KEY}"` | Remote HTTP/SSE headers may send the literal reference; stdio expansion is reliable. | +| Claude Desktop | Key in the config `env` block | Omits the `env` block; `mcp-remote` inherits the OS environment | Claude Desktop has no native config expansion. | + +`--env-ref` is opt-in: confirm reference expansion inside Claude Code or Cursor because `nansen mcp verify` can only check the config shape and resolve the variable in the current shell, not prove the client expands it. Claude Desktop requires OS-level environment variables for this mode, such as `launchctl setenv NANSEN_API_KEY ` on macOS or a Windows user environment variable. + +`install` writes the config; `verify` checks the configured credential path with one real call. The MCP server answers `tools/list` — and even some free tools — without a key, so a broken credential only surfaces on the first real data call. `nansen mcp verify` makes that call (a `token_info` lookup, consuming a small number of API credits) and maps each failure to a fix. With a client argument (`nansen mcp verify cursor`) it checks the key actually stored in that client's config — catching stale keys after rotation; hand-written entries are fine as long as they still target the official server URL/transport (other differences are warned about, not refused), and the key is only ever sent to the official server URL. Without one it checks the `nansen login` / `NANSEN_API_KEY` credential directly. For `--env-ref`, this proves config shape and current-shell resolution only; it cannot prove the client expands the reference. ## Trading diff --git a/src/__tests__/cli.internal.test.js b/src/__tests__/cli.internal.test.js index 07deacf4..58a2497b 100644 --- a/src/__tests__/cli.internal.test.js +++ b/src/__tests__/cli.internal.test.js @@ -1818,6 +1818,15 @@ describe('buildCommands', () => { }); describe('login command', () => { + it('leads help with history-safe login methods', async () => { + await commands.login([], null, { help: true }, {}); + const help = logs.join('\n'); + expect(help.indexOf('nansen login --human')).toBeLessThan(help.indexOf('NANSEN_API_KEY=$(op read')); + expect(help.indexOf('NANSEN_API_KEY=$(op read')).toBeLessThan(help.indexOf('nansen login --api-key ')); + expect(help).toContain('literal values are recorded in shell history'); + expect(help).toContain('Inline NANSEN_API_KEY= assignments are also recorded in shell history'); + }); + it('should exit when no API key provided', async () => { const savedEnv = process.env.NANSEN_API_KEY; delete process.env.NANSEN_API_KEY; @@ -1873,12 +1882,15 @@ describe('buildCommands', () => { }); it('should handle network errors during verification', async () => { - const mockApi = { getAccount: vi.fn().mockRejectedValue({ code: 'NETWORK_ERROR', message: 'Network error' }) }; + const key = 'login-network-secret'; + const mockApi = { getAccount: vi.fn().mockRejectedValue({ code: 'NETWORK_ERROR', message: `Network error for ${key}` }) }; mockDeps.NansenAPIClass.mockImplementation(function() { return mockApi; }); - const err = await commands.login([], null, {}, { 'api-key': 'some-key' }).catch(e => e); + const err = await commands.login([], null, {}, { 'api-key': key }).catch(e => e); expect(err.code).toBe('VERIFICATION_FAILED'); + expect(err.message).toContain('[redacted]'); + expect(err.message).not.toContain(key); expect(mockDeps.saveConfigFn).not.toHaveBeenCalled(); }); diff --git a/src/__tests__/mcp.test.js b/src/__tests__/mcp.test.js index 1ac92638..00e226b2 100644 --- a/src/__tests__/mcp.test.js +++ b/src/__tests__/mcp.test.js @@ -15,11 +15,14 @@ import { buildMcpCommands, NANSEN_MCP_URL, MCP_REMOTE_PIN, + CLAUDE_CODE_KEY_REF, + CURSOR_KEY_REF, extractInstalledKey, entryDriftNotes, parseMcpResponse, classifyVerifyResult, } from '../commands/mcp.js'; +import { parseArgs } from '../cli.js'; const API_KEY = 'test-key-123'; const TOKEN_ADDRESS = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; @@ -89,6 +92,25 @@ describe('buildServerEntry', () => { expect(entry.env).toEqual({ NANSEN_API_KEY: API_KEY }); expect(entry.args).not.toContain('--allow-http'); }); + + it('builds env-ref entries for each client without storing the key', () => { + expect(buildServerEntry('claude-code', API_KEY, { envRef: true })).toEqual({ + type: 'http', + url: NANSEN_MCP_URL, + headers: { 'NANSEN-API-KEY': CLAUDE_CODE_KEY_REF }, + }); + expect(buildServerEntry('cursor', API_KEY, { envRef: true })).toEqual({ + url: NANSEN_MCP_URL, + headers: { 'NANSEN-API-KEY': CURSOR_KEY_REF }, + }); + const desktop = buildServerEntry('claude-desktop', API_KEY, { envRef: true }); + expect(desktop).toEqual({ + command: 'npx', + args: ['-y', MCP_REMOTE_PIN, NANSEN_MCP_URL, '--header', 'NANSEN-API-KEY:${NANSEN_API_KEY}'], + }); + expect(desktop).not.toHaveProperty('env'); + expect(JSON.stringify(desktop)).not.toContain(API_KEY); + }); }); describe('mergeNansenEntry / removeNansenEntry', () => { @@ -214,9 +236,37 @@ describe('MCP verify helpers', () => { expect(extractInstalledKey('vscode', buildServerEntry('cursor', API_KEY))).toBeNull(); }); + it('resolves exact env refs, refuses near-misses, and treats empty env as unset', () => { + const env = { NANSEN_API_KEY: API_KEY }; + expect(extractInstalledKey('claude-code', buildServerEntry('claude-code', API_KEY, { envRef: true }), { env })) + .toBe(API_KEY); + expect(extractInstalledKey('cursor', buildServerEntry('cursor', API_KEY, { envRef: true }), { env })) + .toBe(API_KEY); + expect(extractInstalledKey('claude-desktop', buildServerEntry('claude-desktop', API_KEY, { envRef: true }), { env })) + .toBe(API_KEY); + + expect(extractInstalledKey('claude-code', { + url: NANSEN_MCP_URL, + headers: { 'NANSEN-API-KEY': '${NANSEN_API_KEY}:suffix' }, + }, { env })).toBeNull(); + expect(extractInstalledKey('cursor', { + url: NANSEN_MCP_URL, + headers: { 'NANSEN-API-KEY': CLAUDE_CODE_KEY_REF }, + }, { env })).toBeNull(); + const desktopNearMiss = buildServerEntry('claude-desktop', API_KEY); + desktopNearMiss.env.NANSEN_API_KEY = '${OTHER_KEY}'; + expect(extractInstalledKey('claude-desktop', desktopNearMiss, { env })).toBeNull(); + + const emptyEnv = { NANSEN_API_KEY: '' }; + for (const client of ['claude-code', 'cursor', 'claude-desktop']) { + expect(extractInstalledKey(client, buildServerEntry(client, API_KEY, { envRef: true }), { env: emptyEnv })).toBeNull(); + } + }); + it('names missing, changed, and extra fields without ever quoting a value', () => { for (const client of ['cursor', 'claude-code', 'claude-desktop']) { expect(entryDriftNotes(client, buildServerEntry(client, API_KEY))).toEqual([]); + expect(entryDriftNotes(client, buildServerEntry(client, API_KEY, { envRef: true }))).toEqual([]); } expect(entryDriftNotes('claude-code', { url: NANSEN_MCP_URL, headers: { 'NANSEN-API-KEY': API_KEY } })) .toEqual(['missing "type"']); @@ -328,6 +378,66 @@ describe('mcp command handler', () => { expect(fs.statSync(`${cursorPath()}.bak`).mode & 0o777).toBe(0o600); }); + it('redacts the nansen credential in the backup when replacing an existing entry', async () => { + fs.mkdirSync(path.dirname(cursorPath()), { recursive: true }); + fs.writeFileSync(cursorPath(), JSON.stringify({ + mcpServers: { nansen: buildServerEntry('cursor', API_KEY), other: { command: 'foo' } }, + unrelated: true, + })); + + await run(['install', 'cursor'], { flags: { 'env-ref': true } }); + + const bakText = fs.readFileSync(`${cursorPath()}.bak`, 'utf8'); + expect(bakText).not.toContain(API_KEY); + const bak = JSON.parse(bakText); + expect(bak.mcpServers.nansen.headers['NANSEN-API-KEY']).toBe(''); + expect(bak.mcpServers.other).toEqual({ command: 'foo' }); + expect(bak.unrelated).toBe(true); + expect(fs.statSync(`${cursorPath()}.bak`).mode & 0o777).toBe(0o600); + expect(readCursor().mcpServers.nansen.headers['NANSEN-API-KEY']).toBe(CURSOR_KEY_REF); + expect(logs.join('\n')).toContain('Nansen credential redacted'); + }); + + it('redacts an inline --header key in a hand-written desktop entry', async () => { + const desktopDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nansen-mcp-bak-test-')); + try { + const desktopPath = resolveClientConfigPath('claude-desktop', { platform: 'darwin', homedir: desktopDir, env: {} }); + fs.mkdirSync(path.dirname(desktopPath), { recursive: true }); + fs.writeFileSync(desktopPath, JSON.stringify({ + mcpServers: { + nansen: { command: 'npx', args: ['-y', MCP_REMOTE_PIN, NANSEN_MCP_URL, '--header', `NANSEN-API-KEY:${API_KEY}`] }, + }, + })); + const desktopLogs = []; + const { mcp: desktopMcp } = buildMcpCommands({ + log: (...a) => desktopLogs.push(a.join(' ')), platform: 'darwin', homedirFn: () => desktopDir, env: {}, fetchFn, + }); + + await desktopMcp(['install', 'claude-desktop'], api, {}, {}); + + const bakText = fs.readFileSync(`${desktopPath}.bak`, 'utf8'); + expect(bakText).not.toContain(API_KEY); + expect(JSON.parse(bakText).mcpServers.nansen.args).toContain('NANSEN-API-KEY:'); + expect(desktopLogs.join('\n')).toContain('Nansen credential redacted'); + } finally { + fs.rmSync(desktopDir, { recursive: true, force: true }); + } + }); + + it('keeps an env-ref backup intact and claims no redaction it did not make', async () => { + fs.mkdirSync(path.dirname(cursorPath()), { recursive: true }); + fs.writeFileSync(cursorPath(), JSON.stringify({ + mcpServers: { nansen: buildServerEntry('cursor', undefined, { envRef: true }) }, + })); + + await run(['install', 'cursor']); + + const bak = JSON.parse(fs.readFileSync(`${cursorPath()}.bak`, 'utf8')); + expect(bak.mcpServers.nansen.headers['NANSEN-API-KEY']).toBe(CURSOR_KEY_REF); + expect(logs.join('\n')).toContain('Backed up existing config'); + expect(logs.join('\n')).not.toContain('redacted'); + }); + it('re-running install is idempotent and reports an update', async () => { await run(['install', 'cursor']); logs.length = 0; @@ -379,6 +489,26 @@ describe('mcp command handler', () => { expect(fs.existsSync(cursorPath())).toBe(false); }); + it('installs env-ref entries while logged out in either flag position', async () => { + for (const rawArgs of [ + ['install', '--env-ref', 'cursor'], + ['install', 'cursor', '--env-ref'], + ]) { + const parsed = parseArgs(['mcp', ...rawArgs]); + expect(parsed._).toEqual(['mcp', 'install', 'cursor']); + expect(parsed.flags['env-ref']).toBe(true); + await mcp(parsed._.slice(1), null, parsed.flags, parsed.options); + } + + const config = readCursor(); + const entry = config.mcpServers.nansen; + expect(entry.headers['NANSEN-API-KEY']).toBe(CURSOR_KEY_REF); + expect(JSON.stringify(config)).not.toContain(API_KEY); + expect(logs.join('\n')).toContain('client launch environment'); + expect(logs.join('\n')).toContain('Warning: NANSEN_API_KEY is not set in this shell'); + expect(logs.join('\n')).not.toContain(API_KEY); + }); + 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); @@ -424,6 +554,8 @@ describe('mcp command handler', () => { it('bare `mcp` and `mcp --help` print usage; bad inputs throw actionable errors', async () => { await run([]); expect(logs.join('\n')).toContain('nansen mcp install '); + expect(logs.join('\n')).toContain('--env-ref'); + expect(logs.join('\n')).toContain('CREDENTIALS:'); 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/); @@ -502,6 +634,32 @@ describe('mcp command handler', () => { } }); + it('verifies env-ref entries from the injected environment and rejects unset refs', async () => { + fs.mkdirSync(path.dirname(cursorPath()), { recursive: true }); + fs.writeFileSync(cursorPath(), JSON.stringify({ + mcpServers: { nansen: buildServerEntry('cursor', API_KEY, { envRef: true }) }, + })); + fetchFn.mockResolvedValue(response(successBody)); + + const resolvedLogs = []; + const { mcp: resolvedMcp } = buildMcpCommands({ + log: (...a) => resolvedLogs.push(a.join(' ')), + platform: 'linux', + homedirFn: () => tempDir, + env: { NANSEN_API_KEY: API_KEY }, + fetchFn, + }); + await resolvedMcp(['verify', 'cursor'], null, {}, {}); + expect(fetchFn.mock.calls[0][1].headers['NANSEN-API-KEY']).toBe(API_KEY); + expect(resolvedLogs.join('\n')).not.toContain(API_KEY); + + fetchFn.mockClear(); + const error = await run(['verify', 'cursor'], { apiInstance: null }).then(() => null, err => err); + expect(error?.message).toMatch(/references NANSEN_API_KEY, which is not set in this shell/); + expect(error?.message).not.toContain(API_KEY); + expect(fetchFn).not.toHaveBeenCalled(); + }); + it('rejects missing entries and relocated keys without a network call', async () => { await expect(run(['verify', 'cursor'])).rejects.toThrow(/not installed.*nansen mcp install cursor/); expect(fetchFn).not.toHaveBeenCalled(); @@ -534,6 +692,17 @@ describe('mcp command handler', () => { } }); + it('names an unsupported credential reference instead of blaming the URL', async () => { + fs.mkdirSync(path.dirname(cursorPath()), { recursive: true }); + fs.writeFileSync(cursorPath(), JSON.stringify({ + mcpServers: { nansen: { url: NANSEN_MCP_URL, headers: { 'NANSEN-API-KEY': CLAUDE_CODE_KEY_REF } } }, + })); + + await expect(run(['verify', 'cursor'])) + .rejects.toThrow(/environment reference cursor does not expand there.*nansen mcp install cursor --env-ref/); + expect(fetchFn).not.toHaveBeenCalled(); + }); + it('verifies an official entry with extra fields, warning instead of refusing', async () => { fetchFn.mockResolvedValue(response(successBody)); fs.mkdirSync(path.dirname(cursorPath()), { recursive: true }); @@ -658,6 +827,8 @@ describe('schema + CLI registration', () => { expect(schema.commands.mcp.subcommands.verify.description).toContain('credits'); expect(schema.commands.mcp.subcommands.verify.examples).toContain('nansen mcp verify cursor'); expect(schema.commands.mcp.subcommands.uninstall.options['dry-run'].type).toBe('boolean'); + expect(schema.commands.mcp.subcommands.install.options['env-ref'].type).toBe('boolean'); + expect(schema.commands.mcp.subcommands.install.examples).toContain('nansen mcp install claude-code --env-ref'); }); it('runCLI routes `mcp` and parses --dry-run as a boolean flag', async () => { diff --git a/src/cli.js b/src/cli.js index 22523fa5..d759386f 100644 --- a/src/cli.js +++ b/src/cli.js @@ -11,7 +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 { buildMcpCommands, redactSecret } from './commands/mcp.js'; import { buildResearchCommands, RESEARCH_HISTORICAL_SUBCOMMANDS } from './commands/research.js'; import { resolveAddress, isEnsName } from './ens.js'; import fs from 'fs'; @@ -191,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 === 'dry-run') { + 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' || key === 'env-ref') { result.flags[key] = true; } else if (next && (!next.startsWith('-') || /^-\d/.test(next))) { // Try to parse as JSON first (for objects/arrays/booleans), @@ -990,13 +990,14 @@ export function buildCommands(deps = {}) { if (flags.help || flags.h) { log('nansen login - Save your Nansen API key\n'); log('USAGE:'); - log(' nansen login --api-key '); - log(' NANSEN_API_KEY= nansen login'); - log(' nansen login --human (interactive prompt)\n'); + log(' nansen login --human (interactive masked prompt; key never enters history)'); + log(' NANSEN_API_KEY=$(op read op://vault/item/credential) nansen login (key output is not recorded in history)'); + log(' nansen login --api-key (literal key is recorded in shell history)\n'); log('OPTIONS:'); - log(' --api-key Your Nansen API key'); + log(' --api-key Your Nansen API key (literal values are recorded in shell history)'); log(' --human Enable interactive prompt'); log(' --help Show this help\n'); + log('Inline NANSEN_API_KEY= assignments are also recorded in shell history.'); log('Get your API key at: https://app.nansen.ai/auth/agent-setup'); return; } @@ -1049,9 +1050,10 @@ export function buildCommands(deps = {}) { resolution: ['Check your key at https://app.nansen.ai/auth/agent-setup'], }); } - throw new CommandError(`Could not verify API key: ${error.message}`, 'VERIFICATION_FAILED', { + const safeMessage = redactSecret(redactSecret(error.message, apiKey), apiKey.trim()); + throw new CommandError(`Could not verify API key: ${safeMessage}`, 'VERIFICATION_FAILED', { error: 'VERIFICATION_FAILED', - message: `Could not verify API key: ${error.message}`, + message: `Could not verify API key: ${safeMessage}`, resolution: ['Check your internet connection', 'Try again'], }); } diff --git a/src/commands/mcp.js b/src/commands/mcp.js index a10253a9..675b84c2 100644 --- a/src/commands/mcp.js +++ b/src/commands/mcp.js @@ -23,10 +23,15 @@ export const NANSEN_MCP_URL = 'https://mcp.nansen.ai/ra/mcp'; // release; bump deliberately. export const MCP_REMOTE_PIN = 'mcp-remote@0.1.38'; +export const CLAUDE_CODE_KEY_REF = '${NANSEN_API_KEY}'; +export const CURSOR_KEY_REF = '${env:NANSEN_API_KEY}'; + const SERVER_KEY = 'nansen'; // Claude Desktop passes the key by env-var reference, never inline. No space -// after the colon: Claude Desktop mis-splits args containing spaces. +// after the colon: Claude Desktop mis-splits args containing spaces. The +// ${...} here is mcp-remote's own expansion syntax — same spelling as +// CLAUDE_CODE_KEY_REF only by coincidence, so it stays an independent literal. const DESKTOP_HEADER_ARG = 'NANSEN-API-KEY:${NANSEN_API_KEY}'; // House idiom (see src/api.js CONFIG_DIR): env first so tests can point HOME @@ -49,6 +54,12 @@ CLIENTS: OPTIONS: --dry-run Preview the change (key redacted) without writing + --env-ref Reference NANSEN_API_KEY instead of writing the key (no login required) + +CREDENTIALS: + claude-code headers: "\${NANSEN_API_KEY}" + cursor headers: "\${env:NANSEN_API_KEY}" + claude-desktop omit env; mcp-remote inherits NANSEN_API_KEY from the OS environment The API key is taken from \`nansen login\` / NANSEN_API_KEY. Re-run install after rotating your key to update the entry. Verify performs one real authenticated data @@ -81,20 +92,29 @@ export function resolveClientConfigPath(client, { platform = process.platform, h * 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) { +export function buildServerEntry(client, apiKey, { envRef = false } = {}) { 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 } }; + return { + type: 'http', + url: NANSEN_MCP_URL, + headers: { 'NANSEN-API-KEY': envRef ? CLAUDE_CODE_KEY_REF : apiKey }, + }; case 'cursor': - return { url: NANSEN_MCP_URL, headers: { 'NANSEN-API-KEY': apiKey } }; - case 'claude-desktop': - // No --allow-http: the URL is HTTPS. return { + url: NANSEN_MCP_URL, + headers: { 'NANSEN-API-KEY': envRef ? CURSOR_KEY_REF : apiKey }, + }; + case 'claude-desktop': { + // No --allow-http: the URL is HTTPS. + const entry = { command: 'npx', args: ['-y', MCP_REMOTE_PIN, NANSEN_MCP_URL, '--header', DESKTOP_HEADER_ARG], - env: { NANSEN_API_KEY: apiKey }, }; + if (!envRef) entry.env = { NANSEN_API_KEY: apiKey }; + return entry; + } default: throw new CommandError(`Unknown client: ${client}. Supported: ${SUPPORTED_CLIENTS.join(', ')}`, 'INVALID_PARAMS'); } @@ -107,7 +127,7 @@ const isOfficialUrl = (value) => typeof value === 'string' && value.replace(/\/+$/, '') === NANSEN_MCP_URL; /** - * Extract the installed key, gating only on what decides where that key goes: + * Resolve the installed credential, gating only on what decides where that key goes: * the official URL (for Claude Desktop, the pinned mcp-remote bridge to it) * plus a non-empty key. Anything looser would let verify report success for a * config that actually ships the key elsewhere. @@ -117,7 +137,33 @@ const isOfficialUrl = (value) => * verify sends the key to the NANSEN_MCP_URL constant, never to the config's * URL. Non-security differences are reported by entryDriftNotes() as warnings. */ -export function extractInstalledKey(client, entry) { +const resolveEnvReference = (env) => ({ + key: typeof env?.NANSEN_API_KEY === 'string' && env.NANSEN_API_KEY.length > 0 + ? env.NANSEN_API_KEY + : null, + source: 'env-ref', +}); + +const isNearMissReference = (value, supportedReference) => + typeof value === 'string' && value.includes('${') && value !== supportedReference; + +/** + * The only reference form the client expands in the key's position; null for + * claude-desktop, where the supported form is omitting the env block entirely + * and mcp-remote expands the reference already sitting in args. + */ +const referenceFor = (client) => { + if (client === 'claude-code') return CLAUDE_CODE_KEY_REF; + if (client === 'cursor') return CURSOR_KEY_REF; + return null; +}; + +/** The raw value in the entry's credential slot, whatever it holds. */ +const installedCredentialValue = (client, entry) => (client === 'claude-desktop' + ? entry.env?.NANSEN_API_KEY + : Object.entries(entry.headers || {}).find(([name]) => name.toLowerCase() === 'nansen-api-key')?.[1]); + +function extractInstalledCredential(client, entry, { env = process.env } = {}) { if (!SUPPORTED_CLIENTS.includes(client)) return null; if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; @@ -143,12 +189,15 @@ export function extractInstalledKey(client, entry) { // second key header would make the client send a key we never tested. && args.filter(arg => /^nansen-api-key\s*:/i.test(arg)).length === 1; if (!officialBridge) return null; + if (entry.env === undefined) return resolveEnvReference(env); // npx reads npm_config_*, NODE_OPTIONS and PATH from env, so any extra // variable can redirect which code receives the key: only the key may be set. - const env = entry.env; - if (!env || typeof env !== 'object' || Array.isArray(env)) return null; - if (Object.keys(env).some(name => name !== 'NANSEN_API_KEY')) return null; - return typeof env.NANSEN_API_KEY === 'string' && env.NANSEN_API_KEY.length > 0 ? env.NANSEN_API_KEY : null; + const entryEnv = entry.env; + if (!entryEnv || typeof entryEnv !== 'object' || Array.isArray(entryEnv)) return null; + if (Object.keys(entryEnv).some(name => name !== 'NANSEN_API_KEY')) return null; + const apiKey = entryEnv.NANSEN_API_KEY; + if (typeof apiKey !== 'string' || apiKey.length === 0 || apiKey.includes('${')) return null; + return { key: apiKey, source: 'literal' }; } // Remote HTTP clients: the URL is the transport. A command/args pair means the @@ -162,7 +211,15 @@ export function extractInstalledKey(client, entry) { const keyHeaders = Object.keys(headers).filter(name => name.toLowerCase() === 'nansen-api-key'); if (keyHeaders.length !== 1) return null; const apiKey = headers[keyHeaders[0]]; - return typeof apiKey === 'string' && apiKey.length > 0 ? apiKey : null; + if (typeof apiKey !== 'string' || apiKey.length === 0) return null; + const reference = referenceFor(client); + if (apiKey === reference) return resolveEnvReference(env); + if (isNearMissReference(apiKey, reference)) return null; + return { key: apiKey, source: 'literal' }; +} + +export function extractInstalledKey(client, entry, options = {}) { + return extractInstalledCredential(client, entry, options)?.key || null; } /** @@ -177,10 +234,12 @@ export function extractInstalledKey(client, entry) { export function entryDriftNotes(client, entry) { if (!SUPPORTED_CLIENTS.includes(client)) return []; if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []; - const installedKey = client === 'claude-desktop' - ? entry.env?.NANSEN_API_KEY - : Object.entries(entry.headers || {}).find(([name]) => name.toLowerCase() === 'nansen-api-key')?.[1]; - const expected = buildServerEntry(client, typeof installedKey === 'string' ? installedKey : ''); + const installedKey = installedCredentialValue(client, entry); + const reference = referenceFor(client); + const envRef = client === 'claude-desktop' + ? entry.env === undefined + : installedKey === reference; + const expected = buildServerEntry(client, typeof installedKey === 'string' ? installedKey : '', { envRef }); const notes = []; for (const [field, want] of Object.entries(expected)) { if (entry[field] === undefined) notes.push(`missing "${field}"`); @@ -303,7 +362,7 @@ const VERIFY_REQUEST_ID = 1; const VERIFY_TIMEOUT_MS = 15_000; const VERIFY_TOKEN_ADDRESS = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; -function redactSecret(text, secret) { +export function redactSecret(text, secret) { return typeof text === 'string' && typeof secret === 'string' && secret ? text.split(secret).join('[redacted]') : text; @@ -360,6 +419,45 @@ export function mergeNansenEntry(config, entry, configPath = 'config') { return { ...config, mcpServers: { ...config.mcpServers, [SERVER_KEY]: entry } }; } +/** + * { config, redacted }: a copy of the config with the nansen entry's credential + * slots redacted, and whether any slot actually held a secret. Covers every + * slot install has ever written a key to (headers in any casing, the desktop + * env block) plus the inline `--header NANSEN-API-KEY:` argv form a + * hand-written desktop entry can carry. A `${...}` reference is not a secret + * and is preserved, so the caller never claims a redaction it did not make — + * a key parked under some other header name is copied verbatim, unclaimed. + * Other entries and fields are preserved verbatim. + */ +export function redactNansenEntryCredential(config) { + const entry = config?.mcpServers?.[SERVER_KEY]; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return { config, redacted: false }; + const isSecret = (value) => typeof value === 'string' && value.length > 0 && !value.includes('${'); + const entryCopy = { ...entry }; + let redacted = false; + if (entryCopy.headers && typeof entryCopy.headers === 'object' && !Array.isArray(entryCopy.headers)) { + entryCopy.headers = Object.fromEntries(Object.entries(entryCopy.headers).map(([name, value]) => { + if (name.toLowerCase() !== 'nansen-api-key' || !isSecret(value)) return [name, value]; + redacted = true; + return [name, '']; + })); + } + if (Array.isArray(entryCopy.args)) { + entryCopy.args = entryCopy.args.map((arg) => { + const inlineHeader = typeof arg === 'string' && arg.match(/^(nansen-api-key\s*:\s*)(.*)$/is); + if (!inlineHeader || !isSecret(inlineHeader[2])) return arg; + redacted = true; + return `${inlineHeader[1]}`; + }); + } + if (entryCopy.env && typeof entryCopy.env === 'object' && !Array.isArray(entryCopy.env) && isSecret(entryCopy.env.NANSEN_API_KEY)) { + entryCopy.env = { ...entryCopy.env, NANSEN_API_KEY: '' }; + redacted = true; + } + if (!redacted) return { config, redacted: false }; + return { config: { ...config, mcpServers: { ...config.mcpServers, [SERVER_KEY]: entryCopy } }, redacted: true }; +} + /** * Return { config, removed } with mcpServers.nansen deleted. */ @@ -454,10 +552,17 @@ export function buildMcpCommands(deps = {}) { if (!entry) { throw new CommandError(`Nansen MCP is not installed for ${client} — run: nansen mcp install ${client}`, 'NOT_INSTALLED'); } - apiKey = extractInstalledKey(client, entry); - if (!apiKey) { + const credential = extractInstalledCredential(client, entry, { env }); + if (!credential) { + if (isNearMissReference(installedCredentialValue(client, entry), referenceFor(client))) { + throw new CommandError(`The ${client} Nansen entry passes the API key as an environment reference ${client} does not expand there — re-run install to write the supported form: nansen mcp install ${client} --env-ref`, 'INVALID_CONFIG'); + } throw new CommandError(`Nansen MCP entry for ${client} does not match the official server URL/transport, or carries no API key — re-run install: nansen mcp install ${client}`, 'INVALID_CONFIG'); } + if (credential.source === 'env-ref' && !credential.key) { + throw new CommandError(`The ${client} entry references NANSEN_API_KEY, which is not set in this shell — export it and re-run.`, 'INVALID_CONFIG'); + } + apiKey = credential.key; const notes = entryDriftNotes(client, entry); if (notes.length) { log(`Warning: the ${client} entry differs from what install writes (${notes.join('; ')}). Verifying its key anyway — re-run nansen mcp install ${client} if the client cannot connect.`); @@ -548,14 +653,16 @@ export function buildMcpCommands(deps = {}) { } // install - const apiKey = apiInstance?.apiKey; - if (!apiKey) { + const envRef = Boolean(flags['env-ref']); + const apiKey = envRef ? undefined : apiInstance?.apiKey; + if (!envRef && !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, ''); + // The key is never printed — dry-run shows a redacted inline entry or + // the real environment reference. + const redacted = buildServerEntry(client, envRef ? undefined : '', { envRef }); log(`Would write "${SERVER_KEY}" entry to ${configPath}:`); log(JSON.stringify({ mcpServers: { [SERVER_KEY]: redacted } }, null, 2)); return undefined; @@ -563,21 +670,46 @@ export function buildMcpCommands(deps = {}) { const { config, existed } = readConfig(configPath); const hadEntry = !!config.mcpServers?.[SERVER_KEY]; - const merged = mergeNansenEntry(config, buildServerEntry(client, apiKey), configPath); + const merged = mergeNansenEntry(config, buildServerEntry(client, apiKey, { envRef }), 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}`); + if (hadEntry) { + // The backup must not keep a credential the new config no longer + // holds (an --env-ref migration would otherwise leave the old key + // in the sync/backup path forever). Our entry is regenerable via + // re-install, so its credential slots are redacted; everything + // else is preserved verbatim. + const backup = redactNansenEntryCredential(config); + writeConfig(backupPath, backup.config); + log(backup.redacted + ? `Backed up existing config to ${backupPath} (Nansen credential redacted; restore other entries from it, re-run install for Nansen)` + : `Backed up existing config to ${backupPath}`); + } else { + 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.'); + if (envRef) { + log('Set NANSEN_API_KEY in the environment visible to the client when it launches.'); + if (client === 'claude-desktop') { + log('Claude Desktop needs OS-level environment variables (macOS: launchctl setenv NANSEN_API_KEY ; Windows: user environment variables).'); + } + if (!env.NANSEN_API_KEY) { + log('Warning: NANSEN_API_KEY is not set in this shell; it only needs to be set in the client launch environment.'); + } + log('Some client versions fail to expand environment references in HTTP headers and send the literal reference; confirm expansion inside the client.'); + } else { + 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('Use --env-ref to keep the key out of the generated config.'); + } log(`Restart ${client} to pick up the change.`); log(`Verify your setup: nansen mcp verify ${client}`); return undefined; diff --git a/src/schema.json b/src/schema.json index 25897f3c..1407df50 100644 --- a/src/schema.json +++ b/src/schema.json @@ -1959,11 +1959,16 @@ "dry-run": { "type": "boolean", "description": "Print the target config path and entry (API key redacted) without writing" + }, + "env-ref": { + "type": "boolean", + "description": "Use NANSEN_API_KEY from the client's launch environment instead of storing the key" } }, "examples": [ "nansen mcp install claude-code", - "nansen mcp install cursor --dry-run" + "nansen mcp install cursor --dry-run", + "nansen mcp install claude-code --env-ref" ] }, "uninstall": {