@1sat/cli 0.0.12 → 0.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/commands/mcp-proxy.ts +118 -33
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1sat/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.14",
|
|
4
4
|
"description": "CLI for 1Sat Ordinals SDK",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/cli.ts",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"chalk": "^5.0.0",
|
|
25
25
|
"@clack/prompts": "^0.8.0",
|
|
26
26
|
"bitcoin-backup": "^0.0.11",
|
|
27
|
-
"dotenv": "^17.0.0"
|
|
27
|
+
"dotenv": "^17.0.0",
|
|
28
|
+
"evlog": "^2.10.0"
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
31
|
"@types/bun": "^1.3.9",
|
|
@@ -1,54 +1,126 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 1sat mcp-proxy — stdio-to-HTTP bridge for the wallet-desktop MCP server.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* at :3322, writing responses to stdout.
|
|
7
|
-
*
|
|
8
|
-
* Used by Claude Code's .mcp.json to connect agents to the running
|
|
9
|
-
* 1Sat desktop wallet.
|
|
4
|
+
* Performs BRC-31 handshake, then proxies JSON-RPC messages from stdin
|
|
5
|
+
* to the MCP server at :3322 with signed auth headers on each request.
|
|
10
6
|
*/
|
|
11
7
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
12
|
-
import {
|
|
8
|
+
import { Hash, KeyDeriver, PrivateKey, Signature, Utils } from '@bsv/sdk'
|
|
9
|
+
import type { WalletProtocol } from '@bsv/sdk'
|
|
10
|
+
import { createLogger, initLogger } from 'evlog'
|
|
11
|
+
|
|
12
|
+
const { toBase64, toArray } = Utils
|
|
13
|
+
|
|
14
|
+
initLogger({ env: { service: '1sat-mcp-proxy' } })
|
|
13
15
|
|
|
14
16
|
const MCP_URL = process.env.ONESAT_MCP_URL ?? 'http://127.0.0.1:3322'
|
|
15
17
|
const KEY_DIR = `${process.env.HOME}/.1sat-wallet`
|
|
16
18
|
const CLIENT_KEY_PATH = `${KEY_DIR}/mcp-agent.key`
|
|
19
|
+
const AUTH_PROTOCOL_ID: WalletProtocol = [2, 'authrite message signature']
|
|
17
20
|
|
|
18
|
-
function
|
|
19
|
-
|
|
21
|
+
function generateNonce(): string {
|
|
22
|
+
const bytes = new Uint8Array(32)
|
|
23
|
+
crypto.getRandomValues(bytes)
|
|
24
|
+
return toBase64(Array.from(bytes))
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
function getClientKey(): PrivateKey {
|
|
23
28
|
mkdirSync(KEY_DIR, { recursive: true })
|
|
24
|
-
|
|
25
29
|
if (existsSync(CLIENT_KEY_PATH)) {
|
|
26
30
|
return PrivateKey.fromWif(readFileSync(CLIENT_KEY_PATH, 'utf-8').trim())
|
|
27
31
|
}
|
|
28
|
-
|
|
29
32
|
const key = PrivateKey.fromRandom()
|
|
30
33
|
writeFileSync(CLIENT_KEY_PATH, key.toWif(), { mode: 0o600 })
|
|
31
|
-
log
|
|
34
|
+
const log = createLogger({ context: 'startup' })
|
|
35
|
+
log.set({ event: 'key_generated', path: CLIENT_KEY_PATH })
|
|
36
|
+
log.emit()
|
|
32
37
|
return key
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
interface Session {
|
|
41
|
+
serverIdentityKey: string
|
|
42
|
+
serverNonce: string
|
|
43
|
+
clientKey: PrivateKey
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function handshake(key: PrivateKey): Promise<Session> {
|
|
47
|
+
const pubkey = key.toPublicKey().toString()
|
|
48
|
+
const clientNonce = generateNonce()
|
|
49
|
+
|
|
50
|
+
const res = await fetch(`${MCP_URL}/.well-known/auth`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'Content-Type': 'application/json' },
|
|
53
|
+
body: JSON.stringify({
|
|
54
|
+
authrite: '0.1',
|
|
55
|
+
messageType: 'initialRequest',
|
|
56
|
+
identityKey: pubkey,
|
|
57
|
+
nonce: clientNonce,
|
|
58
|
+
}),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
if (!res.ok) throw new Error(`Handshake failed: HTTP ${res.status}`)
|
|
62
|
+
|
|
63
|
+
const data = await res.json()
|
|
64
|
+
if (data.messageType !== 'initialResponse') throw new Error(`Unexpected: ${data.messageType}`)
|
|
65
|
+
|
|
66
|
+
const serverNonce = data.nonce as string
|
|
67
|
+
const deriver = new KeyDeriver(key)
|
|
68
|
+
const serverPub = deriver.derivePublicKey(
|
|
69
|
+
AUTH_PROTOCOL_ID, `${serverNonce} ${clientNonce}`, data.identityKey as string, false,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const clientNonceBytes = toArray(clientNonce, 'base64')
|
|
73
|
+
const serverNonceBytes = toArray(serverNonce, 'base64')
|
|
74
|
+
const msgHash = Hash.sha256(Array.from(new Uint8Array([...clientNonceBytes, ...serverNonceBytes])))
|
|
75
|
+
|
|
76
|
+
const sig = Signature.fromDER(data.signature as string, 'hex')
|
|
77
|
+
if (!serverPub.verify(Array.from(msgHash), sig)) throw new Error('Server signature verification failed')
|
|
78
|
+
|
|
79
|
+
const log = createLogger({ context: 'auth' })
|
|
80
|
+
log.set({ event: 'handshake_complete', serverIdentityKey: (data.identityKey as string).slice(0, 16) })
|
|
81
|
+
log.emit()
|
|
82
|
+
|
|
83
|
+
return { serverIdentityKey: data.identityKey as string, serverNonce, clientKey: key }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function signHeaders(session: Session, pathname: string): Record<string, string> {
|
|
87
|
+
const { serverIdentityKey, serverNonce, clientKey: key } = session
|
|
88
|
+
const nonce = generateNonce()
|
|
89
|
+
const deriver = new KeyDeriver(key)
|
|
90
|
+
const derivedKey = deriver.derivePrivateKey(AUTH_PROTOCOL_ID, `${nonce} ${serverNonce}`, serverIdentityKey)
|
|
91
|
+
const payload = new TextEncoder().encode(pathname)
|
|
92
|
+
const msgHash = Hash.sha256(Array.from(payload))
|
|
93
|
+
const sig = derivedKey.sign(Array.from(msgHash))
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
'x-bsv-auth-version': '0.1',
|
|
97
|
+
'x-bsv-auth-identity-key': key.toPublicKey().toString(),
|
|
98
|
+
'x-bsv-auth-nonce': nonce,
|
|
99
|
+
'x-bsv-auth-your-nonce': serverNonce,
|
|
100
|
+
'x-bsv-auth-signature': sig.toDER('hex') as string,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
35
104
|
export async function handleMcpProxyCommand(): Promise<void> {
|
|
36
|
-
|
|
105
|
+
const startLog = createLogger({ context: 'startup' })
|
|
106
|
+
|
|
37
107
|
try {
|
|
38
108
|
await fetch(MCP_URL, { signal: AbortSignal.timeout(2000) })
|
|
39
109
|
} catch {
|
|
40
|
-
|
|
110
|
+
startLog.set({ event: 'server_unreachable', url: MCP_URL })
|
|
111
|
+
startLog.emit()
|
|
112
|
+
process.stderr.write(`[1sat mcp-proxy] Server not reachable at ${MCP_URL} — is 1Sat wallet running?\n`)
|
|
41
113
|
process.exit(1)
|
|
42
114
|
}
|
|
43
115
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const authFetch = new AuthFetch(wallet)
|
|
116
|
+
startLog.set({ event: 'started', url: MCP_URL })
|
|
117
|
+
startLog.emit()
|
|
47
118
|
|
|
48
|
-
|
|
119
|
+
const key = getClientKey()
|
|
120
|
+
const session = await handshake(key)
|
|
49
121
|
|
|
50
122
|
let mcpSessionId: string | null = null
|
|
51
|
-
|
|
123
|
+
let requestCount = 0
|
|
52
124
|
const decoder = new TextDecoder()
|
|
53
125
|
const reader = Bun.stdin.stream().getReader()
|
|
54
126
|
let buffer = ''
|
|
@@ -63,12 +135,15 @@ export async function handleMcpProxyCommand(): Promise<void> {
|
|
|
63
135
|
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
|
|
64
136
|
const line = buffer.slice(0, newlineIdx).trim()
|
|
65
137
|
buffer = buffer.slice(newlineIdx + 1)
|
|
66
|
-
|
|
67
138
|
if (!line) continue
|
|
68
139
|
|
|
140
|
+
requestCount++
|
|
141
|
+
const reqLog = createLogger({ context: 'proxy' })
|
|
142
|
+
|
|
69
143
|
const headers: Record<string, string> = {
|
|
70
144
|
'Content-Type': 'application/json',
|
|
71
145
|
Accept: 'application/json, text/event-stream',
|
|
146
|
+
...signHeaders(session, '/mcp'),
|
|
72
147
|
}
|
|
73
148
|
|
|
74
149
|
if (mcpSessionId) {
|
|
@@ -76,17 +151,28 @@ export async function handleMcpProxyCommand(): Promise<void> {
|
|
|
76
151
|
}
|
|
77
152
|
|
|
78
153
|
try {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
154
|
+
let method: string | undefined
|
|
155
|
+
try {
|
|
156
|
+
method = JSON.parse(line).method
|
|
157
|
+
} catch {}
|
|
158
|
+
|
|
159
|
+
const res = await fetch(`${MCP_URL}/mcp`, { method: 'POST', headers, body: line })
|
|
84
160
|
|
|
85
161
|
const sessionHeader = res.headers.get('mcp-session-id')
|
|
86
162
|
if (sessionHeader) mcpSessionId = sessionHeader
|
|
87
163
|
|
|
88
164
|
const contentType = res.headers.get('content-type') ?? ''
|
|
89
165
|
|
|
166
|
+
reqLog.set({
|
|
167
|
+
event: 'request',
|
|
168
|
+
requestNum: requestCount,
|
|
169
|
+
method,
|
|
170
|
+
status: res.status,
|
|
171
|
+
contentType: contentType.split(';')[0],
|
|
172
|
+
sessionId: mcpSessionId?.slice(0, 8),
|
|
173
|
+
})
|
|
174
|
+
reqLog.emit()
|
|
175
|
+
|
|
90
176
|
if (contentType.includes('text/event-stream')) {
|
|
91
177
|
const text = await res.text()
|
|
92
178
|
for (const eventLine of text.split('\n')) {
|
|
@@ -101,14 +187,13 @@ export async function handleMcpProxyCommand(): Promise<void> {
|
|
|
101
187
|
}
|
|
102
188
|
} catch (err) {
|
|
103
189
|
const msg = err instanceof Error ? err.message : String(err)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
)
|
|
190
|
+
reqLog.set({ event: 'request_failed', requestNum: requestCount, error: msg })
|
|
191
|
+
reqLog.emit()
|
|
192
|
+
process.stdout.write(`${JSON.stringify({
|
|
193
|
+
jsonrpc: '2.0',
|
|
194
|
+
error: { code: -32000, message: `MCP proxy error: ${msg}` },
|
|
195
|
+
id: null,
|
|
196
|
+
})}\n`)
|
|
112
197
|
}
|
|
113
198
|
}
|
|
114
199
|
}
|