@prismnetwork/mcp 0.6.0 → 0.8.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.
Files changed (4) hide show
  1. package/README.md +5 -0
  2. package/budget.mjs +260 -0
  3. package/package.json +10 -6
  4. package/server.mjs +203 -22
package/README.md CHANGED
@@ -22,6 +22,7 @@ real prices. Leasing spends money, so those tools ask for a wallet and say so.
22
22
  | `prism_receipts` | no |
23
23
  | `prism_wallet` | yes |
24
24
  | `prism_leases` | yes |
25
+ | `prism_infer` | yes |
25
26
  | `prism_lease_and_run` | yes |
26
27
  | `prism_lease` | yes |
27
28
  | `prism_run` | yes |
@@ -39,6 +40,10 @@ real prices. Leasing spends money, so those tools ask for a wallet and say so.
39
40
  - `prism_receipts`: recent settled receipts from the public proof feed, with the
40
41
  settlement transaction on Robinhood Chain.
41
42
  - `prism_leases`: this wallet's leases and their state.
43
+ - `prism_infer`: buy one LLM generation from the managed inference endpoint,
44
+ paying the quoted USDG price from this wallet (about 0.01 USDG). Waits
45
+ through a cold start; an unconsumed payment is kept and reused on the next
46
+ call instead of paying twice.
42
47
  - `prism_lease_and_run`: lease a GPU, run a command, return the output (one shot).
43
48
  - `prism_lease`: lease a GPU and keep it; returns a `lease_id` and SSH access.
44
49
  - `prism_run`: run a command on an existing lease.
