Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-env-ref.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <client> # 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": "<key>"` | `"NANSEN-API-KEY": "${NANSEN_API_KEY}"` | Some client versions send the literal reference in HTTP headers. |
| Cursor | `"NANSEN-API-KEY": "<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 <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

Expand Down
16 changes: 14 additions & 2 deletions src/__tests__/cli.internal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>'));
expect(help).toContain('literal values are recorded in shell history');
expect(help).toContain('Inline NANSEN_API_KEY=<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;
Expand Down Expand Up @@ -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();
});

Expand Down
171 changes: 171 additions & 0 deletions src/__tests__/mcp.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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"']);
Expand Down Expand Up @@ -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('<redacted>');
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:<redacted>');
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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 <client>');
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/);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading