@prismnetwork/mcp 0.7.0 → 0.8.1
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 +38 -2
- package/budget.mjs +260 -0
- package/package.json +10 -6
- package/server.mjs +136 -31
package/README.md
CHANGED
|
@@ -13,10 +13,14 @@ claude mcp add prism -- npx -y @prismnetwork/mcp
|
|
|
13
13
|
Then ask what you can rent. `prism_list_gpus` answers from live capacity with
|
|
14
14
|
real prices. Leasing spends money, so those tools ask for a wallet and say so.
|
|
15
15
|
|
|
16
|
+
Packaged for Claude Code and Codex as the [Prism plugin](https://plugins.prismnetwork.tech),
|
|
17
|
+
which wires this server up and prompts for the wallet at install time.
|
|
18
|
+
|
|
16
19
|
## Tools
|
|
17
20
|
|
|
18
21
|
| Tool | Wallet |
|
|
19
22
|
| --- | --- |
|
|
23
|
+
| `prism_budget` | no |
|
|
20
24
|
| `prism_list_gpus` | no |
|
|
21
25
|
| `prism_price_index` | no |
|
|
22
26
|
| `prism_receipts` | no |
|
|
@@ -34,6 +38,8 @@ real prices. Leasing spends money, so those tools ask for a wallet and say so.
|
|
|
34
38
|
| `prism_vault_delete` | yes |
|
|
35
39
|
| `prism_vault_release` | yes |
|
|
36
40
|
|
|
41
|
+
- `prism_budget`: the spending limits in force and what has already been spent
|
|
42
|
+
today, with the recent charges.
|
|
37
43
|
- `prism_wallet`: the agent's address and USDG/ETH balances.
|
|
38
44
|
- `prism_list_gpus`: GPUs available to lease, with price per second and per hour.
|
|
39
45
|
- `prism_price_index`: sourced and settled pricing per GPU model, for cost estimates.
|
|
@@ -57,6 +63,29 @@ real prices. Leasing spends money, so those tools ask for a wallet and say so.
|
|
|
57
63
|
- `prism_vault_delete`: permanently delete an item.
|
|
58
64
|
- `prism_vault_release`: authorize an item into a lease that clears its trust floor.
|
|
59
65
|
|
|
66
|
+
## Spending limits
|
|
67
|
+
|
|
68
|
+
Two ceilings bound what this server can spend, and a refusal quotes both.
|
|
69
|
+
|
|
70
|
+
| Variable | Default | What it bounds |
|
|
71
|
+
| --- | --- | --- |
|
|
72
|
+
| `PRISM_MAX_USDG` | 1 | Any single lease or generation. `max_usdg` on a call may lower it, never raise it past this. |
|
|
73
|
+
| `PRISM_DAILY_BUDGET_USDG` | 5 | Everything in a rolling 24 hours. `0` removes the ceiling. |
|
|
74
|
+
| `PRISM_LEDGER_PATH` | `~/.prism/spend.json` | Where the spend is recorded. |
|
|
75
|
+
|
|
76
|
+
Spend is written before the money moves, so a crash between funding an escrow
|
|
77
|
+
and answering is counted rather than forgiven, and a restart does not hand back
|
|
78
|
+
a fresh day's allowance. Only an attempt that provably never reached the chain
|
|
79
|
+
is reverted. Two clients pointed at one wallet share one ceiling, because they
|
|
80
|
+
share the ledger file.
|
|
81
|
+
|
|
82
|
+
None of this is the real limit. Fund a dedicated wallet with what you are
|
|
83
|
+
willing to lose: that balance is what survives a bug in everything above.
|
|
84
|
+
|
|
85
|
+
Tools that spend are annotated `destructiveHint` and carry
|
|
86
|
+
`anthropic/requiresUserInteraction`, so Claude Code asks before every one of
|
|
87
|
+
them even in modes that otherwise approve tools automatically.
|
|
88
|
+
|
|
60
89
|
## Vault
|
|
61
90
|
|
|
62
91
|
An agent that handles a card, an identity document or a credential should not
|
|
@@ -82,7 +111,8 @@ Point your MCP client (Claude Desktop / Code) at the published server:
|
|
|
82
111
|
"args": ["-y", "@prismnetwork/mcp"],
|
|
83
112
|
"env": {
|
|
84
113
|
"PRISM_AGENT_KEY": "0x<agent wallet private key>",
|
|
85
|
-
"
|
|
114
|
+
"PRISM_MAX_USDG": "1",
|
|
115
|
+
"PRISM_DAILY_BUDGET_USDG": "5"
|
|
86
116
|
}
|
|
87
117
|
}
|
|
88
118
|
}
|
|
@@ -95,11 +125,17 @@ Or add it to Claude Code in one line:
|
|
|
95
125
|
|
|
96
126
|
```sh
|
|
97
127
|
claude mcp add prism \
|
|
98
|
-
--env PRISM_ESCROW=0x62C042265991bEa17B07229322A01850974626dA \
|
|
99
128
|
--env PRISM_AGENT_KEY=0x<agent wallet private key> \
|
|
129
|
+
--env PRISM_DAILY_BUDGET_USDG=5 \
|
|
100
130
|
-- npx -y @prismnetwork/mcp
|
|
101
131
|
```
|
|
102
132
|
|
|
133
|
+
Codex takes the same server:
|
|
134
|
+
|
|
135
|
+
```sh
|
|
136
|
+
codex mcp add prism --env PRISM_AGENT_KEY=0x<agent wallet private key> -- npx -y @prismnetwork/mcp
|
|
137
|
+
```
|
|
138
|
+
|
|
103
139
|
## Timing
|
|
104
140
|
|
|
105
141
|
`prism_lease` and `prism_lease_and_run` block while a GPU provisions (usually one to four minutes, occasionally longer on a slow host). Configure your MCP client to allow long tool calls.
|
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.1",
|
|
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} needs the spend limits, and they 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
|
|
|
63
|
-
|
|
64
|
-
|
|
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.
|
|
107
|
+
function maxDeposit(tool, args) {
|
|
108
|
+
if (args.max_usdg === undefined) return requireLedger(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
|
}
|
|
@@ -106,11 +151,28 @@ function leaseId(value) {
|
|
|
106
151
|
return id;
|
|
107
152
|
}
|
|
108
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
|
+
|
|
109
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
|
+
},
|
|
110
171
|
{
|
|
111
172
|
name: "prism_wallet",
|
|
112
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.",
|
|
113
174
|
inputSchema: { type: "object", properties: {} },
|
|
175
|
+
annotations: { title: "Wallet balances", ...reads },
|
|
114
176
|
},
|
|
115
177
|
{
|
|
116
178
|
name: "prism_list_gpus",
|
|
@@ -125,11 +187,13 @@ const TOOLS = [
|
|
|
125
187
|
},
|
|
126
188
|
},
|
|
127
189
|
},
|
|
190
|
+
annotations: { title: "Available GPUs", ...reads },
|
|
128
191
|
},
|
|
129
192
|
{
|
|
130
193
|
name: "prism_price_index",
|
|
131
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.",
|
|
132
195
|
inputSchema: { type: "object", properties: {} },
|
|
196
|
+
annotations: { title: "GPU price index", ...reads },
|
|
133
197
|
},
|
|
134
198
|
{
|
|
135
199
|
name: "prism_receipts",
|
|
@@ -140,11 +204,13 @@ const TOOLS = [
|
|
|
140
204
|
limit: { type: "integer", description: "Max receipts to return (default 10, max 50)." },
|
|
141
205
|
},
|
|
142
206
|
},
|
|
207
|
+
annotations: { title: "Settled receipts", ...reads },
|
|
143
208
|
},
|
|
144
209
|
{
|
|
145
210
|
name: "prism_leases",
|
|
146
211
|
description: "List this wallet's leases on Prism Network with their current state.",
|
|
147
212
|
inputSchema: { type: "object", properties: {} },
|
|
213
|
+
annotations: { title: "Your leases", ...reads },
|
|
148
214
|
},
|
|
149
215
|
{
|
|
150
216
|
name: "prism_batch_run",
|
|
@@ -159,6 +225,8 @@ const TOOLS = [
|
|
|
159
225
|
},
|
|
160
226
|
required: ["command"],
|
|
161
227
|
},
|
|
228
|
+
...spends,
|
|
229
|
+
annotations: { title: "Rent a GPU for one command", ...spends.annotations },
|
|
162
230
|
},
|
|
163
231
|
{
|
|
164
232
|
name: "prism_batch_result",
|
|
@@ -170,6 +238,7 @@ const TOOLS = [
|
|
|
170
238
|
},
|
|
171
239
|
required: ["lease_id"],
|
|
172
240
|
},
|
|
241
|
+
annotations: { title: "Batch result", ...reads },
|
|
173
242
|
},
|
|
174
243
|
{
|
|
175
244
|
name: "prism_infer",
|
|
@@ -183,6 +252,8 @@ const TOOLS = [
|
|
|
183
252
|
},
|
|
184
253
|
required: ["prompt"],
|
|
185
254
|
},
|
|
255
|
+
...spends,
|
|
256
|
+
annotations: { title: "Buy one LLM generation", ...spends.annotations },
|
|
186
257
|
},
|
|
187
258
|
{
|
|
188
259
|
name: "prism_lease_and_run",
|
|
@@ -202,6 +273,8 @@ const TOOLS = [
|
|
|
202
273
|
},
|
|
203
274
|
required: ["command"],
|
|
204
275
|
},
|
|
276
|
+
...spends,
|
|
277
|
+
annotations: { title: "Rent a GPU and run a command", ...spends.annotations },
|
|
205
278
|
},
|
|
206
279
|
{
|
|
207
280
|
name: "prism_lease",
|
|
@@ -219,6 +292,8 @@ const TOOLS = [
|
|
|
219
292
|
max_usdg: { type: "number", description: "Hard cap on the USDG this lease may cost (default 1). Raise it deliberately for longer leases." },
|
|
220
293
|
},
|
|
221
294
|
},
|
|
295
|
+
...spends,
|
|
296
|
+
annotations: { title: "Rent a GPU", ...spends.annotations },
|
|
222
297
|
},
|
|
223
298
|
{
|
|
224
299
|
name: "prism_run",
|
|
@@ -232,6 +307,7 @@ const TOOLS = [
|
|
|
232
307
|
},
|
|
233
308
|
required: ["lease_id", "command"],
|
|
234
309
|
},
|
|
310
|
+
annotations: { title: "Run a command on a lease", readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
235
311
|
},
|
|
236
312
|
{
|
|
237
313
|
name: "prism_end_lease",
|
|
@@ -241,6 +317,7 @@ const TOOLS = [
|
|
|
241
317
|
properties: { lease_id: { type: "integer" } },
|
|
242
318
|
required: ["lease_id"],
|
|
243
319
|
},
|
|
320
|
+
annotations: { title: "Release a lease", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
244
321
|
},
|
|
245
322
|
{
|
|
246
323
|
name: "prism_vault_store",
|
|
@@ -258,11 +335,13 @@ const TOOLS = [
|
|
|
258
335
|
},
|
|
259
336
|
required: ["value"],
|
|
260
337
|
},
|
|
338
|
+
annotations: { title: "Seal a secret", readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
261
339
|
},
|
|
262
340
|
{
|
|
263
341
|
name: "prism_vault_list",
|
|
264
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.",
|
|
265
343
|
inputSchema: { type: "object", properties: {} },
|
|
344
|
+
annotations: { title: "List sealed items", ...reads },
|
|
266
345
|
},
|
|
267
346
|
{
|
|
268
347
|
name: "prism_vault_read",
|
|
@@ -272,6 +351,8 @@ const TOOLS = [
|
|
|
272
351
|
properties: { item_id: { type: "string", description: "The item_id from prism_vault_store or prism_vault_list." } },
|
|
273
352
|
required: ["item_id"],
|
|
274
353
|
},
|
|
354
|
+
_meta: { "anthropic/requiresUserInteraction": true },
|
|
355
|
+
annotations: { title: "Decrypt one sealed item", readOnlyHint: true, openWorldHint: true },
|
|
275
356
|
},
|
|
276
357
|
{
|
|
277
358
|
name: "prism_vault_delete",
|
|
@@ -281,6 +362,8 @@ const TOOLS = [
|
|
|
281
362
|
properties: { item_id: { type: "string" } },
|
|
282
363
|
required: ["item_id"],
|
|
283
364
|
},
|
|
365
|
+
...spends,
|
|
366
|
+
annotations: { title: "Delete a sealed item", ...spends.annotations },
|
|
284
367
|
},
|
|
285
368
|
{
|
|
286
369
|
name: "prism_vault_release",
|
|
@@ -293,10 +376,13 @@ const TOOLS = [
|
|
|
293
376
|
},
|
|
294
377
|
required: ["item_id", "lease_id"],
|
|
295
378
|
},
|
|
379
|
+
...spends,
|
|
380
|
+
annotations: { title: "Release a secret into a lease", ...spends.annotations },
|
|
296
381
|
},
|
|
297
382
|
];
|
|
298
383
|
|
|
299
384
|
async function handle(name, args) {
|
|
385
|
+
if (name === "prism_budget") return requireLedger(name).status();
|
|
300
386
|
if (name === "prism_wallet") {
|
|
301
387
|
const b = await requireWallet("prism_wallet").balances();
|
|
302
388
|
return { address: b.address, usdg: usdg(b.usdg), eth_wei: b.eth };
|
|
@@ -387,22 +473,27 @@ async function handle(name, args) {
|
|
|
387
473
|
if (name === "prism_batch_run") {
|
|
388
474
|
requireCommand(args.command);
|
|
389
475
|
requireWallet(name);
|
|
390
|
-
const cap = maxDeposit(args);
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
476
|
+
const cap = maxDeposit(name, args);
|
|
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
|
+
};
|
|
397
496
|
});
|
|
398
|
-
return {
|
|
399
|
-
lease_id: batch.leaseId,
|
|
400
|
-
funding_tx: batch.fundingHash,
|
|
401
|
-
exit_code: batch.result?.exit_code,
|
|
402
|
-
stdout: batch.result?.stdout,
|
|
403
|
-
stderr: batch.result?.stderr,
|
|
404
|
-
truncated: batch.result?.truncated ?? false,
|
|
405
|
-
};
|
|
406
497
|
}
|
|
407
498
|
if (name === "prism_batch_result") {
|
|
408
499
|
requireWallet(name, "reads this wallet's leases");
|
|
@@ -430,13 +521,21 @@ async function handle(name, args) {
|
|
|
430
521
|
}
|
|
431
522
|
const pendingKey = `${base}:${price}`;
|
|
432
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.
|
|
433
527
|
if (!pending) {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
+
});
|
|
440
539
|
pendingInference.set(pendingKey, pending);
|
|
441
540
|
}
|
|
442
541
|
// A cold endpoint holds the request through provisioning; when it answers
|
|
@@ -472,14 +571,17 @@ async function handle(name, args) {
|
|
|
472
571
|
if (name === "prism_lease_and_run" || name === "prism_lease") {
|
|
473
572
|
if (name === "prism_lease_and_run") requireCommand(args.command);
|
|
474
573
|
requireWallet(name);
|
|
475
|
-
const cap = maxDeposit(args);
|
|
574
|
+
const cap = maxDeposit(name, args);
|
|
476
575
|
sweepExpiredLeases();
|
|
477
|
-
const lease = await
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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 };
|
|
483
585
|
});
|
|
484
586
|
leases.set(lease.leaseId, lease);
|
|
485
587
|
const summary = {
|
|
@@ -577,13 +679,16 @@ async function handleVault(name, args) {
|
|
|
577
679
|
throw new Error(`unknown tool ${name}`);
|
|
578
680
|
}
|
|
579
681
|
|
|
580
|
-
const server = new Server({ name: "prism", version: "0.
|
|
682
|
+
const server = new Server({ name: "prism", version: "0.8.1" }, { capabilities: { tools: {} } });
|
|
581
683
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
582
684
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
583
685
|
try {
|
|
584
686
|
const result = await handle(request.params.name, request.params.arguments ?? {});
|
|
585
687
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
586
688
|
} catch (err) {
|
|
689
|
+
if (err instanceof BudgetError) {
|
|
690
|
+
return { isError: true, content: [{ type: "text", text: `${err.message} See prism_budget.` }] };
|
|
691
|
+
}
|
|
587
692
|
const body = err?.body ?? {};
|
|
588
693
|
const detail = [
|
|
589
694
|
body.cause,
|