diff --git a/.gitignore b/.gitignore index f92955edb..53bef346e 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,7 @@ cvm-context ts-sdk gateway-cli proxy-cli -# sdk \ No newline at end of file +# sdk + +# per-developer MCP client config (local test rigs) +.mcp.json \ No newline at end of file diff --git a/package.json b/package.json index 1acb78c2c..fb2ad0dc3 100644 --- a/package.json +++ b/package.json @@ -93,10 +93,10 @@ "author": "", "license": "MIT", "dependencies": { - "@contextvm/sdk": "^0.11.14", + "@contextvm/sdk": "^0.13.7", "@modelcontextprotocol/sdk": "^1.27.1", "json-schema-to-typescript": "15.0.4", - "nostr-tools": "^2.23.3", + "nostr-tools": "^2.23.9", "xdg-basedir": "^5.1.0", "zod": "^4.3.6" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c0a45209..4882661bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,8 +8,8 @@ importers: .: dependencies: '@contextvm/sdk': - specifier: ^0.11.14 - version: 0.11.14(typescript@5.9.3) + specifier: ^0.13.7 + version: 0.13.7(typescript@5.9.3) '@modelcontextprotocol/sdk': specifier: ^1.27.1 version: 1.27.1(zod@4.3.6) @@ -17,8 +17,8 @@ importers: specifier: 15.0.4 version: 15.0.4 nostr-tools: - specifier: ^2.23.3 - version: 2.23.3(typescript@5.9.3) + specifier: ^2.23.9 + version: 2.23.9(typescript@5.9.3) xdg-basedir: specifier: ^5.1.0 version: 5.1.0 @@ -241,10 +241,19 @@ packages: integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==, } - '@contextvm/sdk@0.11.14': + '@contextvm/mcp-sdk@1.30.0': resolution: { - integrity: sha512-QTRReNJ3NP6kOT2umHYmhYLSUBK4PtX5wFy4MUm7U/CJq1vnmPnmQ3HnHTG+gNtyLNiv4I3epuUPA6MJm3K/WA==, + integrity: sha512-Z4xyxxxtKnczEgVzLagaUdT1EtoVzc1bjr6kpIfTlw7mP5ESAzXeDUAMO9leIwituphz2ELE6ad9BjW7blDjjw==, + } + engines: { node: '>=18' } + peerDependencies: + zod: ^3.25 || ^4.0 + + '@contextvm/sdk@0.13.7': + resolution: + { + integrity: sha512-Y4fKGVYJzOTHM0bNuLE9+HzDWvr+pCmQgrsn4Wh2pT73ThwNIhvLOWw/GJygNPOZnQSQ3h3v46efwYto/g/oBQ==, } engines: { bun: '>=1.2.0' } peerDependencies: @@ -591,19 +600,6 @@ packages: '@cfworker/json-schema': optional: true - '@modelcontextprotocol/sdk@1.29.0': - resolution: - { - integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==, - } - engines: { node: '>=18' } - peerDependencies: - '@cfworker/json-schema': ^4.1.1 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - '@cfworker/json-schema': - optional: true - '@napi-rs/wasm-runtime@1.1.1': resolution: { @@ -656,13 +652,6 @@ packages: } engines: { node: '>= 16' } - '@noble/hashes@1.8.0': - resolution: - { - integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==, - } - engines: { node: ^14.21.3 || >=16 } - '@noble/hashes@2.0.1': resolution: { @@ -1414,16 +1403,16 @@ packages: } engines: { node: '>=12' } - applesauce-core@5.2.0: + applesauce-core@6.2.0: resolution: { - integrity: sha512-aSuM6q6/Gs2FGUqytlHDjKZpSst2xKaT0vMXUQFWUctECNIxvwy6/hTDDInukMuI9mrQdjnO781ZJJgghI7RNw==, + integrity: sha512-O6AlVyzqcuIhTOIuexm6UWmx7mRIa2D98gJP7K7vFGf90YdtvSH78AsKQWZXJ0Pk/K14at+9zkwfCkFr7ghgNw==, } - applesauce-relay@5.2.0: + applesauce-relay@6.2.1: resolution: { - integrity: sha512-ty8PzHenocGdTr3x3It8Ql0rMD9rxB6VGCzGRfL5QF6epdstv2YHKuTyr8QdPBvf7yxfc7oZcMi6djSwNxXqkQ==, + integrity: sha512-YIUHEtL2Fl5FZjeKYe/IuwL3OnMUNP2j4ydSr3iZDeHEZSEda4d1PoChefiNhtjE/U/F5OBHEbAX1zCHRZoxpg==, } argparse@1.0.10: @@ -2515,17 +2504,6 @@ packages: } engines: { node: '>= 0.6' } - nostr-tools@2.18.2: - resolution: - { - integrity: sha512-lUCJQd9YZG3kEvxV5Zgm7qUkBpaeuvFrtqBz4TJLAxHzUn2pE7nmZZRDQmNzp5neEw20tQS3jR16o7XzzF8ncg==, - } - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - nostr-tools@2.19.4: resolution: { @@ -2537,10 +2515,10 @@ packages: typescript: optional: true - nostr-tools@2.23.3: + nostr-tools@2.23.9: resolution: { - integrity: sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA==, + integrity: sha512-PBN3TpGh+EszlTZWpQdvcqEMqhcKcY1vTw0Xkkccg+TyP7y/icu9/iXJw72aFZ/ETm35ehU0hneGTXLaTSkImw==, } peerDependencies: typescript: '>=5.0.0' @@ -3714,20 +3692,27 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 - '@contextvm/sdk@0.11.14(typescript@5.9.3)': + '@contextvm/mcp-sdk@1.30.0(zod@4.4.3)': + dependencies: + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + cross-spawn: 7.0.6 + zod: 4.4.3 + zod-to-json-schema: 3.25.1(zod@4.4.3) + + '@contextvm/sdk@0.13.7(typescript@5.9.3)': dependencies: - '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + '@contextvm/mcp-sdk': 1.30.0(zod@4.4.3) '@noble/hashes': 2.2.0 - applesauce-relay: 5.2.0(typescript@5.9.3) + applesauce-relay: 6.2.1(typescript@5.9.3) canonicalize: 2.1.0 - nostr-tools: 2.18.2(typescript@5.9.3) + nostr-tools: 2.23.9(typescript@5.9.3) pino: 10.3.1 rxjs: 7.8.2 typescript: 5.9.3 ws: 8.20.0 zod: 4.4.3 transitivePeerDependencies: - - '@cfworker/json-schema' - bufferutil - supports-color - utf-8-validate @@ -3899,28 +3884,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': - dependencies: - '@hono/node-server': 1.19.11(hono@4.12.8) - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.0.6 - express: 5.2.1 - express-rate-limit: 8.3.1(express@5.2.1) - hono: 4.12.8 - jose: 6.2.2 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 4.4.3 - zod-to-json-schema: 3.25.1(zod@4.4.3) - transitivePeerDependencies: - - supports-color - '@napi-rs/wasm-runtime@1.1.1': dependencies: '@emnapi/core': 1.9.1 @@ -3948,8 +3911,6 @@ snapshots: '@noble/hashes@1.3.2': {} - '@noble/hashes@1.8.0': {} - '@noble/hashes@2.0.1': {} '@noble/hashes@2.2.0': {} @@ -4274,7 +4235,7 @@ snapshots: ansi-styles@6.2.3: {} - applesauce-core@5.2.0(typescript@5.9.3): + applesauce-core@6.2.0(typescript@5.9.3): dependencies: debug: 4.4.3 fast-deep-equal: 3.1.3 @@ -4286,10 +4247,10 @@ snapshots: - supports-color - typescript - applesauce-relay@5.2.0(typescript@5.9.3): + applesauce-relay@6.2.1(typescript@5.9.3): dependencies: - '@noble/hashes': 1.8.0 - applesauce-core: 5.2.0(typescript@5.9.3) + '@noble/hashes': 2.2.0 + applesauce-core: 6.2.0(typescript@5.9.3) nanoid: 5.1.7 nostr-tools: 2.19.4(typescript@5.9.3) rxjs: 7.8.2 @@ -4880,18 +4841,6 @@ snapshots: negotiator@1.0.0: {} - nostr-tools@2.18.2(typescript@5.9.3): - dependencies: - '@noble/ciphers': 0.5.3 - '@noble/curves': 1.2.0 - '@noble/hashes': 1.3.1 - '@scure/base': 1.1.1 - '@scure/bip32': 1.3.1 - '@scure/bip39': 1.2.1 - nostr-wasm: 0.1.0 - optionalDependencies: - typescript: 5.9.3 - nostr-tools@2.19.4(typescript@5.9.3): dependencies: '@noble/ciphers': 0.5.3 @@ -4904,7 +4853,7 @@ snapshots: optionalDependencies: typescript: 5.9.3 - nostr-tools@2.23.3(typescript@5.9.3): + nostr-tools@2.23.9(typescript@5.9.3): dependencies: '@noble/ciphers': 2.1.1 '@noble/curves': 2.0.1 diff --git a/src/call.test.ts b/src/call.test.ts index 728d1645e..ad03a1b8d 100644 --- a/src/call.test.ts +++ b/src/call.test.ts @@ -9,6 +9,7 @@ import { setCreateRemoteClientFactoryForTests, } from './call.ts'; import { stripAnsi } from './test-utils.ts'; +import { PAYMENT_REQUIRED_ERROR_CODE } from '@contextvm/sdk/payments/constants'; function captureConsoleOutput(render: () => void): string[] { const output: string[] = []; @@ -73,10 +74,24 @@ describe('parseCallArgs', () => { expect(parsed.encryption).toBe(EncryptionMode.REQUIRED); expect(parsed.isStateless).toBe(false); expect(parsed.showServerDetails).toBe(false); - expect(parsed.unknownFlags).toEqual([]); + expect(parsed.paymentMode).toBe('transparent'); + }); + + it('parses --payment-mode flag', () => { + const transparent = parseCallArgs(['server', '--payment-mode', 'transparent']); + expect(transparent.paymentMode).toBe('transparent'); + + const explicit = parseCallArgs(['server', '--payment-mode', 'explicit_gating']); + expect(explicit.paymentMode).toBe('explicit_gating'); + }); + + it('rejects invalid --payment-mode flag', () => { + const invalid = parseCallArgs(['server', '--payment-mode', 'invalid_mode']); + expect(invalid.paymentMode).toBe('transparent'); // defaults to transparent + expect(invalid.unknownFlags).toEqual(['--payment-mode (invalid_mode)']); }); - it('parses server details flag explicitly', () => { + it('parses flags intermixed with kv pairs', () => { const parsed = parseCallArgs(['weather', '--details']); expect(parsed.server).toBe('weather'); @@ -594,6 +609,10 @@ Or pass a direct server identity in hex, npub, or nprofile format.]`); listTools, callTool, }, + transport: { + getServerToolsListEvent: () => undefined, + getServerInitializeEvent: () => undefined, + }, metadata: {}, close, }) as never @@ -833,6 +852,45 @@ Or pass a direct server identity in hex, npub, or nprofile format.]`); resetCreateRemoteClientFactoryForTests(); }); + it('throws ExplicitGatingError for -32042 in explicit_gating mode', async () => { + const listTools = vi.fn().mockResolvedValue({ tools: [] }); + const mockError = new Error('Payment Required') as any; + mockError.code = PAYMENT_REQUIRED_ERROR_CODE; + mockError.data = { foo: 'bar' }; + const callTool = vi.fn().mockRejectedValue(mockError); + const close = vi.fn().mockResolvedValue(undefined); + + setCreateRemoteClientFactoryForTests( + vi.fn().mockResolvedValue({ + client: { listTools, callTool }, + metadata: {}, + close, + }) as never + ); + + let error: any; + try { + await call( + '750682303c9f0ddad75941b49edc9d46e3ed306b9ee3335338a21a3e404c5fa3', + 'read_media_file', + {}, + { + privateKey: 'nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj4xw9h', + paymentMode: 'explicit_gating', + } + ); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error.name).toBe('ExplicitGatingError'); + expect(error.data).toEqual({ foo: 'bar' }); + expect(close).toHaveBeenCalled(); + + resetCreateRemoteClientFactoryForTests(); + }); + it('prints extracted string values without JSON encoding', async () => { const listTools = vi.fn().mockResolvedValue({ tools: [ @@ -882,4 +940,23 @@ Or pass a direct server identity in hex, npub, or nprofile format.]`); resetCreateRemoteClientFactoryForTests(); }); + + describe('isPaymentRequiredError', () => { + it('returns true for an error with code -32042', () => { + const error = new Error('Payment Required') as any; + error.code = PAYMENT_REQUIRED_ERROR_CODE; + expect(__test__.isPaymentRequiredError(error)).toBe(true); + }); + + it('returns false for non-error objects', () => { + expect(__test__.isPaymentRequiredError({ code: PAYMENT_REQUIRED_ERROR_CODE })).toBe(false); + expect(__test__.isPaymentRequiredError(null)).toBe(false); + }); + + it('returns false for other error codes', () => { + const error = new Error('Other Error') as any; + error.code = -32601; + expect(__test__.isPaymentRequiredError(error)).toBe(false); + }); + }); }); diff --git a/src/call.ts b/src/call.ts index 31fc92fae..c9ea1297c 100644 --- a/src/call.ts +++ b/src/call.ts @@ -12,12 +12,32 @@ import { } from './config/index.ts'; import type { CvmiConfig, ServerTargetConfig } from './config/index.ts'; import { generatePrivateKey, normalizePrivateKey, normalizePublicKey } from './utils/crypto.ts'; -import { BOLD, CYAN, DIM, RESET, TEXT } from './constants/ui.ts'; +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 { + parseCapabilityPricing, + formatPrice, + PRICING_FOOTNOTE, + type CapabilityPricing, +} from './call/pricing.ts'; +import { withClientPayments } from '@contextvm/sdk/payments'; +import type { PaymentInteractionMode } from '@contextvm/sdk/payments'; +import { + paymentRequiredNotificationSchema, + renderPaymentRequired, +} from './payments/cli-payment-handler.ts'; +import { PAYMENT_REQUIRED_ERROR_CODE } from '@contextvm/sdk/payments/constants'; const HEX_PUBKEY_PATTERN = /^[0-9a-f]{64}$/i; +export class ExplicitGatingError extends Error { + constructor(public readonly data: any) { + super('Payment Required'); + this.name = 'ExplicitGatingError'; + } +} + function looksLikeDirectServerIdentity(input: string): boolean { return ( HEX_PUBKEY_PATTERN.test(input) || input.startsWith('npub1') || input.startsWith('nprofile1') @@ -37,6 +57,7 @@ export interface CallOptions { prettyRaw?: boolean; extract?: string; help?: boolean; + paymentMode?: PaymentInteractionMode; } export interface ParseCallResult { @@ -56,6 +77,7 @@ export interface ParseCallResult { showServerDetails: boolean; config: string | undefined; unknownFlags: string[]; + paymentMode: PaymentInteractionMode; } interface ResolvedServerTarget { @@ -118,6 +140,7 @@ export function parseCallArgs(args: string[]): ParseCallResult { showServerDetails: false, config: undefined, unknownFlags: [], + paymentMode: 'transparent', }; for (let i = 0; i < args.length; i++) { @@ -167,6 +190,13 @@ export function parseCallArgs(args: string[]): ParseCallResult { result.isStateless = false; } else if (arg === '--details') { result.showServerDetails = true; + } else if (arg === '--payment-mode') { + const value = consumeValue('--payment-mode'); + if (value === 'transparent' || value === 'explicit_gating') { + result.paymentMode = value; + } else { + result.unknownFlags.push(`--payment-mode${value ? ` (${value})` : ''}`); + } } else if (arg.startsWith('--')) { result.unknownFlags.push(arg); } else if (!result.server) { @@ -446,7 +476,7 @@ function resolveServerMetadataContext( return target.description ?? metadata?.about; } -function renderToolList(tools: Tool[]): void { +function renderToolList(tools: Tool[], pricing?: CapabilityPricing): void { if (tools.length === 0) { console.log(` ${DIM}(no tools exposed)${RESET}`); return; @@ -454,8 +484,9 @@ function renderToolList(tools: Tool[]): void { for (const tool of tools) { const signature = formatToolInputSignature(tool); + const price = pricing?.byTool.get(tool.name); console.log( - ` ${CYAN}•${RESET} ${tool.name}${signature ? ` ${DIM}${signature}${RESET}` : ''}${tool.description ? ` ${DIM}— ${tool.description}${RESET}` : ''}` + ` ${CYAN}•${RESET} ${tool.name}${signature ? ` ${DIM}${signature}${RESET}` : ''}${tool.description ? ` ${DIM}— ${tool.description}${RESET}` : ''}${price ? ` ${YELLOW}(${formatPrice(price)})${RESET}` : ''}` ); } } @@ -478,6 +509,7 @@ export const __test__ = { printServerHelp, printToolHelp, printAliasSummaries, + isPaymentRequiredError, }; type RemoteClientFactory = typeof createRemoteClient; @@ -511,11 +543,22 @@ async function createRemoteClient(target: ResolvedServerTarget, options: CallOpt logLevel: options.debug ? 'debug' : 'silent', }); + // PMI-agnostic: advertise no handlers, so the server sends + // `payment_required` for whatever rail it supports (CEP-8 no-client-PMI + // path). The invoice is rendered upstream via setNotificationHandler. + const paidTransport = withClientPayments(transport, { + paymentInteraction: options.paymentMode ?? 'transparent', + }); + const client = new Client({ name: 'cvmi', version: '0.1.0' }); - await client.connect(transport); + client.setNotificationHandler(paymentRequiredNotificationSchema, (notification) => + renderPaymentRequired(notification.params) + ); + await client.connect(paidTransport); return { client, + transport, metadata: { name: transport.getServerInitializeName(), about: transport.getServerInitializeAbout(), @@ -533,7 +576,8 @@ function printServerSummary( target: ResolvedServerTarget, tools: Tool[], metadata?: ServerMetadata, - options: Pick = {} + options: Pick = {}, + pricing?: CapabilityPricing ): void { const shouldShowDetails = options.showServerDetails === true; const primaryLabel = resolveServerMetadataLabel(target, metadata); @@ -546,6 +590,10 @@ function printServerSummary( printSummaryRow('About', primaryContext); } + if (pricing && pricing.pmis.length > 0) { + printSummaryRow('Payments', pricing.pmis.join(', ')); + } + if (shouldShowDetails) { if (target.aliasName || metadata?.name) { printSummaryRow('Identity', formatDisplayPubkey(target.server)); @@ -564,19 +612,23 @@ function printServerSummary( } console.log(); - renderToolList(tools); + renderToolList(tools, pricing); + if (pricing && pricing.byTool.size > 0) { + console.log(` ${DIM}${PRICING_FOOTNOTE}${RESET}`); + } } function printServerHelp( target: ResolvedServerTarget, tools: Tool[], metadata?: ServerMetadata, - options: Pick = {} + options: Pick = {}, + pricing?: CapabilityPricing ): void { printSection('Usage'); console.log(` cvmi call [key=value ...] [options]`); console.log(); - printServerSummary(target, tools, metadata, options); + printServerSummary(target, tools, metadata, options, pricing); console.log(); printSection('Invoke'); console.log( @@ -601,12 +653,20 @@ function printAliasSummaries(aliases: CompactAliasSummary[]): void { console.log(); } -function printToolHelp(target: ResolvedServerTarget, tool: Tool): void { +function printToolHelp( + target: ResolvedServerTarget, + tool: Tool, + pricing?: CapabilityPricing +): void { printSection('Usage'); console.log(` cvmi call ${target.input} ${tool.name} [key=value ...] [options]`); if (tool.description) { console.log(` ${tool.description}`); } + const price = pricing?.byTool.get(tool.name); + if (price) { + console.log(` ${YELLOW}${formatPrice(price)}${RESET} ${DIM}— ${PRICING_FOOTNOTE}${RESET}`); + } console.log(); printSection('Input'); console.log( @@ -697,7 +757,8 @@ function printMissingToolGuidance( capabilityArg: string, tools: Tool[], metadata?: ServerMetadata, - options: Pick = {} + options: Pick = {}, + pricing?: CapabilityPricing ): void { console.error( buildMissingToolError( @@ -707,7 +768,7 @@ function printMissingToolGuidance( ).message ); console.error(); - printServerHelp(target, tools, metadata, options); + printServerHelp(target, tools, metadata, options, pricing); } function isMissingToolInvocationError(error: unknown): boolean { @@ -718,12 +779,25 @@ function isMissingToolInvocationError(error: unknown): boolean { return /tool.+not found|unknown tool|method not found|-32601/i.test(error.message); } +function isPaymentRequiredError(error: unknown): boolean { + // CEP-8 Payment Required JSON-RPC error code + return ( + error instanceof Error && 'code' in error && (error as any).code === PAYMENT_REQUIRED_ERROR_CODE + ); +} + export async function call( serverArg: string | undefined, capabilityArg: string | undefined, input: Record, options: CallOptions ): 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 + // (PMI-agnostic now), so they're gone. An SDK `logLevel` option on + // withClientPayments would be the clean fix if other info logs surface. + const config = await loadConfig( { use: { @@ -754,7 +828,10 @@ export async function call( logVerbose(options.verbose, 'Discovering tools...'); const toolsResult = await remote.client.listTools(); const tools = toolsResult.tools; - printServerHelp(target, tools, remote.metadata, options); + const pricing = parseCapabilityPricing( + remote.transport.getServerToolsListEvent() ?? remote.transport.getServerInitializeEvent() + ); + printServerHelp(target, tools, remote.metadata, options, pricing); return; } @@ -762,6 +839,9 @@ export async function call( if (options.help) { logVerbose(options.verbose, 'Discovering tools...'); const toolsResult = await remote.client.listTools(); + const pricing = parseCapabilityPricing( + remote.transport.getServerToolsListEvent() ?? remote.transport.getServerInitializeEvent() + ); const tool = toolsResult.tools.find((entry) => entry.name === toolName); if (!tool) { printMissingToolGuidance( @@ -769,11 +849,12 @@ export async function call( capabilityArg, toolsResult.tools, remote.metadata, - options + options, + pricing ); process.exit(1); } - printToolHelp(target, tool); + printToolHelp(target, tool, pricing); return; } @@ -793,12 +874,25 @@ export async function call( ); } catch (error) { if (!isMissingToolInvocationError(error)) { + if (options.paymentMode === 'explicit_gating' && isPaymentRequiredError(error)) { + throw new ExplicitGatingError((error as any).data); + } throw error; } logVerbose(options.verbose, 'Discovering tools...'); const toolsResult = await remote.client.listTools(); - printMissingToolGuidance(target, capabilityArg, toolsResult.tools, remote.metadata, options); + const pricing = parseCapabilityPricing( + remote.transport.getServerToolsListEvent() ?? remote.transport.getServerInitializeEvent() + ); + printMissingToolGuidance( + target, + capabilityArg, + toolsResult.tools, + remote.metadata, + options, + pricing + ); process.exit(1); } @@ -845,6 +939,7 @@ ${BOLD}Options:${RESET} --private-key Your Nostr private key (hex/nsec format, overrides env, auto-generated if not provided) --relays Comma-separated relay URLs --encryption-mode Encryption mode: optional, required, disabled + --payment-mode Payment interaction mode: transparent (default), explicit_gating --stateless Enable stateless transport mode (default) --stateful Disable stateless transport mode --details Show resolved server identity and relay details during inspection @@ -866,6 +961,13 @@ ${BOLD}Aliases & config:${RESET} Use ${TEXT}cvmi config add ${RESET} to save an alias, and ${TEXT}cvmi config list${RESET} to inspect available aliases Use ${TEXT}cvmi call ${RESET} to inspect a server and ${TEXT}cvmi call ${RESET} to invoke a tool +${BOLD}Payments (CEP-8):${RESET} only relevant when the server gates the called tool behind payment. + --payment-mode transparent (default) | explicit_gating + transparent Renders the lightning invoice to stderr and waits for out-of-band + settlement (for humans in a terminal). + explicit_gating Prints the raw payment schema as JSON on stdout and exits with code 2 + (for agents: parse stdout, pay out-of-band, then retry). + ${BOLD}Examples:${RESET} ${DIM}$${RESET} cvmi call weather ${DIM}$${RESET} cvmi call weather get_current --help diff --git a/src/call/pricing.test.ts b/src/call/pricing.test.ts new file mode 100644 index 000000000..372c2f992 --- /dev/null +++ b/src/call/pricing.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { formatPrice, parseCapabilityPricing, type ToolPrice } from './pricing.ts'; + +const tag = (...t: unknown[]) => t; + +describe('parseCapabilityPricing', () => { + it('returns empty for undefined / malformed events', () => { + expect(parseCapabilityPricing(undefined)).toEqual({ pmis: [], byTool: new Map() }); + expect(parseCapabilityPricing({})).toEqual({ pmis: [], byTool: new Map() }); + expect(parseCapabilityPricing({ tags: 'nope' })).toEqual({ pmis: [], byTool: new Map() }); + }); + + it('collects pmi tags in order', () => { + const { pmis } = parseCapabilityPricing({ + tags: [tag('pmi', 'bitcoin-lightning-bolt11'), tag('pmi', 'other-rail')], + }); + expect(pmis).toEqual(['bitcoin-lightning-bolt11', 'other-rail']); + }); + + it('maps cap tool tags by name with the tool: prefix stripped', () => { + const { byTool } = parseCapabilityPricing({ + tags: [tag('cap', 'tool:relays/list', '2', 'sats')], + }); + expect(byTool.get('relays/list')).toEqual({ amount: 2, currencyUnit: 'sats' }); + }); + + it('parses range pricing into maxAmount', () => { + const { byTool } = parseCapabilityPricing({ + tags: [tag('cap', 'tool:relays/search', '3-5', 'sats')], + }); + expect(byTool.get('relays/search')).toEqual({ amount: 3, maxAmount: 5, currencyUnit: 'sats' }); + }); + + it('ignores non-tool cap identifiers (prompt:/resource:) and malformed caps', () => { + const { byTool } = parseCapabilityPricing({ + tags: [ + tag('cap', 'prompt:get', '1', 'sats'), + tag('cap', 'resource:uri', '1', 'sats'), + tag('cap', 'tool:bad', 'not-a-number', 'sats'), + tag('cap', 'tool:badrange', '1-x', 'sats'), + tag('cap', 'tool:incomplete'), + ], + }); + expect(byTool.size).toBe(0); + }); + + it('keeps the first cap for a duplicated tool name', () => { + const { byTool } = parseCapabilityPricing({ + tags: [tag('cap', 'tool:x', '2', 'sats'), tag('cap', 'tool:x', '9', 'sats')], + }); + expect(byTool.get('x')).toEqual({ amount: 2, currencyUnit: 'sats' }); + }); +}); + +describe('formatPrice', () => { + it('formats a fixed price', () => { + expect(formatPrice({ amount: 2, currencyUnit: 'sats' } as ToolPrice)).toBe('2 sats'); + }); + + it('formats a range price', () => { + expect(formatPrice({ amount: 2, maxAmount: 5, currencyUnit: 'sats' } as ToolPrice)).toBe( + '2-5 sats' + ); + }); +}); diff --git a/src/call/pricing.ts b/src/call/pricing.ts new file mode 100644 index 000000000..16e35d79c --- /dev/null +++ b/src/call/pricing.ts @@ -0,0 +1,88 @@ +/** + * CEP-8 payment discovery for the `call` command. + * + * Reads server-advertised payment metadata (`pmi` + `cap` tags) from a Nostr + * event so the tool list can show accepted payment methods and per-tool pricing. + * + * `cap` tags are a discovery/reference surface only — the server's `resolvePrice` + * sets the final invoice at call time. Callers should present this as advertised + * pricing, not a guaranteed charge. + */ + +/** Minimal structural view of a Nostr event — enough to read tags. */ +interface TaggedEvent { + tags?: unknown[]; +} + +export interface ToolPrice { + amount: number; + maxAmount?: number; + currencyUnit: string; +} + +export interface CapabilityPricing { + /** Server-advertised payment method identifiers (order preserved). */ + pmis: string[]; + /** Per-tool advertised price, keyed by tool name. */ + byTool: Map; +} + +const PMI_TAG = 'pmi'; +const CAP_TAG = 'cap'; +const TOOL_PREFIX = 'tool:'; + +/** Parse `pmi` + `cap` (tool) tags off a Nostr event. Tolerates undefined. */ +export function parseCapabilityPricing(event: unknown): CapabilityPricing { + const pmis: string[] = []; + const byTool = new Map(); + + const tags = (event as TaggedEvent | null | undefined)?.tags; + if (!Array.isArray(tags)) return { pmis, byTool }; + + for (const tag of tags) { + if (!Array.isArray(tag)) continue; + const [kind, ...rest] = tag as unknown[]; + if (kind === PMI_TAG) { + if (typeof rest[0] === 'string') pmis.push(rest[0]); + continue; + } + if (kind !== CAP_TAG) continue; + + // ['cap', 'tool:', '', ''] + const id = rest[0]; + const price = rest[1]; + const currencyUnit = rest[2]; + if (typeof id !== 'string' || !id.startsWith(TOOL_PREFIX)) continue; // ignore prompt:/resource: + const name = id.slice(TOOL_PREFIX.length); + if (typeof price !== 'string' || typeof currencyUnit !== 'string') continue; + if (byTool.has(name)) continue; // first wins; servers shouldn't dupe + + const dash = price.indexOf('-'); + let amount: number; + let maxAmount: number | undefined; + if (dash >= 0) { + const lo = Number(price.slice(0, dash)); + const hi = Number(price.slice(dash + 1)); + if (!Number.isFinite(lo) || !Number.isFinite(hi)) continue; + amount = lo; + maxAmount = hi; + } else { + const n = Number(price); + if (!Number.isFinite(n)) continue; + amount = n; + } + byTool.set(name, { amount, maxAmount, currencyUnit }); + } + + return { pmis, byTool }; +} + +/** Format a tool price: `2 sats` or `2-5 sats`. */ +export function formatPrice(price: ToolPrice): string { + const value = + price.maxAmount !== undefined ? `${price.amount}-${price.maxAmount}` : `${price.amount}`; + return `${value} ${price.currencyUnit}`; +} + +/** Footnote shown once where any advertised price is displayed. */ +export const PRICING_FOOTNOTE = 'Advertised price; final charge confirmed at call time.'; diff --git a/src/cli.ts b/src/cli.ts index bf7e54546..5879af0bd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,13 +15,14 @@ import { join, dirname, basename } from 'path'; import { homedir } from 'os'; import { fileURLToPath } from 'url'; import { ensureWebSocket, type EncryptionMode } from '@contextvm/sdk'; +import type { PaymentInteractionMode } from '@contextvm/sdk/payments'; import { runAdd, parseAddOptions, initTelemetry } from './add.ts'; import { runList } from './list.ts'; 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 } from './call.ts'; +import { call, parseCallArgs, showCallHelp, ExplicitGatingError } from './call.ts'; import { discover, parseDiscoverArgs, showDiscoverHelp } from './discover.ts'; import { runSync, parseSyncOptions } from './sync.ts'; import { runCn } from './cn/index.ts'; @@ -717,6 +718,7 @@ interface UseParseResult { encryption: EncryptionMode | undefined; config: string | undefined; persistPrivateKey: boolean; + paymentMode: PaymentInteractionMode; unknownFlags: string[]; } @@ -839,6 +841,7 @@ function parseUseArgs(args: string[]): UseParseResult { encryption: undefined, config: undefined, persistPrivateKey: false, + paymentMode: 'explicit_gating', unknownFlags: [], }; @@ -870,6 +873,13 @@ function parseUseArgs(args: string[]): UseParseResult { } else if (arg === '--encryption-mode') { const value = consumeValue('--encryption-mode'); result.encryption = parseEncryptionMode(value, 'CLI flag --encryption-mode'); + } else if (arg === '--payment-mode') { + const value = consumeValue('--payment-mode'); + if (value === 'transparent' || value === 'explicit_gating') { + result.paymentMode = value; + } else { + result.unknownFlags.push(`--payment-mode${value ? ` (${value})` : ''}`); + } } else if (arg === '--server-pubkey') { result.serverPubkey = consumeValue('--server-pubkey'); } else if (arg === '--config') { @@ -1011,6 +1021,7 @@ async function main(): Promise { encryption: parsed.encryption, config: parsed.config, persistPrivateKey: parsed.persistPrivateKey, + paymentMode: parsed.paymentMode, }); break; } @@ -1052,18 +1063,27 @@ async function main(): Promise { break; } - 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, - }); + 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, + }); + } catch (error) { + if (error instanceof ExplicitGatingError) { + console.log(JSON.stringify(error.data, null, 2)); + process.exit(2); + } + throw error; + } process.exit(0); break; } diff --git a/src/config/index.ts b/src/config/index.ts index 53b9f4e82..7f73b6096 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -19,6 +19,7 @@ export { export type { ServeConfig, ServeJsonConfig, + ServePaymentsConfig, UseConfig, UseJsonConfig, ServerTargetConfig, diff --git a/src/config/loader.ts b/src/config/loader.ts index 5030173d6..0a4a8cafd 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -256,6 +256,7 @@ export function getServeConfig( command: cliFlags.command ?? config.command, args: cliFlags.args ?? config.args, env: cliFlags.env ?? config.env, + payments: cliFlags.payments ?? config.payments, }; } diff --git a/src/config/types.ts b/src/config/types.ts index 601f88781..255e461ab 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -2,7 +2,19 @@ * Simplified configuration types for cvmi CLI. * Uses SDK types directly where possible. */ -import type { ServerInfo, EncryptionMode } from '@contextvm/sdk'; +import type { ServerInfo, EncryptionMode, ServerPaymentsOptions } from '@contextvm/sdk'; + +/** + * CEP-8 server-side payments for the serve gateway. Derives from the SDK's + * `ServerPaymentsOptions`, replacing the `processors` instances (not serializable) + * with the NWC connection string; cvmi bundles the NWC payment processor from it. + * All other SDK options (pricedCapabilities, paymentInteraction, resolvePrice, …) + * pass straight through. + */ +export type ServePaymentsConfig = Omit & { + /** NIP-47 `nostr+walletconnect://...` connection string. Override with CVMI_SERVE_PAYMENT_NWC. */ + nwc: string; +}; /** * Configuration for the serve command (gateway). @@ -29,6 +41,8 @@ export interface ServeConfig { env?: Record; /** Optional remote MCP server URL (Streamable HTTP). Mutually exclusive with command/args. */ url?: string; + /** Optional CEP-8 server-side payments. Omit for a free (non-gated) server. */ + payments?: ServePaymentsConfig; } /** diff --git a/src/payments/cli-payment-handler.test.ts b/src/payments/cli-payment-handler.test.ts new file mode 100644 index 000000000..775a76d33 --- /dev/null +++ b/src/payments/cli-payment-handler.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; +import { renderPaymentRequired } from './cli-payment-handler.ts'; + +describe('renderPaymentRequired', () => { + it('should render invoice to stderr', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + renderPaymentRequired({ + amount: 50, + description: 'Test payment', + pmi: 'test-pmi', + ttl: 300, + pay_req: 'lnbc1...', + }); + + expect(consoleErrorSpy).toHaveBeenCalled(); + const calls = consoleErrorSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(calls).toContain('Payment Required'); + expect(calls).toContain('Amount:'); + expect(calls).toContain('50'); + expect(calls).toContain('Description:'); + expect(calls).toContain('Test payment'); + expect(calls).toContain('Invoice:'); + expect(calls).toContain('lnbc1...'); + + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/src/payments/cli-payment-handler.ts b/src/payments/cli-payment-handler.ts new file mode 100644 index 000000000..08ccd52a3 --- /dev/null +++ b/src/payments/cli-payment-handler.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; +import type { PaymentRequiredNotification } from '@contextvm/sdk/payments'; +import { BOLD, CYAN, DIM, RESET, TEXT } from '../constants/ui.ts'; + +/** + * zod schema for the CEP-8 `notifications/payment_required` notification, used + * to register an MCP client notification handler. Kept here next to its only + * consumer (the renderer below). + */ +export const paymentRequiredNotificationSchema = z.object({ + method: z.literal('notifications/payment_required'), + params: z.object({ + amount: z.number(), + pay_req: z.string(), + pmi: z.string(), + description: z.string().optional(), + ttl: z.number().optional(), + }), +}); + +/** + * Render a CEP-8 transparent-mode payment request to stderr. + * + * cvmi is a PMI-agnostic, display-only client: it advertises no payment + * methods, so per CEP-8 the server sends `payment_required` for whatever rail + * it supports. This renders that invoice for a human to pay out-of-band; it + * never executes a payment. + */ +export function renderPaymentRequired(params: PaymentRequiredNotification['params']): void { + console.error(); + console.error(`${BOLD}⚡ Payment Required${RESET}`); + console.error(`${DIM}${'─'.repeat(50)}${RESET}`); + console.error(` ${CYAN}Amount:${RESET} ${params.amount}`); + if (params.description) { + console.error(` ${CYAN}Description:${RESET} ${params.description}`); + } + console.error(` ${CYAN}PMI:${RESET} ${params.pmi}`); + if (params.ttl) { + console.error(` ${CYAN}Expires in:${RESET} ${params.ttl}s`); + } + console.error(`${DIM}${'─'.repeat(50)}${RESET}`); + console.error(` ${CYAN}Invoice:${RESET}`); + console.error(` ${TEXT}${params.pay_req}${RESET}`); + console.error(`${DIM}${'─'.repeat(50)}${RESET}`); + console.error(); + console.error(`${DIM}Pay the invoice above. The CLI is waiting for confirmation...${RESET}`); +} diff --git a/src/serve.test.ts b/src/serve.test.ts new file mode 100644 index 000000000..beaff1789 --- /dev/null +++ b/src/serve.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { LnBolt11NwcPaymentProcessor } from '@contextvm/sdk'; +import { __test__ } from './serve.ts'; + +const { buildNwcPaymentOptions, resolvePaymentOptions } = __test__; + +// Valid NIP-47 shape: ?relay=...&secret=<64hex> +const NWC = `nostr+walletconnect://${'a'.repeat(64)}?relay=wss://relay.example.com&secret=${'b'.repeat(64)}`; + +describe('buildNwcPaymentOptions', () => { + it('bundles the NWC processor from the connection string', () => { + const opts = buildNwcPaymentOptions({ + nwc: NWC, + pricedCapabilities: [{ method: 'tools/call', name: 'echo', amount: 2, currencyUnit: 'sats' }], + }); + expect(opts.processors).toHaveLength(1); + expect(opts.processors[0]).toBeInstanceOf(LnBolt11NwcPaymentProcessor); + }); + + it('passes pricedCapabilities and paymentInteraction straight through', () => { + const priced = [{ method: 'tools/call', name: 'echo', amount: 5, currencyUnit: 'sats' }]; + const opts = buildNwcPaymentOptions({ + nwc: NWC, + pricedCapabilities: priced, + paymentInteraction: 'transparent', + }); + expect(opts.pricedCapabilities).toBe(priced); + expect(opts.paymentInteraction).toBe('transparent'); + }); +}); + +describe('resolvePaymentOptions', () => { + const priced = [{ method: 'tools/call', name: 'echo', amount: 2, currencyUnit: 'sats' }]; + + it('returns undefined when no payments are configured (free server)', () => { + expect(resolvePaymentOptions(undefined, undefined)).toBeUndefined(); + }); + + it('throws when payments are configured but nwc is missing or empty', () => { + expect(() => resolvePaymentOptions({ nwc: '', pricedCapabilities: priced }, undefined)).toThrow( + /serve\.payments\.nwc/ + ); + }); + + it('applies the CVMI_SERVE_PAYMENT_NWC env override over an empty config nwc', () => { + const opts = resolvePaymentOptions({ nwc: '', pricedCapabilities: priced }, NWC); + expect(opts?.processors).toHaveLength(1); + expect(opts?.processors[0]).toBeInstanceOf(LnBolt11NwcPaymentProcessor); + }); +}); diff --git a/src/serve.ts b/src/serve.ts index b3210943c..e88166dac 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -8,8 +8,15 @@ import { StdioClientTransport, } from '@modelcontextprotocol/sdk/client/stdio.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { NostrMCPGateway, PrivateKeySigner, EncryptionMode } from '@contextvm/sdk'; +import { + NostrMCPGateway, + PrivateKeySigner, + EncryptionMode, + LnBolt11NwcPaymentProcessor, +} from '@contextvm/sdk'; +import type { ServerPaymentsOptions } from '@contextvm/sdk'; import { loadConfig, getServeConfig, DEFAULT_RELAYS } from './config/index.ts'; +import type { ServePaymentsConfig } from './config/index.ts'; import { generatePrivateKey, normalizePrivateKey } from './utils/crypto.ts'; import { waitForShutdownSignal } from './utils/process.ts'; import { BOLD, DIM, RESET } from './constants/ui.ts'; @@ -17,6 +24,37 @@ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import { savePrivateKeyToEnv } from './config/loader.ts'; import { normalizeCommandAndArgs, splitCommandString } from './utils/command.ts'; +/** + * Build SDK `ServerPaymentsOptions` from cvmi's config: bundle the NWC payment + * processor from the connection string and pass all other SDK options + * (pricedCapabilities, paymentInteraction, …) straight through. + */ +function buildNwcPaymentOptions(payments: ServePaymentsConfig): ServerPaymentsOptions { + const { nwc, ...rest } = payments; + return { + ...rest, + processors: [new LnBolt11NwcPaymentProcessor({ nwcConnectionString: nwc })], + }; +} + +/** + * Resolve `serve.payments` into SDK `ServerPaymentsOptions`, or `undefined` for a + * free (non-gated) server. Applies the `CVMI_SERVE_PAYMENT_NWC` env override and + * guards a missing nwc connection string; only fires when payments are configured, + * so payment-free serves are unaffected. + */ +function resolvePaymentOptions( + payments: ServePaymentsConfig | undefined, + nwcEnv: string | undefined +): ServerPaymentsOptions | undefined { + if (!payments) return undefined; + const resolved = nwcEnv ? { ...payments, nwc: nwcEnv } : payments; + if (!resolved.nwc) { + throw new Error('serve.payments.nwc is required (or set CVMI_SERVE_PAYMENT_NWC).'); + } + return buildNwcPaymentOptions(resolved); +} + function isHttpUrl(value: string): boolean { try { const url = new URL(value); @@ -77,6 +115,8 @@ export const __test__ = { splitCommandString, normalizeCommandAndArgs, getDefaultEnvironment, + buildNwcPaymentOptions, + resolvePaymentOptions, }; /** CLI options for the serve command */ @@ -164,6 +204,11 @@ export async function serve(serverArgs: string[], options: ServeOptions): Promis p.log.message(`Relays: ${relays.join(', ')}`); p.log.message(`Public server: ${serveConfig.public ? 'yes' : 'no'}`); p.log.message(`Starting MCP target: ${target} ${targetArgs.join(' ')}`); + if (serveConfig.payments) { + p.log.message( + `Payments: enabled (${serveConfig.payments.pricedCapabilities.length} priced capability/ies)` + ); + } } const logLevel: 'debug' | 'info' = options.verbose ? 'debug' : 'info'; @@ -183,6 +228,13 @@ export async function serve(serverArgs: string[], options: ServeOptions): Promis // - Streamable HTTP targets: per-client MCP transports (HTTP transport caches mcp-session-id) let gateway: NostrMCPGateway; try { + // CEP-8 server payments. CVMI_SERVE_PAYMENT_NWC overrides the config nwc + // (wallet secret). Missing nwc throws here and surfaces via the catch below. + const paymentOptions = resolvePaymentOptions( + serveConfig.payments, + process.env.CVMI_SERVE_PAYMENT_NWC + ); + if (isHttpUrl(target)) { if (targetArgs.length > 0) { // In HTTP mode, extra args are ambiguous and almost certainly a user error. @@ -205,12 +257,14 @@ export async function serve(serverArgs: string[], options: ServeOptions): Promis createStreamableHttpMcpTransport(target), announcementMcpTransport: announcementTransport, nostrTransportOptions, + paymentOptions, }); } else { const normalized = normalizeCommandAndArgs(target, targetArgs); gateway = new NostrMCPGateway({ mcpClientTransport: createStdioMcpTransport(normalized.command, normalized.args, mcpEnv), nostrTransportOptions, + paymentOptions, }); } } catch (error) { @@ -275,6 +329,7 @@ ${BOLD}Configuration Sources (priority: CLI > custom config (--config) > project CVMI_SERVE_PUBLIC, CVMI_GATEWAY_PUBLIC CVMI_SERVE_ENCRYPTION, CVMI_GATEWAY_ENCRYPTION CVMI_SERVE_URL, CVMI_GATEWAY_URL + CVMI_SERVE_PAYMENT_NWC NIP-47 wallet connection for serve.payments (overrides config) ${BOLD}SDK Logging (set via environment, not config files):${RESET} LOG_LEVEL (debug|info|warn|error|silent) @@ -295,6 +350,25 @@ ${BOLD}SDK Logging (set via environment, not config files):${RESET} } } + Paid server (CEP-8): gate proxied tools behind Lightning payments via a + bundled NWC (NIP-47) payment processor. cvmi bundles the NWC processor from + the connection string; all other SDK options pass straight through. + { + "serve": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + "payments": { + "nwc": "nostr+walletconnect://...", + "paymentInteraction": "optional", + "pricedCapabilities": [ + { "method": "tools/call", "name": "read_file", "amount": 2, "currencyUnit": "sats" } + ] + } + } + } + Override nwc at runtime with CVMI_SERVE_PAYMENT_NWC (keeps the secret out of config). + paymentInteraction: optional (default, mirrors each client) | transparent. + .env file format (for private keys): CVMI_SERVE_PRIVATE_KEY=nsec1... diff --git a/src/use.ts b/src/use.ts index e6b359c74..ffcc8823d 100644 --- a/src/use.ts +++ b/src/use.ts @@ -5,6 +5,7 @@ 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 { loadConfig, getUseConfig, DEFAULT_RELAYS } from './config/index.ts'; import { generatePrivateKey, normalizePrivateKey } from './utils/crypto.ts'; import { waitForShutdownSignal } from './utils/process.ts'; @@ -19,6 +20,7 @@ export interface UseOptions { encryption?: EncryptionMode; verbose?: boolean; persistPrivateKey?: boolean; + paymentMode?: PaymentInteractionMode; } /** @@ -85,6 +87,9 @@ export async function use(serverPubkeyArg: string | undefined, options: UseOptio 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). const proxy = new NostrMCPProxy({ mcpHostTransport: mcpTransport, nostrTransportOptions: { @@ -94,6 +99,7 @@ export async function use(serverPubkeyArg: string | undefined, options: UseOptio encryptionMode: useConfig.encryption, logLevel: options.verbose ? 'debug' : 'info', }, + paymentOptions: { paymentInteraction: options.paymentMode ?? 'explicit_gating' }, }); // Start proxy @@ -126,6 +132,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,wss://cvm.otherstuff.ai) --encryption-mode Encryption mode: optional, required, disabled (default: optional) + --payment-mode Payment interaction: explicit_gating (default), transparent --verbose Enable verbose logging --help, -h Show this help message @@ -155,6 +162,13 @@ ${BOLD}SDK Logging (set via environment, not config files):${RESET} .env file format (for private keys): 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. + ${BOLD}Examples:${RESET} ${DIM}$${RESET} cvmi use npub1q... ${DIM}# connect to remote server by public key${RESET} ${DIM}$${RESET} cvmi use nprofile1q... ${DIM}# connect using an nprofile with relay hints${RESET}