@prismnetwork/mcp 0.9.3 → 0.10.0

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.
@@ -106,6 +106,32 @@ share the ledger file.
106
106
  None of this is the real limit. Fund a dedicated wallet with what you are
107
107
  willing to lose: that balance is what survives a bug in everything above.
108
108
 
109
+ ## Spend policy
110
+
111
+ The limits above cap how much an agent spends. A spend policy decides whether a
112
+ given lease has a reason you accept. Set `PRISM_SPEND_POLICY` to the policy as
113
+ JSON, or to the path of a JSON file:
114
+
115
+ ```json
116
+ {
117
+ "policy_id": "gpu-v1",
118
+ "allow": ["fine_tune", "benchmark"],
119
+ "require": ["needs_gpu"],
120
+ "minimums": { "needs_gpu": 0.8 }
121
+ }
122
+ ```
123
+
124
+ Every lease tool then takes a `decision`: the action, what made the call, and
125
+ typed answers with a confidence from 0 to 1. A decision that misses the policy
126
+ is refused with every reason before anything is quoted, funded or counted
127
+ against the budget. One that passes is hashed into the escrow deposit, so the
128
+ lease on chain carries a reference only that decision reproduces. `prism_budget`
129
+ shows the policy in force. Without `PRISM_SPEND_POLICY` a decision is optional
130
+ and still binds when given.
131
+
132
+ The binding shows a decision existed before the money moved and met your rules.
133
+ It does not show the decision was sound.
134
+
109
135
  Tools that spend are annotated `destructiveHint` and carry
110
136
  `anthropic/requiresUserInteraction`, so Claude Code asks before every one of
111
137
  them even in modes that otherwise approve tools automatically.
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@prismnetwork/mcp",
3
- "version": "0.9.3",
3
+ "version": "0.10.0",
4
4
  "description": "MCP server for leasing and running on Prism Network GPUs.",
5
5
  "mcpName": "io.github.prismnetwork-tech/mcp",
6
6
  "type": "module",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "budget.mjs",
12
+ "policy.mjs",
12
13
  "server.mjs",
13
14
  "README.md"
14
15
  ],
@@ -18,7 +19,7 @@
18
19
  "dependencies": {
19
20
  "@modelcontextprotocol/sdk": "^1.0.0",
20
21
  "@phala/dcap-qvl": "^0.6.1",
21
- "@prismnetwork/agent-sdk": "^0.7.3",
22
+ "@prismnetwork/agent-sdk": "^0.7.12",
22
23
  "jose": "^6",
23
24
  "viem": "^2"
24
25
  },
