@prismnetwork/mcp 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +35 -2
  2. package/budget.mjs +316 -0
  3. package/package.json +9 -5
  4. package/server.mjs +190 -66
package/README.md CHANGED
@@ -17,6 +17,7 @@ real prices. Leasing spends money, so those tools ask for a wallet and say so.
17
17
 
18
18
  | Tool | Wallet |
19
19
  | --- | --- |
20
+ | `prism_budget` | no |
20
21
  | `prism_list_gpus` | no |
21
22
  | `prism_price_index` | no |
22
23
  | `prism_receipts` | no |
@@ -37,6 +38,8 @@ real prices. Leasing spends money, so those tools ask for a wallet and say so.
37
38
  | `prism_vault_delete` | yes |
38
39
  | `prism_vault_release` | yes |
39
40
 
41
+ - `prism_budget`: the spending limits this server enforces, what it has spent
42
+ in the last 24 hours, and the recent charges.
40
43
  - `prism_wallet`: the agent's address and USDG/ETH balances.
41
44
  - `prism_list_gpus`: GPUs available to lease, with price per second and per hour.
42
45
  - `prism_price_index`: sourced and settled pricing per GPU model, for cost estimates.
@@ -66,6 +69,9 @@ real prices. Leasing spends money, so those tools ask for a wallet and say so.
66
69
  terminates.
67
70
  - `prism_lease_and_run`: lease a GPU, run a command, return the output (one shot).
68
71
  - `prism_lease`: lease a GPU and keep it; returns a `lease_id` and SSH access.
72
+ The SSH block carries `host_key_fingerprint` and `host_key_claim` when the
73
+ network can say which machine should answer, and says so plainly when it
74
+ cannot. Check it before connecting by hand; `prism_run` checks it for you.
69
75
  - `prism_run`: run a command on an existing lease.
70
76
  - `prism_batch_run`: fund a lease that runs one command with no interactive
71
77
  access; the node reports the signed output. Matches only suppliers at trust
@@ -77,6 +83,33 @@ real prices. Leasing spends money, so those tools ask for a wallet and say so.
77
83
  - `prism_vault_delete`: permanently delete an item.
78
84
  - `prism_vault_release`: authorize an item into a lease that clears its trust floor.
79
85
 
86
+ ## Spending limits
87
+
88
+ Two ceilings bound what this server can spend, and a refusal quotes both.
89
+
90
+ | Variable | Default | What it bounds |
91
+ | --- | --- | --- |
92
+ | `PRISM_MAX_USDG` | 1 | Any single lease or generation. `max_usdg` on a call may lower it, never raise it past this. |
93
+ | `PRISM_DAILY_BUDGET_USDG` | 5 | Everything in a rolling 24 hours. `0` removes the ceiling. |
94
+ | `PRISM_LEDGER_PATH` | `~/.prism/spend.json` | Where the spend is recorded. |
95
+
96
+ A `max_usdg` above `PRISM_MAX_USDG` is clamped back to it. The argument is
97
+ written by the agent being bounded, so it is treated as a request for a lower
98
+ ceiling and never as permission for a higher one.
99
+
100
+ Spend is written before the money moves, so a crash between funding an escrow
101
+ and answering is counted rather than forgiven, and a restart does not hand back
102
+ a fresh day's allowance. Only an attempt that provably never reached the chain
103
+ is reverted. Two clients pointed at one wallet share one ceiling, because they
104
+ share the ledger file.
105
+
106
+ None of this is the real limit. Fund a dedicated wallet with what you are
107
+ willing to lose: that balance is what survives a bug in everything above.
108
+
109
+ Tools that spend are annotated `destructiveHint` and carry
110
+ `anthropic/requiresUserInteraction`, so Claude Code asks before every one of
111
+ them even in modes that otherwise approve tools automatically.
112
+
80
113
  ## Vault
81
114
 
82
115
  An agent that handles a card, an identity document or a credential should not
@@ -102,7 +135,7 @@ Point your MCP client (Claude Desktop / Code) at the published server:
102
135
  "args": ["-y", "@prismnetwork/mcp"],
103
136
  "env": {
104
137
  "PRISM_AGENT_KEY": "0x<agent wallet private key>",
105
- "PRISM_ESCROW": "0x62C042265991bEa17B07229322A01850974626dA"
138
+ "PRISM_ESCROW": "0xfD4228eEEfC49e4b76A0CD40af9fdd546220B2FD"
106
139
  }
107
140
  }
108
141
  }
@@ -115,7 +148,7 @@ Or add it to Claude Code in one line:
115
148
 
116
149
  ```sh
117
150
  claude mcp add prism \
118
- --env PRISM_ESCROW=0x62C042265991bEa17B07229322A01850974626dA \
151
+ --env PRISM_ESCROW=0xfD4228eEEfC49e4b76A0CD40af9fdd546220B2FD \
119
152
  --env PRISM_AGENT_KEY=0x<agent wallet private key> \
120
153
  -- npx -y @prismnetwork/mcp
121
154
  ```
