`quartz` puts a NIP's implementation under `nipXX`, `marmot` puts a MIP's under `mipXX`, and both keep named packages only for what no spec number covers. ContextVM had neither: CEP-4 sat in `crypto/`, CEP-6/17/23/24/35 were four unrelated specs sharing one `ServerDiscovery.kt`, CEP-8 in `payment/`. "Which file implements CEP-17" was a grep rather than a path. Now it is `cep17RelayList/ServerRelay`. Four placements are judgment calls, recorded in the README so they can be argued with: - CEP-19 stays inside `cep04Encryption/CvmGiftWrap`. Picking the wrap kind (21059, falling back to 1059) and building the wrap are one negotiation in one class; its own package would separate a method from its only caller. - CEP-16 gets no package. It is a `_meta` key name plus the server-side obligation to inject it -- a constant and fixture behaviour, not a subsystem. - `transfer/ProgressEnvelope` stays cross-cutting: CEP-22 and CEP-41 share that framing, so it is the `marmot/foundation/` analogue. - `core/`, `jsonrpc/`, `transport/` and `mcp/` keep names because the core draft spec is not a CEP and has no number to carry. Pure moves and package/import rewrites; no behaviour changed. Also drops a redundant cast the compiler flagged in `CvmMcpClient`. Suite unchanged at 172 on jvm and 143 on the Android target. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
5.9 KiB
:contextvm
A Kotlin Multiplatform client for ContextVM — the Model Context Protocol (MCP) carried over Nostr.
This is a general MCP-over-Nostr implementation, not a client for any one
server. It exists because Amethyst needs to talk to
cordn coordinators (see
quartz/plans/2026-09-17-cordn-interop.md), but nothing in it is
cordn-specific.
Implemented specification revision
Built from the specification documents in
ContextVM/contextvm-docs at
commit e63bce6, read on 2026-09-17.
Most of these are Draft, including both transfer profiles, so the text can
still move. When bumping the revision, diff against the CVM-* rule ids in the
plan's §6.5 catalog — the test names carry them, so a spec change shows up as
named failures rather than as silent divergence.
| Spec | Status | Implemented in |
|---|---|---|
| Core draft spec | Draft | core/, jsonrpc/, transport/, mcp/ |
| CEP-4 Encryption | Final | cep04Encryption/CvmGiftWrap |
| CEP-6 Public Announcements | Final | cep06Announcements/ |
| CEP-8 Pricing and Payment | Draft | cep08Payments/ |
| CEP-15 Common Tool Schemas | Draft | cep15CommonSchemas/CommonToolSchema |
| CEP-16 Client Pubkey Injection | Final | mcp/McpMethods (the _meta key), fixture/ (server role) |
| CEP-17 Relay List Metadata | Draft | cep17RelayList/ServerRelay |
| CEP-19 Ephemeral Gift Wraps | Draft | cep04Encryption/CvmGiftWrap |
| CEP-21 PMI Recommendations | Draft | cep08Payments/PaymentSession |
| CEP-22 Oversized Transfer | Draft | cep22OversizedTransfer/ |
| CEP-23 Server Profile Metadata | Draft | cep06Announcements/DiscoverySurface (kind 0 via quartz) |
| CEP-24 Server Reviews | Draft | cep24Reviews/ServerReview |
| CEP-35 Stateless Discovery | Draft | cep35Discovery/SessionDiscovery |
| CEP-41 Open Streams | Draft | cep41OpenStreams/ |
Why the packages are named this way
Each CEP gets its own cepNNName/ package, the way quartz uses nipXX and
marmot uses mipXX: a spec number in the path is what makes "is this rule
implemented, and where" answerable without grep. Four placements are judgment
calls rather than mechanics:
- CEP-19 lives in
cep04Encryption/. Choosing the wrap kind (21059 with the 1059 fallback) and building the wrap are one negotiation insideCvmGiftWrap; a separate package would split a single method from its caller. - CEP-16 has no package. It is a
_metakey name plus the server-side obligation to inject it — a key constant and fixture behaviour, not a subsystem. transfer/ProgressEnvelopestays cross-cutting because CEP-22 and CEP-41 share that framing; it is themarmot/foundation/analogue.core/,jsonrpc/,transport/,mcp/keep names because the core draft spec is not a CEP and has no number to carry.
RFC 8785 (JCS), required by CEP-8 and CEP-15, lives in
quartz/…/utils/jcs/JsonCanonicalization.kt — it is a generic primitive, not a
ContextVM concern.
Licensing
Implemented clean-room from the specification. The reference
ContextVM/sdk is LGPL-3.0; Amethyst
ships under MIT, so translating that source would carry copyleft terms into
Quartz. Do not read it while working here — the plan's §6 was written so you do
not have to.
The spec repository carries no LICENSE file. Implementing a published protocol is fine; do not paste spec prose into this repo.
Three things that are easy to get wrong
-
Kind 25910 is ephemeral, so relays do not store it. A subscription opened after the peer published has missed the response permanently. This is why
CvmTransportexposesrequest()and no public publish/subscribe pair: the ordering is the transport's job, not the caller's.InMemoryRelayPooldrops an event nobody is listening for, so the property is tested rather than assumed. -
CEP-41 has two independent ordering fields.
progressorders every frame, control frames included, and is explicitly not a chunk counter;chunkIndex(contiguous from 0) is what validates completeness. Progress sequences are also per-sender, so apong's progress bears no relation to thepingit answers — they match by nonce alone. -
closedoes not complete the request. After a CEP-41 stream closes, the originating JSON-RPC request still needs its own response, and a client must never synthesize success fromclose.ToolCallResultcarriesstreamedfragments and theresultseparately for exactly this reason.
Testing
./gradlew :contextvm:jvmTest # 172 tests
./gradlew :contextvm:testAndroidHostTest # 143 tests
The Android run is smaller because the tests needing real secp256k1 and NIP-44
— the gift wrap round trip, the transport and the MCP client — live in
src/jvmTest where the JVM JNI artifact is on the classpath. Everything
protocol-level is in commonTest and runs on both.
The suite is Tier A and Tier C from the plan's §6.4: rule-derived unit
tests, plus adversarial tests against fixture/CvmFixtureServer, which plays
the server role and can be told to violate any rule on demand (FixtureFaults).
Most tests are negative, because the CEPs are written as failure conditions.
Still open:
- Tier B — live integration against
ghcr.io/cordn-msg/cordn:latest. - Tier D — cross-implementation vector exchange for CEP-4 wraps and CEP-22/41 framing. Worth offering upstream; no official vectors exist.
- Tier E — a real Lightning wallet behind CEP-8 (NIP-47 NWC is already in Quartz).
Not in scope
- A production server.
fixture/plays the server role for tests only and is deliberately not hardened; for a real coordinator,cordn-rsexists. - Full MCP. Lifecycle, tool listing and tool calling are implemented because that is what the CEPs define; sampling, roots and elicitation are not.