@prismnetwork/mcp 0.9.2 → 0.9.4

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/README.md CHANGED
@@ -76,7 +76,7 @@ real prices. Leasing spends money, so those tools ask for a wallet and say so.
76
76
  - `prism_batch_run`: fund a lease that runs one command with no interactive
77
77
  access; the node reports the signed output. Matches only suppliers at trust
78
78
  class `isolated` or above, so it can find no supplier when none is online.
79
- - `prism_end_lease`: release a lease.
79
+ - `prism_end_lease`: release a lease. Billing stops at the release; the unused deposit returns after settlement.
80
80
  - `prism_vault_store`: seal private data under the wallet-derived key.
81
81
  - `prism_vault_list`: list sealed items; values are never returned.
82
82
  - `prism_vault_read`: decrypt one item in this process.
package/budget.mjs CHANGED
@@ -7,10 +7,25 @@
7
7
  // Spend is written before the money moves and reverted only when the attempt
8
8
  // provably cost nothing, so a crash between funding and reply is counted rather
9
9
  // than forgiven. The file is shared across clients on purpose: one wallet gets
10
- // one daily ceiling whoever is holding it.
11
- import { mkdirSync, readFileSync, renameSync, writeFileSync, openSync, closeSync, unlinkSync, statSync } from "node:fs";
10
+ // one daily ceiling whoever is holding it, and the Python SDK's `_budget.py`
11
+ // reads and writes the same entries this module does.
12
+ import {
13
+ closeSync,
14
+ fsyncSync,
15
+ mkdirSync,
16
+ openSync,
17
+ readFileSync,
18
+ readlinkSync,
19
+ realpathSync,
20
+ renameSync,
21
+ statSync,
22
+ unlinkSync,
23
+ utimesSync,
24
+ writeSync,
25
+ } from "node:fs";
26
+ import { randomBytes } from "node:crypto";
12
27
  import { homedir } from "node:os";
13
- import { dirname, join } from "node:path";
28
+ import { basename, dirname, join, resolve } from "node:path";
14
29
 
15
30
  const MICROS = 1_000_000;
16
31
  const DAY_MS = 86_400_000;
@@ -19,6 +34,31 @@ const DAY_MS = 86_400_000;
19
34
  const MEMORY_MS = 2 * DAY_MS;
20
35
  const LOCK_STALE_MS = 15_000;
21
36
  const LOCK_WAIT_MS = 5_000;
