diff --git a/.changeset/mcp-verify.md b/.changeset/mcp-verify.md new file mode 100644 index 00000000..21c39372 --- /dev/null +++ b/.changeset/mcp-verify.md @@ -0,0 +1,5 @@ +--- +"nansen-cli": minor +--- + +Add `nansen mcp verify` to verify the hosted Nansen MCP setup with an authenticated data-path check. diff --git a/README.md b/README.md index 46c24820..dd7d34d4 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,16 @@ Three options — pick whichever fits your setup: 3. **MPP via tempo** (no key needed): install the [tempo CLI](https://docs.tempo.xyz) separately, run `tempo wallet login` to set up, then call the Nansen API through `tempo request`. The Nansen API selects the MPP rail when it sees `Authorization: Payment ...`. See [MPP / Tempo](#mpp--tempo) below. +## Verify your MCP setup + +For the hosted Nansen MCP server, verify server reachability and the supplied API key on the paid data path with: + +```bash +npx -y nansen-cli mcp verify --api-key +``` + +The check calls `tools/list` for reachability, then calls the paid `nansen_score_top_tokens` canary tool. A successful canary costs about 1 credit; tool listings and free tools alone do not prove that a key works. The CLI cannot inspect the key inside your MCP client, so make sure this same key is in the client's `NANSEN-API-KEY` header. For the final client-config check, ask your client: “Use the `nansen_score_top_tokens` tool.” + ## Commands ``` @@ -294,6 +304,7 @@ Any field may be absent or `null`, meaning unknown — never assume zero. A low- | `command not found` | `npm install -g nansen-cli` | | Global install reports an older version | `npm i -g nansen-cli@latest --registry=https://registry.npmjs.org/ --prefer-online`, then check `which -a nansen` for stale binaries | | `UNAUTHORIZED` after login | `nansen auth status` shows which key is active and where it comes from; re-run `nansen login` or set `NANSEN_API_KEY` | +| MCP client lists tools but paid calls fail | Run `npx -y nansen-cli mcp verify --api-key ` and ensure that same key is in the client's `NANSEN-API-KEY` header | | Anything else misbehaving | `nansen doctor` checks your whole setup (auth, wallets, caches, connectivity) with a fix per finding | | Empty perp _research_ results | Use `--symbol BTC`, not `--token`. Perps are Hyperliquid-only. | | `perp` _trading_ prints the usage banner | Trading needs `--coin BTC` (`--symbol` also works); see the Perpetuals section. | diff --git a/src/__tests__/mcp-verify.test.js b/src/__tests__/mcp-verify.test.js new file mode 100644 index 00000000..a99ca115 --- /dev/null +++ b/src/__tests__/mcp-verify.test.js @@ -0,0 +1,345 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { formatMcpVerifyReport, runMcpVerifyChecks } from '../mcp-verify.js'; +import { runCLI, SCHEMA } from '../cli.js'; + +const API_KEY = 'nk_test_1234567890abcdef'; + +function response(message, contentType = 'application/json', status = 200) { + return { + status, + headers: { get: () => contentType }, + text: vi.fn().mockResolvedValue(typeof message === 'string' ? message : JSON.stringify(message)), + }; +} + +function rpcResult(result, contentType = 'application/json') { + return response({ jsonrpc: '2.0', id: 1, result }, contentType); +} + +function rpcError(message, contentType = 'application/json', status = 200) { + return response({ jsonrpc: '2.0', id: 1, error: message }, contentType, status); +} + +function sse(message) { + return response(`event: message\ndata: ${JSON.stringify(message)}\n\n`, 'text/event-stream'); +} + +function listResponse(contentType = 'application/json') { + const message = { tools: [{ name: 'nansen_score_top_tokens' }] }; + return contentType === 'text/event-stream' ? sse({ jsonrpc: '2.0', id: 1, result: message }) : rpcResult(message); +} + +function authSuccessResponse(contentType = 'application/json') { + const message = { content: [{ type: 'text', text: 'ok' }] }; + return contentType === 'text/event-stream' ? sse({ jsonrpc: '2.0', id: 1, result: message }) : rpcResult(message); +} + +describe('remediation URLs (API-390)', () => { + // The auth-failure and missing-key remediations must point at the key + // MANAGEMENT view, not /auth/agent-setup: that page auto-mints a key on load + // and is plan-capped (Free = 1), so a user who already has one either gets a + // duplicate or a 403 -- a second failure on top of the one that sent them there. + // Matches nansen-ra src/nansen_mcp/api/utils/common.py and the Kong 401 in + // nansen-api kubernetes/nansen-api-wrapper/manifest.yaml. + it('uses the key management URL and none of the rejected alternatives', async () => { + const src = await import('node:fs').then((fs) => + fs.readFileSync(new URL('../mcp-verify.js', import.meta.url), 'utf8')); + expect(src).toContain('https://app.nansen.ai/api?tab=api'); + expect(src).not.toContain('/account?tab=api'); + expect(src).not.toContain('/auth/agent-setup'); + }); +}); + +describe('mcp verify', () => { + let tempHome; + let env; + let devConfigPath; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'nansen-mcp-verify-test-')); + env = { HOME: tempHome }; + devConfigPath = path.join(tempHome, 'missing-dev-config.json'); + }); + + afterEach(() => { + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + const runChecks = (fetchFn, overrides = {}) => runMcpVerifyChecks({ + apiKey: API_KEY, + env, + devConfigPath, + fetchFn, + ...overrides, + }); + + const findCheck = (checks, id) => checks.find(check => check.id === id); + + it('verifies an SSE-framed server and paid data call', async () => { + const fetchFn = vi.fn() + .mockResolvedValueOnce(listResponse('text/event-stream')) + .mockResolvedValueOnce(authSuccessResponse('text/event-stream')); + + const checks = await runChecks(fetchFn); + + expect(findCheck(checks, 'mcp-server')).toMatchObject({ id: 'mcp-server', status: 'ok' }); + expect(findCheck(checks, 'mcp-auth')).toMatchObject({ id: 'mcp-auth', status: 'ok' }); + expect(fetchFn).toHaveBeenCalledTimes(2); + const [listCall, authCall] = fetchFn.mock.calls; + expect(listCall[1].headers).not.toHaveProperty('NANSEN-API-KEY'); + expect(JSON.parse(authCall[1].body)).toMatchObject({ + method: 'tools/call', + params: { name: 'nansen_score_top_tokens', arguments: { request: {} } }, + }); + expect(authCall[1].headers['NANSEN-API-KEY']).toBe(API_KEY); + }); + + it('verifies a plain JSON response body', async () => { + const fetchFn = vi.fn() + .mockResolvedValueOnce(listResponse()) + .mockResolvedValueOnce(authSuccessResponse()); + + const checks = await runChecks(fetchFn); + + expect(findCheck(checks, 'mcp-auth').status).toBe('ok'); + }); + + it('skips the paid call when the key is missing', async () => { + const fetchFn = vi.fn().mockResolvedValueOnce(listResponse()); + const checks = await runChecks(fetchFn, { apiKey: null }); + + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(findCheck(checks, 'mcp-api-key')).toMatchObject({ status: 'error' }); + expect(findCheck(checks, 'mcp-auth')).toMatchObject({ status: 'info' }); + }); + + it.each([ + ['401 rejected key', 'failed with status 401: Unauthorized', 'error', /rejected the API key/, /exact key/], + ['missing key response', 'NANSEN-API-KEY header is required', 'error', /rejected the API key/, /exact key/], + ['402 credits', '402 Payment Required: insufficient credits', 'error', /insufficient credits/, /Top up/], + ['429 rate limit', '429 Too Many Requests', 'warn', /rate limited/, /Retry/], + ])('maps %s auth text to an actionable check', async (_name, text, status, message, fix) => { + const fetchFn = vi.fn() + .mockResolvedValueOnce(listResponse()) + .mockResolvedValueOnce(rpcResult({ isError: true, content: [{ type: 'text', text }] })); + + const check = findCheck(await runChecks(fetchFn), 'mcp-auth'); + + expect(check.status).toBe(status); + expect(check.message).toMatch(message); + expect(check.fix).toMatch(fix); + }); + + it('does not verify the key on a malformed tools/call result', async () => { + const fetchFn = vi.fn() + .mockResolvedValueOnce(listResponse()) + .mockResolvedValueOnce(rpcResult({ tools: [{ name: 'nansen_score_top_tokens' }] })); + + const check = findCheck(await runChecks(fetchFn), 'mcp-auth'); + + expect(check.status).toBe('error'); + expect(check.message).toContain('malformed'); + }); + + it('turns a network failure into a server check', async () => { + const error = new Error('fetch failed'); + error.cause = { code: 'ECONNREFUSED' }; + const fetchFn = vi.fn().mockRejectedValue(error); + + const checks = await runChecks(fetchFn); + + expect(findCheck(checks, 'mcp-server')).toMatchObject({ status: 'error' }); + expect(findCheck(checks, 'mcp-server').message).toContain('ECONNREFUSED'); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it('turns a hung request into a timeout check via the abort signal', async () => { + const fetchFn = vi.fn((_url, { signal }) => new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))); + })); + + const checks = await runChecks(fetchFn, { timeoutMs: 5 }); + + expect(findCheck(checks, 'mcp-server')).toMatchObject({ status: 'error' }); + expect(findCheck(checks, 'mcp-server').message).toContain('timed out'); + }); + + it('classifies a mixed credit/rate-limit text as a rate-limit warn, not a credits error', async () => { + const fetchFn = vi.fn() + .mockResolvedValueOnce(listResponse()) + .mockResolvedValueOnce(rpcResult({ + isError: true, + content: [{ type: 'text', text: 'Rate limit exceeded: credit rate limit reached, too many requests' }], + })); + + const check = findCheck(await runChecks(fetchFn), 'mcp-auth'); + + expect(check.status).toBe('warn'); + expect(check.message).toMatch(/rate limited/); + }); + + it('classifies an HTTP 401 with a parseable error body as a rejected key', async () => { + const fetchFn = vi.fn() + .mockResolvedValueOnce(listResponse()) + .mockResolvedValueOnce(rpcError({ code: -32603, message: 'nope' }, 'application/json', 401)); + + const check = findCheck(await runChecks(fetchFn), 'mcp-auth'); + + expect(check.status).toBe('error'); + expect(check.message).toMatch(/rejected the API key/); + }); + + it('parses an SSE message whose JSON-RPC id is a string', async () => { + const fetchFn = vi.fn() + .mockResolvedValueOnce(sse({ jsonrpc: '2.0', id: '1', result: { tools: [] } })) + .mockResolvedValueOnce(authSuccessResponse()); + + const checks = await runChecks(fetchFn); + + expect(findCheck(checks, 'mcp-server').status).toBe('ok'); + }); + + it('warns when a non-default URL will receive the API key', async () => { + const fetchFn = vi.fn() + .mockResolvedValueOnce(listResponse()) + .mockResolvedValueOnce(authSuccessResponse()); + + const checks = await runChecks(fetchFn, { url: 'https://mcp.example.dev/ra/mcp' }); + + expect(findCheck(checks, 'mcp-url')).toMatchObject({ status: 'warn' }); + expect(findCheck(checks, 'mcp-url').message).toContain('mcp.example.dev'); + }); + + it('reports an unparseable server response', async () => { + const fetchFn = vi.fn().mockResolvedValueOnce(response('', 'text/html')); + + const checks = await runChecks(fetchFn); + + expect(findCheck(checks, 'mcp-server')).toMatchObject({ status: 'error' }); + expect(findCheck(checks, 'mcp-server').message).toContain('unexpected response'); + }); + + it('reports a tools/list JSON-RPC failure and does not pay-call afterward', async () => { + const fetchFn = vi.fn().mockResolvedValueOnce(rpcError({ code: -32000, message: 'tools unavailable' })); + + const checks = await runChecks(fetchFn); + + expect(findCheck(checks, 'mcp-server')).toMatchObject({ status: 'error' }); + expect(findCheck(checks, 'mcp-server').message).toContain('tools unavailable'); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it('uses the custom URL and gives the explicit API-key option precedence', async () => { + env.NANSEN_API_KEY = 'nk_env_1234567890abcdef'; + const fetchFn = vi.fn() + .mockResolvedValueOnce(listResponse()) + .mockResolvedValueOnce(authSuccessResponse()); + + const checks = await runChecks(fetchFn, { apiKey: API_KEY, url: 'https://mcp.example.dev/ra/mcp' }); + + expect(findCheck(checks, 'mcp-auth').status).toBe('ok'); + expect(fetchFn.mock.calls.every(([url]) => url === 'https://mcp.example.dev/ra/mcp')).toBe(true); + expect(fetchFn.mock.calls[1][1].headers['NANSEN-API-KEY']).toBe(API_KEY); + }); + + it('formats a report with check lines, fixes, and the verification summary', () => { + const report = formatMcpVerifyReport([ + { id: 'mcp-api-key', status: 'ok', message: 'API key found (nk_t…cdef)' }, + { id: 'mcp-auth', status: 'ok', message: 'Authenticated MCP data call succeeded (~1 credit consumed).' }, + ], 'https://mcp.example.dev/ra/mcp', true); + + expect(report).toContain('Nansen MCP verify — https://mcp.example.dev/ra/mcp'); + expect(report).toContain('✓ API key found'); + expect(report).toContain('Verified:'); + expect(report).toContain('NANSEN-API-KEY header'); + }); + + it('wires mcp verify JSON failures through the non-zero CLI error envelope', async () => { + const output = []; + const exits = []; + const fetchFn = vi.fn() + .mockResolvedValueOnce(listResponse()) + .mockResolvedValueOnce(rpcResult({ + isError: true, + content: [{ type: 'text', text: 'failed with status 401: Unauthorized' }], + })); + const originalCI = process.env.CI; + process.env.CI = '1'; + try { + const result = await runCLI(['mcp', 'verify', '--json', '--api-key', API_KEY], { + output: value => output.push(value), + errorOutput: () => {}, + exit: code => exits.push(code), + NansenAPIClass: class {}, + fetchFn, + env, + devConfigPath, + isTTY: false, + }); + + expect(result.type).toBe('error'); + expect(exits).toEqual([1]); + expect(output).toHaveLength(1); + const envelope = JSON.parse(output[0]); + expect(envelope).toMatchObject({ success: false, code: 'MCP_VERIFY_FAILED' }); + expect(envelope.details.verified).toBe(false); + expect(envelope.details.checks.find(check => check.id === 'mcp-auth').status).toBe('error'); + } finally { + if (originalCI === undefined) delete process.env.CI; + else process.env.CI = originalCI; + } + }); + + it('rejects a valueless --api-key instead of falling back to a saved key', async () => { + const output = []; + const exits = []; + const fetchFn = vi.fn(); + const result = await runCLI(['mcp', 'verify', '--api-key'], { + output: value => output.push(value), + errorOutput: () => {}, + exit: code => exits.push(code), + NansenAPIClass: class {}, + fetchFn, + env, + devConfigPath, + isTTY: false, + }); + + expect(result.type).toBe('error'); + expect(exits).toEqual([1]); + expect(fetchFn).not.toHaveBeenCalled(); + expect(output.join('\n')).toContain('--api-key requires a value'); + }); + + it('rejects a non-string --api-key value such as the literal null', async () => { + const output = []; + const exits = []; + const fetchFn = vi.fn(); + const result = await runCLI(['mcp', 'verify', '--api-key', 'null'], { + output: value => output.push(value), + errorOutput: () => {}, + exit: code => exits.push(code), + NansenAPIClass: class {}, + fetchFn, + env, + devConfigPath, + isTTY: false, + }); + + expect(result.type).toBe('error'); + expect(exits).toEqual([1]); + expect(fetchFn).not.toHaveBeenCalled(); + expect(output.join('\n')).toContain('--api-key must be a single key string'); + }); + + it('documents the mcp verify command in the schema', () => { + expect(SCHEMA.commands.mcp.subcommands.verify.options).toEqual(expect.objectContaining({ + 'api-key': expect.any(Object), + url: expect.any(Object), + json: expect.any(Object), + })); + }); +}); diff --git a/src/__tests__/telemetry-tracking.test.js b/src/__tests__/telemetry-tracking.test.js index 63dcdd6e..5f108e95 100644 --- a/src/__tests__/telemetry-tracking.test.js +++ b/src/__tests__/telemetry-tracking.test.js @@ -200,6 +200,11 @@ describe('telemetry tracking for all first-level commands', () => { } })); + it('mcp usage does not trigger telemetry or a probe', async () => { + await runCLI(['mcp'], baseDeps({ log: () => {} })); + expect(wasTracked()).toBe(0); + }); + it('wallet (help subcommand)', async () => { await runCLI(['wallet'], baseDeps()); expect(wasTracked()).toBe(1); @@ -289,7 +294,7 @@ describe('telemetry tracking for all first-level commands', () => { // operational ('auth' and 'doctor --offline' are tested as deliberately // untracked — the offline contract covers telemetry) 'account', 'auth', 'doctor', 'login', 'logout', 'schema', 'cache', 'changelog', - 'web', + 'web', 'mcp', // wallet, trading, bridge & perp 'wallet', 'trade', 'quote', 'execute', 'bridge-status', 'bridge', 'perp', // help is a meta command, intentionally not tracked diff --git a/src/cli.js b/src/cli.js index 168d7faf..757a1196 100644 --- a/src/cli.js +++ b/src/cli.js @@ -16,6 +16,7 @@ import { resolveAddress, isEnsName } from './ens.js'; import fs from 'fs'; import { getUpdateNotification, getUpgradeNotice, scheduleUpdateCheck } from './update-check.js'; import { getAuthStatus, runDoctorChecks, runConnectivityChecks, formatDoctorReport } from './doctor.js'; +import { DEFAULT_MCP_URL, formatMcpVerifyReport, runMcpVerifyChecks } from './mcp-verify.js'; import { refreshCostMapIfStale, getCostForEndpoint, creditsCharged } from './cost-cache.js'; import { creditWarning, noticeWarnings } from './response-meta.js'; import { trackCommandSucceeded, trackCommandFailed } from './telemetry.js'; @@ -739,6 +740,7 @@ COMMANDS: login Save API key (--api-key , --human, or NANSEN_API_KEY env var) logout Remove saved API key doctor Diagnostics: auth, wallets, caches, connectivity (--offline --json) + mcp Verify hosted MCP setup with an authenticated data call (~1 credit) schema JSON schema for all commands (use "nansen schema " for one) cache clear changelog --since to filter @@ -886,7 +888,9 @@ export function buildCommands(deps = {}) { deleteConfigFn = deleteConfig, getConfigFileFn = getConfigFile, isTTY = process.stdin.isTTY, - env = process.env + env = process.env, + fetchFn = fetch, + devConfigPath } = deps; const cmds = { @@ -919,6 +923,59 @@ export function buildCommands(deps = {}) { log(formatDoctorReport(checks, { cliVersion: VERSION, offline: Boolean(flags.offline) })); }, + 'mcp': async (args, _apiInstance, flags, options) => { + const subcommand = args[0]; + const usage = `nansen mcp — Verify the hosted Nansen MCP setup\n\nUSAGE:\n nansen mcp verify [--api-key ] [--url ] [--json]`; + if (!subcommand || subcommand === 'help' || flags.help || flags.h) { + log(usage); + return; + } + if (subcommand !== 'verify') { + throw new NansenError(`Unknown mcp subcommand: ${subcommand}. Available: verify`, ErrorCode.UNKNOWN); + } + // A valueless --api-key parses as a flag and would silently fall back to + // the saved key — the exact false positive this command exists to catch. + if (flags['api-key']) { + throw new NansenError('--api-key requires a value. Usage: nansen mcp verify --api-key ', ErrorCode.MISSING_PARAM); + } + // parseArgs JSON-parses option values, so `--api-key null` arrives as + // null and a repeated flag as an array — both must fail, not fall back. + if ('api-key' in options && typeof options['api-key'] !== 'string') { + throw new NansenError('--api-key must be a single key string. Usage: nansen mcp verify --api-key ', ErrorCode.INVALID_PARAMS); + } + + const url = options.url || DEFAULT_MCP_URL; + const checks = await runMcpVerifyChecks({ + apiKey: options['api-key'], + url, + env, + fetchFn, + devConfigPath, + }); + const verified = checks.some(checkItem => checkItem.id === 'mcp-auth' && checkItem.status === 'ok'); + const result = { + verified, + url, + checks, + errors: checks.filter(checkItem => checkItem.status === 'error').length, + warnings: checks.filter(checkItem => checkItem.status === 'warn').length, + }; + + if (verified) { + if (flags.json) return result; + log(formatMcpVerifyReport(checks, url, true)); + return; + } + + const reason = (checks.find(checkItem => checkItem.status === 'error') + || checks.find(checkItem => checkItem.id === 'mcp-auth' && checkItem.status !== 'ok'))?.message + || 'the paid data path did not complete'; + const message = `MCP setup verification failed — ${reason}`; + if (flags.json) throw new CommandError(message, 'MCP_VERIFY_FAILED', result); + log(formatMcpVerifyReport(checks, url, false)); + throw new CommandError(message, 'MCP_VERIFY_FAILED'); + }, + 'web': async (args, apiInstance, flags, options) => { const subcommand = args[0] || 'help'; const subArgs = args.slice(1); @@ -1851,7 +1908,8 @@ export async function runCLI(rawArgs, deps = {}) { // `auth` and `doctor --offline` promise zero network activity — that // contract covers the background update-check fetch and telemetry too, // not just the command's own requests. - const isOfflineCommand = command === 'auth' || (command === 'doctor' && flags.offline); + const isMcpUsage = command === 'mcp' && (subcommand !== 'verify' || flags.help || flags.h); + const isOfflineCommand = command === 'auth' || (command === 'doctor' && flags.offline) || isMcpUsage; const trackSucceeded = isOfflineCommand ? async () => {} : trackCommandSucceeded; const trackFailed = isOfflineCommand ? async () => {} : trackCommandFailed; diff --git a/src/doctor.js b/src/doctor.js index 86f7ae0a..a4cfaab5 100644 --- a/src/doctor.js +++ b/src/doctor.js @@ -85,7 +85,7 @@ const DEV_CONFIG_PATH = path.join(__dirname, '..', 'config.json'); * ~/.nansen/config.json, then the repo-local dev config.json, then env * overrides — but lazily and without secrets leaving this function unmasked. */ -function resolveAuthConfig(env, devConfigPath = DEV_CONFIG_PATH) { +export function resolveAuthConfig(env, devConfigPath = DEV_CONFIG_PATH) { const userConfigPath = getConfigFilePath(env); let config = null; @@ -249,7 +249,7 @@ export function getAuthStatus(deps = {}) { // ============= doctor ============= -function check(id, status, message, fix = null) { +export function check(id, status, message, fix = null) { const result = { id, status, message }; if (fix) result.fix = fix; return result; @@ -454,6 +454,18 @@ export async function runConnectivityChecks(deps = {}) { const STATUS_ICONS = { ok: '✓', warn: '⚠️ ', error: '❌', info: 'ℹ' }; +/** + * Render check lines without a header or summary. + */ +export function formatChecks(checks) { + const lines = []; + for (const c of checks) { + lines.push(`${STATUS_ICONS[c.status] || ' '} ${c.message}`); + if (c.fix) lines.push(` ${c.fix}`); + } + return lines.join('\n'); +} + /** * Render doctor checks as human-readable lines with a summary tail. */ @@ -464,10 +476,7 @@ export function formatDoctorReport(checks, { cliVersion = null, offline = false : 'diagnostics (local checks + a credit-free connectivity probe; --offline to skip network)'; lines.push(`Nansen CLI doctor${cliVersion ? ` v${cliVersion}` : ''} — ${mode}`); lines.push(''); - for (const c of checks) { - lines.push(`${STATUS_ICONS[c.status] || ' '} ${c.message}`); - if (c.fix) lines.push(` ${c.fix}`); - } + lines.push(formatChecks(checks)); const warnings = checks.filter(c => c.status === 'warn').length; const errors = checks.filter(c => c.status === 'error').length; lines.push(''); diff --git a/src/mcp-verify.js b/src/mcp-verify.js new file mode 100644 index 00000000..600077a9 --- /dev/null +++ b/src/mcp-verify.js @@ -0,0 +1,285 @@ +import { check, formatChecks, maskKey, resolveAuthConfig } from './doctor.js'; + +export const DEFAULT_MCP_URL = 'https://mcp.nansen.ai/ra/mcp'; +export const CANARY_TOOL = 'nansen_score_top_tokens'; + +class McpRequestError extends Error { + constructor(message, { status = null, rpcMessage = null } = {}) { + super(message); + this.name = 'McpRequestError'; + this.status = status; + this.rpcMessage = rpcMessage; + } +} + +function responseContentType(response) { + return ( + response.headers?.get?.('content-type') + || response.headers?.['content-type'] + || response.headers?.['Content-Type'] + || '' + ).toLowerCase(); +} + +function responseText(message) { + const content = [message?.result?.content, message?.content] + .filter(Array.isArray) + .flat() + .map(item => item?.text) + .filter(text => typeof text === 'string'); + const error = message?.error; + const errorText = typeof error === 'string' + ? error + : [error?.message, error?.data].filter(value => typeof value === 'string').join(': '); + return [...content, errorText].filter(Boolean).join('\n'); +} + +function parseResponseBody(body, contentType, requestId) { + if (!contentType.includes('text/event-stream')) return JSON.parse(body); + + for (const line of body.split(/\r?\n/)) { + if (!line.startsWith('data:')) continue; + const data = line.slice(5).trim(); + if (!data || data === '[DONE]') continue; + try { + const message = JSON.parse(data); + // String compare: a proxy may re-serialize the JSON-RPC id as "1". + if (String(message?.id) === String(requestId)) return message; + } catch { + // Ignore non-JSON SSE data and keep looking for the JSON-RPC message. + } + } + + throw new Error(`no JSON-RPC message with id ${requestId} in SSE response`); +} + +/** + * Send one stateless MCP JSON-RPC request and parse either JSON or SSE output. + */ +export async function mcpRequest(url, method, params, { + apiKey, + fetchFn = fetch, + timeoutMs = 30_000, +} = {}) { + const requestId = 1; + const controller = new AbortController(); + const headers = { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }; + if (apiKey) headers['NANSEN-API-KEY'] = apiKey; + + const timer = setTimeout(() => controller.abort(), timeoutMs); + let response; + let body; + try { + response = await fetchFn(url, { + method: 'POST', + headers, + signal: controller.signal, + body: JSON.stringify({ jsonrpc: '2.0', id: requestId, method, params }), + }); + body = await response.text(); + } finally { + clearTimeout(timer); + } + + let message; + try { + message = parseResponseBody(body, responseContentType(response), requestId); + } catch (error) { + throw new McpRequestError( + `MCP server returned an unexpected response${response?.status ? ` (HTTP ${response.status})` : ''}: ${error.message}`, + { status: response?.status || null }, + ); + } + + if (response?.status && (response.status < 200 || response.status >= 300)) { + throw new McpRequestError( + `MCP server returned HTTP ${response.status}${responseText(message) ? `: ${responseText(message)}` : ''}`, + { status: response.status, rpcMessage: message }, + ); + } + + return message; +} + +function errorReason(error) { + if (error?.name === 'AbortError') return 'timed out'; + return error?.cause?.code || error?.message || 'request failed'; +} + +function errorText(error) { + return error?.rpcMessage ? responseText(error.rpcMessage) : error?.message || 'request failed'; +} + +function authFailureCheck(message, httpStatus = null) { + const text = responseText(message); + const codes = [httpStatus, message?.error?.code, message?.result?.code].filter(Boolean).join(' '); + const combined = `${codes} ${text}`; + + // Order matters: auth rejection first, then rate limit before credits — + // rate-limit texts often mention "credit rate limit" and must stay a warn. + if (/\b40[13]\b|unauthorized|forbidden|api[- ]key.*(?:required|invalid|reject)|header is required/i.test(combined)) { + return check( + 'mcp-auth', + 'error', + `MCP server rejected the API key${text ? `: ${text}` : ''}`, + 'Check the exact key in your MCP client\'s NANSEN-API-KEY header, or create/rotate it at https://app.nansen.ai/api?tab=api', + ); + } + if (/\b429\b|rate[- ]?limit|too many requests/i.test(combined)) { + return check( + 'mcp-auth', + 'warn', + `MCP data call was rate limited; the paid data path is not verified${text ? `: ${text}` : ''}`, + 'Retry the verification shortly.', + ); + } + if (/\b402\b|payment required|insufficient/i.test(combined)) { + return check( + 'mcp-auth', + 'error', + `MCP server reports insufficient credits${text ? `: ${text}` : ''}`, + 'Top up credits or check your Nansen plan.', + ); + } + return check( + 'mcp-auth', + 'error', + `MCP authenticated data call failed${text ? `: ${text}` : ''}`, + 'Check the MCP server response and retry.', + ); +} + +function isRpcError(message) { + return Boolean(message?.error) || message?.result?.isError === true || message?.isError === true; +} + +function skippedAuth(message) { + return check('mcp-auth', 'info', `Skipped authenticated data call: ${message}`); +} + +function keySourceLabel(source) { + if (source === 'env') return 'NANSEN_API_KEY env var'; + if (source === 'config') return 'config file'; + if (source === 'dev-config') return 'development config file'; + return '--api-key'; +} + +/** + * Run the unauthenticated reachability check and the paid authenticated canary. + * Every expected failure is represented as a check instead of escaping. + */ +export async function runMcpVerifyChecks({ + apiKey, + url = DEFAULT_MCP_URL, + env = process.env, + fetchFn = fetch, + timeoutMs = 30_000, + devConfigPath, +} = {}) { + const checks = []; + const auth = resolveAuthConfig(env, devConfigPath); + // An explicitly passed key — even a bogus null — must never silently fall + // back to the saved key: that would verify a key the caller never supplied. + const explicitKey = apiKey !== undefined; + const resolvedKey = explicitKey ? apiKey : auth.apiKey; + const key = typeof resolvedKey === 'string' ? resolvedKey.trim() : ''; + + if (key) { + checks.push(check('mcp-api-key', 'ok', `API key found (${maskKey(key)}, source: ${explicitKey ? '--api-key' : keySourceLabel(auth.apiKeySource)})`)); + } else { + checks.push(check( + 'mcp-api-key', + 'error', + 'No API key available for the authenticated MCP data-path check', + 'Create an API key at https://app.nansen.ai/api?tab=api, then pass --api-key or set NANSEN_API_KEY', + )); + } + + if (key && url !== DEFAULT_MCP_URL) { + checks.push(check('mcp-url', 'warn', `Non-default MCP URL: ${url} — the API key will be sent to this host`)); + } + + let serverReady = false; + try { + const message = await mcpRequest(url, 'tools/list', {}, { fetchFn, timeoutMs }); + if (isRpcError(message) || !Array.isArray(message?.result?.tools)) { + const text = responseText(message); + checks.push(check( + 'mcp-server', + 'error', + `MCP tools/list failed${text ? `: ${text}` : ''}`, + `Check the MCP server URL and network: ${url}`, + )); + } else { + serverReady = true; + checks.push(check( + 'mcp-server', + 'ok', + `MCP server reachable: tools/list returned ${message.result.tools.length} tools (unauthenticated; reachability only)`, + )); + } + } catch (error) { + checks.push(check( + 'mcp-server', + 'error', + `MCP server unreachable: ${errorReason(error)}`, + `Check your network/proxy or try --url ${url}`, + )); + } + + if (!key) { + checks.push(skippedAuth('no API key was provided')); + } else if (!serverReady) { + checks.push(skippedAuth('tools/list did not establish server reachability')); + } else { + try { + const message = await mcpRequest( + url, + 'tools/call', + { name: CANARY_TOOL, arguments: { request: {} } }, + { apiKey: key, fetchFn, timeoutMs }, + ); + if (isRpcError(message)) { + checks.push(authFailureCheck(message)); + } else if (!Array.isArray(message?.result?.content)) { + // A tools/call result always carries a content array; anything else + // (e.g. a proxy echoing an unrelated payload) must not verify the key. + checks.push(check( + 'mcp-auth', + 'error', + 'MCP data call returned a malformed tools/call result — the key is not verified', + `Check the MCP server URL: ${url}`, + )); + } else { + checks.push(check( + 'mcp-auth', + 'ok', + 'Authenticated MCP data call succeeded (~1 credit consumed).', + )); + } + } catch (error) { + const text = errorText(error); + const message = error.rpcMessage || (error.status ? { error: { message: text } } : null); + checks.push(message + ? authFailureCheck(message, error.status) + : check('mcp-auth', 'error', `MCP authenticated data call failed: ${errorReason(error)}`, 'Check your network/proxy or try the verification again.')); + } + } + + return checks; +} + +export function formatMcpVerifyReport(checks, url, verified) { + const lines = [`Nansen MCP verify — ${url}`, '', formatChecks(checks), '']; + if (verified) { + lines.push('Verified: the supplied API key works against the MCP server\'s paid data path (~1 credit consumed). Ensure this same key is in your client\'s NANSEN-API-KEY header.'); + } else { + const errors = checks.filter(item => item.status === 'error').length; + const warnings = checks.filter(item => item.status === 'warn').length; + lines.push(`${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'} found; MCP setup is not verified.`); + } + return lines.join('\n'); +} diff --git a/src/schema.json b/src/schema.json index fda7d128..72194a42 100644 --- a/src/schema.json +++ b/src/schema.json @@ -1894,6 +1894,34 @@ "nansen doctor --json --pretty" ] }, + "mcp": { + "description": "Verify the hosted Nansen MCP server setup with an authenticated paid data call (~1 credit). The unauthenticated tools/list response alone cannot validate an API key.", + "subcommands": { + "verify": { + "description": "Check MCP server reachability and verify an API key on the paid data path (~1 credit)", + "options": { + "api-key": { + "type": "string", + "description": "API key to test; overrides NANSEN_API_KEY and ~/.nansen/config.json" + }, + "url": { + "type": "string", + "default": "https://mcp.nansen.ai/ra/mcp", + "description": "Hosted MCP server URL" + }, + "json": { + "type": "boolean", + "description": "Return machine-readable verification checks and exit non-zero on failure" + } + }, + "examples": [ + "npx -y nansen-cli mcp verify --api-key ", + "nansen mcp verify --api-key --json", + "nansen mcp verify --url https://mcp.example.dev/ra/mcp --api-key " + ] + } + } + }, "web": { "description": "Web search and fetch commands", "subcommands": {