Skip to content

feat: add CEP-8 explicit payment gating to cvmi call#5

Open
abhayguptas wants to merge 10 commits into
ContextVM:mainfrom
abhayguptas:feature/cep8-payment-gating
Open

feat: add CEP-8 explicit payment gating to cvmi call#5
abhayguptas wants to merge 10 commits into
ContextVM:mainfrom
abhayguptas:feature/cep8-payment-gating

Conversation

@abhayguptas

Copy link
Copy Markdown

Add CEP-8 Payment Lifecycle Support to cvmi call

Description

This PR brings full CEP-8 payment handling to the cvmi call command, bringing it up to parity with the contextvm-site client. Previously, calling a paid MCP endpoint via the CLI would fail or hang because the CLI had no awareness of notifications/payment_required.

This update upgrades @contextvm/sdk to 0.13.0 and utilizes its native withClientPayments middleware to handle all the heavy lifting (PMI capability advertisement, NIP-47 heartbeats, and waiting for server settlement).

Changes

  • SDK Upgrade: Bumped @contextvm/sdk to 0.13.0
  • Transport Wrapper: Wrapped NostrClientTransport in createRemoteClient with withClientPayments middleware.
  • CLI Payment Handler: Added a new CliPaymentHandler (mirroring UiOnlyPaymentHandler in contextvm-site). Instead of auto-paying, this handler simply prints the parsed lightning invoice to the terminal using standard UI tokens (BOLD, CYAN, DIM), and waits for human payment out-of-band while the SDK background loop awaits payment_accepted.
  • Payment Modes: Introduced the --payment-mode flag.
    • transparent (Default): Pauses execution, prints the invoice to the terminal, and waits for settlement.
    • explicit_gating: Designed for AI agents. If the server throws a -32042 Payment Required error, the CLI immediately outputs the raw payment schema as structured JSON to stdout and throws an exit code 2.

Usage Example

Human Usage (Transparent):

$ cvmi call <paid-server> <tool> --payment-mode transparent
# Output:
# ⚡ Payment Required
# ──────────────────────────────────────────────────
#   Amount:      50
#   Description: Paid tool execution
#   PMI:         bitcoin-lightning-bolt11
#   Expires in:  300s
# ──────────────────────────────────────────────────
#   Invoice:
#   lnbc...
# ──────────────────────────────────────────────────
#
# Pay the invoice above. The CLI is waiting for confirmation...

Agentic Usage (Explicit Gating):

$ cvmi call <paid-server> <tool> --payment-mode explicit_gating
# Output:
# { "instructions": "...", "payment_options": [ ... ] }
# Exit code: 2

@ContextVM-org

Copy link
Copy Markdown

Solid design — the wrapper + CliPaymentHandler + two interaction modes mirrors contextvm-site well, and the stderr/stdout split is right. Requesting changes though; details below.

🔴 1. Pins sdk@0.13.0, which breaks every stateless cvmi call

I ran the branch and cvmi call <server hangs on every server (free or paid) with MCP error -32001: Request timed out. Published 0.3.1 is fine on the same server.

Cause is an SDK bug this PR is first to hit. cvmi defaults to isStateless: true, so initialize never goes on the wire — NostrClientTransport.send() synthesizes it via emulateInitializeResponse(), which in 0.13.0 delivers the response through onmessage only. But withClientPayments forwards exclusively from the onmessageWithContext path (it drops the onmessage duplicate), so the emulated initialize is swallowed → handshake never completes → timeout. Stateful callers don't hit it (real responses go through handleResponse, which fires both paths).

This reproduces on the very first pnpm dev call <anything — i.e. the branch wasn't smoke-tested against a server before opening. Please run cvmi call <server on every PR here before requesting review.

We fixed the SDK and shipped 0.13.2 (emulateInitializeResponse now also calls onmessageWithContext). I bumped locally and confirmed both tools/list and a full paid callTool work end-to-end. Bump @contextvm/sdk to ^0.13.2 — without that floor, main ships a broken CLI.