package/budget.mjs ADDED
@@ -0,0 +1,316 @@
1
+ // What stands between a model deciding to rent a GPU and a wallet paying for it.
2
+ // The real ceiling is the balance; fund the agent wallet with what you are
3
+ // willing to lose. This is the second line, and it exists because `max_usdg`
4
+ // never was one: it bounds a single lease and says nothing about the fortieth in
5
+ // a row, which is what an unattended agent actually does.
6
+ //
7
+ // Spend is written before the money moves and reverted only when the attempt
8
+ // provably cost nothing, so a crash between funding and reply is counted rather
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";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+
15
+ const MICROS = 1_000_000;
16
+ const DAY_MS = 86_400_000;
17
+ // Entries older than this are dropped on write. Two days covers the rolling
18
+ // window with room for a clock that stepped backwards.
19
+ const MEMORY_MS = 2 * DAY_MS;
20
+ const LOCK_STALE_MS = 15_000;
21
+ const LOCK_WAIT_MS = 5_000;
22
+
23
+ export class BudgetError extends Error {
24
+ constructor(message, detail = {}) {
25
+ super(message);
26
+ this.name = "BudgetError";
27
+ this.detail = detail;
28
+ }
29
+ }
30
+
31
+ export const usdg = (micros) => `${(Number(micros) / MICROS).toFixed(6)} USDG`;
32
+
33
+ function positiveNumber(raw, fallback, name) {
34
+ if (raw === undefined || raw === null || String(raw).trim() === "") return fallback;
35
+ const value = Number(raw);
36
+ if (!Number.isFinite(value) || value < 0) {
37
+ throw new BudgetError(`${name} must be a non-negative number of USDG, got ${JSON.stringify(raw)}`);
38
+ }
39
+ return value;
40
+ }
41
+
42
+ // One .mcp.json serves every client, and they disagree about templates: Claude
43
+ // Code expands ${user_config.x} before launch, Codex passes it through. Reading
44
+ // the literal template as a value would turn "no wallet configured" into
45
+ // "wallet configured and broken", so an unexpanded placeholder is nothing.
46
+ export function stripUnexpanded(env = process.env) {
47
+ for (const [key, value] of Object.entries(env)) {
48
+ if (key.startsWith("PRISM_") && /^\$\{[^}]*\}$/.test(String(value ?? "").trim())) delete env[key];
49
+ }
50
+ return env;
51
+ }
52
+
53
+ export function defaultLedgerPath(env = process.env) {
54
+ if (env.PRISM_LEDGER_PATH) return env.PRISM_LEDGER_PATH;
55
+ return join(env.HOME || homedir(), ".prism", "spend.json");
56
+ }
57
+
58
+ // A missing budget is not an unlimited one. The default is deliberately small:
59
+ // enough for a handful of real leases, cheap enough that discovering the plugin
60
+ // spends money costs about the price of a coffee rather than a rent cheque.
61
+ export function readBudget(env = process.env) {
62
+ const maxPerCall = positiveNumber(env.PRISM_MAX_USDG, 1, "PRISM_MAX_USDG");
63
+ const daily = positiveNumber(env.PRISM_DAILY_BUDGET_USDG, 5, "PRISM_DAILY_BUDGET_USDG");
64
+ if (maxPerCall <= 0) throw new BudgetError("PRISM_MAX_USDG must be above zero");
65
+ // A per-call cap above the day's allowance is a cap in name only, and the
66
+ // mismatch is always a configuration mistake rather than an intention.
67
+ if (daily > 0 && maxPerCall > daily) {
68
+ throw new BudgetError(
69
+ `PRISM_MAX_USDG (${maxPerCall}) cannot exceed PRISM_DAILY_BUDGET_USDG (${daily}); lower the per-call cap or raise the daily one`,
70
+ );
71
+ }
72
+ return {
73
+ maxPerCallMicros: Math.round(maxPerCall * MICROS),
74
+ // Zero means the operator explicitly removed the daily ceiling. It is not
75
+ // the default and it is not what a missing variable produces.
76
+ dailyMicros: Math.round(daily * MICROS),
77
+ ledgerPath: defaultLedgerPath(env),
78
+ };
79
+ }
80
+
81
+ // What a single call is allowed to spend, given what it asked for. The caller's
82
+ // figure is written by the thing being bounded, so it lowers the operator's
83
+ // ceiling and can never lift it.
84
+ export function callCeiling(maxUsdg, ceilingMicros) {
85
+ if (maxUsdg === undefined || maxUsdg === null) return ceilingMicros;
86
+ if (typeof maxUsdg !== "number" || !Number.isFinite(maxUsdg) || maxUsdg <= 0) {
87
+ throw new BudgetError("max_usdg must be a positive number of USDG.");
88
+ }
89
+ return Math.min(Math.round(maxUsdg * MICROS), ceilingMicros);
90
+ }
91
+
92
+ // 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`;
97
+ mkdirSync(dirname(path), { recursive: true });
98
+ const deadline = Date.now() + waitMs;
99
+ for (;;) {
100
+ let fd;
101
+ try {
102
+ fd = openSync(lock, "wx");
103
+ } catch (err) {
104
+ if (err?.code !== "EEXIST") throw err;
105
+ let age = 0;
106
+ try {
107
+ age = Date.now() - statSync(lock).mtimeMs;
108
+ } catch {
109
+ continue; // it vanished between the open and the stat; retry immediately
110
+ }
111
+ if (age > LOCK_STALE_MS) {
112
+ try {
113
+ unlinkSync(lock);
114
+ } catch {
115
+ /* another process broke it first, which is the outcome we wanted */
116
+ }
117
+ continue;
118
+ }
119
+ if (Date.now() > deadline) {
120
+ throw new BudgetError(
121
+ `the spend ledger at ${path} is locked by another Prism process; nothing was charged. Retry in a moment.`,
122
+ );
123
+ }
124
+ // Busy-wait deliberately: this holds for milliseconds and the alternative
125
+ // is making every caller of a synchronous ledger asynchronous.
126
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
127
+ continue;
128
+ }
129
+ try {
130
+ closeSync(fd);
131
+ return fn();
132
+ } finally {
133
+ try {
134
+ unlinkSync(lock);
135
+ } catch {
136
+ /* already gone */
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ function readState(path) {
143
+ 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)) };
147
+ } catch (err) {
148
+ if (err?.code === "ENOENT") return { entries: [] };
149
+ // A corrupt ledger must not read as an empty one: that would hand the
150
+ // 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
+ );
154
+ }
155
+ }
156
+
157
+ function writeState(path, state, now) {
158
+ 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);
162
+ }
163
+
164
+ export function spentInWindow(entries, now) {
165
+ return entries.reduce((total, e) => (now - e.at < DAY_MS ? total + e.micros : total), 0);
166
+ }
167
+
168
+ export class SpendLedger {
169
+ constructor({ ledgerPath, dailyMicros, maxPerCallMicros, lockWaitMs = LOCK_WAIT_MS }) {
170
+ this.path = ledgerPath;
171
+ this.dailyMicros = dailyMicros;
172
+ this.maxPerCallMicros = maxPerCallMicros;
173
+ this.lockWaitMs = lockWaitMs;
174
+ }
175
+
176
+ // What is left of today, for the caller to show before it asks to spend.
177
+ remaining(now = Date.now()) {
178
+ if (this.dailyMicros <= 0) return null;
179
+ const { entries } = readState(this.path);
180
+ return Math.max(0, this.dailyMicros - spentInWindow(entries, now));
181
+ }
182
+
183
+ status(now = Date.now()) {
184
+ const { entries } = readState(this.path);
185
+ const spent = spentInWindow(entries, now);
186
+ return {
187
+ daily_budget: this.dailyMicros > 0 ? usdg(this.dailyMicros) : "unlimited (PRISM_DAILY_BUDGET_USDG=0)",
188
+ spent_last_24h: usdg(spent),
189
+ remaining_today: this.dailyMicros > 0 ? usdg(Math.max(0, this.dailyMicros - spent)) : "unlimited",
190
+ max_per_call: usdg(this.maxPerCallMicros),
191
+ ledger: this.path,
192
+ charges_last_24h: entries
193
+ .filter((e) => now - e.at < DAY_MS)
194
+ .sort((a, b) => b.at - a.at)
195
+ .slice(0, 20)
196
+ .map((e) => ({
197
+ at: new Date(e.at).toISOString(),
198
+ tool: e.tool,
199
+ amount: usdg(e.micros),
200
+ ...(e.reference ? { reference: e.reference } : {}),
201
+ })),
202
+ };
203
+ }
204
+
205
+ // Records the spend before the money moves. Returns a handle the caller
206
+ // reverts only when it can prove nothing was charged.
207
+ commit({ tool, micros, now = Date.now() }) {
208
+ if (!Number.isFinite(micros) || micros <= 0) {
209
+ throw new BudgetError("a spend must be a positive number of micros");
210
+ }
211
+ if (micros > this.maxPerCallMicros) {
212
+ throw new BudgetError(
213
+ `${tool} would commit up to ${usdg(micros)}, past the ${usdg(this.maxPerCallMicros)} per-call cap. ` +
214
+ `Lower max_usdg for this call, or raise PRISM_MAX_USDG.`,
215
+ { required: micros, cap: this.maxPerCallMicros },
216
+ );
217
+ }
218
+ return withLock(
219
+ this.path,
220
+ () => {
221
+ const state = readState(this.path);
222
+ const spent = spentInWindow(state.entries, now);
223
+ if (this.dailyMicros > 0 && spent + micros > this.dailyMicros) {
224
+ throw new BudgetError(
225
+ `${tool} would take today's Prism spend to ${usdg(spent + micros)}, past the ${usdg(this.dailyMicros)} ` +
226
+ `daily cap (${usdg(spent)} already spent). Nothing was charged. Raise PRISM_DAILY_BUDGET_USDG to continue.`,
227
+ { spent, requested: micros, cap: this.dailyMicros },
228
+ );
229
+ }
230
+ const id = `${now.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
231
+ state.entries.push({ id, at: now, tool, micros });
232
+ writeState(this.path, state, now);
233
+ return id;
234
+ },
235
+ this.lockWaitMs,
236
+ );
237
+ }
238
+
239
+ // Only for an attempt that provably cost nothing. A funded lease whose command
240
+ // failed is not one of those.
241
+ revert(id) {
242
+ if (!id) return false;
243
+ return withLock(this.path, () => {
244
+ const state = readState(this.path);
245
+ const before = state.entries.length;
246
+ state.entries = state.entries.filter((e) => e.id !== id);
247
+ if (state.entries.length === before) return false;
248
+ writeState(this.path, state, Date.now());
249
+ return true;
250
+ }, this.lockWaitMs);
251
+ }
252
+
253
+ // Replaces the reserved figure with what was actually committed on-chain and
254
+ // pins the receipt to it, so the ledger reads like a statement rather than a
255
+ // list of intentions.
256
+ //
257
+ // A payment the endpoint never consumed is redeemed by the next attempt at the
258
+ // same request, so one transaction can settle more than one reservation.
259
+ // Booking each would charge the day twice for money that moved once: a
260
+ // reference already on file keeps its entry and this one is released.
261
+ settle(id, { micros, reference } = {}) {
262
+ if (!id) return false;
263
+ return withLock(this.path, () => {
264
+ const state = readState(this.path);
265
+ const entry = state.entries.find((e) => e.id === id);
266
+ if (!entry) return false;
267
+ const booked = reference ? state.entries.find((e) => e.id !== id && e.reference === reference) : undefined;
268
+ const target = booked ?? entry;
269
+ if (Number.isFinite(micros) && micros >= 0) target.micros = micros;
270
+ if (reference) target.reference = reference;
271
+ if (booked) state.entries = state.entries.filter((e) => e.id !== id);
272
+ writeState(this.path, state, Date.now());
273
+ return true;
274
+ }, this.lockWaitMs);
275
+ }
276
+ }
277
+
278
+ /// Records a spend before the money moves, then reconciles it against what
279
+ /// happened. A failure that never reached the chain is given back; anything
280
+ /// that funded an escrow or paid an endpoint keeps its entry and gains the
281
+ /// transaction that proves it, because a ledger that forgets a spend is worse
282
+ /// than no ledger at all.
283
+ export async function recordSpend(book, tool, micros, run) {
284
+ const id = book.commit({ tool, micros });
285
+ // Reconciling is bookkeeping and must never be the reason a caller loses a
286
+ // machine it paid for, or the reason a failure is reported as the wrong
287
+ // failure. A ledger that cannot be written says so on stderr and the
288
+ // committed figure stands, which errs towards counting the spend.
289
+ const reconcile = (action, ...args) => {
290
+ try {
291
+ book[action](id, ...args);
292
+ } catch (err) {
293
+ console.error(`prism mcp: could not ${action} the ledger entry for ${tool}: ${err?.message ?? err}`);
294
+ }
295
+ };
296
+ try {
297
+ const { value, settledMicros, reference } = await run();
298
+ reconcile("settle", { micros: settledMicros, reference });
299
+ return value;
300
+ } catch (err) {
301
+ const paid = err?.body?.funding_hash ?? err?.body?.payment_tx;
302
+ if (paid) {
303
+ reconcile("settle", { reference: paid });
304
+ } else if (err?.code === "chain_error") {
305
+ // Signed, broadcast, and then something went wrong reading it back, which
306
+ // is not the same as never having reached the chain. Handing the
307
+ // reservation back would let one wallet fund escrow after escrow through
308
+ // an rpc having a bad hour while the day's ceiling reported nothing
309
+ // spent, so the entry stands at what it reserved.
310
+ reconcile("settle");
311
+ } else {
312
+ reconcile("revert");
313
+ }
314
+ throw err;
315
+ }
316
+ }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@prismnetwork/mcp",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "MCP server for leasing and running on Prism Network GPUs.",
5
- "mcpName": "io.github.prismnetwork-tech/mcp",
5
+ "mcpName": "io.github.winter0x/mcp",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "prism-mcp": "server.mjs"
9
9
  },
10
10
  "files": [
11
+ "budget.mjs",
11
12
  "server.mjs",
12
13
  "README.md"
13
14
  ],
@@ -17,7 +18,7 @@
17
18
  "dependencies": {
18
19
  "@modelcontextprotocol/sdk": "^1.0.0",
19
20
  "@phala/dcap-qvl": "^0.6.1",
20
- "@prismnetwork/agent-sdk": "^0.7.0",
21
+ "@prismnetwork/agent-sdk": "^0.7.3",
21
22
  "jose": "^6",
22
23
  "viem": "^2"
23
24
  },
@@ -32,14 +33,17 @@
32
33
  "homepage": "https://prismnetwork.tech",
33
34
  "repository": {
34
35
  "type": "git",
35
- "url": "git+https://github.com/prismnetwork-tech/prism.git",
36
+ "url": "git+https://github.com/winter0x/prism.git",
36
37
  "directory": "mcp"
37
38
  },
38
39
  "bugs": {
39
- "url": "https://github.com/prismnetwork-tech/prism/issues"
40
+ "url": "https://github.com/winter0x/prism/issues"
40
41
  },
41
42
  "license": "Apache-2.0",
42
43
  "publishConfig": {
43
44
  "access": "public"
45
+ },
46
+ "scripts": {
47
+ "test": "node --test"
44
48
  }
45
49
  }
package/server.mjs CHANGED
@@ -9,17 +9,21 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
9
9
  import {
10
10
  DEFAULT_IMAGE,
11
11
  DEFAULT_TRUST_FLOOR,
12
+ hostKeyPolicy,
12
13
  PrismAgent,
13
14
  TRUST_CLASSES,
14
15
  verifyConfidential,
15
16
  } from "@prismnetwork/agent-sdk";
17
+ import { BudgetError, SpendLedger, callCeiling, readBudget, recordSpend, stripUnexpanded } from "./budget.mjs";
18
+
19
+ stripUnexpanded(process.env);
16
20
 
17
21
  const IMAGE = process.env.PRISM_DEFAULT_IMAGE ?? DEFAULT_IMAGE;
18
22
 
19
23
  const PUBLIC_API = process.env.PRISM_PUBLIC_API ?? "https://api.prismnetwork.tech";
20
24
  // The live lease escrow. Overridable, but its absence must not silently
21
25
  // disable the wallet the way a missing key does.
22
- const DEFAULT_ESCROW = "0x62C042265991bEa17B07229322A01850974626dA";
26
+ const DEFAULT_ESCROW = "0xfD4228eEEfC49e4b76A0CD40af9fdd546220B2FD";
23
27
  // Matches the limit the SDK and the control plane enforce, so a command that
24
28
  // cannot run is rejected before an escrow is funded.
25
29
  const MAX_COMMAND_BYTES = 8 * 1024;
@@ -48,6 +52,18 @@ if (!agent) {
48
52
  );
49
53
  }
50
54
 
55
+ // A budget the operator got wrong must stop spending, not fall back to none.
56
+ // Reading capacity and prices is unaffected, so a typo is discoverable rather
57
+ // than fatal.
58
+ let ledger = null;
59
+ let budgetProblem = null;
60
+ try {
61
+ ledger = new SpendLedger(readBudget());
62
+ } catch (err) {
63
+ budgetProblem = err?.message ?? String(err);
64
+ console.error(`prism mcp: ${budgetProblem}`);
65
+ }
66
+
51
67
  function requireWallet(tool, reason = "spends money") {
52
68
  if (!agent) {
53
69
  throw new Error(
@@ -57,6 +73,17 @@ function requireWallet(tool, reason = "spends money") {
57
73
  return agent;
58
74
  }
59
75
 
76
+ function requireLedger(tool) {
77
+ if (!ledger) {
78
+ throw new Error(`${tool} needs the spend limits, and they are unusable: ${budgetProblem}`);
79
+ }
80
+ return ledger;
81
+ }
82
+
83
+ // The ledger has to be usable before anything is spent, and the refusal names
84
+ // the tool that asked.
85
+ const spending = (tool, micros, run) => recordSpend(requireLedger(tool), tool, micros, run);
86
+
60
87
  function requireCommand(value) {
61
88
  if (typeof value !== "string" || value.trim() === "") {
62
89
  throw new Error("command is required: the shell command to run on the GPU, e.g. 'nvidia-smi'.");
@@ -67,12 +94,29 @@ function requireCommand(value) {
67
94
  return value;
68
95
  }
69
96
 
70
- function maxDeposit(args) {
71
- const cap = args.max_usdg ?? 1;
72
- if (typeof cap !== "number" || !Number.isFinite(cap) || cap <= 0) {
73
- throw new Error("max_usdg must be a positive number of USDG.");
74
- }
75
- return Math.round(cap * 1e6);
97
+ // What the caller can check the machine against if they open their own session.
98
+ // A lease that publishes nothing says so, rather than leaving the field out and
99
+ // letting its absence read as "fine".
100
+ function hostKey(access) {
101
+ const policy = hostKeyPolicy(access);
102
+ return policy.fingerprint === null
103
+ ? { host_key: "unpublished: this lease cannot tell you which machine answers" }
104
+ : { host_key_fingerprint: policy.fingerprint, host_key_claim: policy.mode };
105
+ }
106
+
107
+ // What the escrow actually holds, which is what the day's budget should count.
108
+ // Booking the caller's ceiling instead charged a 0.2 USDG lease against a 0.5
109
+ // USDG cap, so a 5 USDG day bought ten leases where it could afford twenty-five.
110
+ function escrowed(quote) {
111
+ const held = Number(quote?.maximum_escrow);
112
+ return Number.isFinite(held) && held > 0 ? held : undefined;
113
+ }
114
+
115
+ // The per-call ceiling is the operator's, not the model's: an omitted max_usdg
116
+ // takes PRISM_MAX_USDG rather than a hardcoded number, and a stated one above it
117
+ // is clamped back down to it.
118
+ function maxDeposit(tool, args) {
119
+ return callCeiling(args.max_usdg, requireLedger(tool).maxPerCallMicros);
76
120
  }
77
121
 
78
122
  const PROOF_FEED = process.env.PRISM_PROOF_URL ?? "https://prismnetwork.tech/api/proof";
@@ -129,13 +173,14 @@ async function inferenceOffer(base, requested) {
129
173
  return { offer, model, unit: BigInt(offer.price_micros ?? 0) };
130
174
  }
131
175
 
132
- function withinCap(price, maxUsdg, fallback, what) {
133
- const cap = maxUsdg ?? fallback;
134
- if (typeof cap !== "number" || !Number.isFinite(cap) || cap <= 0) {
135
- throw new Error("max_usdg must be a positive number of USDG.");
136
- }
137
- if (price <= 0n || price > BigInt(Math.round(cap * 1e6))) {
138
- throw new Error(`the endpoint quotes ${usdg(price)} ${what}, past the ${cap} USDG cap.`);
176
+ // The quoted price against the lower of what this call asked for and what the
177
+ // operator allows, so the refusal names whichever ceiling stopped it.
178
+ function withinCap(tool, price, maxUsdg, fallback, what) {
179
+ const operator = requireLedger(tool).maxPerCallMicros;
180
+ const cap = callCeiling(maxUsdg ?? fallback, operator);
181
+ if (price <= 0n || price > BigInt(cap)) {
182
+ const named = cap === operator ? "PRISM_MAX_USDG" : "max_usdg";
183
+ throw new Error(`the endpoint quotes ${usdg(price)} ${what}, past the ${named} cap of ${usdg(cap)}.`);
139
184
  }
140
185
  }
141
186
 
@@ -156,11 +201,28 @@ function leaseId(value) {
156
201
  return id;
157
202
  }
158
203
 
204
+ // Hints a client uses to decide what it may run unattended. `reads` is anything
205
+ // that cannot change state or move money; `spends` is anything that can, and it
206
+ // carries the Claude Code marker that forces a confirmation prompt on every call
207
+ // even in modes that otherwise auto-approve.
208
+ const reads = { readOnlyHint: true, openWorldHint: true };
209
+ const spends = {
210
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
211
+ _meta: { "anthropic/requiresUserInteraction": true },
212
+ };
213
+
159
214
  const TOOLS = [
215
+ {
216
+ name: "prism_budget",
217
+ description: "Show the spending limits this server enforces and what it has already spent in the last 24 hours, with the recent charges. Needs no wallet. Check this before a long job; a lease refused for budget says the same numbers.",
218
+ inputSchema: { type: "object", properties: {} },
219
+ annotations: { title: "Spending limits", readOnlyHint: true, openWorldHint: false },
220
+ },
160
221
  {
161
222
  name: "prism_wallet",
162
223
  description: "Show the agent's wallet address and on-chain balances (USDG and ETH for gas) on Robinhood Chain. Check this before leasing to confirm the wallet can pay.",
163
224
  inputSchema: { type: "object", properties: {} },
225
+ annotations: { title: "Wallet balances", ...reads },
164
226
  },
165
227
  {
166
228
  name: "prism_list_gpus",
@@ -175,11 +237,13 @@ const TOOLS = [
175
237
  },
176
238
  },
177
239
  },
240
+ annotations: { title: "Available GPUs", ...reads },
178
241
  },
179
242
  {
180
243
  name: "prism_price_index",
181
244
  description: "Current GPU pricing on Prism Network by model: sourced low/median/high and settled mean, in USDG per hour. Needs no wallet. Use it to estimate what an analysis job will cost before leasing.",
182
245
  inputSchema: { type: "object", properties: {} },
246
+ annotations: { title: "GPU price index", ...reads },
183
247
  },
184
248
  {
185
249
  name: "prism_receipts",
@@ -190,11 +254,13 @@ const TOOLS = [
190
254
  limit: { type: "integer", description: "Max receipts to return (default 10, max 50)." },
191
255
  },
192
256
  },
257
+ annotations: { title: "Settled receipts", ...reads },
193
258
  },
194
259
  {
195
260
  name: "prism_leases",
196
261
  description: "List this wallet's leases on Prism Network with their current state.",
197
262
  inputSchema: { type: "object", properties: {} },
263
+ annotations: { title: "Your leases", ...reads },
198
264
  },
199
265
  {
200
266
  name: "prism_batch_run",
@@ -205,10 +271,12 @@ const TOOLS = [
205
271
  command: { type: "string", description: "Shell command to run (max 8 KiB)." },
206
272
  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." },
207
273
  min_vram_mib: { type: "integer", description: "Minimum GPU memory in MiB (default 16000)." },
208
- max_usdg: { type: "number", description: "Hard cap on the USDG this lease may cost (default 1). Raise it deliberately for longer leases." },
274
+ 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." },
209
275
  },
210
276
  required: ["command"],
211
277
  },
278
+ ...spends,
279
+ annotations: { title: "Rent a GPU for one command", ...spends.annotations },
212
280
  },
213
281
  {
214
282
  name: "prism_batch_result",
@@ -220,6 +288,7 @@ const TOOLS = [
220
288
  },
221
289
  required: ["lease_id"],
222
290
  },
291
+ annotations: { title: "Batch result", ...reads },
223
292
  },
224
293
  {
225
294
  name: "prism_infer",
@@ -229,10 +298,12 @@ const TOOLS = [
229
298
  properties: {
230
299
  prompt: { type: "string", description: "The prompt to generate from (max 32 KiB)." },
231
300
  model: { type: "string", description: "Model to use; defaults to the endpoint's first offered model." },
232
- max_usdg: { type: "number", description: "Refuse if the quoted price exceeds this (default 0.05)." },
301
+ max_usdg: { type: "number", description: "Refuse if the quoted price exceeds this (default 0.05). The operator's PRISM_MAX_USDG binds it either way." },
233
302
  },
234
303
  required: ["prompt"],
235
304
  },
305
+ ...spends,
306
+ annotations: { title: "Buy one LLM generation", ...spends.annotations },
236
307
  },
237
308
  {
238
309
  name: "prism_infer_batch",
@@ -248,10 +319,12 @@ const TOOLS = [
248
319
  description: "Independent prompts, answered in the order given (each max 32 KiB).",
249
320
  },
250
321
  model: { type: "string", description: "Model to use; defaults to the endpoint's first offered model." },
251
- max_usdg: { type: "number", description: "Refuse if the quoted total exceeds this (default 0.5)." },
322
+ max_usdg: { type: "number", description: "Refuse if the quoted total exceeds this (default 0.5). The operator's PRISM_MAX_USDG binds it either way." },
252
323
  },
253
324
  required: ["prompts"],
254
325
  },
326
+ ...spends,
327
+ annotations: { title: "Buy many LLM generations", ...spends.annotations },
255
328
  },
256
329
  {
257
330
  name: "prism_confidential_infer",
@@ -262,11 +335,13 @@ const TOOLS = [
262
335
  prompt: { type: "string", description: "The prompt to generate from." },
263
336
  model: { type: "string", description: "Confidential model to use; defaults to the endpoint's first." },
264
337
  max_tokens: { type: "integer", description: "Cap on generated tokens (default 512). The price is quoted against this cap." },
265
- max_usdg: { type: "number", description: "Refuse if the quoted price exceeds this (default 0.25)." },
338
+ max_usdg: { type: "number", description: "Refuse if the quoted price exceeds this (default 0.25). The operator's PRISM_MAX_USDG binds it either way." },
266
339
  e2ee: { type: "boolean", description: "Encrypt message contents to the attested enclave key (default true). Turn it off only when the relay is allowed to read the prompt." },
267
340
  },
268
341
  required: ["prompt"],
269
342
  },
343
+ ...spends,
344
+ annotations: { title: "Buy one confidential LLM generation", ...spends.annotations },
270
345
  },
271
346
  {
272
347
  name: "prism_verify_attestation",
@@ -279,6 +354,7 @@ const TOOLS = [
279
354
  },
280
355
  required: ["receipt_id"],
281
356
  },
357
+ annotations: { title: "Check a confidential generation", ...reads },
282
358
  },
283
359
  {
284
360
  name: "prism_lease_and_run",
@@ -294,10 +370,12 @@ const TOOLS = [
294
370
  enum: TRUST_CLASSES,
295
371
  description: "Refuse suppliers below this trust class (default 'open'). Raise it for anything the host operator must not read.",
296
372
  },
297
- max_usdg: { type: "number", description: "Hard cap on the USDG this lease may cost (default 1). Raise it deliberately for longer leases." },
373
+ 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." },
298
374
  },
299
375
  required: ["command"],
300
376
  },
377
+ ...spends,
378
+ annotations: { title: "Rent a GPU and run a command", ...spends.annotations },
301
379
  },
302
380
  {
303
381
  name: "prism_lease",
@@ -312,9 +390,11 @@ const TOOLS = [
312
390
  enum: TRUST_CLASSES,
313
391
  description: "Refuse suppliers below this trust class (default 'open'). Raise it for anything the host operator must not read.",
314
392
  },
315
- max_usdg: { type: "number", description: "Hard cap on the USDG this lease may cost (default 1). Raise it deliberately for longer leases." },
393
+ 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." },
316
394
  },
317
395
  },
396
+ ...spends,
397
+ annotations: { title: "Rent a GPU", ...spends.annotations },
318
398
  },
319
399
  {
320
400
  name: "prism_run",
@@ -328,6 +408,7 @@ const TOOLS = [
328
408
  },
329
409
  required: ["lease_id", "command"],
330
410
  },
411
+ annotations: { title: "Run a command on a lease", readOnlyHint: false, destructiveHint: true, openWorldHint: true },
331
412
  },
332
413
  {
333
414
  name: "prism_end_lease",
@@ -337,6 +418,7 @@ const TOOLS = [
337
418
  properties: { lease_id: { type: "integer" } },
338
419
  required: ["lease_id"],
339
420
  },
421
+ annotations: { title: "Release a lease", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
340
422
  },
341
423
  {
342
424
  name: "prism_vault_store",
@@ -354,11 +436,13 @@ const TOOLS = [
354
436
  },
355
437
  required: ["value"],
356
438
  },
439
+ annotations: { title: "Seal a secret", readOnlyHint: false, destructiveHint: false, openWorldHint: true },
357
440
  },
358
441
  {
359
442
  name: "prism_vault_list",
360
443
  description: "List the agent's sealed vault items: item_id, label, version and trust floor. Values are not returned and are not readable by Prism.",
361
444
  inputSchema: { type: "object", properties: {} },
445
+ annotations: { title: "List sealed items", ...reads },
362
446
  },
363
447
  {
364
448
  name: "prism_vault_read",
@@ -368,6 +452,8 @@ const TOOLS = [
368
452
  properties: { item_id: { type: "string", description: "The item_id from prism_vault_store or prism_vault_list." } },
369
453
  required: ["item_id"],
370
454
  },
455
+ _meta: { "anthropic/requiresUserInteraction": true },
456
+ annotations: { title: "Decrypt one sealed item", readOnlyHint: true, openWorldHint: true },
371
457
  },
372
458
  {
373
459
  name: "prism_vault_delete",
@@ -377,6 +463,8 @@ const TOOLS = [
377
463
  properties: { item_id: { type: "string" } },
378
464
  required: ["item_id"],
379
465
  },
466
+ ...spends,
467
+ annotations: { title: "Delete a sealed item", ...spends.annotations },
380
468
  },
381
469
  {
382
470
  name: "prism_vault_release",
@@ -389,10 +477,13 @@ const TOOLS = [
389
477
  },
390
478
  required: ["item_id", "lease_id"],
391
479
  },
480
+ ...spends,
481
+ annotations: { title: "Release a secret into a lease", ...spends.annotations },
392
482
  },
393
483
  ];
394
484
 
395
485
  async function handle(name, args) {
486
+ if (name === "prism_budget") return requireLedger(name).status();
396
487
  if (name === "prism_wallet") {
397
488
  const b = await requireWallet("prism_wallet").balances();
398
489
  return { address: b.address, usdg: usdg(b.usdg), eth_wei: b.eth };
@@ -483,22 +574,28 @@ async function handle(name, args) {
483
574
  if (name === "prism_batch_run") {
484
575
  requireCommand(args.command);
485
576
  requireWallet(name);
486
- const cap = maxDeposit(args);
487
- const batch = await agent.lease({
488
- image: IMAGE,
489
- durationSeconds: args.duration_seconds ?? 900,
490
- minVramMib: args.min_vram_mib ?? 16000,
491
- maxDeposit: cap,
492
- command: args.command,
577
+ const cap = maxDeposit(name, args);
578
+ return spending(name, cap, async () => {
579
+ const batch = await agent.lease({
580
+ image: IMAGE,
581
+ durationSeconds: args.duration_seconds ?? 900,
582
+ minVramMib: args.min_vram_mib ?? 16000,
583
+ maxDeposit: cap,
584
+ command: args.command,
585
+ });
586
+ return {
587
+ reference: batch.fundingHash,
588
+ settledMicros: escrowed(batch.quote),
589
+ value: {
590
+ lease_id: batch.leaseId,
591
+ funding_tx: batch.fundingHash,
592
+ exit_code: batch.result?.exit_code,
593
+ stdout: batch.result?.stdout,
594
+ stderr: batch.result?.stderr,
595
+ truncated: batch.result?.truncated ?? false,
596
+ },
597
+ };
493
598
  });
494
- return {
495
- lease_id: batch.leaseId,
496
- funding_tx: batch.fundingHash,
497
- exit_code: batch.result?.exit_code,
498
- stdout: batch.result?.stdout,
499
- stderr: batch.result?.stderr,
500
- truncated: batch.result?.truncated ?? false,
501
- };
502
599
  }
503
600
  if (name === "prism_batch_result") {
504
601
  requireWallet(name, "reads this wallet's leases");
@@ -512,14 +609,17 @@ async function handle(name, args) {
512
609
  requireWallet(name);
513
610
  const base = inferenceBase();
514
611
  const { offer, model, unit } = await inferenceOffer(base, args.model);
515
- withinCap(unit, args.max_usdg, 0.05, "per generation");
516
- return payAndPost({
517
- base,
518
- path: "/v1/inference",
519
- price: unit,
520
- payTo: offer.pay_to,
521
- body: { model, prompt: args.prompt },
522
- tool: "prism_infer",
612
+ withinCap(name, unit, args.max_usdg, 0.05, "per generation");
613
+ return spending(name, Number(unit), async () => {
614
+ const value = await payAndPost({
615
+ base,
616
+ path: "/v1/inference",
617
+ price: unit,
618
+ payTo: offer.pay_to,
619
+ body: { model, prompt: args.prompt },
620
+ tool: "prism_infer",
621
+ });
622
+ return { value, settledMicros: Number(unit), reference: value.payment_tx };
523
623
  });
524
624
  }
525
625
  if (name === "prism_infer_batch") {
@@ -537,14 +637,17 @@ async function handle(name, args) {
537
637
  const base = inferenceBase();
538
638
  const { offer, model, unit } = await inferenceOffer(base, args.model);
539
639
  const price = unit * BigInt(prompts.length);
540
- withinCap(price, args.max_usdg, 0.5, `for ${prompts.length} generations`);
541
- return payAndPost({
542
- base,
543
- path: "/v1/batch",
544
- price,
545
- payTo: offer.pay_to,
546
- body: { model, prompts },
547
- tool: "prism_infer_batch",
640
+ withinCap(name, price, args.max_usdg, 0.5, `for ${prompts.length} generations`);
641
+ return spending(name, Number(price), async () => {
642
+ const value = await payAndPost({
643
+ base,
644
+ path: "/v1/batch",
645
+ price,
646
+ payTo: offer.pay_to,
647
+ body: { model, prompts },
648
+ tool: "prism_infer_batch",
649
+ });
650
+ return { value, settledMicros: Number(price), reference: value.payment_tx };
548
651
  });
549
652
  }
550
653
  if (name === "prism_confidential_infer") {
@@ -553,13 +656,19 @@ async function handle(name, args) {
553
656
  }
554
657
  requireWallet(name);
555
658
  const base = inferenceBase();
556
- const run = await agent.confidentialInfer({
557
- prompt: args.prompt,
558
- model: args.model,
559
- maxTokens: args.max_tokens ?? 512,
560
- maxUsdg: args.max_usdg ?? 0.25,
561
- e2ee: args.e2ee ?? true,
562
- endpoint: base,
659
+ // The endpoint quotes inside the SDK, so the day is charged the ceiling up
660
+ // front and corrected to the quoted price once the call has been served.
661
+ const cap = callCeiling(args.max_usdg ?? 0.25, requireLedger(name).maxPerCallMicros);
662
+ const run = await spending(name, cap, async () => {
663
+ const served = await agent.confidentialInfer({
664
+ prompt: args.prompt,
665
+ model: args.model,
666
+ maxTokens: args.max_tokens ?? 512,
667
+ maxUsdg: cap / 1e6,
668
+ e2ee: args.e2ee ?? true,
669
+ endpoint: base,
670
+ });
671
+ return { value: served, settledMicros: Number(served.priceMicros), reference: served.tx };
563
672
  });
564
673
  rememberConfidentialCall(run.receiptId, {
565
674
  base,
@@ -622,20 +731,32 @@ async function handle(name, args) {
622
731
  if (name === "prism_lease_and_run" || name === "prism_lease") {
623
732
  if (name === "prism_lease_and_run") requireCommand(args.command);
624
733
  requireWallet(name);
625
- const cap = maxDeposit(args);
734
+ const cap = maxDeposit(name, args);
626
735
  sweepExpiredLeases();
627
- const lease = await agent.lease({
628
- image: IMAGE,
629
- durationSeconds: args.duration_seconds ?? 900,
630
- minVramMib: args.min_vram_mib ?? 16000,
631
- maxDeposit: cap,
632
- minTrustClass: args.min_trust_class ?? "open",
736
+ const lease = await spending(name, cap, async () => {
737
+ const funded = await agent.lease({
738
+ image: IMAGE,
739
+ durationSeconds: args.duration_seconds ?? 900,
740
+ minVramMib: args.min_vram_mib ?? 16000,
741
+ maxDeposit: cap,
742
+ minTrustClass: args.min_trust_class ?? "open",
743
+ });
744
+ return { value: funded, reference: funded.fundingHash, settledMicros: escrowed(funded.quote) };
633
745
  });
634
746
  leases.set(lease.leaseId, lease);
635
747
  const summary = {
636
748
  lease_id: lease.leaseId,
637
749
  funding_tx: lease.fundingHash,
638
- ssh: { host: lease.access.ssh_host, port: lease.access.ssh_port, user: lease.access.ssh_user },
750
+ // `prism_run` checks this itself. It is in the summary because the
751
+ // caller is being handed an address they may connect to by hand, and an
752
+ // address with no key to check is an invitation to accept whatever
753
+ // answers.
754
+ ssh: {
755
+ host: lease.access.ssh_host,
756
+ port: lease.access.ssh_port,
757
+ user: lease.access.ssh_user,
758
+ ...hostKey(lease.access),
759
+ },
639
760
  trust: lease.quote?.trust_class ?? "open",
640
761
  expires_at: lease.access.expires_at,
641
762
  };
@@ -734,6 +855,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
734
855
  const result = await handle(request.params.name, request.params.arguments ?? {});
735
856
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
736
857
  } catch (err) {
858
+ if (err instanceof BudgetError) {
859
+ return { isError: true, content: [{ type: "text", text: `${err.message} See prism_budget.` }] };
860
+ }
737
861
  const body = err?.body ?? {};
738
862
  const detail = [
739
863
  body.cause,