/** * 1sat mcp-proxy — stdio-to-HTTP bridge for the wallet-desktop MCP server. * * Performs BRC-31 handshake, then proxies JSON-RPC messages from stdin * to the MCP server at :3322 with signed auth headers on each request. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { Hash, KeyDeriver, PrivateKey, Signature, Utils } from '@bsv/sdk' import type { WalletProtocol } from '@bsv/sdk' import { createLogger, initLogger } from 'evlog' const { toBase64, toArray } = Utils initLogger({ env: { service: '1sat-mcp-proxy' } }) const MCP_URL = process.env.ONESAT_MCP_URL ?? 'http://127.0.0.1:3322' const KEY_DIR = `${process.env.HOME}/.1sat-wallet` const CLIENT_KEY_PATH = `${KEY_DIR}/mcp-agent.key` const AUTH_PROTOCOL_ID: WalletProtocol = [2, 'authrite message signature'] function generateNonce(): string { const bytes = new Uint8Array(32) crypto.getRandomValues(bytes) return toBase64(Array.from(bytes)) } function getClientKey(): PrivateKey { mkdirSync(KEY_DIR, { recursive: true }) if (existsSync(CLIENT_KEY_PATH)) { return PrivateKey.fromWif(readFileSync(CLIENT_KEY_PATH, 'utf-8').trim()) } const key = PrivateKey.fromRandom() writeFileSync(CLIENT_KEY_PATH, key.toWif(), { mode: 0o600 }) const log = createLogger({ context: 'startup' }) log.set({ event: 'key_generated', path: CLIENT_KEY_PATH }) log.emit() return key } interface Session { serverIdentityKey: string serverNonce: string clientKey: PrivateKey } async function handshake(key: PrivateKey): Promise { const pubkey = key.toPublicKey().toString() const clientNonce = generateNonce() const res = await fetch(`${MCP_URL}/.well-known/auth`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ authrite: '0.1', messageType: 'initialRequest', identityKey: pubkey, nonce: clientNonce, }), }) if (!res.ok) throw new Error(`Handshake failed: HTTP ${res.status}`) const data = await res.json() if (data.messageType !== 'initialResponse') throw new Error(`Unexpected: ${data.messageType}`) const serverNonce = data.nonce as string const deriver = new KeyDeriver(key) const serverPub = deriver.derivePublicKey( AUTH_PROTOCOL_ID, `${serverNonce} ${clientNonce}`, data.identityKey as string, false, ) const clientNonceBytes = toArray(clientNonce, 'base64') const serverNonceBytes = toArray(serverNonce, 'base64') const msgHash = Hash.sha256( Array.from(new Uint8Array([...clientNonceBytes, ...serverNonceBytes])), ) const sig = Signature.fromDER(data.signature as string, 'hex') if (!serverPub.verify(Array.from(msgHash), sig)) throw new Error('Server signature verification failed') const log = createLogger({ context: 'auth' }) log.set({ event: 'handshake_complete', serverIdentityKey: (data.identityKey as string).slice(0, 16), }) log.emit() return { serverIdentityKey: data.identityKey as string, serverNonce, clientKey: key, } } function signHeaders( session: Session, pathname: string, ): Record { const { serverIdentityKey, serverNonce, clientKey: key } = session const nonce = generateNonce() const deriver = new KeyDeriver(key) const derivedKey = deriver.derivePrivateKey( AUTH_PROTOCOL_ID, `${nonce} ${serverNonce}`, serverIdentityKey, ) const payload = new TextEncoder().encode(pathname) const msgHash = Hash.sha256(Array.from(payload)) const sig = derivedKey.sign(Array.from(msgHash)) return { 'x-bsv-auth-version': '0.1', 'x-bsv-auth-identity-key': key.toPublicKey().toString(), 'x-bsv-auth-nonce': nonce, 'x-bsv-auth-your-nonce': serverNonce, 'x-bsv-auth-signature': sig.toDER('hex') as string, } } export async function handleMcpProxyCommand(): Promise { const startLog = createLogger({ context: 'startup' }) try { await fetch(MCP_URL, { signal: AbortSignal.timeout(2000) }) } catch { startLog.set({ event: 'server_unreachable', url: MCP_URL }) startLog.emit() process.stderr.write( `[1sat mcp-proxy] Server not reachable at ${MCP_URL} — is 1Sat wallet running?\n`, ) process.exit(1) } startLog.set({ event: 'started', url: MCP_URL }) startLog.emit() const key = getClientKey() const session = await handshake(key) let mcpSessionId: string | null = null let requestCount = 0 const decoder = new TextDecoder() const reader = Bun.stdin.stream().getReader() let buffer = '' while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) let newlineIdx: number while ((newlineIdx = buffer.indexOf('\n')) !== -1) { const line = buffer.slice(0, newlineIdx).trim() buffer = buffer.slice(newlineIdx + 1) if (!line) continue requestCount++ const reqLog = createLogger({ context: 'proxy' }) const headers: Record = { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', ...signHeaders(session, '/mcp'), } if (mcpSessionId) { headers['mcp-session-id'] = mcpSessionId } try { let method: string | undefined try { method = JSON.parse(line).method } catch {} const res = await fetch(`${MCP_URL}/mcp`, { method: 'POST', headers, body: line, }) const sessionHeader = res.headers.get('mcp-session-id') if (sessionHeader) mcpSessionId = sessionHeader const contentType = res.headers.get('content-type') ?? '' reqLog.set({ event: 'request', requestNum: requestCount, method, status: res.status, contentType: contentType.split(';')[0], sessionId: mcpSessionId?.slice(0, 8), }) reqLog.emit() if (contentType.includes('text/event-stream')) { // Stream SSE events incrementally — res.text() would hang // because SSE connections stay open indefinitely const sseReader = res.body!.getReader() const sseDecoder = new TextDecoder() let sseBuf = '' while (true) { const { done: sseDone, value: sseValue } = await sseReader.read() if (sseDone) break sseBuf += sseDecoder.decode(sseValue, { stream: true }) const sseLines = sseBuf.split('\n') sseBuf = sseLines.pop()! for (const sseLine of sseLines) { if (sseLine.startsWith('data: ')) { const data = sseLine.slice(6).trim() if (data) process.stdout.write(`${data}\n`) } } } if (sseBuf.startsWith('data: ')) { const data = sseBuf.slice(6).trim() if (data) process.stdout.write(`${data}\n`) } } else { const body = await res.text() if (body.trim()) process.stdout.write(`${body.trim()}\n`) } } catch (err) { const msg = err instanceof Error ? err.message : String(err) reqLog.set({ event: 'request_failed', requestNum: requestCount, error: msg, }) reqLog.emit() process.stdout.write( `${JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: `MCP proxy error: ${msg}` }, id: null, })}\n`, ) } } } }