🔴 2. tsc is red — unused verbose field

CliPaymentHandler stores this.verbose but never reads it → pnpm type-check fails TS6133, CI fails. Either wire it up (one verbose line in handle()) or drop the field, the constructor arg, and the verbose pass-through in createRemoteClient.

🟡 3. SDK payment logs leak into the terminal

From a real paid call, the invoice gets bracketed by raw SDK JSON:

{"level":30,...,"module":"client-payments",...,"msg":"processing payment_required"}
⚡ Payment Required
...
Pay the invoice above. The CLI is waiting for confirmation...
{"level":30,...,"msg":"payment handler completed successfully"}

These are pino info lines from the SDK's own logger. They go to stderr (so --raw/--extract stdout stays clean), but they defeat the whole point of the carefully-rendered invoice in transparent mode. The disconnect: cvmi sets logLevel: 'silent' on the transport, but withClientPayments creates its own logger with no config and ignores that knob. Fix is a one-liner in cvmi — default the process to LOG_ENABLED=false (or LOG_LEVEL=warn) unless --debug, same gating you already use for the transport.

🟡 4. No tests

New flag (two modes), new handler, an exit-code contract, and error classification — zero coverage. The __test__ export already supports internals; please add at least: --payment-mode parsing (incl. garbage → unknownFlags), CliPaymentHandler.handle renders the invoice to stderr, explicit_gating + a -32042 McpError prints error.data as JSON and exits 2, and isPaymentRequiredError accept/reject cases.

🟢 5. process.exit(2) skips the finally { remote.close() }

It's inside the try, so the Nostr transport/relay connections leak on gated exits. Minor for a dying CLI, but cleaner to throw a typed error and set exit code at the CLI boundary — also makes call() testable.

Verdict: request changes. Required for merge: bump SDK to ^0.13.2, fix the tsc failure, silence the SDK logger by default, add tests. Core approach is good once these are sanded down.

abhayguptas and others added 3 commits June 29, 2026 20:47
0.13.4 makes withClientPayments `handlers` optional, enabling a
PMI-agnostic client that advertises no PMIs and lets the server send
payment_required for whatever rail it supports. Verified end-to-end
against the live paid server.
Drop the bolt11-locked CliPaymentHandler and advertise no PMIs, so the
server sends notifications/payment_required for whatever rail it supports
(CEP-8 no-client-PMI path). Render the invoice via an upstream
client.setNotificationHandler instead of a PaymentHandler.handle() that
only fired on an exact-PMI match.

This fixes a silent timeout against any non-bolt11 server: previously the
exact-PMI lookup missed, the notification was forwarded but never
rendered, and the request hung until -32001.

Side benefit: the two client-payments info logs that bracketed the invoice
("processing payment_required" / "payment handler completed successfully")
only fired on the handler-execution path we no longer take, so they no
longer appear. The no-op process.env.LOG_ENABLED assignment (import-time
singleton — never worked) is removed.

- Remove PMI_BITCOIN_LIGHTNING_BOLT11 + CliPaymentHandler class
- withClientPayments(transport, {}) + setNotificationHandler renderer
- cli.ts: narrow via instanceof ExplicitGatingError
- cli-payment-handler.test.ts: test renderPaymentRequired directly
@gzuuus gzuuus force-pushed the feature/cep8-payment-gating branch from 2b7541f to 034448a Compare June 30, 2026 13:04
@ContextVM-org

Copy link
Copy Markdown

Pushed two commits on top of c44a7d8 (f1af671, 034448a) to address the review directly rather than just leaving comments. Summary of what + why.

f1af671 — bump @contextvm/sdk to ^0.13.4

0.13.4 makes withClientPayments's handlers option optional — the enabler for the refactor below. A client can now advertise no PMIs at all.

034448a — make transparent-mode payments PMI-agnostic (the main change)

Previously cvmi registered a CliPaymentHandler locked to bitcoin-lightning-bolt11. That had a real bug, and 0.13.4 lets us fix it properly.

