diff options
Diffstat (limited to 'src/contextvm.js')
| -rw-r--r-- | src/contextvm.js | 87 |
1 files changed, 87 insertions, 0 deletions
diff --git a/src/contextvm.js b/src/contextvm.js new file mode 100644 index 0000000..7af55a9 --- /dev/null +++ b/src/contextvm.js @@ -0,0 +1,87 @@ +// ContextVM — MCP JSON-RPC over nostr, the transport Cordn coordinators speak. +// https://github.com/ContextVM/contextvm-docs +// +// A call is one kind-25910 event: the JSON-RPC request as content, a `p` tag +// naming the server. It travels sealed in an ephemeral gift wrap (kind 21059, +// a throwaway key, NIP-44 to the server), the only form the public Cordn +// coordinator answered when probed (2026-10-06); plain 25910 requests from +// us went unanswered. The reply comes back the same way, addressed to our +// pubkey, its inner event signed by the server and `e`-tagged to the request. +// Nothing here is Cordn-specific: any ContextVM tool server works. + +import { subscribeOn, publishOn, nip44, finalizeEvent, generateSecretKey } from './nostr.js'; +import { verifyEvent } from 'nostr-tools/pure'; + +export const KIND = { MESSAGE: 25910, WRAP: 21059 }; +const now = () => Math.floor(Date.now() / 1000); + +// { server, relays, pubkey, sign, decrypt, subClosed } +// sign(unsigned) -> signed event (the caller's key: the server reads the +// caller's identity off the inner event's signature) +// decrypt(peerPubkey, ciphertext) -> plaintext (NIP-44, our key) +export function cvmClient({ server, relays, pubkey, sign, decrypt, subClosed }) { + const waiting = new Map(); // inner request event id -> { id, resolve } + let unsub = null, stopped = false, seq = 0; + + function listen() { + if (stopped) return; + if (unsub) { try { unsub(); } catch {} } + unsub = subscribeOn(relays, { kinds: [KIND.MESSAGE, KIND.WRAP], '#p': [pubkey], since: now() - 60 }, onEvent, + subClosed ? { onclose: () => { if (!stopped) subClosed(); } } : {}); + } + + async function onEvent(ev) { + let inner = ev; + if (ev.kind === KIND.WRAP) { + try { inner = JSON.parse(await decrypt(ev.pubkey, ev.content)); } catch { return; } + if (!inner || typeof inner !== 'object' || !verifyEvent(inner)) return; + } + if (inner.pubkey !== server) return; + let msg; + try { msg = JSON.parse(inner.content); } catch { return; } + if (!msg || msg.method) return; // a notification (stream frames): not used yet + // matched by the request event it answers, never by JSON-RPC id: another + // client of the same pubkey (a second tab) numbers its requests the same way + const re = (inner.tags.find((t) => t[0] === 'e') || [])[1]; + const w = re && waiting.get(re); + if (w) w.resolve(msg); + } + + // One tools/call. Resolves to the tool's structured result; throws on a + // JSON-RPC error, a tool error, or silence. + async function call(name, args = {}, { timeoutMs = 20_000 } = {}) { + if (stopped) throw new Error('transport closed'); + if (!unsub) listen(); + const id = (++seq) + '-' + Math.random().toString(36).slice(2, 8); + const inner = await sign({ + pubkey, kind: KIND.MESSAGE, created_at: now(), tags: [['p', server]], + content: JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } }), + }); + const wsk = generateSecretKey(); + const wrap = finalizeEvent({ + kind: KIND.WRAP, created_at: now(), tags: [['p', server]], + content: nip44.encrypt(JSON.stringify(inner), nip44.getConversationKey(wsk, server)), + }, wsk); + const res = await new Promise((resolve, reject) => { + const t = setTimeout(() => { waiting.delete(inner.id); reject(new Error('coordinator did not answer: ' + name)); }, timeoutMs); + waiting.set(inner.id, { id, resolve: (m) => { clearTimeout(t); waiting.delete(inner.id); resolve(m); } }); + publishOn(relays, wrap).then((ok) => { + if (!ok) { clearTimeout(t); waiting.delete(inner.id); reject(new Error('no relay took the request')); } + }); + }); + if (res.error) { const e = new Error(res.error.message || 'coordinator error'); e.code = res.error.code; throw e; } + const r = res.result || {}; + if (r.isError) throw new Error((r.content || []).map((c) => c.text).filter(Boolean).join(' ') || 'tool error'); + return r.structuredContent || r; + } + + function stop() { + stopped = true; + if (unsub) { try { unsub(); } catch {} } + unsub = null; + for (const w of waiting.values()) w.resolve({ error: { message: 'transport closed' } }); + waiting.clear(); + } + + return { server, relays, call, listen, stop }; +} |