package/policy.mjs ADDED
@@ -0,0 +1,117 @@
1
+ // The operator's spending rules for leases. The budget caps how much an agent
2
+ // may spend; the policy decides whether a given spend has a reason the operator
3
+ // accepts. The agent states its reason as a decision, the SDK checks it against
4
+ // this policy before anything is quoted, and only the decision's hash leaves
5
+ // the machine, bound into the escrow deposit.
6
+ import { readFileSync } from "node:fs";
7
+ import { answer, decision as makeDecision, decisionDigest, refusals } from "@prismnetwork/agent-sdk/decision";
8
+
9
+ export class PolicyError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "PolicyError";
13
+ }
14
+ }
15
+
16
+ const strings = (value, field) => {
17
+ if (value === undefined) return [];
18
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string" || !v.trim())) {
19
+ throw new PolicyError(`PRISM_SPEND_POLICY: ${field} must be a list of names`);
20
+ }
21
+ return value;
22
+ };
23
+
24
+ /// PRISM_SPEND_POLICY holds the policy as JSON, or the path to a JSON file.
25
+ /// Unset means no policy: a decision is optional and, when given, still binds.
26
+ export function readPolicy(raw = process.env.PRISM_SPEND_POLICY) {
27
+ if (raw === undefined || raw.trim() === "") return null;
28
+ let text = raw.trim();
29
+ if (!text.startsWith("{")) {
30
+ try {
31
+ text = readFileSync(text, "utf8");
32
+ } catch (err) {
33
+ throw new PolicyError(`PRISM_SPEND_POLICY: cannot read ${raw}: ${err.code ?? err.message}`);
34
+ }
35
+ }
36
+ let parsed;
37
+ try {
38
+ parsed = JSON.parse(text);
39
+ } catch {
40
+ throw new PolicyError("PRISM_SPEND_POLICY is not valid JSON");
41
+ }
42
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
43
+ throw new PolicyError("PRISM_SPEND_POLICY must be a JSON object");
44
+ }
45
+ const policyId = parsed.policy_id;
46
+ if (typeof policyId !== "string" || !policyId.trim()) {
47
+ throw new PolicyError("PRISM_SPEND_POLICY needs a policy_id, so every decision records which rules admitted it");
48
+ }
49
+ const minimums = parsed.minimums ?? {};
50
+ if (typeof minimums !== "object" || Array.isArray(minimums)) {
51
+ throw new PolicyError("PRISM_SPEND_POLICY: minimums must map an answer name to a confidence floor");
52
+ }
53
+ for (const [name, floor] of Object.entries(minimums)) {
54
+ if (typeof floor !== "number" || !(floor >= 0 && floor <= 1)) {
55
+ throw new PolicyError(`PRISM_SPEND_POLICY: the floor for ${name} must be a number from 0 to 1`);
56
+ }
57
+ }
58
+ return {
59
+ policyId,
60
+ allow: strings(parsed.allow, "allow"),
61
+ require: strings(parsed.require, "require"),
62
+ minimums,
63
+ };
64
+ }
65
+
66
+ /// What prism_budget shows, so an agent can shape its decision before it asks.
67
+ export function describePolicy(policy) {
68
+ if (!policy) return { spend_policy: "none: leases need no stated reason" };
69
+ return {
70
+ spend_policy: {
71
+ policy_id: policy.policyId,
72
+ allowed_actions: policy.allow.length ? policy.allow : "any",
73
+ required_answers: policy.require,
74
+ confidence_floors: policy.minimums,
75
+ },
76
+ };
77
+ }
78
+
79
+ /// The decision a tool call carries, in the SDK's form. Malformed input is the
80
+ /// caller's mistake and says which field; a missing decision under a policy is
81
+ /// refused with what the policy needs.
82
+ export function decisionFrom(input, policy) {
83
+ if (input === undefined || input === null) {
84
+ if (!policy) return null;
85
+ const needs = [
86
+ policy.allow.length ? `action one of ${policy.allow.join(", ")}` : "an action",
87
+ ...policy.require.map((n) => `an answer for ${n}`),
88
+ ...Object.entries(policy.minimums).map(([n, f]) => `${n} with confidence at least ${f}`),
89
+ ];
90
+ throw new PolicyError(
91
+ `the operator's spend policy ${policy.policyId} requires a decision with this lease: ${needs.join("; ")}. Nothing was quoted or funded.`,
92
+ );
93
+ }
94
+ if (typeof input !== "object") throw new PolicyError("decision must be an object with action, source and answers");
95
+ const answers = (input.answers ?? []).map((a) => {
96
+ if (!a || typeof a.name !== "string") throw new PolicyError("each decision answer needs a name");
97
+ return answer(a.name, a.value ?? null, a.confidence ?? null);
98
+ });
99
+ return makeDecision({
100
+ action: input.action,
101
+ source: input.source,
102
+ answers,
103
+ policyId: input.policy_id ?? policy?.policyId ?? null,
104
+ });
105
+ }
106
+
107
+ /// Checked here, before the spend is booked against the budget, so a refused
108
+ /// decision neither costs anything nor holds capacity.
109
+ export function authorised(policy, d) {
110
+ if (!policy) return;
111
+ const reasons = refusals(policy, d);
112
+ if (reasons.length) {
113
+ throw new PolicyError(`the spend policy ${policy.policyId} refused this lease: ${reasons.join("; ")}. Nothing was quoted or funded.`);
114
+ }
115
+ }
116
+
117
+ export { decisionDigest };
package/server.mjs CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  verifyConfidential,
16
16
  } from "@prismnetwork/agent-sdk";
17
17
  import { BudgetError, SpendLedger, callCeiling, readBudget, recordSpend, stripUnexpanded } from "./budget.mjs";
18
+ import { authorised, decisionDigest, decisionFrom, describePolicy, readPolicy } from "./policy.mjs";
18
19
 
19
20
  stripUnexpanded(process.env);
20
21
 
@@ -64,6 +65,26 @@ try {
64
65
  console.error(`prism mcp: ${budgetProblem}`);
65
66
  }
66
67
 
