@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 +27 -1
- package/budget.mjs +294 -40
- package/package.json +3 -2
- package/policy.mjs +117 -0
- package/server.mjs +70 -7
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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(
|
|
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
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
|
152
|
-
|
|
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
|
-
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
298
|
-
reconcile("settle", { micros: settledMicros, reference });
|
|
299
|
-
return value;
|
|
533
|
+
outcome = await run();
|
|
300
534
|
} catch (err) {
|
|
301
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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")
|
|
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
|
-
|
|
794
|
-
|
|
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:
|
|
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.
|
|
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 {
|