package/budget.mjs ADDED
@@ -0,0 +1,260 @@
1
+ // What stands between a model deciding to rent a GPU and a wallet paying for it.
2
+ //
3
+ // The wallet balance is the real ceiling: fund the agent wallet with what you
4
+ // are willing to lose, because nothing here can spend money that is not in the
5
+ // account. This file is the second line, and it exists because the per-call cap
6
+ // alone never was one. `max_usdg` bounds a single lease at 1 USDG by default and
7
+ // says nothing about the fortieth lease in a row, which is the failure an
8
+ // unattended agent actually produces.
9
+ //
10
+ // Spend is written before the money moves and reverted only when the attempt
11
+ // provably cost nothing, so a crash between funding and reply is counted rather
12
+ // than forgiven. The file is shared across clients on purpose: one wallet gets
13
+ // one daily ceiling whether Claude, Codex, or a script is holding it.
14
+ import { mkdirSync, readFileSync, renameSync, writeFileSync, openSync, closeSync, unlinkSync, statSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { dirname, join } from "node:path";
17
+
18
+ const MICROS = 1_000_000;
19
+ const DAY_MS = 86_400_000;
20
+ // Entries older than this are dropped on write. Two days covers the rolling
21
+ // window with room for a clock that stepped backwards.
22
+ const MEMORY_MS = 2 * DAY_MS;
23
+ const LOCK_STALE_MS = 15_000;
24
+ const LOCK_WAIT_MS = 5_000;
25
+
26
+ export class BudgetError extends Error {
27
+ constructor(message, detail = {}) {
28
+ super(message);
29
+ this.name = "BudgetError";
30
+ this.detail = detail;
31
+ }
32
+ }
33
+
34
+ export const usdg = (micros) => `${(Number(micros) / MICROS).toFixed(6)} USDG`;
35
+
36
+ function positiveNumber(raw, fallback, name) {
37
+ if (raw === undefined || raw === null || String(raw).trim() === "") return fallback;
38
+ const value = Number(raw);
39
+ if (!Number.isFinite(value) || value < 0) {
40
+ throw new BudgetError(`${name} must be a non-negative number of USDG, got ${JSON.stringify(raw)}`);
41
+ }
42
+ return value;
43
+ }
44
+
45
+ // One .mcp.json serves every client, and they disagree about templates: Claude
46
+ // Code expands ${user_config.x} before launch, Codex passes it through. Reading
47
+ // the literal template as a value would turn "no wallet configured" into
48
+ // "wallet configured and broken", so an unexpanded placeholder is nothing.
49
+ export function stripUnexpanded(env = process.env) {
50
+ for (const [key, value] of Object.entries(env)) {
51
+ if (key.startsWith("PRISM_") && /^\$\{[^}]*\}$/.test(String(value ?? "").trim())) delete env[key];
52
+ }
53
+ return env;
54
+ }
55
+
56
+ export function defaultLedgerPath(env = process.env) {
57
+ if (env.PRISM_LEDGER_PATH) return env.PRISM_LEDGER_PATH;
58
+ return join(env.HOME || homedir(), ".prism", "spend.json");
59
+ }
60
+
61
+ // A missing budget is not an unlimited one. The default is deliberately small:
62
+ // enough for a handful of real leases, cheap enough that discovering the plugin
63
+ // spends money costs about the price of a coffee rather than a rent cheque.
64
+ export function readBudget(env = process.env) {
65
+ const maxPerCall = positiveNumber(env.PRISM_MAX_USDG, 1, "PRISM_MAX_USDG");
66
+ const daily = positiveNumber(env.PRISM_DAILY_BUDGET_USDG, 5, "PRISM_DAILY_BUDGET_USDG");
67
+ if (maxPerCall <= 0) throw new BudgetError("PRISM_MAX_USDG must be above zero");
68
+ // A per-call cap above the day's allowance is a cap in name only, and the
69
+ // mismatch is always a configuration mistake rather than an intention.
70
+ if (daily > 0 && maxPerCall > daily) {
71
+ throw new BudgetError(
72
+ `PRISM_MAX_USDG (${maxPerCall}) cannot exceed PRISM_DAILY_BUDGET_USDG (${daily}); lower the per-call cap or raise the daily one`,
73
+ );
74
+ }
75
+ return {
76
+ maxPerCallMicros: Math.round(maxPerCall * MICROS),
77
+ // Zero means the operator explicitly removed the daily ceiling. It is not
78
+ // the default and it is not what a missing variable produces.
79
+ dailyMicros: Math.round(daily * MICROS),
80
+ ledgerPath: defaultLedgerPath(env),
81
+ };
82
+ }
83
+
84
+ // A lock rather than last-write-wins, because two clients sharing one wallet is
85
+ // the case this file exists for. A lock older than LOCK_STALE_MS belonged to a
86
+ // process that died; breaking it is safe and not breaking it wedges the wallet.
87
+ function withLock(path, fn, waitMs = LOCK_WAIT_MS) {
88
+ const lock = `${path}.lock`;
89
+ mkdirSync(dirname(path), { recursive: true });
90
+ const deadline = Date.now() + waitMs;
91
+ for (;;) {
92
+ let fd;
93
+ try {
94
+ fd = openSync(lock, "wx");
95
+ } catch (err) {
96
+ if (err?.code !== "EEXIST") throw err;
97
+ let age = 0;
98
+ try {
99
+ age = Date.now() - statSync(lock).mtimeMs;
100
+ } catch {
101
+ continue; // it vanished between the open and the stat; retry immediately
102
+ }
103
+ if (age > LOCK_STALE_MS) {
104
+ try {
105
+ unlinkSync(lock);
106
+ } catch {
107
+ /* another process broke it first, which is the outcome we wanted */
108
+ }
109
+ continue;
110
+ }
111
+ if (Date.now() > deadline) {
112
+ throw new BudgetError(
113
+ `the spend ledger at ${path} is locked by another Prism process; nothing was charged. Retry in a moment.`,
114
+ );
115
+ }
116
+ // Busy-wait deliberately: this holds for milliseconds and the alternative
117
+ // is making every caller of a synchronous ledger asynchronous.
118
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
119
+ continue;
120
+ }
121
+ try {
122
+ closeSync(fd);
123
+ return fn();
124
+ } finally {
125
+ try {
126
+ unlinkSync(lock);
127
+ } catch {
128
+ /* already gone */
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ function readState(path) {
135
+ try {
136
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
137
+ if (!parsed || !Array.isArray(parsed.entries)) return { entries: [] };
138
+ return { entries: parsed.entries.filter((e) => e && Number.isFinite(e.at) && Number.isFinite(e.micros)) };
139
+ } catch (err) {
140
+ if (err?.code === "ENOENT") return { entries: [] };
141
+ // A corrupt ledger must not read as an empty one: that would hand the
142
+ // caller a fresh day's budget every time the file got truncated.
143
+ throw new BudgetError(
144
+ `the spend ledger at ${path} is unreadable (${err?.message ?? err}), so spending is refused. Move or repair the file.`,
145
+ );
146
+ }
147
+ }
148
+
149
+ function writeState(path, state, now) {
150
+ const entries = state.entries.filter((e) => now - e.at < MEMORY_MS);
151
+ const tmp = `${path}.${process.pid}.tmp`;
152
+ writeFileSync(tmp, `${JSON.stringify({ version: 1, entries }, null, 2)}\n`, { mode: 0o600 });
153
+ renameSync(tmp, path);
154
+ }
155
+
156
+ export function spentInWindow(entries, now) {
157
+ return entries.reduce((total, e) => (now - e.at < DAY_MS ? total + e.micros : total), 0);
158
+ }
159
+
160
+ export class SpendLedger {
161
+ constructor({ ledgerPath, dailyMicros, maxPerCallMicros, lockWaitMs = LOCK_WAIT_MS }) {
162
+ this.path = ledgerPath;
163
+ this.dailyMicros = dailyMicros;
164
+ this.maxPerCallMicros = maxPerCallMicros;
165
+ this.lockWaitMs = lockWaitMs;
166
+ }
167
+
168
+ // What is left of today, for the caller to show before it asks to spend.
169
+ remaining(now = Date.now()) {
170
+ if (this.dailyMicros <= 0) return null;
171
+ const { entries } = readState(this.path);
172
+ return Math.max(0, this.dailyMicros - spentInWindow(entries, now));
173
+ }
174
+
175
+ status(now = Date.now()) {
176
+ const { entries } = readState(this.path);
177
+ const spent = spentInWindow(entries, now);
178
+ return {
179
+ daily_budget: this.dailyMicros > 0 ? usdg(this.dailyMicros) : "unlimited (PRISM_DAILY_BUDGET_USDG=0)",
180
+ spent_last_24h: usdg(spent),
181
+ remaining_today: this.dailyMicros > 0 ? usdg(Math.max(0, this.dailyMicros - spent)) : "unlimited",
182
+ max_per_call: usdg(this.maxPerCallMicros),
183
+ ledger: this.path,
184
+ charges_last_24h: entries
185
+ .filter((e) => now - e.at < DAY_MS)
186
+ .sort((a, b) => b.at - a.at)
187
+ .slice(0, 20)
188
+ .map((e) => ({
189
+ at: new Date(e.at).toISOString(),
190
+ tool: e.tool,
191
+ amount: usdg(e.micros),
192
+ ...(e.reference ? { reference: e.reference } : {}),
193
+ })),
194
+ };
195
+ }
196
+
197
+ // Records the spend before the money moves. Returns a handle the caller
198
+ // reverts only when it can prove nothing was charged.
199
+ commit({ tool, micros, now = Date.now() }) {
200
+ if (!Number.isFinite(micros) || micros <= 0) {
201
+ throw new BudgetError("a spend must be a positive number of micros");
202
+ }
203
+ if (micros > this.maxPerCallMicros) {
204
+ throw new BudgetError(
205
+ `${tool} would commit up to ${usdg(micros)}, past the ${usdg(this.maxPerCallMicros)} per-call cap. ` +
206
+ `Lower max_usdg for this call, or raise PRISM_MAX_USDG.`,
207
+ { required: micros, cap: this.maxPerCallMicros },
208
+ );
209
+ }
210
+ return withLock(
211
+ this.path,
212
+ () => {
213
+ const state = readState(this.path);
214
+ const spent = spentInWindow(state.entries, now);
215
+ if (this.dailyMicros > 0 && spent + micros > this.dailyMicros) {
216
+ throw new BudgetError(
217
+ `${tool} would take today's Prism spend to ${usdg(spent + micros)}, past the ${usdg(this.dailyMicros)} ` +
218
+ `daily cap (${usdg(spent)} already spent). Nothing was charged. Raise PRISM_DAILY_BUDGET_USDG to continue.`,
219
+ { spent, requested: micros, cap: this.dailyMicros },
220
+ );
221
+ }
222
+ const id = `${now.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
223
+ state.entries.push({ id, at: now, tool, micros });
224
+ writeState(this.path, state, now);
225
+ return id;
226
+ },
227
+ this.lockWaitMs,
228
+ );
229
+ }
230
+
231
+ // Only for an attempt that provably cost nothing. A funded lease whose command
232
+ // failed is not one of those.
233
+ revert(id) {
234
+ if (!id) return false;
235
+ return withLock(this.path, () => {
236
+ const state = readState(this.path);
237
+ const before = state.entries.length;
238
+ state.entries = state.entries.filter((e) => e.id !== id);
239
+ if (state.entries.length === before) return false;
240
+ writeState(this.path, state, Date.now());
241
+ return true;
242
+ }, this.lockWaitMs);
243
+ }
244
+
245
+ // Replaces the reserved figure with what was actually committed on-chain and
246
+ // pins the receipt to it, so the ledger reads like a statement rather than a
247
+ // list of intentions.
248
+ settle(id, { micros, reference } = {}) {
249
+ if (!id) return false;
250
+ return withLock(this.path, () => {
251
+ const state = readState(this.path);
252
+ const entry = state.entries.find((e) => e.id === id);
253
+ if (!entry) return false;
254
+ if (Number.isFinite(micros) && micros >= 0) entry.micros = micros;
255
+ if (reference) entry.reference = reference;
256
+ writeState(this.path, state, Date.now());
257
+ return true;
258
+ }, this.lockWaitMs);
259
+ }
260
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prismnetwork/mcp",
3
- "version": "0.6.0",
3
+ "version": "0.8.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
  "server.mjs",
12
+ "budget.mjs",
12
13
  "README.md"
13
14
  ],
14
15
  "engines": {
@@ -16,16 +17,19 @@
16
17
  },
17
18
  "dependencies": {
18
19
  "@modelcontextprotocol/sdk": "^1.0.0",
19
- "@prismnetwork/agent-sdk": "^0.5.0",
20
+ "@prismnetwork/agent-sdk": "^0.6.1",
20
21
  "viem": "^2"
21
22
  },
22
23
  "keywords": [
23
- "prism",
24
- "mcp",
25
- "gpu",
26
24
  "agent",
27
25
  "claude",
28
- "compute"
26
+ "codex",
27
+ "compute",
28
+ "gpu",
29
+ "gpu-rental",
30
+ "mcp",
31
+ "prism",
32
+ "x402"
29
33
  ],
30
34
  "homepage": "https://prismnetwork.tech",
31
35
  "repository": {
package/server.mjs CHANGED
@@ -6,6 +6,9 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
7
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
8
8
  import { DEFAULT_IMAGE, DEFAULT_TRUST_FLOOR, PrismAgent, TRUST_CLASSES } from "@prismnetwork/agent-sdk";
9
+ import { BudgetError, SpendLedger, readBudget, stripUnexpanded } from "./budget.mjs";
10
+
11
+ stripUnexpanded(process.env);
9
12
 
10
13
  const IMAGE = process.env.PRISM_DEFAULT_IMAGE ?? DEFAULT_IMAGE;
11
14
 
@@ -41,6 +44,18 @@ if (!agent) {
41
44
  );
42
45
  }
43
46
 
47
+ // A budget the operator got wrong must stop spending, not fall back to none.
48
+ // Reading capacity and prices is unaffected, so a typo is discoverable rather
49
+ // than fatal.
50
+ let ledger = null;
51
+ let budgetProblem = null;
52
+ try {
53
+ ledger = new SpendLedger(readBudget());
54
+ } catch (err) {
55
+ budgetProblem = err?.message ?? String(err);
56
+ console.error(`prism mcp: ${budgetProblem}`);
57
+ }
58
+
44
59
  function requireWallet(tool, reason = "spends money") {
45
60
  if (!agent) {
46
61
  throw new Error(
@@ -50,6 +65,32 @@ function requireWallet(tool, reason = "spends money") {
50
65
  return agent;
51
66
  }
52
67
 
68
+ function requireLedger(tool) {
69
+ if (!ledger) {
70
+ throw new Error(`${tool} spends money and the spend limits are unusable: ${budgetProblem}`);
71
+ }
72
+ return ledger;
73
+ }
74
+
75
+ // Records the spend before the money moves, then reconciles. An attempt that
76
+ // never reached the chain is reverted; anything that funded an escrow keeps its
77
+ // entry and gains the transaction that proves it, because a ledger that forgets
78
+ // a funded lease is worse than no ledger at all.
79
+ async function spending(tool, micros, run) {
80
+ const book = requireLedger(tool);
81
+ const id = book.commit({ tool, micros });
82
+ try {
83
+ const { value, settledMicros, reference } = await run();
84
+ book.settle(id, { micros: settledMicros, reference });
85
+ return value;
86
+ } catch (err) {
87
+ const funded = err?.body?.funding_hash;
88
+ if (funded) book.settle(id, { reference: funded });
89
+ else book.revert(id);
90
+ throw err;
91
+ }
92
+ }
93
+
53
94
  function requireCommand(value) {
54
95
  if (typeof value !== "string" || value.trim() === "") {
55
96
  throw new Error("command is required: the shell command to run on the GPU, e.g. 'nvidia-smi'.");
@@ -60,8 +101,12 @@ function requireCommand(value) {
60
101
  return value;
61
102
  }
62
103
 
104
+ // The per-call ceiling is the operator's, not the model's: an omitted max_usdg
105
+ // takes the configured cap rather than a hardcoded one, and a stated max_usdg
106
+ // above it is refused when the ledger checks the commit.
63
107
  function maxDeposit(args) {
64
- const cap = args.max_usdg ?? 1;
108
+ if (args.max_usdg === undefined) return requireLedger("this tool").maxPerCallMicros;
109
+ const cap = args.max_usdg;
65
110
  if (typeof cap !== "number" || !Number.isFinite(cap) || cap <= 0) {
66
111
  throw new Error("max_usdg must be a positive number of USDG.");
67
112
  }
@@ -82,6 +127,9 @@ async function publicJson(url, what) {
82
127
  }
83
128
 
84
129
  const leases = new Map();
130
+ // Unconsumed inference payments, keyed by endpoint and price, so a failed
131
+ // generation is retried with the same paid header instead of paying again.
132
+ const pendingInference = new Map();
85
133
  // null in, null out: a missing price must never render as 0.000000 USDG.
86
134
  const usdg = (micros) =>
87
135
  micros == null || !Number.isFinite(Number(micros)) ? null : `${(Number(micros) / 1e6).toFixed(6)} USDG`;
@@ -103,11 +151,28 @@ function leaseId(value) {
103
151
  return id;
104
152
  }
105
153
 
154
+ // Hints a client uses to decide what it may run unattended. `reads` is anything
155
+ // that cannot change state or move money; `spends` is anything that can, and it
156
+ // carries the Claude Code marker that forces a confirmation prompt on every call
157
+ // even in modes that otherwise auto-approve.
158
+ const reads = { readOnlyHint: true, openWorldHint: true };
159
+ const spends = {
160
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
161
+ _meta: { "anthropic/requiresUserInteraction": true },
162
+ };
163
+
106
164
  const TOOLS = [
165
+ {
166
+ name: "prism_budget",
167
+ 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.",
168
+ inputSchema: { type: "object", properties: {} },
169
+ annotations: { title: "Spending limits", readOnlyHint: true, openWorldHint: false },
170
+ },
107
171
  {
108
172
  name: "prism_wallet",
109
173
  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.",
110
174
  inputSchema: { type: "object", properties: {} },
175
+ annotations: { title: "Wallet balances", ...reads },
111
176
  },
112
177
  {
113
178
  name: "prism_list_gpus",
@@ -122,11 +187,13 @@ const TOOLS = [
122
187
  },
123
188
  },
124
189
  },
190
+ annotations: { title: "Available GPUs", ...reads },
125
191
  },
126
192
  {
127
193
  name: "prism_price_index",
128
194
  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.",
129
195
  inputSchema: { type: "object", properties: {} },
196
+ annotations: { title: "GPU price index", ...reads },
130
197
  },
131
198
  {
132
199
  name: "prism_receipts",
@@ -137,11 +204,13 @@ const TOOLS = [
137
204
  limit: { type: "integer", description: "Max receipts to return (default 10, max 50)." },
138
205
  },
139
206
  },
207
+ annotations: { title: "Settled receipts", ...reads },
140
208
  },
141
209
  {
142
210
  name: "prism_leases",
143
211
  description: "List this wallet's leases on Prism Network with their current state.",
144
212
  inputSchema: { type: "object", properties: {} },
213
+ annotations: { title: "Your leases", ...reads },
145
214
  },
146
215
  {
147
216
  name: "prism_batch_run",
@@ -156,6 +225,8 @@ const TOOLS = [
156
225
  },
157
226
  required: ["command"],
158
227
  },
228
+ ...spends,
229
+ annotations: { title: "Rent a GPU for one command", ...spends.annotations },
159
230
  },
160
231
  {
161
232
  name: "prism_batch_result",
@@ -167,6 +238,22 @@ const TOOLS = [
167
238
  },
168
239
  required: ["lease_id"],
169
240
  },
241
+ annotations: { title: "Batch result", ...reads },
242
+ },
243
+ {
244
+ name: "prism_infer",
245
+ description: "Buy one LLM generation from Prism's managed inference endpoint. Pays the quoted USDG price from this wallet (about 0.01 USDG), waits through a cold start when no box is warm (up to a few minutes), and returns the generation with token usage. Cheaper and simpler than leasing when all you need is a completion.",
246
+ inputSchema: {
247
+ type: "object",
248
+ properties: {
249
+ prompt: { type: "string", description: "The prompt to generate from (max 32 KiB)." },
250
+ 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 price exceeds this (default 0.05)." },
252
+ },
253
+ required: ["prompt"],
254
+ },
255
+ ...spends,
256
+ annotations: { title: "Buy one LLM generation", ...spends.annotations },
170
257
  },
171
258
  {
172
259
  name: "prism_lease_and_run",
@@ -186,6 +273,8 @@ const TOOLS = [
186
273
  },
187
274
  required: ["command"],
188
275
  },
276
+ ...spends,
277
+ annotations: { title: "Rent a GPU and run a command", ...spends.annotations },
189
278
  },
190
279
  {
191
280
  name: "prism_lease",
@@ -203,6 +292,8 @@ const TOOLS = [
203
292
  max_usdg: { type: "number", description: "Hard cap on the USDG this lease may cost (default 1). Raise it deliberately for longer leases." },
204
293
  },
205
294
  },
295
+ ...spends,
296
+ annotations: { title: "Rent a GPU", ...spends.annotations },
206
297
  },
207
298
  {
208
299
  name: "prism_run",
@@ -216,6 +307,7 @@ const TOOLS = [
216
307
  },
217
308
  required: ["lease_id", "command"],
218
309
  },
310
+ annotations: { title: "Run a command on a lease", readOnlyHint: false, destructiveHint: true, openWorldHint: true },
219
311
  },
220
312
  {
221
313
  name: "prism_end_lease",
@@ -225,6 +317,7 @@ const TOOLS = [
225
317
  properties: { lease_id: { type: "integer" } },
226
318
  required: ["lease_id"],
227
319
  },
320
+ annotations: { title: "Release a lease", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
228
321
  },
229
322
  {
230
323
  name: "prism_vault_store",
@@ -242,11 +335,13 @@ const TOOLS = [
242
335
  },
243
336
  required: ["value"],
244
337
  },
338
+ annotations: { title: "Seal a secret", readOnlyHint: false, destructiveHint: false, openWorldHint: true },
245
339
  },
246
340
  {
247
341
  name: "prism_vault_list",
248
342
  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.",
249
343
  inputSchema: { type: "object", properties: {} },
344
+ annotations: { title: "List sealed items", ...reads },
250
345
  },
251
346
  {
252
347
  name: "prism_vault_read",
@@ -256,6 +351,8 @@ const TOOLS = [
256
351
  properties: { item_id: { type: "string", description: "The item_id from prism_vault_store or prism_vault_list." } },
257
352
  required: ["item_id"],
258
353
  },
354
+ _meta: { "anthropic/requiresUserInteraction": true },
355
+ annotations: { title: "Decrypt one sealed item", readOnlyHint: true, openWorldHint: true },
259
356
  },
260
357
  {
261
358
  name: "prism_vault_delete",
@@ -265,6 +362,8 @@ const TOOLS = [
265
362
  properties: { item_id: { type: "string" } },
266
363
  required: ["item_id"],
267
364
  },
365
+ ...spends,
366
+ annotations: { title: "Delete a sealed item", ...spends.annotations },
268
367
  },
269
368
  {
270
369
  name: "prism_vault_release",
@@ -277,10 +376,13 @@ const TOOLS = [
277
376
  },
278
377
  required: ["item_id", "lease_id"],
279
378
  },
379
+ ...spends,
380
+ annotations: { title: "Release a secret into a lease", ...spends.annotations },
280
381
  },
281
382
  ];
282
383
 
283
384
  async function handle(name, args) {
385
+ if (name === "prism_budget") return requireLedger(name).status();
284
386
  if (name === "prism_wallet") {
285
387
  const b = await requireWallet("prism_wallet").balances();
286
388
  return { address: b.address, usdg: usdg(b.usdg), eth_wei: b.eth };
@@ -372,38 +474,114 @@ async function handle(name, args) {
372
474
  requireCommand(args.command);
373
475
  requireWallet(name);
374
476
  const cap = maxDeposit(args);
375
- const batch = await agent.lease({
376
- image: IMAGE,
377
- durationSeconds: args.duration_seconds ?? 900,
378
- minVramMib: args.min_vram_mib ?? 16000,
379
- maxDeposit: cap,
380
- command: args.command,
477
+ return spending(name, cap, async () => {
478
+ const batch = await agent.lease({
479
+ image: IMAGE,
480
+ durationSeconds: args.duration_seconds ?? 900,
481
+ minVramMib: args.min_vram_mib ?? 16000,
482
+ maxDeposit: cap,
483
+ command: args.command,
484
+ });
485
+ return {
486
+ reference: batch.fundingHash,
487
+ value: {
488
+ lease_id: batch.leaseId,
489
+ funding_tx: batch.fundingHash,
490
+ exit_code: batch.result?.exit_code,
491
+ stdout: batch.result?.stdout,
492
+ stderr: batch.result?.stderr,
493
+ truncated: batch.result?.truncated ?? false,
494
+ },
495
+ };
381
496
  });
382
- return {
383
- lease_id: batch.leaseId,
384
- funding_tx: batch.fundingHash,
385
- exit_code: batch.result?.exit_code,
386
- stdout: batch.result?.stdout,
387
- stderr: batch.result?.stderr,
388
- truncated: batch.result?.truncated ?? false,
389
- };
390
497
  }
391
498
  if (name === "prism_batch_result") {
392
499
  requireWallet(name, "reads this wallet's leases");
393
500
  const id = leaseId(args.lease_id);
394
501
  return { lease_id: id, result: await agent.result(id) };
395
502
  }
503
+ if (name === "prism_infer") {
504
+ if (typeof args.prompt !== "string" || args.prompt.trim() === "") {
505
+ throw new Error("prompt is required.");
506
+ }
507
+ requireWallet(name);
508
+ const base = (process.env.PRISM_INFERENCE_URL ?? "https://api.prismnetwork.tech/inference").replace(/\/$/, "");
509
+ const offer = await publicJson(`${base}/v1/models`, "inference endpoint");
510
+ const model = args.model ?? offer.models?.[0];
511
+ if (!model || (Array.isArray(offer.models) && !offer.models.includes(model))) {
512
+ throw new Error(`model must be one of ${offer.models?.join(", ") ?? "(endpoint offered none)"}`);
513
+ }
514
+ const price = BigInt(offer.price_micros ?? 0);
515
+ const cap = args.max_usdg ?? 0.05;
516
+ if (typeof cap !== "number" || !Number.isFinite(cap) || cap <= 0) {
517
+ throw new Error("max_usdg must be a positive number of USDG.");
518
+ }
519
+ if (price <= 0n || price > BigInt(Math.round(cap * 1e6))) {
520
+ throw new Error(`the endpoint quotes ${usdg(price)} per generation, past the ${cap} USDG cap.`);
521
+ }
522
+ const pendingKey = `${base}:${price}`;
523
+ let pending = pendingInference.get(pendingKey);
524
+ // A kept payment was already charged and already counted. Only a new
525
+ // transfer touches the ledger, or a retry would bill the day twice for one
526
+ // generation.
527
+ if (!pending) {
528
+ pending = await spending(name, Number(price), async () => {
529
+ const paymentTx = await agent.transferUsdg(offer.pay_to, price);
530
+ const signature = await agent.account.signMessage({ message: paymentTx });
531
+ return {
532
+ reference: paymentTx,
533
+ value: {
534
+ tx: paymentTx,
535
+ header: Buffer.from(JSON.stringify({ txHash: paymentTx, signature })).toString("base64"),
536
+ },
537
+ };
538
+ });
539
+ pendingInference.set(pendingKey, pending);
540
+ }
541
+ // A cold endpoint holds the request through provisioning; when it answers
542
+ // 503 instead, the payment is not consumed and the same header retries.
543
+ const deadline = Date.now() + 600_000;
544
+ for (;;) {
545
+ const res = await fetch(`${base}/v1/inference`, {
546
+ method: "POST",
547
+ headers: { "content-type": "application/json", "x-payment": pending.header },
548
+ body: JSON.stringify({ model, prompt: args.prompt }),
549
+ signal: AbortSignal.timeout(620_000),
550
+ });
551
+ const body = await res.json().catch(() => null);
552
+ if (res.status === 200 && body) {
553
+ pendingInference.delete(pendingKey);
554
+ return { ...body, paid: usdg(price), payment_tx: pending.tx };
555
+ }
556
+ const last = body?.detail ?? body?.error ?? `status ${res.status}`;
557
+ // 503 means the box is warming; a 402 for a payment that is merely too
558
+ // young (confirmations still landing, receipt not yet visible) heals by
559
+ // itself. Everything else is final.
560
+ const retryable =
561
+ res.status === 503 ||
562
+ (res.status === 402 && ["insufficient_confirmations", "tx_not_found"].includes(body?.error));
563
+ if (!retryable || Date.now() > deadline) {
564
+ throw new Error(
565
+ `inference failed: ${last}. The payment (tx ${pending.tx}) was not consumed and is kept; the next prism_infer call retries with it instead of paying again.`,
566
+ );
567
+ }
568
+ await new Promise((r) => setTimeout(r, 15_000));
569
+ }
570
+ }
396
571
  if (name === "prism_lease_and_run" || name === "prism_lease") {
397
572
  if (name === "prism_lease_and_run") requireCommand(args.command);
398
573
  requireWallet(name);
399
574
  const cap = maxDeposit(args);
400
575
  sweepExpiredLeases();
401
- const lease = await agent.lease({
402
- image: IMAGE,
403
- durationSeconds: args.duration_seconds ?? 900,
404
- minVramMib: args.min_vram_mib ?? 16000,
405
- maxDeposit: cap,
406
- minTrustClass: args.min_trust_class ?? "open",
576
+ const lease = await spending(name, cap, async () => {
577
+ const funded = await agent.lease({
578
+ image: IMAGE,
579
+ durationSeconds: args.duration_seconds ?? 900,
580
+ minVramMib: args.min_vram_mib ?? 16000,
581
+ maxDeposit: cap,
582
+ minTrustClass: args.min_trust_class ?? "open",
583
+ });
584
+ return { value: funded, reference: funded.fundingHash };
407
585
  });
408
586
  leases.set(lease.leaseId, lease);
409
587
  const summary = {
@@ -501,13 +679,16 @@ async function handleVault(name, args) {
501
679
  throw new Error(`unknown tool ${name}`);
502
680
  }
503
681
 
504
- const server = new Server({ name: "prism", version: "0.6.0" }, { capabilities: { tools: {} } });
682
+ const server = new Server({ name: "prism", version: "0.8.0" }, { capabilities: { tools: {} } });
505
683
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
506
684
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
507
685
  try {
508
686
  const result = await handle(request.params.name, request.params.arguments ?? {});
509
687
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
510
688
  } catch (err) {
689
+ if (err instanceof BudgetError) {
690
+ return { isError: true, content: [{ type: "text", text: `${err.message} See prism_budget.` }] };
691
+ }
511
692
  const body = err?.body ?? {};
512
693
  const detail = [
513
694
  body.cause,