68
+ // Same rule as the budget: a policy the operator got wrong stops leasing rather
69
+ // than letting every lease through unchecked.
70
+ let policy = null;
71
+ let policyProblem = null;
72
+ try {
73
+ policy = readPolicy();
74
+ } catch (err) {
75
+ policyProblem = err?.message ?? String(err);
76
+ console.error(`prism mcp: ${policyProblem}`);
77
+ }
78
+
79
+ /// The decision this lease carries, checked against the operator's policy
80
+ /// before anything is booked, quoted or funded.
81
+ function leaseDecision(tool, args) {
82
+ if (policyProblem) throw new Error(`${tool} is disabled until the spend policy is fixed: ${policyProblem}`);
83
+ const d = decisionFrom(args.decision, policy);
84
+ if (d) authorised(policy, d);
85
+ return d;
86
+ }
87
+
67
88
  function requireWallet(tool, reason = "spends money") {
68
89
  if (!agent) {
69
90
  throw new Error(
@@ -211,6 +232,33 @@ const spends = {
211
232
  _meta: { "anthropic/requiresUserInteraction": true },
212
233
  };
213
234
 
235
+ // Why a lease is being funded. Only its hash leaves the machine, bound into the
236
+ // escrow deposit, so whoever holds the decision can prove it came first.
237
+ const DECISION_SCHEMA = {
238
+ type: "object",
239
+ description:
240
+ "Why this lease is being funded. Required when the operator set a spend policy (see prism_budget), and the lease is refused before anything is quoted if the decision does not meet it. Only a hash of it leaves this machine, recorded with the escrow deposit.",
241
+ properties: {
242
+ action: { type: "string", description: "What the spend is for, e.g. 'fine_tune' or 'benchmark'." },
243
+ source: { type: "string", description: "What made the call: a model name, a rule, or 'operator'." },
244
+ answers: {
245
+ type: "array",
246
+ description: "Typed answers behind the decision. confidence (0 to 1) is what a policy floor reads.",
247
+ items: {
248
+ type: "object",
249
+ properties: {
250
+ name: { type: "string", description: "Lowercase identifier, e.g. 'needs_gpu'." },
251
+ value: { description: "The answer: an option, a score, or a probability." },
252
+ confidence: { type: "number", minimum: 0, maximum: 1 },
253
+ },
254
+ required: ["name", "value"],
255
+ },
256
+ },
257
+ policy_id: { type: "string", description: "The policy this decision was made under; defaults to the operator's." },
258
+ },
259
+ required: ["action", "source"],
260
+ };
261
+
214
262
  const TOOLS = [
215
263
  {
216
264
  name: "prism_budget",
@@ -272,6 +320,7 @@ const TOOLS = [
272
320
  duration_seconds: { type: "integer", description: "Paid window in seconds (default 900, max 21600). A command still running at the end is killed and reported exit 124." },
273
321
  min_vram_mib: { type: "integer", description: "Minimum GPU memory in MiB (default 16000)." },
274
322
  max_usdg: { type: "number", description: "Cost ceiling for this lease in USDG. It lowers the operator's PRISM_MAX_USDG and cannot raise it; omitted, that ceiling applies. See prism_budget." },
323
+ decision: DECISION_SCHEMA,
275
324
  },
276
325
  required: ["command"],
277
326
  },
@@ -371,6 +420,7 @@ const TOOLS = [
371
420
  description: "Refuse suppliers below this trust class (default 'open'). Raise it for anything the host operator must not read.",
372
421
  },
373
422
  max_usdg: { type: "number", description: "Cost ceiling for this lease in USDG. It lowers the operator's PRISM_MAX_USDG and cannot raise it; omitted, that ceiling applies. See prism_budget." },
423
+ decision: DECISION_SCHEMA,
374
424
  },
375
425
  required: ["command"],
376
426
  },
@@ -391,6 +441,7 @@ const TOOLS = [
391
441
  description: "Refuse suppliers below this trust class (default 'open'). Raise it for anything the host operator must not read.",
392
442
  },
393
443
  max_usdg: { type: "number", description: "Cost ceiling for this lease in USDG. It lowers the operator's PRISM_MAX_USDG and cannot raise it; omitted, that ceiling applies. See prism_budget." },
444
+ decision: DECISION_SCHEMA,
394
445
  },
395
446
  },
396
447
  ...spends,
@@ -412,7 +463,7 @@ const TOOLS = [
412
463
  },