The bug. handle() only fires on an exact-PMI match. Against a server whose only rail isn't bolt11 (e.g. cashu), the server sends payment_required for cashu, the lookup misses, nothing renders, and the request hangs until -32001 — a silent timeout with no hint that payment was the cause.

The fix. Drop the handler + the PMI_BITCOIN_LIGHTNING_BOLT11 constant; pass withClientPayments(transport, {}) (advertises no PMIs); per CEP-8 the server then sends payment_required for whatever rail it has, and we render it upstream via client.setNotificationHandler(...). Same invoice UI, now agnostic to the server's rail. The render logic moved from CliPaymentHandler.handle() to a plain renderPaymentRequired() fed by the notification; cli-payment-handler.test.ts tests that function directly.

Bonus — the leaked SDK logs are gone. The two client-payments pino lines that bracketed the invoice (processing payment_required / payment handler completed successfully) only fired on the handler-execution path we no longer take, so they no longer appear. I also removed the process.env.LOG_ENABLED = 'false' line — it was a no-op because the SDK logger is an import-time singleton a runtime assignment can't reach. Left a comment in call.ts explaining both points.

cli.tsinstanceof narrowing

Swapped error.name === 'ExplicitGatingError' for error instanceof ExplicitGatingError (imported it); drops the (error as any).data cast.

@abhayguptas

abhayguptas commented Jun 30, 2026

Copy link
Copy Markdown
Author

Thanks for jumping in with those commits! The PMI-agnostic approach using setNotificationHandler is significantly cleaner than locking into Bolt11, and relying on the CEP-8 no-client-PMI fallback makes total sense to avoid those silent timeouts on other rails like Cashu.

Also, good catch on the LOG_ENABLED import-time singleton issue, it's great that bypassing the handler path organically killed the log pollution anyway. I've pulled the commits locally, audited the changes, and everything (including the tests) is perfectly green on my end. This looks ready to merge whenever you are!

Update @contextvm/sdk to version 0.13.6 and nostr-tools to version 2.23.9, with corresponding lockfile updates.
Display per-tool prices and payment method indicators when server provides pricing data. This helps users evaluate costs before invoking tools.
@abhayguptas

abhayguptas commented Jul 3, 2026

Copy link
Copy Markdown
Author

hi, @ContextVM-org! Pulled and reviewed the four commits. Pricing module is clean and the use defaulting to explicit_gating makes sense for agent-facing flows. A few things I noticed:

Negative price range parsing in pricing.ts

price.indexOf('-') on line 60 uses dash >= 0, so a value like "-5" hits the range branch. price.slice(0, 0) produces "", Number("") is 0, and you get { amount: 0, maxAmount: 5 } instead of rejecting or treating it as a fixed negative price. Changing to dash > 0 would fix this since a leading dash can't be a range separator.

Repeated parseCapabilityPricing pattern in call()

The same block appears three times:

const pricing = parseCapabilityPricing(
  remote.transport.getServerToolsListEvent() ?? remote.transport.getServerInitializeEvent()
);

Could be a small helper like getPricing(remote) to keep things DRY.

renderToolList line 489 readability

The template literal now chains name + signature + description + price in one expression and it is getting hard to scan. Breaking it into parts would help:

const parts = [`  ${CYAN}${RESET} ${tool.name}`];
if (signature) parts.push(` ${DIM}${signature}${RESET}`);
if (tool.description) parts.push(` ${DIM}${tool.description}${RESET}`);
if (price) parts.push(` ${YELLOW}(${formatPrice(price)})${RESET}`);
console.log(parts.join(''));

Everything else looks solid. Tests pass locally (189/189).

- Upgrade @contextvm/sdk dependency to ^0.13.7
- Add `ServePaymentsConfig` type for CEP-8 server-side payment configuration
- Extend `ServeConfig` with optional `payments` field
- Include payments config in `getServeConfig` loader
- Document `--payment-mode` flag in call command help text
- Add `.mcp.json` to `.gitignore` for local MCP client configs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants