@cello-protocol/client 0.0.2
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/dist/agent-hash-queue.d.ts +206 -0
- package/dist/agent-hash-queue.d.ts.map +1 -0
- package/dist/agent-hash-queue.js +380 -0
- package/dist/agent-hash-queue.js.map +1 -0
- package/dist/backup-key-derivation.d.ts +37 -0
- package/dist/backup-key-derivation.d.ts.map +1 -0
- package/dist/backup-key-derivation.js +48 -0
- package/dist/backup-key-derivation.js.map +1 -0
- package/dist/client-backup.d.ts +144 -0
- package/dist/client-backup.d.ts.map +1 -0
- package/dist/client-backup.js +273 -0
- package/dist/client-backup.js.map +1 -0
- package/dist/client.d.ts +249 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +4664 -0
- package/dist/client.js.map +1 -0
- package/dist/connection-policy.d.ts +163 -0
- package/dist/connection-policy.d.ts.map +1 -0
- package/dist/connection-policy.js +248 -0
- package/dist/connection-policy.js.map +1 -0
- package/dist/db-key-derivation.d.ts +26 -0
- package/dist/db-key-derivation.d.ts.map +1 -0
- package/dist/db-key-derivation.js +37 -0
- package/dist/db-key-derivation.js.map +1 -0
- package/dist/encrypted-file-signing-key-provider.d.ts +92 -0
- package/dist/encrypted-file-signing-key-provider.d.ts.map +1 -0
- package/dist/encrypted-file-signing-key-provider.js +251 -0
- package/dist/encrypted-file-signing-key-provider.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-server.d.ts +270 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +1155 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/network-directory-node.d.ts +85 -0
- package/dist/network-directory-node.d.ts.map +1 -0
- package/dist/network-directory-node.js +584 -0
- package/dist/network-directory-node.js.map +1 -0
- package/dist/s3-cloud-storage-provider.d.ts +54 -0
- package/dist/s3-cloud-storage-provider.d.ts.map +1 -0
- package/dist/s3-cloud-storage-provider.js +78 -0
- package/dist/s3-cloud-storage-provider.js.map +1 -0
- package/dist/sqlcipher-client-store.d.ts +68 -0
- package/dist/sqlcipher-client-store.d.ts.map +1 -0
- package/dist/sqlcipher-client-store.js +382 -0
- package/dist/sqlcipher-client-store.js.map +1 -0
- package/dist/types.d.ts +408 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/package.json +48 -0
|
@@ -0,0 +1,1155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CELLO MCP Session Server — mcp-server.ts (CELLO-MCP-003)
|
|
3
|
+
*
|
|
4
|
+
* createMcpSessionServer(node, client, keyProvider): McpServer
|
|
5
|
+
* Registers the M3 tool set (19 tools) against a CelloClient.
|
|
6
|
+
* Transport-agnostic: identical tool names, schemas, and wiring under
|
|
7
|
+
* InMemoryTransport (tests) and stdio (production). AC-016.
|
|
8
|
+
*
|
|
9
|
+
* MCP-003 additions (10 new tools):
|
|
10
|
+
* cello_register — REG-001 DKG registration
|
|
11
|
+
* cello_request_connection — CONNREQ-002 Round 1 sender
|
|
12
|
+
* cello_respond_to_disclosure_request — CONNREQ-002 Round 2 sender
|
|
13
|
+
* cello_await_connection_request — waits for agent-review inbound request
|
|
14
|
+
* cello_accept_connection — inference-mode accept
|
|
15
|
+
* cello_reject_connection — inference-mode reject
|
|
16
|
+
* cello_request_more_disclosure — inference-mode ask for more
|
|
17
|
+
* cello_list_connections — list active connections
|
|
18
|
+
* cello_set_policy — configure policy engine
|
|
19
|
+
* cello_get_policy — read current policy
|
|
20
|
+
*
|
|
21
|
+
* MCP-002 modifications:
|
|
22
|
+
* cello_initiate_session: checks hasConnection() first
|
|
23
|
+
* cello_status: gains registered, agent_id, connection_count, policy_mode, policy_review_mode
|
|
24
|
+
*
|
|
25
|
+
* PSEUDOCODE (Phase P — MCP-003):
|
|
26
|
+
*
|
|
27
|
+
* cello_register({ phone_stub }):
|
|
28
|
+
* 1. result = await client.register(phone_stub)
|
|
29
|
+
* 2. if 'error' in result: return { error: { reason: result.error } }
|
|
30
|
+
* 3. return { registered: true, agent_id, primary_pubkey, ml_dsa_pubkey }
|
|
31
|
+
* SI-001: never include ml_dsa secret
|
|
32
|
+
*
|
|
33
|
+
* cello_request_connection({ target_pubkey, include_endorsements?, include_attestations? }):
|
|
34
|
+
* 1. Build a minimal ConnectionPackage (empty endorsements/attestations unless requested)
|
|
35
|
+
* 2. result = await client.cello_request_connection({ target_pubkey, package_cbor })
|
|
36
|
+
* 3. Map result to MCP response shape
|
|
37
|
+
*
|
|
38
|
+
* cello_respond_to_disclosure_request({ connection_request_id, ... }):
|
|
39
|
+
* 1. result = await client.cello_respond_to_disclosure_request({ connection_request_id, package_cbor })
|
|
40
|
+
* 2. Map result
|
|
41
|
+
*
|
|
42
|
+
* cello_await_connection_request({ timeout_ms? }):
|
|
43
|
+
* 1. result = await client.awaitConnectionRequest(timeout_ms ?? 30_000)
|
|
44
|
+
* 2. if timeout: return { type: 'timeout' }
|
|
45
|
+
* 3. Return { type: 'pending_review', connection_request_id, from_pubkey, report }
|
|
46
|
+
* SI-003: report is ConnectionReport — no raw signatures, no full pubkeys
|
|
47
|
+
*
|
|
48
|
+
* cello_accept_connection({ connection_request_id }):
|
|
49
|
+
* 1. result = await client.acceptConnection(connection_request_id)
|
|
50
|
+
* 2. Map to { accepted: true, connection_id } or { error: { reason } }
|
|
51
|
+
*
|
|
52
|
+
* cello_reject_connection({ connection_request_id, reason? }):
|
|
53
|
+
* 1. result = await client.rejectConnection(connection_request_id, reason)
|
|
54
|
+
* 2. Map to { rejected: true } or { error: { reason } }
|
|
55
|
+
*
|
|
56
|
+
* cello_request_more_disclosure({ connection_request_id, requested_items }):
|
|
57
|
+
* 1. result = await client.requestMoreDisclosure(connection_request_id, requested_items)
|
|
58
|
+
* 2. Map to { request_sent: true } or { error: { reason } }
|
|
59
|
+
*
|
|
60
|
+
* cello_list_connections():
|
|
61
|
+
* 1. connections = client.listConnections()
|
|
62
|
+
* 2. Map each record to { connection_id, counterparty_pubkey, counterparty_primary_pubkey,
|
|
63
|
+
* established_at, status: 'active' }
|
|
64
|
+
*
|
|
65
|
+
* cello_set_policy({ mode, review_mode, requirements? }):
|
|
66
|
+
* 1. policy = { mode, review_mode, requirements: requirements ?? [] }
|
|
67
|
+
* 2. client.setPolicy(policy)
|
|
68
|
+
* 3. return { policy_set: true, mode, review_mode, requirement_count }
|
|
69
|
+
*
|
|
70
|
+
* cello_get_policy():
|
|
71
|
+
* 1. policy = client.getPolicy()
|
|
72
|
+
* 2. return { mode, review_mode, requirements }
|
|
73
|
+
*
|
|
74
|
+
* Modified: cello_initiate_session({ target_pubkey }):
|
|
75
|
+
* 1. NEW: check client.hasConnection(target_pubkey)
|
|
76
|
+
* if !connection → return { ok: false, reason: 'no_connection' }
|
|
77
|
+
* (existing transport guard + directory signaling unchanged)
|
|
78
|
+
*
|
|
79
|
+
* Modified: cello_status():
|
|
80
|
+
* + registered: bool
|
|
81
|
+
* + agent_id: hex | null
|
|
82
|
+
* + connection_count: number
|
|
83
|
+
* + policy_mode: string
|
|
84
|
+
* + policy_review_mode: string
|
|
85
|
+
*
|
|
86
|
+
* PSEUDOCODE (Phase P):
|
|
87
|
+
*
|
|
88
|
+
* State held by the MCP server instance:
|
|
89
|
+
* startedAt: number = Date.now()
|
|
90
|
+
* inboundSessionQueue: Uint8Array[] — FIFO queue of inbound session_id bytes
|
|
91
|
+
*
|
|
92
|
+
* Server setup:
|
|
93
|
+
* client.onSessionAssignment((sessionIdBytes) => {
|
|
94
|
+
* inboundSessionQueue.push(sessionIdBytes)
|
|
95
|
+
* })
|
|
96
|
+
*
|
|
97
|
+
* Helper: transportStarted():
|
|
98
|
+
* return node.listenAddresses().length > 0
|
|
99
|
+
*
|
|
100
|
+
* Helper: directoryReachable():
|
|
101
|
+
* // In M1, we determine directory reachability from whether we have active sessions
|
|
102
|
+
* // with a directory_endpoint set. Best-effort check from session records.
|
|
103
|
+
* return any session in client.listSessions() has a directory_endpoint with a peer_id
|
|
104
|
+
*
|
|
105
|
+
* Helper: toHex(bytes):
|
|
106
|
+
* return Buffer.from(bytes).toString('hex')
|
|
107
|
+
*
|
|
108
|
+
* Helper: fromHex(str):
|
|
109
|
+
* return Buffer.from(str, 'hex')
|
|
110
|
+
*
|
|
111
|
+
* ─── tool: cello_initiate_session({ target_pubkey }) ─────────────────────────────
|
|
112
|
+
* 1. Guard: if !transportStarted() → return transport_not_started error
|
|
113
|
+
* 2. SESSION-002 session_request flow:
|
|
114
|
+
* a. Look up target_pubkey in client's sessions (M1 stub: directory signaling is
|
|
115
|
+
* handled via receiveSessionAssignment. The session_request must be sent through
|
|
116
|
+
* the directory signaling stream. In M1 this is driven externally by the test
|
|
117
|
+
* harness calling receiveSessionAssignment on both clients. Here we emit the
|
|
118
|
+
* request via the stored directory stream if available, then poll listSessions()
|
|
119
|
+
* for the resulting session_id.)
|
|
120
|
+
* b. Actually: The NODE-001 signaling flow is: client initiates a session_request
|
|
121
|
+
* to the directory over the persistent signaling stream, the directory creates a
|
|
122
|
+
* SessionAssignment and pushes it to both clients. In M1, the directory is a
|
|
123
|
+
* separate process that receives the request. For the MCP tool surface:
|
|
124
|
+
* - The client must have a way to send a session_request to the directory.
|
|
125
|
+
* - CelloClientImpl already holds directoryStreams per session. But those are
|
|
126
|
+
* post-assignment. The pre-session directory signaling stream is separate.
|
|
127
|
+
* c. Per CONTEXT.md: session establishment — directory issues signed SessionAssignment.
|
|
128
|
+
* The client sends session_request to directory's /cello/signaling/1.0.0 stream.
|
|
129
|
+
* d. Implementation: initiate a directory signaling stream at the MCP server level
|
|
130
|
+
* (separate from the per-session streams in CelloClientImpl). The MCP server
|
|
131
|
+
* must know the directory endpoint. In M1 tests, the directory endpoint is
|
|
132
|
+
* obtained from an existing session's record OR passed at construction time.
|
|
133
|
+
*
|
|
134
|
+
* NOTE: In M1, cello_initiate_session is driven by the test harness calling
|
|
135
|
+
* receiveSessionAssignment on both clients (the directory side is real in e2e tests).
|
|
136
|
+
* The MCP tool implements the client-side: send the session_request frame to the
|
|
137
|
+
* directory signaling stream and poll until session_assignment arrives.
|
|
138
|
+
*
|
|
139
|
+
* For the actual M1 implementation:
|
|
140
|
+
* 1. Connect to directory /cello/signaling/1.0.0 (using stored endpoint from existing session,
|
|
141
|
+
* or a pre-configured directory multiaddr)
|
|
142
|
+
* 2. Auth challenge-response
|
|
143
|
+
* 3. Send { type: "session_request", target_pubkey: fromHex(target_pubkey) }
|
|
144
|
+
* 4. Poll listSessions() every 100ms until a new session with counterparty_pubkey == target_pubkey
|
|
145
|
+
* appears, or timeout (10s)
|
|
146
|
+
* 5. Return session details from the new SessionRecord
|
|
147
|
+
*
|
|
148
|
+
* ─── tool: cello_await_session({ timeout_ms }) ────────────────────────────────────
|
|
149
|
+
* 1. deadline = Date.now() + timeout_ms
|
|
150
|
+
* 2. Poll every 20ms until deadline:
|
|
151
|
+
* a. if inboundSessionQueue.length > 0:
|
|
152
|
+
* sessionId = inboundSessionQueue.shift()
|
|
153
|
+
* sessionIdHex = toHex(sessionId)
|
|
154
|
+
* record = client.listSessions().find(s => toHex(s.session_id) === sessionIdHex)
|
|
155
|
+
* if record: return { type: 'new_session', session_id: sessionIdHex,
|
|
156
|
+
* counterparty_pubkey: toHex(record.counterparty_pubkey),
|
|
157
|
+
* genesis_prev_root: toHex(record.genesis_prev_root) }
|
|
158
|
+
* 3. return { type: 'timeout' }
|
|
159
|
+
*
|
|
160
|
+
* ─── tool: cello_send({ session_id, content }) ────────────────────────────────────
|
|
161
|
+
* 1. Guard: transport_not_started
|
|
162
|
+
* 2. result = await client.sendMessage(session_id, TextEncoder.encode(content))
|
|
163
|
+
* 3. if result.ok:
|
|
164
|
+
* // Retrieve leaf_hash from the session record's most recent leaf
|
|
165
|
+
* record = client.listSessions().find(s => toHex(s.session_id) === session_id)
|
|
166
|
+
* leafHash = computeLeafHash(record.local_tree_leaves[last])
|
|
167
|
+
* return { delivered: true, leaf_hash: toHex(leafHash) }
|
|
168
|
+
* else:
|
|
169
|
+
* return { delivered: false, reason: result.reason }
|
|
170
|
+
*
|
|
171
|
+
* ─── tool: cello_receive_session({ session_id, timeout_ms }) — session-locked ────────
|
|
172
|
+
* 1. Guard: transport_not_started
|
|
173
|
+
* 2. deadline = Date.now() + timeout_ms
|
|
174
|
+
* 3. Poll every 20ms until deadline:
|
|
175
|
+
* a. msg = client.receiveMessage(session_id)
|
|
176
|
+
* b. if msg:
|
|
177
|
+
* return { type: 'message', content: TextDecoder.decode(msg.content),
|
|
178
|
+
* sender_pubkey: toHex(msg.senderPubkey),
|
|
179
|
+
* sequence_number: msg.sequenceNumber,
|
|
180
|
+
* leaf_hash: toHex(msg.leafHash) }
|
|
181
|
+
* 4. return { type: 'timeout' }
|
|
182
|
+
*
|
|
183
|
+
* ─── tool: cello_receive({ timeout_ms }) — any-session default ───────────────────────
|
|
184
|
+
* 1. Guard: transport_not_started
|
|
185
|
+
* 2. result = await client.receiveMessageAsync(timeout_ms)
|
|
186
|
+
* 3. if timeout: return { type: 'timeout' }
|
|
187
|
+
* 4. return { type: 'message', session_id, content, sender_pubkey, sequence_number, leaf_hash }
|
|
188
|
+
*
|
|
189
|
+
* ─── tool: cello_close_session({ session_id }) ────────────────────────────────────
|
|
190
|
+
* 1. Guard: transport_not_started
|
|
191
|
+
* 2. result = await client.initiateSessionSeal(session_id)
|
|
192
|
+
* if !result.ok: return { status: 'seal_rejected', sealed_root: null,
|
|
193
|
+
* close_timestamp: Date.now(), reason: result.reason, mmr_peak: null }
|
|
194
|
+
* 3. Poll listSessions() every 100ms for up to 30s:
|
|
195
|
+
* a. record = sessions.find(s => toHex(s.session_id) === session_id)
|
|
196
|
+
* b. if record.status === 'sealed':
|
|
197
|
+
* return { status: 'sealed', sealed_root: toHex(record.sealed_root),
|
|
198
|
+
* close_timestamp: Date.now(), reason: null, mmr_peak: null }
|
|
199
|
+
* c. if record.status === 'seal_rejected':
|
|
200
|
+
* return { status: 'seal_rejected', sealed_root: null,
|
|
201
|
+
* close_timestamp: Date.now(), reason: 'directory_rejected', mmr_peak: null }
|
|
202
|
+
* 4. return { status: 'seal_deferred', sealed_root: null,
|
|
203
|
+
* close_timestamp: Date.now(), reason: 'directory_unreachable', mmr_peak: null }
|
|
204
|
+
*
|
|
205
|
+
* ─── tool: cello_list_sessions() ──────────────────────────────────────────────────
|
|
206
|
+
* 1. records = client.listSessions()
|
|
207
|
+
* 2. For each record:
|
|
208
|
+
* emit { session_id: hex, counterparty_pubkey: hex, counterparty_peer_id: string,
|
|
209
|
+
* relay_endpoint: { peer_id: hex, multiaddrs }, status, last_seen_seq,
|
|
210
|
+
* leaf_count: record.local_tree_leaves.length }
|
|
211
|
+
*
|
|
212
|
+
* ─── tool: cello_status() ─────────────────────────────────────────────────────────
|
|
213
|
+
* No transport_not_started guard (same as MCP-001).
|
|
214
|
+
* 1. ownPubkey = toHex(await keyProvider.getPublicKey())
|
|
215
|
+
* 2. activeSessions = client.listSessions().filter(s => s.status === 'active')
|
|
216
|
+
* 3. return {
|
|
217
|
+
* transport_started: transportStarted(),
|
|
218
|
+
* own_pubkey: ownPubkey,
|
|
219
|
+
* listen_addresses: node.listenAddresses(),
|
|
220
|
+
* connected_peer_count: node.getConnections().length,
|
|
221
|
+
* uptime_seconds: Math.floor((Date.now() - startedAt) / 1000),
|
|
222
|
+
* active_session_count: activeSessions.length,
|
|
223
|
+
* directory_reachable: directoryReachable()
|
|
224
|
+
* }
|
|
225
|
+
*
|
|
226
|
+
* ─── tool: cello_get_sealed_receipt({ session_id }) ──────────────────────────────
|
|
227
|
+
* SI-001: MUST NOT return for seal_rejected sessions.
|
|
228
|
+
* SI-002: MUST NOT return private key material.
|
|
229
|
+
* 1. record = client.listSessions().find(s => toHex(s.session_id) === session_id)
|
|
230
|
+
* 2. if !record: return { error: { reason: 'session_not_found', session_id } }
|
|
231
|
+
* 3. if record.status !== 'sealed': return { error: { reason: 'session_not_sealed', session_id } }
|
|
232
|
+
* 4. pubA = record (lower hex participant), pubB = higher hex participant (from genesis_prev_root ordering)
|
|
233
|
+
* Actually: participants are [own_pubkey, counterparty_pubkey] sorted as stored. The spec says
|
|
234
|
+
* [A_pubkey, B_pubkey] which is the order they appear in the session (A=initiator, B=responder).
|
|
235
|
+
* Since we don't know which role we played, emit [own, counterparty] in canonical order.
|
|
236
|
+
* 5. return { session_id, sealed_root: hex, participants: [hex, hex],
|
|
237
|
+
* close_timestamp: <from seal>, attestation_self: 'PENDING',
|
|
238
|
+
* attestation_counterparty: 'PENDING',
|
|
239
|
+
* leaf_count: record.local_tree_leaves.length,
|
|
240
|
+
* directory_signature: hex }
|
|
241
|
+
*
|
|
242
|
+
* ─── tool: cello_get_inclusion_proof({ session_id, leaf_index }) ──────────────────
|
|
243
|
+
* SI-001: MUST NOT return proof for seal_rejected sessions.
|
|
244
|
+
* SI-003: returned sealed_root MUST equal inclusionProof reconstruction root.
|
|
245
|
+
* 1. record = client.listSessions().find(s => toHex(s.session_id) === session_id)
|
|
246
|
+
* 2. if !record || record.status !== 'sealed':
|
|
247
|
+
* return { error: { reason: 'session_not_sealed' } }
|
|
248
|
+
* 3. treeSize = record.local_tree_leaves.length
|
|
249
|
+
* 4. if leaf_index < 0 || leaf_index >= treeSize:
|
|
250
|
+
* return { error: { reason: 'leaf_index_out_of_range', leaf_index, tree_size: treeSize } }
|
|
251
|
+
* 5. Build tree from record.local_tree_leaves using buildMerkleTree(inputs: LeafInput[])
|
|
252
|
+
* 6. leafHash = tree.levelHashes[0][leaf_index]
|
|
253
|
+
* 7. proof = inclusionProof(tree, leaf_index)
|
|
254
|
+
* 8. root = merkleRoot(tree)
|
|
255
|
+
* 9. SI-003: assert root equals record.sealed_root (consistent snapshot)
|
|
256
|
+
* NOTE: sealed_root from directory is based on the canonical leaf sequence. The local
|
|
257
|
+
* tree should match if seal completed. If they don't match, return internal_error.
|
|
258
|
+
* 10. return { leaf_hash: hex, leaf_index, tree_size: treeSize, proof: [hex], sealed_root: hex }
|
|
259
|
+
*/
|
|
260
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
261
|
+
import { createHash } from "node:crypto";
|
|
262
|
+
import { z } from "zod";
|
|
263
|
+
import { buildMerkleTree, merkleRoot, inclusionProof } from "@cello-protocol/crypto";
|
|
264
|
+
import { buildPseudonymBinding, encodeConnectionPackage } from "@cello-protocol/protocol-types";
|
|
265
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
266
|
+
function jsonText(value) {
|
|
267
|
+
return { content: [{ type: "text", text: JSON.stringify(value) }] };
|
|
268
|
+
}
|
|
269
|
+
const TRANSPORT_NOT_STARTED = jsonText({ error: { reason: "transport_not_started" } });
|
|
270
|
+
function toHex(bytes) {
|
|
271
|
+
return Buffer.from(bytes).toString("hex");
|
|
272
|
+
}
|
|
273
|
+
function sleep(ms) {
|
|
274
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
275
|
+
}
|
|
276
|
+
// ─── createMcpSessionServer ───────────────────────────────────────────────────
|
|
277
|
+
// INVARIANT: construct at most one McpSessionServer per CelloClient instance.
|
|
278
|
+
// CelloClient.onSessionAssignment is last-writer-wins — a second call replaces the first
|
|
279
|
+
// handler, silently dropping all inbound session events from the earlier server instance.
|
|
280
|
+
export function createMcpSessionServer(node, client, keyProvider, opts) {
|
|
281
|
+
const checkpointStatusProvider = opts?.checkpointStatusProvider;
|
|
282
|
+
const clientBackup = opts?.clientBackup;
|
|
283
|
+
const startedAt = Date.now();
|
|
284
|
+
// FIFO queue of inbound session assignment events.
|
|
285
|
+
// Populated by client.onSessionAssignment callback.
|
|
286
|
+
const inboundSessionQueue = [];
|
|
287
|
+
function transportStarted() {
|
|
288
|
+
return node.listenAddresses().length > 0;
|
|
289
|
+
}
|
|
290
|
+
function directoryReachable() {
|
|
291
|
+
// Check whether the libp2p node currently has an open connection to the directory peer.
|
|
292
|
+
// This is accurate: true means the signaling stream is (or was recently) live;
|
|
293
|
+
// false means the directory is genuinely unreachable right now.
|
|
294
|
+
const dirPeerId = client.getDirectoryPeerId();
|
|
295
|
+
if (!dirPeerId)
|
|
296
|
+
return false;
|
|
297
|
+
return node.getConnections().some((c) => c.peerId === dirPeerId);
|
|
298
|
+
}
|
|
299
|
+
const server = new McpServer({ name: "cello-session", version: "0.1.0" }, { capabilities: { experimental: { "claude/channel": {} } } });
|
|
300
|
+
// Register inbound session assignment handler (participant B role).
|
|
301
|
+
// Fires after server is created so the notification push can reference server.
|
|
302
|
+
// SI-001: notification payload contains exactly type, from, and session_id.
|
|
303
|
+
client.onSessionAssignment((event) => {
|
|
304
|
+
inboundSessionQueue.push(event);
|
|
305
|
+
// Push claude/channel wake-up notification — agent calls cello_await_session for details
|
|
306
|
+
server.server.notification({
|
|
307
|
+
method: "notifications/claude/channel",
|
|
308
|
+
params: {
|
|
309
|
+
type: "cello_session_request",
|
|
310
|
+
from: event.counterpartyPubkeyHex,
|
|
311
|
+
session_id: event.sessionIdHex,
|
|
312
|
+
},
|
|
313
|
+
}).catch(() => {
|
|
314
|
+
// Transport may not be connected or may have closed — silently swallow
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
// ── cello_initiate_session ─────────────────────────────────────────────────
|
|
318
|
+
//
|
|
319
|
+
// ADAPTER-003: delegates to client.initiateSession() which opens a persistent
|
|
320
|
+
// signaling stream, sends session_request, and awaits session_assignment from
|
|
321
|
+
// the directory. The M1 polling stub has been replaced.
|
|
322
|
+
server.registerTool("cello_initiate_session", {
|
|
323
|
+
description: "Initiate a session with a target agent by their K_local public key. Requires an existing connection (M3+).",
|
|
324
|
+
inputSchema: {
|
|
325
|
+
target_pubkey: z.string().describe("Target agent K_local pubkey as lowercase hex (64 chars)"),
|
|
326
|
+
timeout_ms: z.number().int().min(0).optional().describe("Optional timeout in milliseconds"),
|
|
327
|
+
},
|
|
328
|
+
}, async ({ target_pubkey, timeout_ms }) => {
|
|
329
|
+
if (!transportStarted())
|
|
330
|
+
return TRANSPORT_NOT_STARTED;
|
|
331
|
+
if (!client.hasConnection(target_pubkey)) {
|
|
332
|
+
return jsonText({ ok: false, reason: "no_connection" });
|
|
333
|
+
}
|
|
334
|
+
const result = await client.initiateSession(target_pubkey, { timeoutMs: timeout_ms });
|
|
335
|
+
if (result.ok) {
|
|
336
|
+
return jsonText({
|
|
337
|
+
ok: true,
|
|
338
|
+
session_id: toHex(result.sessionId),
|
|
339
|
+
genesis_prev_root: toHex(result.genesisPrevRoot),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return jsonText({ ok: false, reason: result.reason });
|
|
343
|
+
});
|
|
344
|
+
// ── cello_await_session ────────────────────────────────────────────────────
|
|
345
|
+
//
|
|
346
|
+
// Drain the inbound session queue, or block until a session_assignment frame
|
|
347
|
+
// arrives for an inbound session (participant B role), or timeout.
|
|
348
|
+
server.registerTool("cello_await_session", {
|
|
349
|
+
description: "Wait for an inbound session request, or drain the buffered queue.",
|
|
350
|
+
inputSchema: {
|
|
351
|
+
timeout_ms: z.number().int().min(0).describe("Maximum wait time in milliseconds"),
|
|
352
|
+
},
|
|
353
|
+
}, async ({ timeout_ms }) => {
|
|
354
|
+
if (!transportStarted())
|
|
355
|
+
return TRANSPORT_NOT_STARTED;
|
|
356
|
+
const deadline = Date.now() + timeout_ms;
|
|
357
|
+
while (Date.now() < deadline) {
|
|
358
|
+
if (inboundSessionQueue.length > 0) {
|
|
359
|
+
const event = inboundSessionQueue.shift();
|
|
360
|
+
return jsonText({
|
|
361
|
+
type: "new_session",
|
|
362
|
+
session_id: event.sessionIdHex,
|
|
363
|
+
counterparty_pubkey: event.counterpartyPubkeyHex,
|
|
364
|
+
genesis_prev_root: event.genesisPrevRootHex,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
const remaining = deadline - Date.now();
|
|
368
|
+
if (remaining <= 0)
|
|
369
|
+
break;
|
|
370
|
+
await sleep(Math.min(20, remaining));
|
|
371
|
+
}
|
|
372
|
+
return jsonText({ type: "timeout" });
|
|
373
|
+
});
|
|
374
|
+
// ── cello_send ─────────────────────────────────────────────────────────────
|
|
375
|
+
//
|
|
376
|
+
// MSG-004 dual-path send. Returns leaf_hash from the local tree after the
|
|
377
|
+
// relay echoes back the Structure 2 leaf.
|
|
378
|
+
server.registerTool("cello_send", {
|
|
379
|
+
description: "Send a UTF-8 message on an active session.",
|
|
380
|
+
inputSchema: {
|
|
381
|
+
session_id: z.string().describe("Session ID as lowercase hex"),
|
|
382
|
+
content: z.string().describe("UTF-8 message content"),
|
|
383
|
+
},
|
|
384
|
+
}, async ({ session_id, content }) => {
|
|
385
|
+
if (!transportStarted())
|
|
386
|
+
return TRANSPORT_NOT_STARTED;
|
|
387
|
+
const contentBytes = new TextEncoder().encode(content);
|
|
388
|
+
const result = await client.sendMessage(session_id, contentBytes);
|
|
389
|
+
if (!result.ok) {
|
|
390
|
+
return jsonText({ delivered: false, reason: result.reason });
|
|
391
|
+
}
|
|
392
|
+
// Retrieve leaf_hash from the most recent leaf in the local tree.
|
|
393
|
+
// sendMessage returns ok:true only after the own-echo confirms the leaf
|
|
394
|
+
// was accepted, so local_tree_leaves will have grown by exactly one entry.
|
|
395
|
+
const record = client.listSessions().find((s) => toHex(s.session_id) === session_id);
|
|
396
|
+
if (!record || record.local_tree_leaves.length === 0) {
|
|
397
|
+
return jsonText({ delivered: false, reason: "session_not_found" });
|
|
398
|
+
}
|
|
399
|
+
// Compute leaf hash directly from the last leaf — SHA-256(kind_byte || s2_cbor).
|
|
400
|
+
// No full-tree rebuild needed; the hash of a single leaf is derivable from its data alone.
|
|
401
|
+
const lastLeaf = record.local_tree_leaves[record.local_tree_leaves.length - 1];
|
|
402
|
+
const kindByte = lastLeaf.kind === "ctrl" ? 0x02 : 0x00;
|
|
403
|
+
const leafHash = new Uint8Array(createHash("sha256")
|
|
404
|
+
.update(new Uint8Array([kindByte]))
|
|
405
|
+
.update(lastLeaf.s2_cbor)
|
|
406
|
+
.digest());
|
|
407
|
+
return jsonText({ delivered: true, leaf_hash: toHex(leafHash) });
|
|
408
|
+
});
|
|
409
|
+
// ── cello_receive ──────────────────────────────────────────────────────────
|
|
410
|
+
//
|
|
411
|
+
// SESSION-007: uses receiveSessionMessageAsync (Promise-based wake) instead of polling.
|
|
412
|
+
// SI-004: content is only returned after the underlying client's dual-path
|
|
413
|
+
// validation (cross-check + signature verify) has completed.
|
|
414
|
+
// SESSION-007: surfaces session_sealed events inline; includes other_sessions_pending.
|
|
415
|
+
server.registerTool("cello_receive_session", {
|
|
416
|
+
description: "Wait for a message or lifecycle event on a specific session (session-locked), or timeout. Returns session_sealed inline when the session is sealed by the counterparty.",
|
|
417
|
+
inputSchema: {
|
|
418
|
+
session_id: z.string().describe("Session ID as lowercase hex"),
|
|
419
|
+
timeout_ms: z.number().int().min(0).describe("Maximum wait time in milliseconds"),
|
|
420
|
+
},
|
|
421
|
+
}, async ({ session_id, timeout_ms }) => {
|
|
422
|
+
if (!transportStarted())
|
|
423
|
+
return TRANSPORT_NOT_STARTED;
|
|
424
|
+
const msg = await client.receiveSessionMessageAsync(session_id, timeout_ms);
|
|
425
|
+
if (!msg) {
|
|
426
|
+
return jsonText({ type: "timeout" });
|
|
427
|
+
}
|
|
428
|
+
if (msg.type === "session_sealed") {
|
|
429
|
+
return jsonText({
|
|
430
|
+
type: "session_sealed",
|
|
431
|
+
session_id: msg.sessionIdHex,
|
|
432
|
+
sealed_root: toHex(msg.sealedRoot),
|
|
433
|
+
close_timestamp: msg.closeTimestamp,
|
|
434
|
+
checkpoint_status: msg.checkpointStatus,
|
|
435
|
+
...(msg.otherSessionsPending && msg.otherSessionsPending.length > 0
|
|
436
|
+
? { other_sessions_pending: msg.otherSessionsPending }
|
|
437
|
+
: {}),
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
// type === "message"
|
|
441
|
+
let content;
|
|
442
|
+
try {
|
|
443
|
+
content = new TextDecoder("utf-8", { fatal: true }).decode(msg.content);
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
// Non-UTF-8 content: return raw hex instead of failing
|
|
447
|
+
content = toHex(msg.content);
|
|
448
|
+
}
|
|
449
|
+
return jsonText({
|
|
450
|
+
type: "message",
|
|
451
|
+
content,
|
|
452
|
+
sender_pubkey: toHex(msg.senderPubkey),
|
|
453
|
+
sequence_number: msg.sequenceNumber,
|
|
454
|
+
leaf_hash: toHex(msg.leafHash),
|
|
455
|
+
...(msg.otherSessionsPending && msg.otherSessionsPending.length > 0
|
|
456
|
+
? { other_sessions_pending: msg.otherSessionsPending }
|
|
457
|
+
: {}),
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
// ── cello_receive ──────────────────────────────────────────────────────────
|
|
461
|
+
//
|
|
462
|
+
// SESSION-007: default receive — returns the next inbound message from ANY active session.
|
|
463
|
+
// Includes session_id in response so caller knows which session it came from.
|
|
464
|
+
// Returns { type: 'timeout' } if no message arrives within timeout_ms.
|
|
465
|
+
server.registerTool("cello_receive", {
|
|
466
|
+
description: "Wait for a message or lifecycle event from any active session, or timeout. Returns session_id so caller knows which session the message came from. This is the default receive tool.",
|
|
467
|
+
inputSchema: {
|
|
468
|
+
timeout_ms: z.number().int().min(0).describe("Maximum wait time in milliseconds"),
|
|
469
|
+
},
|
|
470
|
+
}, async ({ timeout_ms }) => {
|
|
471
|
+
if (!transportStarted())
|
|
472
|
+
return TRANSPORT_NOT_STARTED;
|
|
473
|
+
const result = await client.receiveMessageAsync(timeout_ms);
|
|
474
|
+
if (result.type === "timeout") {
|
|
475
|
+
return jsonText({ type: "timeout" });
|
|
476
|
+
}
|
|
477
|
+
if (result.type === "session_sealed") {
|
|
478
|
+
return jsonText({
|
|
479
|
+
type: "session_sealed",
|
|
480
|
+
session_id: result.sessionIdHex,
|
|
481
|
+
sealed_root: toHex(result.sealedRoot),
|
|
482
|
+
close_timestamp: result.closeTimestamp,
|
|
483
|
+
checkpoint_status: result.checkpointStatus,
|
|
484
|
+
...(result.otherSessionsPending && result.otherSessionsPending.length > 0
|
|
485
|
+
? { other_sessions_pending: result.otherSessionsPending }
|
|
486
|
+
: {}),
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
// type === "message"
|
|
490
|
+
let content;
|
|
491
|
+
try {
|
|
492
|
+
content = new TextDecoder("utf-8", { fatal: true }).decode(result.content);
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
content = toHex(result.content);
|
|
496
|
+
}
|
|
497
|
+
return jsonText({
|
|
498
|
+
type: "message",
|
|
499
|
+
session_id: result.sessionIdHex,
|
|
500
|
+
content,
|
|
501
|
+
sender_pubkey: toHex(result.senderPubkey),
|
|
502
|
+
sequence_number: result.sequenceNumber,
|
|
503
|
+
leaf_hash: toHex(result.leafHash),
|
|
504
|
+
...(result.otherSessionsPending && result.otherSessionsPending.length > 0
|
|
505
|
+
? { other_sessions_pending: result.otherSessionsPending }
|
|
506
|
+
: {}),
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
// ── cello_close_session ────────────────────────────────────────────────────
|
|
510
|
+
//
|
|
511
|
+
// SESSION-003 bilateral seal. Initiates the seal and polls for the outcome.
|
|
512
|
+
// Returns sealed / seal_rejected / seal_deferred per the story spec.
|
|
513
|
+
// AC-010: if directory is unreachable, returns seal_deferred.
|
|
514
|
+
// mmr_peak is always null in M1 (populated in M10).
|
|
515
|
+
server.registerTool("cello_close_session", {
|
|
516
|
+
description: "Initiate the bilateral seal ceremony for a session.",
|
|
517
|
+
inputSchema: {
|
|
518
|
+
session_id: z.string().describe("Session ID as lowercase hex"),
|
|
519
|
+
},
|
|
520
|
+
}, async ({ session_id }) => {
|
|
521
|
+
if (!transportStarted())
|
|
522
|
+
return TRANSPORT_NOT_STARTED;
|
|
523
|
+
const result = await client.initiateSessionSeal(session_id);
|
|
524
|
+
if (!result.ok) {
|
|
525
|
+
return jsonText({
|
|
526
|
+
status: "seal_rejected",
|
|
527
|
+
sealed_root: null,
|
|
528
|
+
close_timestamp: Date.now(),
|
|
529
|
+
reason: result.reason,
|
|
530
|
+
mmr_peak: null,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
// Poll for sealed / seal_rejected status, or timeout after 30s → seal_deferred.
|
|
534
|
+
const deadline = Date.now() + 30_000;
|
|
535
|
+
while (Date.now() < deadline) {
|
|
536
|
+
const sessions = client.listSessions();
|
|
537
|
+
const record = sessions.find((s) => toHex(s.session_id) === session_id);
|
|
538
|
+
if (!record) {
|
|
539
|
+
return jsonText({
|
|
540
|
+
status: "seal_rejected",
|
|
541
|
+
sealed_root: null,
|
|
542
|
+
close_timestamp: Date.now(),
|
|
543
|
+
reason: "session_not_found",
|
|
544
|
+
mmr_peak: null,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
if (record.status === "sealed") {
|
|
548
|
+
// PERSIST-017: include checkpoint_status if CheckpointStatusProvider is wired in.
|
|
549
|
+
let checkpointFields = {};
|
|
550
|
+
if (checkpointStatusProvider) {
|
|
551
|
+
const stagingStatus = await checkpointStatusProvider.getSealStagingStatus(session_id);
|
|
552
|
+
if (stagingStatus.status === "pending") {
|
|
553
|
+
checkpointFields = { checkpoint_status: "pending", staged_at: stagingStatus.staged_at };
|
|
554
|
+
}
|
|
555
|
+
else if (stagingStatus.status === "confirmed") {
|
|
556
|
+
checkpointFields = {
|
|
557
|
+
checkpoint_status: "confirmed",
|
|
558
|
+
session_mmr_peak: stagingStatus.checkpoint_peak_hash,
|
|
559
|
+
leaf_index: stagingStatus.leaf_index,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return jsonText({
|
|
564
|
+
status: "sealed",
|
|
565
|
+
sealed_root: record.sealed_root ? toHex(record.sealed_root) : null,
|
|
566
|
+
// Use the directory-confirmed close_timestamp from the sealed session record,
|
|
567
|
+
// not Date.now() — this is the timestamp the directory signed and is the
|
|
568
|
+
// authoritative value for verification against the directory signature.
|
|
569
|
+
close_timestamp: record.close_timestamp ?? Date.now(),
|
|
570
|
+
reason: null,
|
|
571
|
+
mmr_peak: null,
|
|
572
|
+
...checkpointFields,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
if (record.status === "seal_rejected") {
|
|
576
|
+
// SI-001: never return sealed_root for a seal_rejected session
|
|
577
|
+
return jsonText({
|
|
578
|
+
status: "seal_rejected",
|
|
579
|
+
sealed_root: null,
|
|
580
|
+
close_timestamp: Date.now(),
|
|
581
|
+
reason: "directory_rejected",
|
|
582
|
+
mmr_peak: null,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
const remaining = deadline - Date.now();
|
|
586
|
+
if (remaining <= 0)
|
|
587
|
+
break;
|
|
588
|
+
await sleep(Math.min(100, remaining));
|
|
589
|
+
}
|
|
590
|
+
// Timeout — directory did not confirm the seal within 30s (AC-010)
|
|
591
|
+
return jsonText({
|
|
592
|
+
status: "seal_deferred",
|
|
593
|
+
sealed_root: null,
|
|
594
|
+
close_timestamp: Date.now(),
|
|
595
|
+
reason: "directory_unreachable",
|
|
596
|
+
mmr_peak: null,
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
// ── cello_list_sessions ────────────────────────────────────────────────────
|
|
600
|
+
//
|
|
601
|
+
// Return all known session records.
|
|
602
|
+
server.registerTool("cello_list_sessions", {
|
|
603
|
+
description: "List all known sessions and their current status.",
|
|
604
|
+
inputSchema: {},
|
|
605
|
+
}, async () => {
|
|
606
|
+
const records = client.listSessions();
|
|
607
|
+
const sessions = records.map((s) => ({
|
|
608
|
+
session_id: toHex(s.session_id),
|
|
609
|
+
counterparty_pubkey: toHex(s.counterparty_pubkey),
|
|
610
|
+
counterparty_peer_id: s.counterparty_peer_id,
|
|
611
|
+
relay_endpoint: {
|
|
612
|
+
peer_id: s.relay_endpoint.peer_id,
|
|
613
|
+
multiaddrs: s.relay_endpoint.multiaddrs,
|
|
614
|
+
},
|
|
615
|
+
status: s.status,
|
|
616
|
+
last_seen_seq: s.last_seen_seq,
|
|
617
|
+
leaf_count: s.local_tree_leaves.length,
|
|
618
|
+
}));
|
|
619
|
+
return jsonText(sessions);
|
|
620
|
+
});
|
|
621
|
+
// ── cello_status ──────────────────────────────────────────────────────────
|
|
622
|
+
//
|
|
623
|
+
// MCP-001 cello_status extended with active_session_count and directory_reachable.
|
|
624
|
+
// No transport_not_started guard — always responds (AC-009 carries over from MCP-001).
|
|
625
|
+
// SI-002: never emits K_local private key material.
|
|
626
|
+
server.registerTool("cello_status", {
|
|
627
|
+
description: "Return transport status, own pubkey, session count, registration state, connection count, and policy info.",
|
|
628
|
+
inputSchema: {},
|
|
629
|
+
}, async () => {
|
|
630
|
+
// SI-002: getPublicKey() returns the public key only — KeyProvider never exposes private key
|
|
631
|
+
const ownPubkey = toHex(await keyProvider.getPublicKey());
|
|
632
|
+
const allSessions = client.listSessions();
|
|
633
|
+
const activeSessions = allSessions.filter((s) => s.status === "active");
|
|
634
|
+
// MCP-003 AC-015: registration state
|
|
635
|
+
const regState = typeof client.getRegistrationState === "function"
|
|
636
|
+
? client.getRegistrationState()
|
|
637
|
+
: null;
|
|
638
|
+
const registered = regState !== null;
|
|
639
|
+
const agentId = registered ? regState.agent_id : null;
|
|
640
|
+
// MCP-003 AC-015: connection count
|
|
641
|
+
const connections = typeof client.listConnections === "function"
|
|
642
|
+
? client.listConnections()
|
|
643
|
+
: [];
|
|
644
|
+
const connectionCount = connections.length;
|
|
645
|
+
// MCP-003 AC-015: policy info
|
|
646
|
+
const policy = typeof client.getPolicy === "function"
|
|
647
|
+
? client.getPolicy()
|
|
648
|
+
: { mode: "open", review_mode: "deterministic" };
|
|
649
|
+
return jsonText({
|
|
650
|
+
transport_started: transportStarted(),
|
|
651
|
+
own_pubkey: ownPubkey,
|
|
652
|
+
listen_addresses: node.listenAddresses(),
|
|
653
|
+
connected_peer_count: node.getConnections().length,
|
|
654
|
+
uptime_seconds: Math.floor((Date.now() - startedAt) / 1000),
|
|
655
|
+
active_session_count: activeSessions.length,
|
|
656
|
+
directory_reachable: directoryReachable(),
|
|
657
|
+
// MCP-003 additions
|
|
658
|
+
registered,
|
|
659
|
+
agent_id: agentId,
|
|
660
|
+
connection_count: connectionCount,
|
|
661
|
+
policy_mode: policy.mode,
|
|
662
|
+
policy_review_mode: policy.review_mode,
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
// ── cello_get_sealed_receipt ───────────────────────────────────────────────
|
|
666
|
+
//
|
|
667
|
+
// Return the local seal record populated after directory confirmation.
|
|
668
|
+
// SI-001: MUST NOT return for seal_rejected sessions.
|
|
669
|
+
// SI-002: MUST NOT emit private key material in any error path.
|
|
670
|
+
// Attestation fields are uniformly 'PENDING' in M1 (per roadmap deferred-items policy).
|
|
671
|
+
server.registerTool("cello_get_sealed_receipt", {
|
|
672
|
+
description: "Retrieve the sealed receipt for a confirmed sealed session.",
|
|
673
|
+
inputSchema: {
|
|
674
|
+
session_id: z.string().describe("Session ID as lowercase hex"),
|
|
675
|
+
},
|
|
676
|
+
}, async ({ session_id }) => {
|
|
677
|
+
const record = client.listSessions().find((s) => toHex(s.session_id) === session_id);
|
|
678
|
+
if (!record) {
|
|
679
|
+
return jsonText({ error: { reason: "session_not_found", session_id } });
|
|
680
|
+
}
|
|
681
|
+
// SI-001: seal_rejected sessions must not return a sealed_root or receipt
|
|
682
|
+
if (record.status !== "sealed") {
|
|
683
|
+
return jsonText({ error: { reason: "session_not_sealed", session_id } });
|
|
684
|
+
}
|
|
685
|
+
// Participants: emit own pubkey and counterparty pubkey.
|
|
686
|
+
// Own pubkey is obtained from keyProvider (public key only — SI-002).
|
|
687
|
+
const ownPubkeyBytes = await keyProvider.getPublicKey();
|
|
688
|
+
const ownPubkeyHex = toHex(ownPubkeyBytes);
|
|
689
|
+
const counterpartyPubkeyHex = toHex(record.counterparty_pubkey);
|
|
690
|
+
// directory_signature is stored in the sealed_root field alongside the seal.
|
|
691
|
+
// In M1 the client stores the directory signature in the session record when
|
|
692
|
+
// it receives session_sealed. Since SessionRecord doesn't yet expose directory_sig
|
|
693
|
+
// separately, we emit what we have. The directory_signature field is required by
|
|
694
|
+
// AC-004. For M1 we store it on the record via a side-channel or provide it as empty hex.
|
|
695
|
+
// NOTE: The SessionRecord doesn't currently store directory_signature. For AC-004
|
|
696
|
+
// to pass in e2e tests, we need to add it. For unit tests (AC-007) the record
|
|
697
|
+
// doesn't need it because the test hits the not_sealed path first.
|
|
698
|
+
// We return empty string for now — the e2e layer will verify via the full flow.
|
|
699
|
+
const dirSigHex = record.directory_signature
|
|
700
|
+
? toHex(record.directory_signature)
|
|
701
|
+
: "";
|
|
702
|
+
// PERSIST-017: include checkpoint_status if CheckpointStatusProvider is wired in.
|
|
703
|
+
let checkpointFields = {};
|
|
704
|
+
if (checkpointStatusProvider) {
|
|
705
|
+
const stagingStatus = await checkpointStatusProvider.getSealStagingStatus(session_id);
|
|
706
|
+
if (stagingStatus.status === "pending") {
|
|
707
|
+
checkpointFields = {
|
|
708
|
+
checkpoint_status: "pending",
|
|
709
|
+
staged_at: stagingStatus.staged_at,
|
|
710
|
+
attestation_self: "PENDING",
|
|
711
|
+
attestation_self_reason: "MMR checkpoint not yet written",
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
else if (stagingStatus.status === "confirmed") {
|
|
715
|
+
checkpointFields = {
|
|
716
|
+
checkpoint_status: "confirmed",
|
|
717
|
+
session_mmr_peak: stagingStatus.checkpoint_peak_hash,
|
|
718
|
+
leaf_index: stagingStatus.leaf_index,
|
|
719
|
+
checkpoint_id: stagingStatus.checkpoint_id,
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return jsonText({
|
|
724
|
+
session_id,
|
|
725
|
+
sealed_root: record.sealed_root ? toHex(record.sealed_root) : null,
|
|
726
|
+
participants: [ownPubkeyHex, counterpartyPubkeyHex],
|
|
727
|
+
// close_timestamp comes from the directory-signed session_sealed frame.
|
|
728
|
+
// If absent (invariant violation — sealed sessions always have it), emit null
|
|
729
|
+
// rather than a misleading epoch timestamp.
|
|
730
|
+
close_timestamp: record.close_timestamp ?? null,
|
|
731
|
+
attestation_self: "PENDING",
|
|
732
|
+
attestation_counterparty: "PENDING",
|
|
733
|
+
leaf_count: record.local_tree_leaves.length,
|
|
734
|
+
directory_signature: dirSigHex,
|
|
735
|
+
...checkpointFields,
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
// ── cello_get_inclusion_proof ──────────────────────────────────────────────
|
|
739
|
+
//
|
|
740
|
+
// RFC 6962 §2.1.1 inclusion proof from the local tree copy.
|
|
741
|
+
// Ref: RFC 6962 §2.1.1 — Merkle Audit Paths
|
|
742
|
+
// SI-001: MUST NOT return proof for seal_rejected sessions.
|
|
743
|
+
// SI-003: reconstructed root MUST equal sealed_root (self-consistency).
|
|
744
|
+
server.registerTool("cello_get_inclusion_proof", {
|
|
745
|
+
description: "Compute an RFC 6962 inclusion proof for a leaf in a sealed session.",
|
|
746
|
+
inputSchema: {
|
|
747
|
+
session_id: z.string().describe("Session ID as lowercase hex"),
|
|
748
|
+
leaf_index: z.number().int().min(0).describe("Zero-based index of the target leaf"),
|
|
749
|
+
},
|
|
750
|
+
}, async ({ session_id, leaf_index }) => {
|
|
751
|
+
// PERSIST-017: if CheckpointStatusProvider is wired in, check MMR checkpoint status first.
|
|
752
|
+
// Returns the pending/confirmed/error response based on staging table state.
|
|
753
|
+
if (checkpointStatusProvider) {
|
|
754
|
+
const stagingStatus = await checkpointStatusProvider.getSealStagingStatus(session_id);
|
|
755
|
+
if (stagingStatus.status === "pending") {
|
|
756
|
+
return jsonText({
|
|
757
|
+
status: "pending",
|
|
758
|
+
staged_at: stagingStatus.staged_at,
|
|
759
|
+
message: "sealed_root staged; inclusion proof available after next MMR checkpoint",
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
if (stagingStatus.status === "confirmed") {
|
|
763
|
+
return jsonText({
|
|
764
|
+
status: "confirmed",
|
|
765
|
+
leaf_index: stagingStatus.leaf_index,
|
|
766
|
+
checkpoint_peak_hash: stagingStatus.checkpoint_peak_hash,
|
|
767
|
+
checkpoint_id: stagingStatus.checkpoint_id,
|
|
768
|
+
sibling_hashes: stagingStatus.sibling_hashes,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
// not_staged: session was never sealed — error distinguishes from 'pending'
|
|
772
|
+
return jsonText({ status: "error", reason: "not_yet_sealed" });
|
|
773
|
+
}
|
|
774
|
+
const record = client.listSessions().find((s) => toHex(s.session_id) === session_id);
|
|
775
|
+
// (a) Check sealed status — SI-001: no proof for unsealed or seal_rejected
|
|
776
|
+
if (!record || record.status !== "sealed") {
|
|
777
|
+
return jsonText({ error: { reason: "session_not_sealed" } });
|
|
778
|
+
}
|
|
779
|
+
const treeSize = record.local_tree_leaves.length;
|
|
780
|
+
// (b) Validate leaf_index range — AC-008
|
|
781
|
+
if (leaf_index < 0 || leaf_index >= treeSize) {
|
|
782
|
+
return jsonText({
|
|
783
|
+
error: {
|
|
784
|
+
reason: "leaf_index_out_of_range",
|
|
785
|
+
leaf_index,
|
|
786
|
+
tree_size: treeSize,
|
|
787
|
+
},
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
// (c) Build the local tree from committed leaves — RFC 6962 §2.1
|
|
791
|
+
const inputs = record.local_tree_leaves.map((l) => ({
|
|
792
|
+
kind: l.kind,
|
|
793
|
+
data: l.s2_cbor,
|
|
794
|
+
}));
|
|
795
|
+
const tree = buildMerkleTree(inputs);
|
|
796
|
+
// Compute leaf hash from the leaf-level hashes
|
|
797
|
+
const leafHash = tree.levelHashes[0][leaf_index];
|
|
798
|
+
// (d) Generate proof — RFC 6962 §2.1.1
|
|
799
|
+
const proof = inclusionProof(tree, leaf_index);
|
|
800
|
+
const root = merkleRoot(tree);
|
|
801
|
+
// SI-003: self-consistency check — local tree root MUST equal directory-confirmed sealed_root.
|
|
802
|
+
// A mismatch means the local tree is inconsistent with what the directory notarized.
|
|
803
|
+
const localRootHex = toHex(root);
|
|
804
|
+
if (record.sealed_root) {
|
|
805
|
+
const directoryRootHex = toHex(record.sealed_root);
|
|
806
|
+
if (localRootHex !== directoryRootHex) {
|
|
807
|
+
return jsonText({
|
|
808
|
+
error: {
|
|
809
|
+
reason: "local_tree_inconsistent",
|
|
810
|
+
session_id,
|
|
811
|
+
local_root: localRootHex,
|
|
812
|
+
directory_root: directoryRootHex,
|
|
813
|
+
},
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return jsonText({
|
|
818
|
+
leaf_hash: toHex(leafHash),
|
|
819
|
+
leaf_index,
|
|
820
|
+
tree_size: treeSize,
|
|
821
|
+
proof: proof.map(toHex),
|
|
822
|
+
sealed_root: localRootHex,
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
// ── cello_register ────────────────────────────────────────────────────────
|
|
826
|
+
//
|
|
827
|
+
// REG-001: DKG registration. Idempotent — second call returns already_registered.
|
|
828
|
+
// SI-001: never emits ML-DSA secret key material.
|
|
829
|
+
server.registerTool("cello_register", {
|
|
830
|
+
description: "Register this agent with the CELLO directory. Runs the REG-001 DKG ceremony. Idempotent — returns already_registered if already done.",
|
|
831
|
+
inputSchema: {
|
|
832
|
+
phone_stub: z.string().describe("Phone stub for identity binding (format: +E.164 or hex stub)"),
|
|
833
|
+
pre_auth_token: z.string().optional().describe("OPS-AGENT-001: Pre-authorization token issued by POST /internal/pre-authorize. Required in M6+. Use any 'DEV-' prefixed token in CELLO_ENV=local."),
|
|
834
|
+
},
|
|
835
|
+
}, async ({ phone_stub, pre_auth_token }) => {
|
|
836
|
+
const result = await client.register(phone_stub, pre_auth_token);
|
|
837
|
+
if ("error" in result) {
|
|
838
|
+
return jsonText({ error: { reason: result.error } });
|
|
839
|
+
}
|
|
840
|
+
// SI-001: return only public fields — never the ML-DSA secret
|
|
841
|
+
return jsonText({
|
|
842
|
+
registered: true,
|
|
843
|
+
agent_id: result.agent_id,
|
|
844
|
+
primary_pubkey: result.primary_pubkey,
|
|
845
|
+
ml_dsa_pubkey: result.ml_dsa_pubkey,
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
// ── cello_request_connection ───────────────────────────────────────────────
|
|
849
|
+
//
|
|
850
|
+
// CONNREQ-002: Send Round 1 connection_request to target B.
|
|
851
|
+
// Blocks up to 5 min (directory default). Returns accepted/rejected/etc.
|
|
852
|
+
server.registerTool("cello_request_connection", {
|
|
853
|
+
description: "Send a connection request to a target agent. Blocks until the target responds (up to 5 minutes).",
|
|
854
|
+
inputSchema: {
|
|
855
|
+
target_pubkey: z.string().describe("Target agent's own_pubkey (Ed25519 identity key) as lowercase hex (64 chars). This is the value from cello_status().own_pubkey on the target agent — NOT their primary_pubkey."),
|
|
856
|
+
include_endorsements: z.boolean().optional().describe("Include endorsements in the connection package"),
|
|
857
|
+
include_attestations: z.boolean().optional().describe("Include attestations in the connection package"),
|
|
858
|
+
},
|
|
859
|
+
}, async ({ target_pubkey }) => {
|
|
860
|
+
const extendedClient = client;
|
|
861
|
+
if (typeof extendedClient.cello_request_connection !== "function") {
|
|
862
|
+
return jsonText({ error: { reason: "not_registered" } });
|
|
863
|
+
}
|
|
864
|
+
// Build a real ConnectionPackage (pseudonym binding) from registration state.
|
|
865
|
+
// Falls back to an empty package if the agent is not yet registered.
|
|
866
|
+
let packageCbor = new Uint8Array(0);
|
|
867
|
+
const regState = typeof extendedClient.getRegistrationState === "function"
|
|
868
|
+
? extendedClient.getRegistrationState()
|
|
869
|
+
: null;
|
|
870
|
+
const mlDsaProvider = typeof extendedClient.getMlDsaProvider === "function"
|
|
871
|
+
? extendedClient.getMlDsaProvider()
|
|
872
|
+
: null;
|
|
873
|
+
if (regState && mlDsaProvider) {
|
|
874
|
+
const kLocalPubkey = await keyProvider.getPublicKey();
|
|
875
|
+
const mlDsaPubkey = await mlDsaProvider.getPublicKey();
|
|
876
|
+
const bindingResult = await buildPseudonymBinding({
|
|
877
|
+
pseudonym_label: regState.agent_id,
|
|
878
|
+
k_local_pubkey: new Uint8Array(kLocalPubkey),
|
|
879
|
+
primary_pubkey: Buffer.from(regState.primary_pubkey, "hex"),
|
|
880
|
+
ml_dsa_pubkey: new Uint8Array(mlDsaPubkey),
|
|
881
|
+
created_at: regState.registered_at ?? Date.now(),
|
|
882
|
+
}, mlDsaProvider);
|
|
883
|
+
if (bindingResult.ok) {
|
|
884
|
+
const pkg = {
|
|
885
|
+
pseudonym_binding: bindingResult.binding,
|
|
886
|
+
endorsements: [],
|
|
887
|
+
attestations: [],
|
|
888
|
+
};
|
|
889
|
+
packageCbor = encodeConnectionPackage(pkg);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
const result = await extendedClient.cello_request_connection({
|
|
893
|
+
target_pubkey,
|
|
894
|
+
package_cbor: packageCbor,
|
|
895
|
+
});
|
|
896
|
+
if (result.result === "established") {
|
|
897
|
+
return jsonText({ result: "accepted", connection_id: result.connection_id });
|
|
898
|
+
}
|
|
899
|
+
if (result.result === "rejected") {
|
|
900
|
+
return jsonText({ result: "rejected", reason: result.reason });
|
|
901
|
+
}
|
|
902
|
+
if (result.result === "insufficient") {
|
|
903
|
+
return jsonText({ result: "insufficient", unmet_requirements: result.unmet_requirements });
|
|
904
|
+
}
|
|
905
|
+
if (result.result === "disclosure_requested") {
|
|
906
|
+
return jsonText({
|
|
907
|
+
result: "disclosure_requested",
|
|
908
|
+
connection_request_id: result.connection_request_id,
|
|
909
|
+
requested_items: result.requested_items,
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
if (result.result === "timeout") {
|
|
913
|
+
return jsonText({ result: "timeout" });
|
|
914
|
+
}
|
|
915
|
+
return jsonText({ error: { reason: result.reason } });
|
|
916
|
+
});
|
|
917
|
+
// ── cello_respond_to_disclosure_request ───────────────────────────────────
|
|
918
|
+
//
|
|
919
|
+
// CONNREQ-002 Round 2 sender side. Responds to disclosure_requested with updated package.
|
|
920
|
+
server.registerTool("cello_respond_to_disclosure_request", {
|
|
921
|
+
description: "Respond to a disclosure request (Round 2 sender side). Sends updated disclosure to target and awaits final decision.",
|
|
922
|
+
inputSchema: {
|
|
923
|
+
connection_request_id: z.string().describe("Connection request ID from the disclosure_requested response"),
|
|
924
|
+
include_endorsements: z.boolean().optional().describe("Include endorsements in the updated package"),
|
|
925
|
+
include_attestations: z.boolean().optional().describe("Include attestations in the updated package"),
|
|
926
|
+
},
|
|
927
|
+
}, async ({ connection_request_id }) => {
|
|
928
|
+
const extendedClient = client;
|
|
929
|
+
if (typeof extendedClient.cello_respond_to_disclosure_request !== "function") {
|
|
930
|
+
return jsonText({ error: { reason: "no_pending_disclosure_request" } });
|
|
931
|
+
}
|
|
932
|
+
// Build a real package for Round 2 (same structure as Round 1).
|
|
933
|
+
let packageCbor = new Uint8Array(0);
|
|
934
|
+
const regState = typeof extendedClient.getRegistrationState === "function"
|
|
935
|
+
? extendedClient.getRegistrationState()
|
|
936
|
+
: null;
|
|
937
|
+
const mlDsaProvider = typeof extendedClient.getMlDsaProvider === "function"
|
|
938
|
+
? extendedClient.getMlDsaProvider()
|
|
939
|
+
: null;
|
|
940
|
+
if (regState && mlDsaProvider) {
|
|
941
|
+
const kLocalPubkey = await keyProvider.getPublicKey();
|
|
942
|
+
const mlDsaPubkey = await mlDsaProvider.getPublicKey();
|
|
943
|
+
const bindingResult = await buildPseudonymBinding({
|
|
944
|
+
pseudonym_label: regState.agent_id,
|
|
945
|
+
k_local_pubkey: new Uint8Array(kLocalPubkey),
|
|
946
|
+
primary_pubkey: Buffer.from(regState.primary_pubkey, "hex"),
|
|
947
|
+
ml_dsa_pubkey: new Uint8Array(mlDsaPubkey),
|
|
948
|
+
created_at: regState.registered_at ?? Date.now(),
|
|
949
|
+
}, mlDsaProvider);
|
|
950
|
+
if (bindingResult.ok) {
|
|
951
|
+
const pkg = {
|
|
952
|
+
pseudonym_binding: bindingResult.binding,
|
|
953
|
+
endorsements: [],
|
|
954
|
+
attestations: [],
|
|
955
|
+
};
|
|
956
|
+
packageCbor = encodeConnectionPackage(pkg);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
const result = await extendedClient.cello_respond_to_disclosure_request({
|
|
960
|
+
connection_request_id,
|
|
961
|
+
package_cbor: packageCbor,
|
|
962
|
+
});
|
|
963
|
+
if (result.result === "established") {
|
|
964
|
+
return jsonText({ result: "accepted", connection_id: result.connection_id });
|
|
965
|
+
}
|
|
966
|
+
if (result.result === "rejected") {
|
|
967
|
+
return jsonText({ result: "rejected", reason: result.reason });
|
|
968
|
+
}
|
|
969
|
+
if (result.result === "timeout") {
|
|
970
|
+
return jsonText({ result: "timeout" });
|
|
971
|
+
}
|
|
972
|
+
return jsonText({ error: { reason: "already_responded" } });
|
|
973
|
+
});
|
|
974
|
+
// ── cello_await_connection_request ────────────────────────────────────────
|
|
975
|
+
//
|
|
976
|
+
// MCP-003: blocks until an inference-mode inbound request needs review,
|
|
977
|
+
// or timeout expires.
|
|
978
|
+
// SI-003: ConnectionReport must not contain raw signatures or full pubkeys.
|
|
979
|
+
server.registerTool("cello_await_connection_request", {
|
|
980
|
+
description: "Wait for an inbound connection request that requires agent review (inference mode), or timeout.",
|
|
981
|
+
inputSchema: {
|
|
982
|
+
timeout_ms: z.number().int().min(0).optional().describe("Maximum wait time in ms. Default: 30000"),
|
|
983
|
+
},
|
|
984
|
+
}, async ({ timeout_ms }) => {
|
|
985
|
+
const result = await client.awaitConnectionRequest(timeout_ms ?? 30_000);
|
|
986
|
+
if (result.type === "timeout") {
|
|
987
|
+
return jsonText({ type: "timeout" });
|
|
988
|
+
}
|
|
989
|
+
// SI-003: ConnectionReport is already human-readable (no raw signatures).
|
|
990
|
+
return jsonText({
|
|
991
|
+
type: "pending_review",
|
|
992
|
+
connection_request_id: result.connection_request_id,
|
|
993
|
+
from_pubkey: result.from_pubkey,
|
|
994
|
+
report: result.report,
|
|
995
|
+
});
|
|
996
|
+
});
|
|
997
|
+
// ── cello_accept_connection ────────────────────────────────────────────────
|
|
998
|
+
//
|
|
999
|
+
// Accepts a pending inbound connection request in inference review mode.
|
|
1000
|
+
server.registerTool("cello_accept_connection", {
|
|
1001
|
+
description: "Accept a pending inbound connection request (inference review mode). Returns { accepted: true, connection_id }.",
|
|
1002
|
+
inputSchema: {
|
|
1003
|
+
connection_request_id: z.string().describe("Connection request ID from cello_await_connection_request"),
|
|
1004
|
+
},
|
|
1005
|
+
}, async ({ connection_request_id }) => {
|
|
1006
|
+
const result = await client.acceptConnection(connection_request_id);
|
|
1007
|
+
if ("error" in result) {
|
|
1008
|
+
return jsonText({ error: result.error });
|
|
1009
|
+
}
|
|
1010
|
+
return jsonText({ accepted: true, connection_id: result.connection_id });
|
|
1011
|
+
});
|
|
1012
|
+
// ── cello_reject_connection ────────────────────────────────────────────────
|
|
1013
|
+
//
|
|
1014
|
+
// Rejects a pending inbound connection request in inference review mode.
|
|
1015
|
+
server.registerTool("cello_reject_connection", {
|
|
1016
|
+
description: "Reject a pending inbound connection request (inference review mode). Returns { rejected: true }.",
|
|
1017
|
+
inputSchema: {
|
|
1018
|
+
connection_request_id: z.string().describe("Connection request ID from cello_await_connection_request"),
|
|
1019
|
+
reason: z.string().optional().describe("Optional rejection reason"),
|
|
1020
|
+
},
|
|
1021
|
+
}, async ({ connection_request_id, reason }) => {
|
|
1022
|
+
const result = await client.rejectConnection(connection_request_id, reason);
|
|
1023
|
+
if ("error" in result) {
|
|
1024
|
+
return jsonText({ error: result.error });
|
|
1025
|
+
}
|
|
1026
|
+
return jsonText({ rejected: true });
|
|
1027
|
+
});
|
|
1028
|
+
// ── cello_request_more_disclosure ─────────────────────────────────────────
|
|
1029
|
+
//
|
|
1030
|
+
// Target-side: asks sender for additional disclosure items (Round 2 initiation).
|
|
1031
|
+
// Only valid in Round 1. Returns max_rounds_reached if already in Round 2.
|
|
1032
|
+
server.registerTool("cello_request_more_disclosure", {
|
|
1033
|
+
description: "Ask the connection requester for additional disclosure (Round 2). Only valid once per request.",
|
|
1034
|
+
inputSchema: {
|
|
1035
|
+
connection_request_id: z.string().describe("Connection request ID from cello_await_connection_request"),
|
|
1036
|
+
requested_items: z.array(z.record(z.string(), z.unknown())).describe("List of disclosure items to request"),
|
|
1037
|
+
},
|
|
1038
|
+
}, async ({ connection_request_id, requested_items }) => {
|
|
1039
|
+
const result = await client.requestMoreDisclosure(connection_request_id, requested_items);
|
|
1040
|
+
if ("error" in result) {
|
|
1041
|
+
return jsonText({ error: result.error });
|
|
1042
|
+
}
|
|
1043
|
+
return jsonText({ request_sent: true });
|
|
1044
|
+
});
|
|
1045
|
+
// ── cello_list_connections ─────────────────────────────────────────────────
|
|
1046
|
+
//
|
|
1047
|
+
// Returns all active connection records (no raw crypto).
|
|
1048
|
+
server.registerTool("cello_list_connections", {
|
|
1049
|
+
description: "List all active connections for this agent.",
|
|
1050
|
+
inputSchema: {},
|
|
1051
|
+
}, async () => {
|
|
1052
|
+
const records = client.listConnections();
|
|
1053
|
+
const connections = records.map((c) => ({
|
|
1054
|
+
connection_id: c.connection_id,
|
|
1055
|
+
counterparty_pubkey: c.counterparty_pubkey,
|
|
1056
|
+
counterparty_primary_pubkey: c.counterparty_primary_pubkey,
|
|
1057
|
+
established_at: c.established_at,
|
|
1058
|
+
status: c.status,
|
|
1059
|
+
}));
|
|
1060
|
+
return jsonText({ connections });
|
|
1061
|
+
});
|
|
1062
|
+
// ── cello_set_policy ──────────────────────────────────────────────────────
|
|
1063
|
+
//
|
|
1064
|
+
// Configure the connection policy engine (mode + review_mode + requirements).
|
|
1065
|
+
server.registerTool("cello_set_policy", {
|
|
1066
|
+
description: "Configure the connection policy for evaluating inbound connection requests.",
|
|
1067
|
+
inputSchema: {
|
|
1068
|
+
mode: z.enum(["open", "selective", "guarded", "closed"]).describe("Policy mode"),
|
|
1069
|
+
review_mode: z.enum(["deterministic", "inference"]).describe("Review mode"),
|
|
1070
|
+
requirements: z.array(z.object({
|
|
1071
|
+
signal_type: z.enum(["endorsement", "attestation", "pseudonym_age", "registration_age"]),
|
|
1072
|
+
condition: z.record(z.string(), z.unknown()),
|
|
1073
|
+
})).optional().describe("Signal requirements (optional)"),
|
|
1074
|
+
},
|
|
1075
|
+
}, async ({ mode, review_mode, requirements }) => {
|
|
1076
|
+
const policy = {
|
|
1077
|
+
mode,
|
|
1078
|
+
review_mode,
|
|
1079
|
+
requirements: (requirements ?? []),
|
|
1080
|
+
};
|
|
1081
|
+
const setFn = client.setPolicy;
|
|
1082
|
+
if (typeof setFn === "function") {
|
|
1083
|
+
setFn.call(client, policy);
|
|
1084
|
+
}
|
|
1085
|
+
return jsonText({
|
|
1086
|
+
policy_set: true,
|
|
1087
|
+
mode,
|
|
1088
|
+
review_mode,
|
|
1089
|
+
requirement_count: policy.requirements.length,
|
|
1090
|
+
});
|
|
1091
|
+
});
|
|
1092
|
+
// ── cello_get_policy ──────────────────────────────────────────────────────
|
|
1093
|
+
//
|
|
1094
|
+
// Returns the current connection policy.
|
|
1095
|
+
server.registerTool("cello_get_policy", {
|
|
1096
|
+
description: "Return the current connection policy configuration.",
|
|
1097
|
+
inputSchema: {},
|
|
1098
|
+
}, async () => {
|
|
1099
|
+
const getFn = client.getPolicy;
|
|
1100
|
+
const policy = typeof getFn === "function"
|
|
1101
|
+
? getFn.call(client)
|
|
1102
|
+
: { mode: "open", review_mode: "deterministic", requirements: [] };
|
|
1103
|
+
return jsonText({
|
|
1104
|
+
mode: policy.mode,
|
|
1105
|
+
review_mode: policy.review_mode,
|
|
1106
|
+
requirements: policy.requirements,
|
|
1107
|
+
});
|
|
1108
|
+
});
|
|
1109
|
+
// ── cello_backup ──────────────────────────────────────────────────────────
|
|
1110
|
+
//
|
|
1111
|
+
// PERSIST-022: Trigger an immediate encrypted backup of the local database.
|
|
1112
|
+
// Returns { ok: true } on success or when not configured (null cloudStorage).
|
|
1113
|
+
// Returns { ok: false, reason } on upload failure or other errors.
|
|
1114
|
+
server.registerTool("cello_backup", {
|
|
1115
|
+
description: "Trigger an immediate encrypted backup of the local CELLO database to cloud storage. " +
|
|
1116
|
+
"Returns ok:true on success. Returns ok:false with reason if backup fails or is not configured.",
|
|
1117
|
+
inputSchema: {},
|
|
1118
|
+
}, async () => {
|
|
1119
|
+
if (!clientBackup) {
|
|
1120
|
+
return jsonText({ ok: false, reason: "not_configured" });
|
|
1121
|
+
}
|
|
1122
|
+
try {
|
|
1123
|
+
const result = await clientBackup.backup();
|
|
1124
|
+
return jsonText(result);
|
|
1125
|
+
}
|
|
1126
|
+
catch (err) {
|
|
1127
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1128
|
+
return jsonText({ ok: false, reason });
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
// ── cello_restore ─────────────────────────────────────────────────────────
|
|
1132
|
+
//
|
|
1133
|
+
// PERSIST-022: Download and decrypt the backup, replacing the local database file.
|
|
1134
|
+
// On checksum mismatch or decrypt failure the local file is NOT overwritten and restore throws.
|
|
1135
|
+
server.registerTool("cello_restore", {
|
|
1136
|
+
description: "Restore the local CELLO database from the most recent cloud backup. " +
|
|
1137
|
+
"Replaces the local database file only after checksum verification passes. " +
|
|
1138
|
+
"Returns ok:true on success. Returns ok:false with reason if restore fails or is not configured.",
|
|
1139
|
+
inputSchema: {},
|
|
1140
|
+
}, async () => {
|
|
1141
|
+
if (!clientBackup) {
|
|
1142
|
+
return jsonText({ ok: false, reason: "not_configured" });
|
|
1143
|
+
}
|
|
1144
|
+
try {
|
|
1145
|
+
await clientBackup.restore();
|
|
1146
|
+
return jsonText({ ok: true });
|
|
1147
|
+
}
|
|
1148
|
+
catch (err) {
|
|
1149
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1150
|
+
return jsonText({ ok: false, reason });
|
|
1151
|
+
}
|
|
1152
|
+
});
|
|
1153
|
+
return server;
|
|
1154
|
+
}
|
|
1155
|
+
//# sourceMappingURL=mcp-server.js.map
|