413
464
  {
414
465
  name: "prism_end_lease",
415
- description: "Release a lease's local access. The on-chain lease settles at the end of its paid duration.",
466
+ 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
467
  inputSchema: {
417
468
  type: "object",
418
469
  properties: { lease_id: { type: "integer" } },
@@ -483,7 +534,9 @@ const TOOLS = [
483
534
  ];
484
535
 
485
536
  async function handle(name, args) {
486
- if (name === "prism_budget") return requireLedger(name).status();
537
+ if (name === "prism_budget") {
538
+ return { ...requireLedger(name).status(), ...(policyProblem ? { spend_policy_error: policyProblem } : describePolicy(policy)) };
539
+ }
487
540
  if (name === "prism_wallet") {
488
541
  const b = await requireWallet("prism_wallet").balances();
489
542
  return { address: b.address, usdg: usdg(b.usdg), eth_wei: b.eth };
@@ -574,6 +627,7 @@ async function handle(name, args) {
574
627
  if (name === "prism_batch_run") {
575
628
  requireCommand(args.command);
576
629
  requireWallet(name);
630
+ const decision = leaseDecision(name, args);
577
631
  const cap = maxDeposit(name, args);
578
632
  return spending(name, cap, async () => {
579
633
  const batch = await agent.lease({
@@ -582,6 +636,8 @@ async function handle(name, args) {
582
636
  minVramMib: args.min_vram_mib ?? 16000,
583
637
  maxDeposit: cap,
584
638
  command: args.command,
639
+ decision,
640
+ policy,
585
641
  });
586
642
  return {
587
643
  reference: batch.fundingHash,
@@ -589,6 +645,7 @@ async function handle(name, args) {
589
645
  value: {
590
646
  lease_id: batch.leaseId,
591
647
  funding_tx: batch.fundingHash,
648
+ ...(decision ? { decision_hash: decisionDigest(decision) } : {}),
592
649
  exit_code: batch.result?.exit_code,
593
650
  stdout: batch.result?.stdout,
594
651
  stderr: batch.result?.stderr,
@@ -731,6 +788,7 @@ async function handle(name, args) {
731
788
  if (name === "prism_lease_and_run" || name === "prism_lease") {
732
789
  if (name === "prism_lease_and_run") requireCommand(args.command);
733
790
  requireWallet(name);
791
+ const decision = leaseDecision(name, args);
734
792
  const cap = maxDeposit(name, args);
735
793
  sweepExpiredLeases();
736
794
  const lease = await spending(name, cap, async () => {
@@ -740,6 +798,8 @@ async function handle(name, args) {
740
798
  minVramMib: args.min_vram_mib ?? 16000,
741
799
  maxDeposit: cap,
742
800
  minTrustClass: args.min_trust_class ?? "open",
801
+ decision,
802
+ policy,
743
803
  });
744
804
  return { value: funded, reference: funded.fundingHash, settledMicros: escrowed(funded.quote) };
745
805
  });
@@ -747,6 +807,7 @@ async function handle(name, args) {
747
807
  const summary = {
748
808
  lease_id: lease.leaseId,
749
809
  funding_tx: lease.fundingHash,
810
+ ...(decision ? { decision_hash: decisionDigest(decision) } : {}),
750
811
  // `prism_run` checks this itself. It is in the summary because the
751
812
  // caller is being handed an address they may connect to by hand, and an
752
813
  // address with no key to check is an invitation to accept whatever
@@ -789,11 +850,13 @@ async function handle(name, args) {
789
850
  if (name === "prism_end_lease") {
790
851
  const id = leaseId(args.lease_id);
791
852
  const lease = leases.get(id);
792
- if (lease) {
793
- agent.endLease(lease);
794
- leases.delete(id);
853
+ 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" };
854
+ leases.delete(id);
855
+ const out = await agent.endLease(lease);
856
+ if (out.release === "failed") {
857
+ 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
858
  }
796
- return { lease_id: id, released: Boolean(lease) };
859
+ 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
860
  }
798
861
  if (name.startsWith("prism_vault_")) return handleVault(name, args);
799
862
  throw new Error(`unknown tool ${name}. Valid tools: ${TOOLS.map((t) => t.name).join(", ")}`);
@@ -848,7 +911,7 @@ async function handleVault(name, args) {
848
911
  throw new Error(`unknown tool ${name}`);
849
912
  }
850
913
 
851
- const server = new Server({ name: "prism", version: "0.9.0" }, { capabilities: { tools: {} } });
914
+ const server = new Server({ name: "prism", version: "0.10.0" }, { capabilities: { tools: {} } });
852
915
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
853
916
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
854
917
  try {