37
+ // Above this a USDG figure is a typo or an attack, and multiplying it out is how
38
+ // the arithmetic stops being arithmetic.
39
+ const MAX_CALL_USDG = 1e12;
40
+ // The last instant both languages can name: Python's datetime stops at year
41
+ // 9999 where this one keeps going, so a stamp past this is one only this client
42
+ // could print, and it would sit in the file forever.
43
+ export const MAX_AT_MS = 253_402_300_799_999;
44
+ // One number grammar for both languages. Node's Number() takes "0x10" and
45
+ // Python's float() takes "1_000", and an operator who typed either meant
46
+ // neither. \d is ASCII here and every decimal digit there is in Python, so the
47
+ // range is spelled out: Python reads "٣" as a 3 and no ledger should.
48
+ const DECIMAL = /^[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$/;
49
+ // The two languages also disagree about what padding is: trim() takes U+FEFF
50
+ // where Python's strip does not, and Python's takes U+001C where trim() does
51
+ // not. Trimming these four leaves one answer on both sides.
52
+ const PADDING = new Set([" ", "\t", "\r", "\n"]);
53
+
54
+ function trim(value) {
55
+ const text = String(value);
56
+ let start = 0;
57
+ let end = text.length;
58
+ while (start < end && PADDING.has(text[start])) start += 1;
59
+ while (end > start && PADDING.has(text[end - 1])) end -= 1;
60
+ return text.slice(start, end);
61
+ }
22
62
 
23
63
  export class BudgetError extends Error {
24
64
  constructor(message, detail = {}) {
@@ -30,9 +70,14 @@ export class BudgetError extends Error {
30
70
 
31
71
  export const usdg = (micros) => `${(Number(micros) / MICROS).toFixed(6)} USDG`;
32
72
 
73
+ const isAmount = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0;
74
+
75
+ const isStamp = (value) => isAmount(value) && value <= MAX_AT_MS;
76
+
33
77
  function positiveNumber(raw, fallback, name) {
34
- if (raw === undefined || raw === null || String(raw).trim() === "") return fallback;
35
- const value = Number(raw);
78
+ const text = raw === undefined || raw === null ? "" : trim(raw);
79
+ if (text === "") return fallback;
80
+ const value = DECIMAL.test(text) ? Number(text) : Number.NaN;
36
81
  if (!Number.isFinite(value) || value < 0) {
37
82
  throw new BudgetError(`${name} must be a non-negative number of USDG, got ${JSON.stringify(raw)}`);
38
83
  }
@@ -45,7 +90,7 @@ function positiveNumber(raw, fallback, name) {
45
90
  // "wallet configured and broken", so an unexpanded placeholder is nothing.
46
91
  export function stripUnexpanded(env = process.env) {
47
92
  for (const [key, value] of Object.entries(env)) {
48
- if (key.startsWith("PRISM_") && /^\$\{[^}]*\}$/.test(String(value ?? "").trim())) delete env[key];
93
+ if (key.startsWith("PRISM_") && /^\$\{[^}]*\}$/.test(trim(value ?? ""))) delete env[key];
49
94
  }
50
95
  return env;
51
96
  }
@@ -86,22 +131,76 @@ export function callCeiling(maxUsdg, ceilingMicros) {
86
131
  if (typeof maxUsdg !== "number" || !Number.isFinite(maxUsdg) || maxUsdg <= 0) {
87
132
  throw new BudgetError("max_usdg must be a positive number of USDG.");
88
133
  }
134
+ // Checked before the multiplication, because that is where a figure this size
135
+ // stops being a number.
136
+ if (maxUsdg > MAX_CALL_USDG) throw new BudgetError(`max_usdg must be at most ${MAX_CALL_USDG} USDG.`);
89
137
  return Math.min(Math.round(maxUsdg * MICROS), ceilingMicros);
90
138
  }
91
139
 
140
+ function lockOwner(lock) {
141
+ try {
142
+ return readFileSync(lock, "latin1").slice(0, 128);
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ // A holder whose lock was broken as stale must not delete the lock its breaker
149
+ // now holds, which is how two processes end up inside the ledger at once.
150
+ function release(lock, token) {
151
+ if (lockOwner(lock) !== token) return;
152
+ try {
153
+ unlinkSync(lock);
154
+ } catch {
155
+ /* already gone */
156
+ }
157
+ }
158
+
159
+ // The lock this process is writing under, so the write itself can keep it alive
160
+ // and refuse to publish under someone else's. A timer would do it in Python,
161
+ // where the heartbeat runs on a thread; here the whole ledger is synchronous,
162
+ // so nothing on this loop could fire between taking the lock and writing.
163
+ let writing = null;
164
+
165
+ function touch(lock, token) {
166
+ // Refreshing a lock that was broken and retaken would be this process
167
+ // vouching for a lock it does not hold.
168
+ if (lockOwner(lock) !== token) return;
169
+ const stamp = new Date();
170
+ try {
171
+ utimesSync(lock, stamp, stamp);
172
+ } catch {
173
+ /* it went while we were looking at it */
174
+ }
175
+ }
176
+
177
+ const keepLock = () => writing && touch(writing.lock, writing.token);
178
+
179
+ const holdingLock = () => writing === null || lockOwner(writing.lock) === writing.token;
180
+
92
181
  // A lock rather than last-write-wins, because two clients sharing one wallet is
93
- // the case this file exists for. A lock older than LOCK_STALE_MS belonged to a
94
- // process that died; breaking it is safe and not breaking it wedges the wallet.
95
- function withLock(path, fn, waitMs = LOCK_WAIT_MS) {
96
- const lock = `${path}.lock`;
182
+ // the case this file exists for. A holder refreshes its lock as it writes, so a
183
+ // lock older than LOCK_STALE_MS belonged to a process that died; breaking it is
184
+ // safe and not breaking it wedges the wallet. A break that happens anyway,
185
+ // because a machine slept or a clock stepped, is caught at the write.
186
+ //
187
+ // Exported because the tests that matter here need two real processes inside it.
188
+ export function withLock(path, fn, waitMs = LOCK_WAIT_MS) {
97
189
  mkdirSync(dirname(path), { recursive: true });
190
+ // The lock names the file the write lands on rather than the name the caller
191
+ // spelled. One client reaching the ledger through a link and another through
192
+ // its target would otherwise hold two different locks over one file, and each
193
+ // would publish a state read before the other's charge existed.
194
+ const lock = `${resolveTarget(path)}.lock`;
98
195
  const deadline = Date.now() + waitMs;
196
+ const token = `${process.pid}-${randomBytes(8).toString("hex")}`;
99
197
  for (;;) {
100
198
  let fd;
101
199
  try {
102
- fd = openSync(lock, "wx");
200
+ fd = openSync(lock, "wx", 0o600);
103
201
  } catch (err) {
104
202
  if (err?.code !== "EEXIST") throw err;
203
+ const held = lockOwner(lock);
105
204
  let age = 0;
106
205
  try {
107
206
  age = Date.now() - statSync(lock).mtimeMs;
@@ -109,10 +208,15 @@ function withLock(path, fn, waitMs = LOCK_WAIT_MS) {
109
208
  continue; // it vanished between the open and the stat; retry immediately
110
209
  }
111
210
  if (age > LOCK_STALE_MS) {
112
- try {
113
- unlinkSync(lock);
114
- } catch {
115
- /* another process broke it first, which is the outcome we wanted */
211
+ // Break the lock that was read as stale and no other: re-reading the
212
+ // token keeps a slow breaker from deleting a lock a third process took
213
+ // in the meantime.
214
+ if (lockOwner(lock) === held) {
215
+ try {
216
+ unlinkSync(lock);
217
+ } catch {
218
+ /* another process broke it first, which is the outcome we wanted */
219
+ }
116
220
  }
117
221
  continue;
118
222
  }
@@ -127,38 +231,140 @@ function withLock(path, fn, waitMs = LOCK_WAIT_MS) {
127
231
  continue;
128
232
  }
129
233
  try {
234
+ writeSync(fd, token);
235
+ fsyncSync(fd);
236
+ } catch (err) {
130
237
  closeSync(fd);
238
+ release(lock, token);
239
+ throw err;
240
+ }
241
+ closeSync(fd);
242
+ const outer = writing;
243
+ writing = { lock, token };
244
+ try {
131
245
  return fn();
132
246
  } finally {
133
- try {
134
- unlinkSync(lock);
135
- } catch {
136
- /* already gone */
137
- }
247
+ writing = outer;
248
+ release(lock, token);
138
249
  }
139
250
  }
140
251
  }
141
252
 
253
+ const unreadable = (path, reason) =>
254
+ new BudgetError(`the spend ledger at ${path} is unreadable (${reason}), so spending is refused. Move or repair the file.`);
255
+
142
256
  function readState(path) {
257
+ let raw;
143
258
  try {
144
- const parsed = JSON.parse(readFileSync(path, "utf8"));
145
- if (!parsed || !Array.isArray(parsed.entries)) return { entries: [] };
146
- return { entries: parsed.entries.filter((e) => e && Number.isFinite(e.at) && Number.isFinite(e.micros)) };
259
+ raw = readFileSync(path);
147
260
  } catch (err) {
148
261
  if (err?.code === "ENOENT") return { entries: [] };
262
+ throw unreadable(path, err?.message ?? err);
263
+ }
264
+ let parsed;
265
+ try {
266
+ // Invalid UTF-8 is refused rather than patched up with replacement
267
+ // characters, because the bytes a ledger cannot read are the bytes it must
268
+ // not spend against.
269
+ parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw));
270
+ } catch (err) {
149
271
  // A corrupt ledger must not read as an empty one: that would hand the
150
272
  // caller a fresh day's budget every time the file got truncated.
151
- throw new BudgetError(
152
- `the spend ledger at ${path} is unreadable (${err?.message ?? err}), so spending is refused. Move or repair the file.`,
153
- );
273
+ throw unreadable(path, err?.message ?? err);
274
+ }
275
+ const entries = parsed && typeof parsed === "object" ? parsed.entries : undefined;
276
+ if (!Array.isArray(entries)) throw unreadable(path, "it holds no list of entries");
277
+ // Dropping the entries that fail this check would be the corrupt-reads-as-
278
+ // empty bug again, one charge at a time.
279
+ for (const entry of entries) {
280
+ if (!entry || typeof entry !== "object" || !isStamp(entry.at) || !isAmount(entry.micros)) {
281
+ throw unreadable(path, "an entry is not a charge with non-negative micros and an at a date can hold");
282
+ }
283
+ }
284
+ return { entries };
285
+ }
286
+
287
+ // The entry is written before the money moves, so it has to survive the crash
288
+ // that lands between the two.
289
+ function fsyncDir(directory) {
290
+ let fd;
291
+ try {
292
+ fd = openSync(directory, "r");
293
+ } catch {
294
+ return; // not every platform lets a directory be opened, and the rename is still ordered
295
+ }
296
+ try {
297
+ fsyncSync(fd);
298
+ } catch {
299
+ /* some filesystems refuse it */
300
+ } finally {
301
+ closeSync(fd);
302
+ }
303
+ }
304
+
305
+ // A symlinked ledger is written through, not replaced: an operator who pointed
306
+ // the path at a file elsewhere means to keep reading that file. realpathSync
307
+ // gives up when the target does not exist yet, so a link is followed by hand
308
+ // and the last name is resolved against its own directory, which is how the
309
+ // first write creates the file through the link rather than over it.
310
+ function resolveTarget(path) {
311
+ let current = path;
312
+ for (let hop = 0; hop < 40; hop += 1) {
313
+ try {
314
+ return realpathSync(current);
315
+ } catch {
316
+ /* nothing there yet */
317
+ }
318
+ let link;
319
+ try {
320
+ link = readlinkSync(current);
321
+ } catch {
322
+ break; // a plain name that is not there, which is the ordinary first write
323
+ }
324
+ current = resolve(dirname(current), link);
325
+ }
326
+ try {
327
+ return join(realpathSync(dirname(current)), basename(current));
328
+ } catch {
329
+ return current;
154
330
  }
155
331
  }
156
332
 
157
- function writeState(path, state, now) {
333
+ // Exported alongside withLock, for the same reason: what a write does when the
334
+ // lock under it changed hands can only be tested from a second real process.
335
+ export function writeState(path, state, now) {
158
336
  const entries = state.entries.filter((e) => now - e.at < MEMORY_MS);
159
- const tmp = `${path}.${process.pid}.tmp`;
160
- writeFileSync(tmp, `${JSON.stringify({ version: 1, entries }, null, 2)}\n`, { mode: 0o600 });
161
- renameSync(tmp, path);
337
+ const target = resolveTarget(path);
338
+ const tmp = `${target}.${process.pid}.tmp`;
339
+ try {
340
+ unlinkSync(tmp);
341
+ } catch {
342
+ /* nothing to clear */
343
+ }
344
+ keepLock();
345
+ // Exclusive, so a temp file left behind by anyone else cannot lend this one
346
+ // its permissions.
347
+ const fd = openSync(tmp, "wx", 0o600);
348
+ try {
349
+ writeSync(fd, `${JSON.stringify({ version: 1, entries }, null, 2)}\n`);
350
+ fsyncSync(fd);
351
+ } finally {
352
+ closeSync(fd);
353
+ }
354
+ // The last moment this is still a decision. A lock read as stale is broken by
355
+ // whoever wants it next, and if that happened while these bytes were being
356
+ // prepared they no longer describe the file: publishing them would erase the
357
+ // charge the breaker just recorded.
358
+ if (!holdingLock()) {
359
+ try {
360
+ unlinkSync(tmp);
361
+ } catch {
362
+ /* nothing to clear */
363
+ }
364
+ throw new BudgetError("lost the ledger lock; nothing written");
365
+ }
366
+ renameSync(tmp, target);
367
+ fsyncDir(dirname(target));
162
368
  }
163
369
 
164
370
  export function spentInWindow(entries, now) {
@@ -195,7 +401,7 @@ export class SpendLedger {
195
401
  .slice(0, 20)
196
402
  .map((e) => ({
197
403
  at: new Date(e.at).toISOString(),
198
- tool: e.tool,
404
+ ...("tool" in e ? { tool: e.tool } : {}),
199
405
  amount: usdg(e.micros),
200
406
  ...(e.reference ? { reference: e.reference } : {}),
201
407
  })),
@@ -215,6 +421,11 @@ export class SpendLedger {
215
421
  { required: micros, cap: this.maxPerCallMicros },
216
422
  );
217
423
  }
424
+ // A caller handing this nanoseconds writes an entry no reader can date and
425
+ // no write can prune, which wedges the ledger for good.
426
+ if (!isStamp(now)) {
427
+ throw new BudgetError("a charge has to be stamped in milliseconds since the epoch, and this one is not");
428
+ }
218
429
  return withLock(
219
430
  this.path,
220
431
  () => {
@@ -227,7 +438,10 @@ export class SpendLedger {
227
438
  { spent, requested: micros, cap: this.dailyMicros },
228
439
  );
229
440
  }
230
- const id = `${now.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
441
+ // The suffix separates two charges stamped in the same millisecond, and
442
+ // Math.random makes that a guess anyone sharing the file can make:
443
+ // settle and revert both take an id.
444
+ const id = `${Math.max(Math.trunc(now), 0).toString(36)}-${randomBytes(8).toString("hex")}`;
231
445
  state.entries.push({ id, at: now, tool, micros });
232
446
  writeState(this.path, state, now);
233
447
  return id;
@@ -252,24 +466,45 @@ export class SpendLedger {
252
466
 
253
467
  // Replaces the reserved figure with what was actually committed on-chain and
254
468
  // pins the receipt to it, so the ledger reads like a statement rather than a
255
- // list of intentions.
469
+ // list of intentions. The reservation is the ceiling the escrow was funded
470
+ // against, so settling can only lower it.
256
471
  //
257
472
  // A payment the endpoint never consumed is redeemed by the next attempt at the
258
473
  // same request, so one transaction can settle more than one reservation.
259
474
  // Booking each would charge the day twice for money that moved once: a
260
475
  // reference already on file keeps its entry and this one is released.
476
+ //
477
+ // The fold rests on a reference naming exactly one payment, which holds
478
+ // because recordSpend passes nothing but a transaction this process
479
+ // broadcast. It is bounded to the same tool inside the same day regardless:
480
+ // the file remembers two days, and folding today's reservation into a charge
481
+ // the window no longer counts would take a spend off the day's total rather
482
+ // than deduplicate one.
261
483
  settle(id, { micros, reference } = {}) {
262
484
  if (!id) return false;
263
485
  return withLock(this.path, () => {
486
+ const now = Date.now();
264
487
  const state = readState(this.path);
265
488
  const entry = state.entries.find((e) => e.id === id);
266
489
  if (!entry) return false;
267
- const booked = reference ? state.entries.find((e) => e.id !== id && e.reference === reference) : undefined;
490
+ const booked = reference
491
+ ? state.entries.find(
492
+ (e) => e.id !== id && e.reference === reference && e.tool === entry.tool && now - e.at < DAY_MS,
493
+ )
494
+ : undefined;
268
495
  const target = booked ?? entry;
269
- if (Number.isFinite(micros) && micros >= 0) target.micros = micros;
496
+ if (isAmount(micros)) {
497
+ if (micros > target.micros) {
498
+ console.error(
499
+ `prism: a settlement of ${usdg(micros)} is above the ${usdg(target.micros)} reserved for ledger entry ${target.id}; the reservation stands.`,
500
+ );
501
+ } else {
502
+ target.micros = micros;
503
+ }
504
+ }
270
505
  if (reference) target.reference = reference;
271
506
  if (booked) state.entries = state.entries.filter((e) => e.id !== id);
272
- writeState(this.path, state, Date.now());
507
+ writeState(this.path, state, now);
273
508
  return true;
274
509
  }, this.lockWaitMs);
275
510
  }
@@ -293,12 +528,15 @@ export async function recordSpend(book, tool, micros, run) {
293
528
  console.error(`prism mcp: could not ${action} the ledger entry for ${tool}: ${err?.message ?? err}`);
294
529
  }
295
530
  };
531
+ let outcome;
296
532
  try {
297
- const { value, settledMicros, reference } = await run();
298
- reconcile("settle", { micros: settledMicros, reference });
299
- return value;
533
+ outcome = await run();
300
534
  } catch (err) {
301
- const paid = err?.body?.funding_hash ?? err?.body?.payment_tx;
535
+ // Only a transaction this process put on the wire proves money moved. A
536
+ // failure's body is whatever the control plane sent back, and reading a
537
+ // hash out of it would let the far side decide which reservations stand and
538
+ // which later ones are folded into them.
539
+ const paid = typeof err?.broadcast === "string" && err.broadcast ? err.broadcast : null;
302
540
  if (paid) {
303
541
  reconcile("settle", { reference: paid });
304
542
  } else if (err?.code === "chain_error") {
@@ -313,4 +551,20 @@ export async function recordSpend(book, tool, micros, run) {
313
551
  }
314
552
  throw err;
315
553
  }
554
+ // The attempt ran, so the money is as gone as the caller's ability to
555
+ // describe it. The reservation stands and the shape is reported as the
556
+ // programming error it is.
557
+ // An array carries no settledMicros and no reference, so reading one as an
558
+ // outcome would book the reservation as if it had been settled and hand the
559
+ // caller undefined. Python refuses the same shape.
560
+ if (!outcome || typeof outcome !== "object" || Array.isArray(outcome)) {
561
+ reconcile("settle");
562
+ const shape = outcome === null ? "null" : Array.isArray(outcome) ? "array" : typeof outcome;
563
+ throw new BudgetError(
564
+ `${tool} reported ${shape} where the ledger needs an object of ` +
565
+ `value, settledMicros and reference; the reservation stands.`,
566
+ );
567
+ }
568
+ reconcile("settle", { micros: outcome.settledMicros, reference: outcome.reference });
569
+ return outcome.value;
316
570
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@prismnetwork/mcp",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "MCP server for leasing and running on Prism Network GPUs.",
5
- "mcpName": "io.github.winter0x/mcp",
5
+ "mcpName": "io.github.prismnetwork-tech/mcp",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "prism-mcp": "server.mjs"
@@ -18,7 +18,7 @@
18
18
  "dependencies": {
19
19
  "@modelcontextprotocol/sdk": "^1.0.0",
20
20
  "@phala/dcap-qvl": "^0.6.1",
21
- "@prismnetwork/agent-sdk": "^0.7.3",
21
+ "@prismnetwork/agent-sdk": "^0.7.8",
22
22
  "jose": "^6",
23
23
  "viem": "^2"
24
24
  },
@@ -33,11 +33,11 @@
33
33
  "homepage": "https://prismnetwork.tech",
34
34
  "repository": {
35
35
  "type": "git",
36
- "url": "git+https://github.com/winter0x/prism.git",
36
+ "url": "git+https://github.com/prismnetwork-tech/prism.git",
37
37
  "directory": "mcp"
38
38
  },
39
39
  "bugs": {
40
- "url": "https://github.com/winter0x/prism/issues"
40
+ "url": "https://github.com/prismnetwork-tech/prism/issues"
41
41
  },
42
42
  "license": "Apache-2.0",
43
43
  "publishConfig": {
package/server.mjs CHANGED
@@ -412,7 +412,7 @@ const TOOLS = [
412
412
  },
413
413
  {
414
414
  name: "prism_end_lease",
415
- description: "Release a lease's local access. The on-chain lease settles at the end of its paid duration.",
415
+ description: "Release a lease. Access closes and billing stops here: settlement charges the seconds the lease was open and returns the rest of the deposit. A lease nobody releases bills until its window ends.",
416
416
  inputSchema: {
417
417
  type: "object",
418
418
  properties: { lease_id: { type: "integer" } },
@@ -789,11 +789,13 @@ async function handle(name, args) {
789
789
  if (name === "prism_end_lease") {
790
790
  const id = leaseId(args.lease_id);
791
791
  const lease = leases.get(id);
792
- if (lease) {
793
- agent.endLease(lease);
794
- leases.delete(id);
792
+ if (!lease) return { lease_id: id, released: false, next: "no lease with this id is open in this session; prism_leases lists the wallet's leases" };
793
+ leases.delete(id);
794
+ const out = await agent.endLease(lease);
795
+ if (out.release === "failed") {
796
+ return { lease_id: id, released: false, error: out.error, next: "the access key is gone but the meter may still be running; check prism_receipts for the settled charge" };
795
797
  }
796
- return { lease_id: id, released: Boolean(lease) };
798
+ return { lease_id: id, released: true, release: out.release, next: "billing stopped here; the unused deposit returns after settlement and prism_receipts shows the charge" };
797
799
  }
798
800
  if (name.startsWith("prism_vault_")) return handleVault(name, args);
799
801
  throw new Error(`unknown tool ${name}. Valid tools: ${TOOLS.map((t) => t.name).join(", ")}`);