diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec580b11e..e3fd9212d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: run: pnpm install --frozen-lockfile - name: Type check + run: pnpm type-check + + - name: Build run: pnpm build - name: Prettier diff --git a/package.json b/package.json index a44638822..f99335499 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "prepare": "husky", - "test": "vitest run --dir src", + "test": "vitest run", "type-check": "tsc --noEmit", "release": "pnpm run build && npm publish", "publish:snapshot": "npm version prerelease --preid=snapshot --no-git-tag-version && npm publish --tag snapshot" diff --git a/src/call.test.ts b/src/call.test.ts index ad03a1b8d..62be6db0f 100644 --- a/src/call.test.ts +++ b/src/call.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { EncryptionMode } from '@contextvm/sdk'; +import { nip19 } from 'nostr-tools'; import type { Progress } from '@modelcontextprotocol/sdk/types.js'; import { __test__, @@ -10,6 +11,7 @@ import { } from './call.ts'; import { stripAnsi } from './test-utils.ts'; import { PAYMENT_REQUIRED_ERROR_CODE } from '@contextvm/sdk/payments/constants'; +import { DEFAULT_RELAYS } from './config/index.ts'; function captureConsoleOutput(render: () => void): string[] { const output: string[] = []; @@ -60,6 +62,8 @@ describe('parseCallArgs', () => { '--encryption-mode', 'required', '--stateful', + '--timeout', + '45000', ]); expect(parsed.server).toBe('weather'); @@ -73,6 +77,7 @@ describe('parseCallArgs', () => { expect(parsed.relays).toEqual(['wss://relay.example.com', 'wss://relay.two']); expect(parsed.encryption).toBe(EncryptionMode.REQUIRED); expect(parsed.isStateless).toBe(false); + expect(parsed.timeout).toBe(45000); expect(parsed.showServerDetails).toBe(false); expect(parsed.paymentMode).toBe('transparent'); }); @@ -120,6 +125,59 @@ describe('parseCallArgs', () => { expect(parsed.unknownFlags).toEqual([]); }); + it('rejects non-positive and non-numeric timeout values', () => { + expect(parseCallArgs(['weather', '--timeout', '0']).unknownFlags).toEqual(['--timeout (0)']); + expect(parseCallArgs(['weather', '--timeout', 'soon']).unknownFlags).toEqual([ + '--timeout (soon)', + ]); + expect(parseCallArgs(['weather', '--timeout', '2147483648']).unknownFlags).toEqual([ + '--timeout (2147483648)', + ]); + }); + + it('uses server announcements to disable optional encryption only when unsupported', async () => { + const target = { + input: 'weather', + server: '750682303c9f0ddad75941b49edc9d46e3ed306b9ee3335338a21a3e404c5fa3', + relays: ['wss://relay.contextvm.org'], + encryption: EncryptionMode.OPTIONAL, + isStateless: true, + }; + + await expect( + __test__.resolveEffectiveEncryptionMode(target, 1000, async () => false) + ).resolves.toBe(EncryptionMode.DISABLED); + await expect( + __test__.resolveEffectiveEncryptionMode(target, 1000, async () => true) + ).resolves.toBe(EncryptionMode.OPTIONAL); + await expect( + __test__.resolveEffectiveEncryptionMode(target, 1000, async () => undefined) + ).resolves.toBe(EncryptionMode.OPTIONAL); + await expect( + __test__.resolveEffectiveEncryptionMode( + { ...target, encryption: EncryptionMode.REQUIRED }, + 1000, + async () => false + ) + ).rejects.toThrow('does not advertise encryption support'); + }); + + it('falls back to bootstrap relays for a relay-less nprofile announcement lookup', () => { + const server = nip19.nprofileEncode({ + pubkey: '750682303c9f0ddad75941b49edc9d46e3ed306b9ee3335338a21a3e404c5fa3', + relays: [], + }); + + expect( + __test__.getDisplayRelays({ + input: server, + server, + encryption: EncryptionMode.OPTIONAL, + isStateless: true, + }) + ).toEqual(DEFAULT_RELAYS); + }); + it('extracts nested result values with array access', () => { expect( __test__.extractResultValue( @@ -597,12 +655,6 @@ Or pass a direct server identity in hex, npub, or nprofile format.]`); .fn() .mockRejectedValue(new Error('Tool not found: height of the eiffel tower')); const close = vi.fn().mockResolvedValue(undefined); - const exitSpy = vi - .spyOn(process, 'exit') - .mockImplementation((code?: string | number | null) => { - throw new Error(`EXIT:${code}`); - }); - setCreateRemoteClientFactoryForTests( vi.fn().mockResolvedValue({ client: { @@ -618,24 +670,24 @@ Or pass a direct server identity in hex, npub, or nprofile format.]`); }) as never ); + let exitCode: number | undefined; const output = await captureConsoleOutputAsync(async () => { - await expect( - call( - '750682303c9f0ddad75941b49edc9d46e3ed306b9ee3335338a21a3e404c5fa3', - 'height of the eiffel tower', - {}, - { privateKey: 'nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj4xw9h' } - ) - ).rejects.toThrow('EXIT:1'); + exitCode = await call( + '750682303c9f0ddad75941b49edc9d46e3ed306b9ee3335338a21a3e404c5fa3', + 'height of the eiffel tower', + {}, + { privateKey: 'nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj4xw9h' } + ); }); expect(callTool).toHaveBeenCalledTimes(1); + expect(exitCode).toBe(1); + expect(close).toHaveBeenCalled(); expect(output.join('\n')).toContain('Tool not found: height of the eiffel tower'); expect(output.join('\n')).toContain('cvmi call [key=value ...] [options]'); expect(output.join('\n')).toContain('search'); resetCreateRemoteClientFactoryForTests(); - exitSpy.mockRestore(); }); it('enables MCP progress handling by default for tool calls', async () => { @@ -685,6 +737,8 @@ Or pass a direct server identity in hex, npub, or nprofile format.]`); expect.objectContaining({ onprogress: expect.any(Function), resetTimeoutOnProgress: true, + timeout: 60000, + maxTotalTimeout: 300000, }) ); @@ -785,8 +839,9 @@ Or pass a direct server identity in hex, npub, or nprofile format.]`); }) as never ); + let exitCode: number | undefined; const output = await captureConsoleOutputAsync(async () => { - await call( + exitCode = await call( '750682303c9f0ddad75941b49edc9d46e3ed306b9ee3335338a21a3e404c5fa3', 'read_media_file', { path: './ot-demo/img.jpg' }, @@ -799,6 +854,434 @@ Or pass a direct server identity in hex, npub, or nprofile format.]`); expect(listTools).not.toHaveBeenCalled(); expect(output).toContain('{"content":[{"data":"abc"}]}'); + expect(exitCode).toBe(0); + + resetCreateRemoteClientFactoryForTests(); + }); + + it('keeps verbose diagnostics off stdout in raw mode', async () => { + const close = vi.fn().mockResolvedValue(undefined); + setCreateRemoteClientFactoryForTests( + vi.fn().mockResolvedValue({ + client: { + callTool: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }), + }, + metadata: {}, + close, + }) as never + ); + + const stdout: string[] = []; + const stderr: string[] = []; + const originalLog = console.log; + const originalError = console.error; + console.log = (message?: unknown) => stdout.push(String(message ?? '')); + console.error = (message?: unknown) => stderr.push(String(message ?? '')); + try { + await call( + '750682303c9f0ddad75941b49edc9d46e3ed306b9ee3335338a21a3e404c5fa3', + 'read_media_file', + { path: './ot-demo/img.jpg' }, + { + privateKey: 'nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj4xw9h', + raw: true, + verbose: true, + } + ); + } finally { + console.log = originalLog; + console.error = originalError; + } + + expect(stdout).toHaveLength(1); + expect(() => JSON.parse(stdout[0]!)).not.toThrow(); + expect(stderr.join('\n')).toContain('Connecting to'); + expect(stderr.join('\n')).toContain('Calling tool: read_media_file'); + expect(close).toHaveBeenCalled(); + + resetCreateRemoteClientFactoryForTests(); + }); + + it('returns a non-zero status for MCP tool error results after rendering them', async () => { + const callTool = vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Unknown model' }], + isError: true, + }); + const close = vi.fn().mockResolvedValue(undefined); + + setCreateRemoteClientFactoryForTests( + vi.fn().mockResolvedValue({ + client: { callTool }, + metadata: {}, + close, + }) as never + ); + + let exitCode: number | undefined; + const output = await captureConsoleOutputAsync(async () => { + exitCode = await call( + '750682303c9f0ddad75941b49edc9d46e3ed306b9ee3335338a21a3e404c5fa3', + 'chat.complete', + { model: 'missing' }, + { privateKey: 'nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj4xw9h' } + ); + }); + + expect(output).toContain('Unknown model'); + expect(exitCode).toBe(1); + expect(close).toHaveBeenCalled(); + + resetCreateRemoteClientFactoryForTests(); + }); + + it('prints CEP-41 chunks and returns the terminal tool result', async () => { + const dispose = vi.fn(); + const abort = vi.fn().mockResolvedValue(undefined); + const terminalResult = { + content: [], + structuredContent: { finish_reason: 'stop' }, + }; + let resolveResult!: (value: typeof terminalResult) => void; + const resultPromise = new Promise((resolve) => { + resolveResult = resolve; + }); + const stream = { + isActive: true, + abort, + dispose, + async *[Symbol.asyncIterator]() { + yield { + chunkIndex: 0, + value: '{"object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant"}}]}', + }; + yield { + chunkIndex: 1, + value: '{"object":"chat.completion.chunk","choices":[{"delta":{"content":"hello"}}]}', + }; + resolveResult(terminalResult); + await new Promise((resolve) => setTimeout(resolve, 20)); + yield { + chunkIndex: 2, + value: '{"object":"chat.completion.chunk","choices":[{"delta":{"content":" world"}}]}', + }; + yield { + chunkIndex: 3, + value: + '{"object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"total_tokens":7}}', + }; + }, + }; + const prepareOutboundOpenStreamSession = vi.fn().mockResolvedValue({ + progressToken: 'stream-token', + stream, + }); + const callTool = vi.fn().mockReturnValue(resultPromise); + const close = vi.fn().mockResolvedValue(undefined); + + setCreateRemoteClientFactoryForTests( + vi.fn().mockResolvedValue({ + client: { callTool }, + transport: { prepareOutboundOpenStreamSession }, + metadata: {}, + close, + }) as never + ); + + let exitCode: number | undefined; + const streamedOutput: string[] = []; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => { + streamedOutput.push(String(chunk)); + return true; + }) as typeof process.stdout.write); + let output: string[] = []; + try { + output = await captureConsoleOutputAsync(async () => { + exitCode = await call( + '750682303c9f0ddad75941b49edc9d46e3ed306b9ee3335338a21a3e404c5fa3', + 'chat.complete', + { model: 'faux-1', stream: true }, + { privateKey: 'nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj4xw9h' } + ); + }); + } finally { + writeSpy.mockRestore(); + } + + expect(streamedOutput.join('')).toBe('hello world\n'); + expect(output.join('\n')).not.toContain('chat.completion.chunk'); + expect(output.join('\n')).toContain('finish_reason: stop'); + expect(exitCode).toBe(0); + expect(dispose).toHaveBeenCalled(); + expect(abort).not.toHaveBeenCalled(); + expect(callTool).toHaveBeenCalledWith( + { + name: 'chat.complete', + arguments: { model: 'faux-1', stream: true }, + }, + undefined, + expect.objectContaining({ + timeout: 60000, + maxTotalTimeout: 300000, + }) + ); + + resetCreateRemoteClientFactoryForTests(); + }); + + it('does not hang when a streaming call fails before the first chunk', async () => { + const dispose = vi.fn(); + const stream = { + dispose, + [Symbol.asyncIterator]() { + return { + next: () => new Promise>(() => {}), + }; + }, + }; + const prepareOutboundOpenStreamSession = vi.fn().mockResolvedValue({ + progressToken: 'stream-token', + stream, + }); + const rejection = new Error('Payment Required') as Error & { code: number }; + rejection.code = PAYMENT_REQUIRED_ERROR_CODE; + const callTool = vi.fn().mockRejectedValue(rejection); + const close = vi.fn().mockResolvedValue(undefined); + + setCreateRemoteClientFactoryForTests( + vi.fn().mockResolvedValue({ + client: { callTool }, + transport: { prepareOutboundOpenStreamSession }, + metadata: {}, + close, + }) as never + ); + + await expect( + call( + '750682303c9f0ddad75941b49edc9d46e3ed306b9ee3335338a21a3e404c5fa3', + 'chat.complete', + { model: 'faux-1', stream: true }, + { + privateKey: 'nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj4xw9h', + paymentMode: 'explicit_gating', + } + ) + ).rejects.toMatchObject({ name: 'ExplicitGatingError' }); + expect(dispose).toHaveBeenCalled(); + expect(close).toHaveBeenCalled(); + + resetCreateRemoteClientFactoryForTests(); + }); + + it('aborts the producer when a call fails after streaming starts', async () => { + const dispose = vi.fn(); + const abort = vi.fn().mockResolvedValue(undefined); + let rejectResult!: (error: Error) => void; + const resultPromise = new Promise((_resolve, reject) => { + rejectResult = reject; + }); + const stream = { + isActive: true, + abort, + dispose, + async *[Symbol.asyncIterator]() { + yield { chunkIndex: 0, value: '{"delta":"partial"}' }; + rejectResult(new Error('request timed out')); + await new Promise(() => {}); + }, + }; + const prepareOutboundOpenStreamSession = vi.fn().mockResolvedValue({ + progressToken: 'stream-token', + stream, + }); + const close = vi.fn().mockResolvedValue(undefined); + + setCreateRemoteClientFactoryForTests( + vi.fn().mockResolvedValue({ + client: { callTool: vi.fn().mockReturnValue(resultPromise) }, + transport: { prepareOutboundOpenStreamSession }, + metadata: {}, + close, + }) as never + ); + + await captureConsoleOutputAsync(async () => { + await expect( + call( + '750682303c9f0ddad75941b49edc9d46e3ed306b9ee3335338a21a3e404c5fa3', + 'chat.complete', + { model: 'faux-1', stream: true }, + { + privateKey: 'nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj4xw9h', + raw: true, + } + ) + ).rejects.toThrow('request timed out'); + }); + + expect(abort).toHaveBeenCalledWith('request timed out'); + expect(dispose).toHaveBeenCalled(); + expect(close).toHaveBeenCalled(); + + resetCreateRemoteClientFactoryForTests(); + }); + + it('disposes a pending stream when the terminal result is a tool error', async () => { + const dispose = vi.fn(); + const prepareOutboundOpenStreamSession = vi.fn().mockResolvedValue({ + progressToken: 'stream-token', + stream: { + dispose, + [Symbol.asyncIterator]() { + return { + next: () => + new Promise>(() => {}), + }; + }, + }, + }); + const close = vi.fn().mockResolvedValue(undefined); + setCreateRemoteClientFactoryForTests( + vi.fn().mockResolvedValue({ + client: { + callTool: vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Unknown model' }], + isError: true, + }), + }, + transport: { prepareOutboundOpenStreamSession }, + metadata: {}, + close, + }) as never + ); + + let exitCode: number | undefined; + const output = await captureConsoleOutputAsync(async () => { + exitCode = await call( + '750682303c9f0ddad75941b49edc9d46e3ed306b9ee3335338a21a3e404c5fa3', + 'chat.complete', + { model: 'missing', stream: true }, + { privateKey: 'nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj4xw9h' } + ); + }); + + expect(output).toContain('Unknown model'); + expect(exitCode).toBe(1); + expect(dispose).toHaveBeenCalled(); + expect(close).toHaveBeenCalled(); + + resetCreateRemoteClientFactoryForTests(); + }); + + it('falls back to the terminal result when no stream frames arrive', async () => { + const dispose = vi.fn(); + const prepareOutboundOpenStreamSession = vi.fn().mockResolvedValue({ + progressToken: 'stream-token', + stream: { + dispose, + [Symbol.asyncIterator]() { + return { + next: () => + new Promise>(() => {}), + }; + }, + }, + }); + const close = vi.fn().mockResolvedValue(undefined); + setCreateRemoteClientFactoryForTests( + vi.fn().mockResolvedValue({ + client: { + callTool: vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'ordinary result' }], + }), + }, + transport: { prepareOutboundOpenStreamSession }, + metadata: {}, + close, + }) as never + ); + + let exitCode: number | undefined; + const output = await captureConsoleOutputAsync(async () => { + exitCode = await call( + '750682303c9f0ddad75941b49edc9d46e3ed306b9ee3335338a21a3e404c5fa3', + 'chat.complete', + { model: 'faux-1', stream: true }, + { privateKey: 'nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj4xw9h' } + ); + }); + + expect(output).toContain('ordinary result'); + expect(exitCode).toBe(0); + expect(dispose).toHaveBeenCalled(); + expect(close).toHaveBeenCalled(); + + resetCreateRemoteClientFactoryForTests(); + }); + + it('bounds the post-result drain instead of hanging on a stuck stream', async () => { + const dispose = vi.fn(); + const terminalResult = { + content: [{ type: 'text', text: 'final answer' }], + }; + let resolveResult!: (value: typeof terminalResult) => void; + const resultPromise = new Promise((resolve) => { + resolveResult = resolve; + }); + const stream = { + isActive: true, + abort: vi.fn().mockResolvedValue(undefined), + dispose, + async *[Symbol.asyncIterator]() { + yield { + chunkIndex: 0, + value: '{"object":"chat.completion.chunk","choices":[{"delta":{"content":"hi"}}]}', + }; + resolveResult(terminalResult); + await new Promise(() => {}); // iterator never advances again + }, + }; + const prepareOutboundOpenStreamSession = vi.fn().mockResolvedValue({ + progressToken: 'stream-token', + stream, + }); + const close = vi.fn().mockResolvedValue(undefined); + + setCreateRemoteClientFactoryForTests( + vi.fn().mockResolvedValue({ + client: { callTool: vi.fn().mockReturnValue(resultPromise) }, + transport: { prepareOutboundOpenStreamSession }, + metadata: {}, + close, + }) as never + ); + + let exitCode: number | undefined; + const streamedOutput: string[] = []; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => { + streamedOutput.push(String(chunk)); + return true; + }) as typeof process.stdout.write); + let output: string[] = []; + try { + output = await captureConsoleOutputAsync(async () => { + // Small timeout shrinks the drain grace so the test completes quickly. + exitCode = await call( + '750682303c9f0ddad75941b49edc9d46e3ed306b9ee3335338a21a3e404c5fa3', + 'chat.complete', + { model: 'faux-1', stream: true }, + { privateKey: 'nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqj4xw9h', timeout: 50 } + ); + }); + } finally { + writeSpy.mockRestore(); + } + + expect(streamedOutput.join('')).toBe('hi\n'); + expect(output.join('\n')).toContain('final answer'); + expect(exitCode).toBe(0); + expect(dispose).toHaveBeenCalled(); + expect(close).toHaveBeenCalled(); resetCreateRemoteClientFactoryForTests(); }); diff --git a/src/call.ts b/src/call.ts index 08b91b09f..fe73294a8 100644 --- a/src/call.ts +++ b/src/call.ts @@ -1,8 +1,8 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import type { Progress, Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { CallToolResult, Progress, Tool } from '@modelcontextprotocol/sdk/types.js'; import { PrivateKeySigner, EncryptionMode } from '@contextvm/sdk'; import { NostrClientTransport } from '@contextvm/sdk/transport'; -import { nip19 } from 'nostr-tools'; +import { nip19, SimplePool } from 'nostr-tools'; import { loadConfig, loadCallPrivateKeyFromEnv, @@ -15,6 +15,11 @@ import { generatePrivateKey, normalizePrivateKey, normalizePublicKey } from './u import { BOLD, CYAN, DIM, RESET, TEXT, YELLOW } from './constants/ui.ts'; import { renderDefaultResult } from './call/render-result.ts'; import { renderSchemaProperties, renderToolSchema } from './call/render-schema.ts'; +import { + announcementHexPubkey, + announcementSupportsEncryption, + SERVER_ANNOUNCEMENT_KIND, +} from './discover.ts'; import { parseCapabilityPricing, formatPrice, @@ -30,6 +35,11 @@ import { import { PAYMENT_REQUIRED_ERROR_CODE } from '@contextvm/sdk/payments/constants'; const HEX_PUBKEY_PATTERN = /^[0-9a-f]{64}$/i; +const ENCRYPTION_DISCOVERY_TIMEOUT_MS = 4000; +const MAX_STREAM_DRAIN_GRACE_MS = 30000; +export const DEFAULT_CALL_TIMEOUT_MS = 60000; +export const DEFAULT_CALL_MAX_TOTAL_TIMEOUT_MS = 300000; +const MAX_CALL_TIMEOUT_MS = 2147483647; export class ExplicitGatingError extends Error { constructor(public readonly data: any) { @@ -58,6 +68,8 @@ export interface CallOptions { extract?: string; help?: boolean; paymentMode?: PaymentInteractionMode; + timeout?: number; + openStream?: boolean; } export interface ParseCallResult { @@ -78,6 +90,7 @@ export interface ParseCallResult { config: string | undefined; unknownFlags: string[]; paymentMode: PaymentInteractionMode; + timeout: number | undefined; } interface ResolvedServerTarget { @@ -141,6 +154,7 @@ export function parseCallArgs(args: string[]): ParseCallResult { config: undefined, unknownFlags: [], paymentMode: 'transparent', + timeout: undefined, }; for (let i = 0; i < args.length; i++) { @@ -197,6 +211,21 @@ export function parseCallArgs(args: string[]): ParseCallResult { } else { result.unknownFlags.push(`--payment-mode${value ? ` (${value})` : ''}`); } + } else if (arg === '--timeout') { + const value = consumeValue('--timeout'); + if (value !== undefined) { + const timeout = Number(value); + if ( + /^\d+$/.test(value) && + Number.isSafeInteger(timeout) && + timeout > 0 && + timeout <= MAX_CALL_TIMEOUT_MS + ) { + result.timeout = timeout; + } else { + result.unknownFlags.push(`--timeout (${value})`); + } + } } else if (arg.startsWith('--')) { result.unknownFlags.push(arg); } else if (!result.server) { @@ -280,7 +309,10 @@ function getDisplayRelays(target: ResolvedServerTarget): string[] { try { const decoded = nip19.decode(target.server); if (decoded.type === 'nprofile') { - return decoded.data.relays ?? []; + const profileRelays = decoded.data.relays ?? []; + if (profileRelays.length > 0) { + return profileRelays; + } } } catch { // Fall back below when the server identity is not a decodable nprofile. @@ -289,9 +321,66 @@ function getDisplayRelays(target: ResolvedServerTarget): string[] { return DEFAULT_RELAYS; } +type EncryptionSupportLookup = ( + target: ResolvedServerTarget, + maxWaitMs: number +) => Promise; + +async function lookupServerEncryptionSupport( + target: ResolvedServerTarget, + maxWaitMs: number +): Promise { + const relays = getDisplayRelays(target); + if (relays.length === 0) return undefined; + + const pool = new SimplePool(); + try { + const events = await pool.querySync( + relays, + { + kinds: [SERVER_ANNOUNCEMENT_KIND], + authors: [announcementHexPubkey(target.server)], + }, + { maxWait: maxWaitMs } + ); + const latest = events.sort((left, right) => right.created_at - left.created_at)[0]; + return latest ? announcementSupportsEncryption(latest) : undefined; + } finally { + pool.close(relays); + } +} + +async function resolveEffectiveEncryptionMode( + target: ResolvedServerTarget, + maxWaitMs: number, + lookup: EncryptionSupportLookup = lookupServerEncryptionSupport +): Promise { + if (target.encryption === EncryptionMode.DISABLED) { + return EncryptionMode.DISABLED; + } + + let supportsEncryption: boolean | undefined; + try { + supportsEncryption = await lookup(target, maxWaitMs); + } catch { + return target.encryption; + } + + if (supportsEncryption === false) { + if (target.encryption === EncryptionMode.REQUIRED) { + throw new Error( + 'Server announcement does not advertise encryption support, but encryption is required.' + ); + } + return EncryptionMode.DISABLED; + } + + return target.encryption; +} + function logVerbose(enabled: boolean | undefined, message: string): void { if (enabled) { - console.log(message); + console.error(message); } } @@ -313,7 +402,7 @@ function createProgressHandler( return (progress: Progress): void => { const summary = formatProgressValue(progress); const message = typeof progress.message === 'string' ? ` ${progress.message}` : ''; - console.log(`${DIM}Progress:${RESET} ${summary}${message}`); + console.error(`${DIM}Progress:${RESET} ${summary}${message}`); }; } @@ -415,6 +504,202 @@ function printExtractedResult(result: unknown, path: string): void { printRawResult(extracted, false); } +function isToolErrorResult(result: unknown): boolean { + return ( + typeof result === 'object' && + result !== null && + (result as { isError?: unknown }).isError === true + ); +} + +interface StreamChunk { + chunkIndex: number; + value: string; +} + +interface StreamSession { + readonly isActive?: boolean; + abort(reason?: string): Promise; + dispose(): void; + [Symbol.asyncIterator](): AsyncIterator; +} + +type ToolResultOutcome = + { type: 'result'; value: CallToolResult } | { type: 'error'; error: unknown }; + +function parseStreamChunkValue(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function parseChatCompletionChunk(value: unknown): { + recognized: boolean; + text?: string; +} { + if (typeof value !== 'object' || value === null) return { recognized: false }; + if ((value as Record).object !== 'chat.completion.chunk') { + return { recognized: false }; + } + + const choices = (value as Record).choices; + if (!Array.isArray(choices)) return { recognized: true }; + const firstChoice = choices[0]; + if (typeof firstChoice !== 'object' || firstChoice === null) return { recognized: true }; + const delta = (firstChoice as Record).delta; + if (typeof delta !== 'object' || delta === null) return { recognized: true }; + const content = (delta as Record).content; + return { + recognized: true, + ...(typeof content === 'string' ? { text: content } : {}), + }; +} + +function renderStreamChunk( + chunk: StreamChunk, + options: CallOptions, + inlineTextOpen: boolean +): boolean { + if (options.extract) return inlineTextOpen; + + const value = parseStreamChunkValue(chunk.value); + if (options.raw) { + printRawResult( + { + type: 'chunk', + chunkIndex: chunk.chunkIndex, + value, + }, + options.prettyRaw ?? false + ); + return false; + } + + const completionChunk = parseChatCompletionChunk(value); + if (completionChunk.recognized) { + if (completionChunk.text) { + process.stdout.write(completionChunk.text); + return true; + } + return inlineTextOpen; + } + + if (inlineTextOpen) process.stdout.write('\n'); + console.log(chunk.value); + return false; +} + +function resolveRequestTimeouts(options: CallOptions): { + timeout: number; + maxTotalTimeout: number; +} { + return { + timeout: options.timeout ?? DEFAULT_CALL_TIMEOUT_MS, + maxTotalTimeout: options.timeout ?? DEFAULT_CALL_MAX_TOTAL_TIMEOUT_MS, + }; +} + +/** Race a promise against a drain deadline; the timer is always cleared. */ +function raceDrain(promise: Promise, graceMs: number): Promise { + let timer: ReturnType | undefined; + const drain = new Promise<{ type: 'drained' }>((resolve) => { + timer = setTimeout(() => resolve({ type: 'drained' }), graceMs); + }); + return Promise.race([promise, drain]).finally(() => clearTimeout(timer)); +} + +async function callStreamingTool( + remote: RemoteClient, + toolName: string, + input: Record, + options: CallOptions +): Promise { + const pendingSession = remote.transport.prepareOutboundOpenStreamSession(); + const sessionOutcome = pendingSession.then( + (value) => ({ type: 'session' as const, value }), + (error: unknown) => ({ type: 'error' as const, error }) + ); + const requestTimeouts = resolveRequestTimeouts(options); + const resultPromise = remote.client.callTool({ name: toolName, arguments: input }, undefined, { + onprogress: createProgressHandler(options.verbose), + resetTimeoutOnProgress: true, + ...requestTimeouts, + }) as Promise; + const resultOutcome: Promise = resultPromise.then( + (value) => ({ type: 'result', value }), + (error: unknown) => ({ type: 'error', error }) + ); + // A request can fail before the transport has paired its CEP-41 session. + // Race setup so that path surfaces immediately instead of waiting forever. + const setup = await Promise.race([sessionOutcome, resultOutcome]); + if (setup.type === 'error') throw setup.error; + if (setup.type === 'result') return setup.value; + + const stream = setup.value.stream as StreamSession; + const iterator = stream[Symbol.asyncIterator](); + const drainGraceMs = Math.min(requestTimeouts.timeout, MAX_STREAM_DRAIN_GRACE_MS); + let terminalResult: CallToolResult | undefined; + let sawChunk = false; + let inlineTextOpen = false; + let pendingNext: + | Promise< + { type: 'next'; value: IteratorResult } | { type: 'error'; error: unknown } + > + | undefined; + + try { + while (true) { + pendingNext ??= iterator.next().then( + (value) => ({ type: 'next' as const, value }), + (error: unknown) => ({ type: 'error' as const, error }) + ); + // ponytail: after the terminal result, drain is best-effort — a zero-tick + // grace can drop an in-flight first chunk (the result is authoritative + // anyway), and a stuck iterator is bounded by the grace deadline instead + // of hanging the CLI forever. + const outcome = terminalResult + ? await raceDrain(pendingNext, sawChunk ? drainGraceMs : 0) + : await Promise.race([pendingNext, resultOutcome]); + + if (outcome.type === 'error') throw outcome.error; + if (outcome.type === 'result') { + if (isToolErrorResult(outcome.value)) { + if (inlineTextOpen) process.stdout.write('\n'); + return outcome.value; + } + terminalResult = outcome.value; + continue; + } + if (outcome.type === 'drained') { + if (inlineTextOpen) process.stdout.write('\n'); + return terminalResult!; + } + pendingNext = undefined; + if (outcome.value.done) { + if (inlineTextOpen) process.stdout.write('\n'); + return await resultPromise; + } + + sawChunk = true; + inlineTextOpen = renderStreamChunk(outcome.value.value, options, inlineTextOpen); + } + } catch (error) { + if (inlineTextOpen) process.stdout.write('\n'); + if (sawChunk && stream.isActive !== false) { + try { + await stream.abort(error instanceof Error ? error.message : String(error)); + } catch { + // Preserve the original call/stream failure. + } + } + throw error; + } finally { + stream.dispose(); + } +} + function formatSchemaTypeCompact(schema: Record | undefined): string { if (!schema) return 'unknown'; @@ -512,6 +797,9 @@ export const __test__ = { printToolHelp, printAliasSummaries, isPaymentRequiredError, + getDisplayRelays, + announcementSupportsEncryption, + resolveEffectiveEncryptionMode, }; type RemoteClientFactory = typeof createRemoteClient; @@ -519,9 +807,14 @@ type RemoteClient = Awaited>; /** List tools + advertised pricing in one round-trip; always fetched together. */ async function discoverTools( - remote: RemoteClient + remote: RemoteClient, + timeoutMs: number, + maxTotalTimeoutMs: number ): Promise<{ tools: Tool[]; pricing: CapabilityPricing }> { - const toolsResult = await remote.client.listTools(); + const toolsResult = await remote.client.listTools(undefined, { + timeout: timeoutMs, + maxTotalTimeout: maxTotalTimeoutMs, + }); const pricing = parseCapabilityPricing( remote.transport.getServerToolsListEvent() ?? remote.transport.getServerInitializeEvent() ); @@ -546,15 +839,32 @@ async function createRemoteClient(target: ResolvedServerTarget, options: CallOpt privateKey = normalizePrivateKey(privateKey); + const { timeout: timeoutMs, maxTotalTimeout: maxTotalTimeoutMs } = + resolveRequestTimeouts(options); + // Only stateless calls (the default) probe: they never send an initialize + // handshake, so the announcement is the only pre-flight signal. Stateful + // connections learn capabilities during the handshake — skip the extra + // relay roundtrip. + const encryptionMode = target.isStateless + ? await resolveEffectiveEncryptionMode( + target, + Math.min(timeoutMs, ENCRYPTION_DISCOVERY_TIMEOUT_MS) + ) + : target.encryption; + if (encryptionMode !== target.encryption) { + logVerbose(options.verbose, 'Server does not advertise encryption; using unencrypted mode.'); + } + const signer = new PrivateKeySigner(privateKey); const transport = new NostrClientTransport({ signer, relayHandler: target.relays ?? [], serverPubkey: target.server, discoveryRelayUrls: DEFAULT_RELAYS, - encryptionMode: target.encryption, + encryptionMode, isStateless: target.isStateless, logLevel: options.debug ? 'debug' : 'silent', + openStream: options.openStream ? { enabled: true } : undefined, }); // PMI-agnostic: advertise no handlers, so the server sends @@ -568,7 +878,10 @@ async function createRemoteClient(target: ResolvedServerTarget, options: CallOpt client.setNotificationHandler(paymentRequiredNotificationSchema, (notification) => renderPaymentRequired(notification.params) ); - await client.connect(paidTransport); + await client.connect(paidTransport, { + timeout: timeoutMs, + maxTotalTimeout: maxTotalTimeoutMs, + }); return { client, @@ -805,7 +1118,7 @@ export async function call( capabilityArg: string | undefined, input: Record, options: CallOptions -): Promise { +): Promise { // LOG_ENABLED isn't set here: the SDK logger is an import-time singleton, so // a runtime assignment is a no-op. The client-payments info logs that used // to bracket the invoice only fired on the handler path we no longer take @@ -826,52 +1139,66 @@ export async function call( const serverInput = serverArg ?? useConfig.serverPubkey; if (!serverInput) { await showCallHelp(options.config); - process.exit(1); + return 1; } assertKnownServerInput(config, serverInput); const target = resolveServerTarget(config, serverInput, options); + const requestTimeouts = resolveRequestTimeouts(options); + const isStreamingCall = input.stream === true; logVerbose(options.verbose, `Connecting to ${target.aliasName ?? target.server}...`); const remote = await createRemoteClientFactory(target, { ...options, privateKey: options.privateKey ?? loadCallPrivateKeyFromEnv(), + openStream: isStreamingCall, }); try { if (!capabilityArg) { logVerbose(options.verbose, 'Discovering tools...'); - const { tools, pricing } = await discoverTools(remote); + const { tools, pricing } = await discoverTools( + remote, + requestTimeouts.timeout, + requestTimeouts.maxTotalTimeout + ); printServerHelp(target, tools, remote.metadata, options, pricing); - return; + return 0; } const toolName = resolveToolName(capabilityArg); if (options.help) { logVerbose(options.verbose, 'Discovering tools...'); - const { tools, pricing } = await discoverTools(remote); + const { tools, pricing } = await discoverTools( + remote, + requestTimeouts.timeout, + requestTimeouts.maxTotalTimeout + ); const tool = tools.find((entry) => entry.name === toolName); if (!tool) { printMissingToolGuidance(target, capabilityArg, tools, remote.metadata, options, pricing); - process.exit(1); + return 1; } printToolHelp(target, tool, pricing); - return; + return 0; } logVerbose(options.verbose, `Calling tool: ${toolName}`); let result; try { - result = await remote.client.callTool( - { - name: toolName, - arguments: input, - }, - undefined, - { - onprogress: createProgressHandler(options.verbose), - resetTimeoutOnProgress: true, - } - ); + result = isStreamingCall + ? await callStreamingTool(remote, toolName, input, options) + : await remote.client.callTool( + { + name: toolName, + arguments: input, + }, + undefined, + { + onprogress: createProgressHandler(options.verbose), + resetTimeoutOnProgress: true, + ...requestTimeouts, + } + ); } catch (error) { if (!isMissingToolInvocationError(error)) { if (options.paymentMode === 'explicit_gating' && isPaymentRequiredError(error)) { @@ -881,22 +1208,30 @@ export async function call( } logVerbose(options.verbose, 'Discovering tools...'); - const { tools, pricing } = await discoverTools(remote); + const { tools, pricing } = await discoverTools( + remote, + requestTimeouts.timeout, + requestTimeouts.maxTotalTimeout + ); printMissingToolGuidance(target, capabilityArg, tools, remote.metadata, options, pricing); - process.exit(1); + return 1; } if (options.extract) { printExtractedResult(result, options.extract); - return; + return isToolErrorResult(result) ? 1 : 0; } if (options.raw) { - printRawResult(result, options.prettyRaw ?? false); - return; + printRawResult( + isStreamingCall ? { type: 'result', value: result } : result, + options.prettyRaw ?? false + ); + return isToolErrorResult(result) ? 1 : 0; } renderDefaultResult(result); + return isToolErrorResult(result) ? 1 : 0; } finally { await remote.close(); } @@ -930,6 +1265,7 @@ ${BOLD}Options:${RESET} --relays Comma-separated relay URLs --encryption-mode Encryption mode: optional, required, disabled --payment-mode Payment interaction mode: transparent (default), explicit_gating + --timeout Request timeout; caps both idle and total time (defaults: 60s idle / 5m total) --stateless Enable stateless transport mode (default) --stateful Disable stateless transport mode --details Show resolved server identity and relay details during inspection @@ -945,6 +1281,8 @@ ${BOLD}Private key:${RESET} ${BOLD}Tool input:${RESET} Use key=value arguments. Quote the full argument when passing JSON values, e.g. 'filters={"kinds":[1],"limit":10}' + Passing stream=true consumes CEP-41 output. Raw streaming output is emitted as JSON records, + with one record per chunk followed by the terminal MCP result. ${BOLD}Aliases & config:${RESET} Priority: CLI > custom config (--config) > project .cvmi.json > global ~/.cvmi/config.json > env vars @@ -963,7 +1301,7 @@ ${BOLD}Examples:${RESET} ${DIM}$${RESET} cvmi call weather get_current --help ${DIM}$${RESET} cvmi call weather get_current city=Lisbon ${DIM}$${RESET} cvmi call weather get_current city=Lisbon --raw - ${DIM}$${RESET} cvmi call files read_media_file path=./img.jpg --extract content[0].data + ${DIM}$${RESET} cvmi call files read_media_file path=./img.jpg --extract 'content[0].data' `); printAliasSummaries(aliases); diff --git a/src/cli.ts b/src/cli.ts index 5879af0bd..e440a4272 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,7 +22,14 @@ import { removeCommand, parseRemoveOptions } from './remove.ts'; import { track } from './telemetry.ts'; import { serve, showServeHelp } from './serve.ts'; import { showUseHelp, use } from './use.ts'; -import { call, parseCallArgs, showCallHelp, ExplicitGatingError } from './call.ts'; +import { + call, + parseCallArgs, + showCallHelp, + ExplicitGatingError, + type CallOptions, + type ParseCallResult, +} from './call.ts'; import { discover, parseDiscoverArgs, showDiscoverHelp } from './discover.ts'; import { runSync, parseSyncOptions } from './sync.ts'; import { runCn } from './cn/index.ts'; @@ -718,7 +725,7 @@ interface UseParseResult { encryption: EncryptionMode | undefined; config: string | undefined; persistPrivateKey: boolean; - paymentMode: PaymentInteractionMode; + paymentMode: PaymentInteractionMode | undefined; unknownFlags: string[]; } @@ -841,7 +848,7 @@ function parseUseArgs(args: string[]): UseParseResult { encryption: undefined, config: undefined, persistPrivateKey: false, - paymentMode: 'explicit_gating', + paymentMode: undefined, unknownFlags: [], }; @@ -898,11 +905,31 @@ function parseUseArgs(args: string[]): UseParseResult { return result; } +function toCallOptions(parsed: ParseCallResult): CallOptions { + return { + debug: parsed.debug, + verbose: parsed.verbose, + raw: parsed.raw, + prettyRaw: parsed.prettyRaw, + extract: parsed.extract, + help: parsed.help, + showServerDetails: parsed.showServerDetails, + privateKey: parsed.privateKey, + relays: parsed.relays, + encryption: parsed.encryption, + isStateless: parsed.isStateless, + config: parsed.config, + paymentMode: parsed.paymentMode, + timeout: parsed.timeout, + }; +} + // Exported for tests only (keeps parsing logic single-sourced). export const __test__ = { parseServeArgs, parseUseArgs, parseCallArgs, + toCallOptions, }; // ============================================ @@ -1063,28 +1090,23 @@ async function main(): Promise { break; } + let exitCode: number; try { - await call(parsed.server, parsed.capability, parsed.input, { - debug: parsed.debug, - verbose: parsed.verbose, - raw: parsed.raw, - help: parsed.help, - showServerDetails: parsed.showServerDetails, - privateKey: parsed.privateKey, - relays: parsed.relays, - encryption: parsed.encryption, - isStateless: parsed.isStateless, - config: parsed.config, - paymentMode: parsed.paymentMode, - }); + exitCode = await call( + parsed.server, + parsed.capability, + parsed.input, + toCallOptions(parsed) + ); } catch (error) { if (error instanceof ExplicitGatingError) { console.log(JSON.stringify(error.data, null, 2)); - process.exit(2); + exitCode = 2; + } else { + throw error; } - throw error; } - process.exit(0); + process.exitCode = exitCode; break; } case 'config': { diff --git a/src/discover.test.ts b/src/discover.test.ts index f7b195efd..0ba98068e 100644 --- a/src/discover.test.ts +++ b/src/discover.test.ts @@ -1,8 +1,25 @@ import { describe, expect, it } from 'vitest'; -import { parseDiscoverArgs, showDiscoverHelp } from './discover.ts'; +import { __test__, parseDiscoverArgs, showDiscoverHelp } from './discover.ts'; import { stripAnsi } from './test-utils.ts'; describe('parseDiscoverArgs', () => { + it('recognizes the singleton CEP encryption-support tag', () => { + const server = __test__.parseAnnouncement( + { + id: 'event-id', + pubkey: 'a'.repeat(64), + created_at: 1, + kind: 11316, + tags: [['support_encryption']], + content: '{}', + sig: 'signature', + }, + 'wss://relay.example.com' + ); + + expect(server.supportsEncryption).toBe(true); + }); + it('parses supported flags', () => { const parsed = parseDiscoverArgs([ '--relays', diff --git a/src/discover.ts b/src/discover.ts index a7dc03b90..69096e50f 100644 --- a/src/discover.ts +++ b/src/discover.ts @@ -1,10 +1,45 @@ import { nip19, SimplePool, type Event } from 'nostr-tools'; import { DEFAULT_RELAYS } from './config/index.ts'; +import { normalizePublicKey } from './utils/crypto.ts'; import { BOLD, CYAN, DIM, RESET } from './constants/ui.ts'; const SERVER_ANNOUNCEMENT_KIND = 11316; const DISCOVER_TIMEOUT_MS = 4000; +export { SERVER_ANNOUNCEMENT_KIND }; + +/** Hex pubkey for an announcement lookup; accepts hex, npub, or nprofile. */ +export function announcementHexPubkey(server: string): string { + if (server.startsWith('nprofile')) { + const decoded = nip19.decode(server); + if (decoded.type !== 'nprofile') { + throw new Error(`Expected nprofile server identity, got ${decoded.type}`); + } + return decoded.data.pubkey; + } + + return normalizePublicKey(server); +} + +/** Whether a kind-11316 announcement advertises encryption support. */ +export function announcementSupportsEncryption(event: Event): boolean { + const encryptionTag = event.tags.find((tag) => tag[0] === 'support_encryption'); + if (encryptionTag) { + return encryptionTag.length === 1 || encryptionTag[1] === 'true'; + } + + try { + const content = event.content ? (JSON.parse(event.content) as Record) : {}; + const serverInfo = + content.serverInfo && typeof content.serverInfo === 'object' + ? (content.serverInfo as Record) + : undefined; + return content.support_encryption === true || serverInfo?.support_encryption === true; + } catch { + return false; + } +} + export interface DiscoverOptions { relays?: string[]; raw?: boolean; @@ -52,7 +87,6 @@ function parseAnnouncement(event: Event, relay: string): DiscoveredServer { const match = event.tags.find((tag) => tag[0] === name); return typeof match?.[1] === 'string' ? match[1] : undefined; }; - const nestedInfo = content.serverInfo && typeof content.serverInfo === 'object' ? (content.serverInfo as Record) @@ -70,10 +104,7 @@ function parseAnnouncement(event: Event, relay: string): DiscoveredServer { (typeof content.website === 'string' ? content.website : undefined) ?? (typeof nestedInfo?.website === 'string' ? nestedInfo.website : undefined) ?? tagValue('website'); - const supportsEncryption = - tagValue('support_encryption') === 'true' || - content.support_encryption === true || - nestedInfo?.support_encryption === true; + const supportsEncryption = announcementSupportsEncryption(event); return { pubkey: event.pubkey, @@ -87,6 +118,8 @@ function parseAnnouncement(event: Event, relay: string): DiscoveredServer { }; } +export const __test__ = { parseAnnouncement }; + function mergeServers( existing: DiscoveredServer | undefined, incoming: DiscoveredServer diff --git a/src/use.test.ts b/src/use.test.ts new file mode 100644 index 000000000..31fe6527f --- /dev/null +++ b/src/use.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import type { Event } from 'nostr-tools'; +import { __test__ } from './use.ts'; + +function announcement(tags: string[][]): Event { + return { + id: 'event-id', + pubkey: 'a'.repeat(64), + created_at: 1, + kind: 11316, + tags, + content: '{}', + sig: 'signature', + } as Event; +} + +describe('resolveProxyPaymentOptions', () => { + it('does not force payment negotiation by default', () => { + expect(__test__.resolveProxyPaymentOptions(undefined)).toBeUndefined(); + }); + + it('forwards an explicitly selected payment mode', () => { + expect(__test__.resolveProxyPaymentOptions('transparent')).toEqual({ + paymentInteraction: 'transparent', + }); + expect(__test__.resolveProxyPaymentOptions('explicit_gating')).toEqual({ + paymentInteraction: 'explicit_gating', + }); + }); +}); + +describe('resolveDefaultPaymentMode', () => { + it('requests explicit gating when the announcement advertises it', async () => { + const mode = await __test__.resolveDefaultPaymentMode( + ['wss://relay.example.com'], + 'a'.repeat(64), + async () => announcement([['payment_interaction', 'explicit_gating']]) + ); + expect(mode).toBe('explicit_gating'); + }); + + it('leaves negotiation unset for unpriced or legacy servers', async () => { + const noTag = await __test__.resolveDefaultPaymentMode( + ['wss://relay.example.com'], + 'a'.repeat(64), + async () => announcement([['support_encryption']]) + ); + expect(noTag).toBeUndefined(); + + const noAnnouncement = await __test__.resolveDefaultPaymentMode( + ['wss://relay.example.com'], + 'a'.repeat(64), + async () => undefined + ); + expect(noAnnouncement).toBeUndefined(); + }); + + it('falls back to the compat default when the lookup fails', async () => { + const mode = await __test__.resolveDefaultPaymentMode( + ['wss://relay.example.com'], + 'a'.repeat(64), + async () => { + throw new Error('relay unreachable'); + } + ); + expect(mode).toBeUndefined(); + }); +}); diff --git a/src/use.ts b/src/use.ts index d73cb12fe..f3e3e645a 100644 --- a/src/use.ts +++ b/src/use.ts @@ -5,8 +5,10 @@ import * as p from '@clack/prompts'; import pc from 'picocolors'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { NostrMCPProxy, PrivateKeySigner, EncryptionMode } from '@contextvm/sdk'; -import type { PaymentInteractionMode } from '@contextvm/sdk/payments'; +import type { ClientPaymentsOptions, PaymentInteractionMode } from '@contextvm/sdk/payments'; +import { SimplePool, type Event } from 'nostr-tools'; import { loadConfig, getUseConfig, DEFAULT_RELAYS } from './config/index.ts'; +import { announcementHexPubkey, SERVER_ANNOUNCEMENT_KIND } from './discover.ts'; import { generatePrivateKey, normalizePrivateKey } from './utils/crypto.ts'; import { waitForShutdownSignal } from './utils/process.ts'; import { BOLD, DIM, RESET } from './constants/ui.ts'; @@ -23,6 +25,70 @@ export interface UseOptions { paymentMode?: PaymentInteractionMode; } +function resolveProxyPaymentOptions( + paymentMode: PaymentInteractionMode | undefined +): ClientPaymentsOptions | undefined { + return paymentMode ? { paymentInteraction: paymentMode } : undefined; +} + +const PAYMENT_DISCOVERY_TIMEOUT_MS = 4000; + +type AnnouncementLookup = ( + relays: string[], + serverPubkey: string, + maxWaitMs: number +) => Promise; + +async function lookupLatestServerAnnouncement( + relays: string[], + serverPubkey: string, + maxWaitMs: number +): Promise { + const pool = new SimplePool(); + try { + const events = await pool.querySync( + relays, + { kinds: [SERVER_ANNOUNCEMENT_KIND], authors: [serverPubkey] }, + { maxWait: maxWaitMs } + ); + return events.sort((left, right) => right.created_at - left.created_at)[0]; + } finally { + pool.close(relays); + } +} + +function announcementSupportsExplicitGating(event: Event): boolean { + // CEP-8: a gating-capable server advertises ['payment_interaction', + // 'explicit_gating'] as an availability tag on its announcement. + return event.tags.some((tag) => tag[0] === 'payment_interaction' && tag[1] === 'explicit_gating'); +} + +/** + * Default payment interaction for `use`: request explicit gating only when the + * server's announcement advertises it. Otherwise leave negotiation unset — + * free/legacy/transparent-only servers keep working (a transparent-only server + * outright rejects an explicit_gating request), and a priced tool surfaces a + * clean -32042 error an agent host can relay instead of an unwatched invoice. + */ +async function resolveDefaultPaymentMode( + relays: string[], + serverPubkey: string, + lookup: AnnouncementLookup = lookupLatestServerAnnouncement +): Promise { + try { + const announcement = await lookup( + relays, + announcementHexPubkey(serverPubkey), + PAYMENT_DISCOVERY_TIMEOUT_MS + ); + return announcement && announcementSupportsExplicitGating(announcement) + ? 'explicit_gating' + : undefined; + } catch { + return undefined; + } +} + /** * Run the use command. */ @@ -86,10 +152,15 @@ export async function use(serverPubkeyArg: string | undefined, options: UseOptio // Create stdio transport for MCP host const mcpTransport = new StdioServerTransport(); - // Create proxy - // ponytail: default explicit_gating for `use` (agent host over stdio) — a priced - // tool surfaces a clean -32042 error the agent can relay, instead of streaming an - // invoice no human is watching. `call` defaults to transparent (human in terminal). + // Payment default is announcement-driven: explicit gating is requested only + // when the server advertises support (CEP-8), otherwise negotiation stays + // unset for compatibility with free, legacy, and transparent-only servers. + const paymentMode = + options.paymentMode ?? (await resolveDefaultPaymentMode(relays, serverPubkey)); + if (options.verbose) { + p.log.message(`Payment interaction: ${paymentMode ?? 'transparent (server default)'}`); + } + const paymentOptions = resolveProxyPaymentOptions(paymentMode); const proxy = new NostrMCPProxy({ mcpHostTransport: mcpTransport, nostrTransportOptions: { @@ -99,7 +170,7 @@ export async function use(serverPubkeyArg: string | undefined, options: UseOptio encryptionMode: useConfig.encryption, logLevel: options.verbose ? 'debug' : 'info', }, - paymentOptions: { paymentInteraction: options.paymentMode ?? 'explicit_gating' }, + ...(paymentOptions ? { paymentOptions } : {}), }); // Start proxy @@ -132,7 +203,7 @@ ${BOLD}Options:${RESET} --persist-private-key Save private key to .env file for future use --relays Comma-separated relay URLs (default: wss://relay.contextvm.org) --encryption-mode Encryption mode: optional, required, disabled (default: optional) - --payment-mode Payment interaction: explicit_gating (default), transparent + --payment-mode Payment interaction: transparent | explicit_gating (auto-detected by default) --verbose Enable verbose logging --help, -h Show this help message @@ -163,11 +234,13 @@ ${BOLD}SDK Logging (set via environment, not config files):${RESET} CVMI_USE_PRIVATE_KEY=nsec1... ${BOLD}Payments (CEP-8):${RESET} only relevant when the remote server gates tools behind payment. - --payment-mode explicit_gating (default) | transparent - explicit_gating A priced tool surfaces a -32042 "Payment Required" error carrying the - payment schema, so the host (e.g. an agent) can handle it. Default, - since nothing watches stdio for an invoice. - transparent Streams a lightning invoice notification for a human to pay. + --payment-mode transparent | explicit_gating + transparent Uses the interoperable default without requesting explicit gating. + Payment notifications are forwarded over stdio. + explicit_gating Requests a -32042 "Payment Required" error carrying the payment + schema, so agent hosts fail fast instead of hanging on an invoice. + Default (no flag): explicit_gating when the server announcement advertises support, + otherwise transparent. ${BOLD}Examples:${RESET} ${DIM}$${RESET} cvmi use npub1q... ${DIM}# connect to remote server by public key${RESET} @@ -177,3 +250,5 @@ ${BOLD}Examples:${RESET} ${DIM}$${RESET} cvmi use --help ${DIM}# show this help${RESET} `); } + +export const __test__ = { resolveProxyPaymentOptions, resolveDefaultPaymentMode }; diff --git a/tests/cli-parsing.test.ts b/tests/cli-parsing.test.ts index b6ff4285f..27c240dac 100644 --- a/tests/cli-parsing.test.ts +++ b/tests/cli-parsing.test.ts @@ -131,9 +131,26 @@ describe('CLI Argument Parsing', () => { it('parses server pubkey as positional', () => { const result = __test__.parseUseArgs(['npub1abcdef123456']); expect(result.serverPubkey).toBe('npub1abcdef123456'); + expect(result.paymentMode).toBeUndefined(); expect(result.unknownFlags).toEqual([]); }); + it('parses an explicit payment interaction mode', () => { + const transparent = __test__.parseUseArgs([ + 'npub1abcdef123456', + '--payment-mode', + 'transparent', + ]); + expect(transparent.paymentMode).toBe('transparent'); + + const explicitGating = __test__.parseUseArgs([ + 'npub1abcdef123456', + '--payment-mode', + 'explicit_gating', + ]); + expect(explicitGating.paymentMode).toBe('explicit_gating'); + }); + it('prefers --server-pubkey flag over positional', () => { const result = __test__.parseUseArgs([ '--server-pubkey', @@ -183,6 +200,27 @@ describe('CLI Argument Parsing', () => { }); }); + describe('parseCallArgs', () => { + it('forwards every parsed output and timeout option to the call implementation', () => { + const parsed = __test__.parseCallArgs([ + 'npub1server', + 'read_media_file', + '--pretty-raw', + '--extract', + 'content[0].data', + '--timeout', + '45000', + ]); + + expect(__test__.toCallOptions(parsed)).toMatchObject({ + raw: true, + prettyRaw: true, + extract: 'content[0].data', + timeout: 45000, + }); + }); + }); + describe('parseServeArgs missing value handling', () => { it('reports missing value for --private-key', () => { const result = __test__.parseServeArgs(['--private-key']);