@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.
- package/README.md +5 -0
- package/budget.mjs +260 -0
- package/package.json +10 -6
- 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.
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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.
|
|
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,
|