@prismnetwork/mcp 0.5.0 → 0.6.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 (3) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -2
  3. package/server.mjs +130 -59
package/README.md CHANGED
@@ -60,7 +60,7 @@ derived from the agent's wallet inside this server process; Prism receives
60
60
  ciphertext and holds no way to read it.
61
61
 
62
62
  Each item names the weakest workspace trust class it may ever be released into,
63
- and new items default to `confidential` above anything the network serves
63
+ and new items default to `confidential`, above anything the network serves
64
64
  today. `prism_vault_release` is therefore refused on current capacity instead of
65
65
  handing a secret to a host that can read it. Lowering an item's floor is a
66
66
  deliberate act, and allowed releases are recorded against the account.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prismnetwork/mcp",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@modelcontextprotocol/sdk": "^1.0.0",
19
- "@prismnetwork/agent-sdk": "^0.3.1",
19
+ "@prismnetwork/agent-sdk": "^0.5.0",
20
20
  "viem": "^2"
21
21
  },
22
22
  "keywords": [
package/server.mjs CHANGED
@@ -9,59 +9,82 @@ import { DEFAULT_IMAGE, DEFAULT_TRUST_FLOOR, PrismAgent, TRUST_CLASSES } from "@
9
9
 
10
10
  const IMAGE = process.env.PRISM_DEFAULT_IMAGE ?? DEFAULT_IMAGE;
11
11
 
12
- function requireEnv(name) {
13
- const value = process.env[name];
14
- if (!value) throw new Error(`${name} is required`);
15
- return value;
16
- }
17
-
18
12
  const PUBLIC_API = process.env.PRISM_PUBLIC_API ?? "https://api.prismnetwork.tech";
13
+ // The live lease escrow. Overridable, but its absence must not silently
14
+ // disable the wallet the way a missing key does.
15
+ const DEFAULT_ESCROW = "0x62C042265991bEa17B07229322A01850974626dA";
16
+ // Matches the limit the SDK and the control plane enforce, so a command that
17
+ // cannot run is rejected before an escrow is funded.
18
+ const MAX_COMMAND_BYTES = 8 * 1024;
19
19
 
20
20
  // Refusing to start without a wallet meant nobody could ask what a GPU costs
21
21
  // without first producing a private key, which is a strange thing to demand of
22
22
  // someone deciding whether to use you at all.
23
23
  let agent = null;
24
- try {
25
- agent = new PrismAgent({
26
- privateKey: requireEnv("PRISM_AGENT_KEY"),
27
- escrow: requireEnv("PRISM_ESCROW"),
28
- apiBase: process.env.PRISM_API_BASE ?? "https://prismnetwork.tech",
29
- rpcUrl: process.env.PRISM_RPC_URL,
30
- });
31
- } catch {
24
+ let walletProblem = "PRISM_AGENT_KEY is not set";
25
+ if (process.env.PRISM_AGENT_KEY) {
26
+ try {
27
+ agent = new PrismAgent({
28
+ privateKey: process.env.PRISM_AGENT_KEY,
29
+ escrow: process.env.PRISM_ESCROW ?? DEFAULT_ESCROW,
30
+ apiBase: process.env.PRISM_API_BASE ?? "https://prismnetwork.tech",
31
+ rpcUrl: process.env.PRISM_RPC_URL,
32
+ });
33
+ walletProblem = null;
34
+ } catch (err) {
35
+ walletProblem = `PRISM_AGENT_KEY is set but unusable: ${err?.message ?? err}`;
36
+ }
37
+ }
38
+ if (!agent) {
32
39
  console.error(
33
- "prism mcp: no wallet configured, so capacity and pricing are readable and leasing is not. " +
34
- "Set PRISM_AGENT_KEY and PRISM_ESCROW to lease.",
40
+ `prism mcp: no wallet configured (${walletProblem}), so capacity and pricing are readable and leasing is not.`,
35
41
  );
36
42
  }
37
43
 
38
- function requireWallet(tool) {
44
+ function requireWallet(tool, reason = "spends money") {
39
45
  if (!agent) {
40
46
  throw new Error(
41
- `${tool} spends money, so it needs a wallet. Set PRISM_AGENT_KEY and PRISM_ESCROW in this server's environment and restart it.`,
47
+ `${tool} ${reason} and needs a wallet. None is configured: ${walletProblem}. Fix the environment and restart the server.`,
42
48
  );
43
49
  }
44
50
  return agent;
45
51
  }
46
52
 
47
- async function publicOffers(minTrust) {
48
- const url = new URL("/v1/offers", PUBLIC_API);
49
- if (minTrust) url.searchParams.set("min_trust", minTrust);
50
- const response = await fetch(url, { headers: { accept: "application/json" } });
51
- if (!response.ok) throw new Error(`prism offers unavailable (${response.status})`);
52
- return response.json();
53
+ function requireCommand(value) {
54
+ if (typeof value !== "string" || value.trim() === "") {
55
+ throw new Error("command is required: the shell command to run on the GPU, e.g. 'nvidia-smi'.");
56
+ }
57
+ if (Buffer.byteLength(value, "utf8") > MAX_COMMAND_BYTES) {
58
+ throw new Error(`command exceeds the ${MAX_COMMAND_BYTES / 1024} KiB limit.`);
59
+ }
60
+ return value;
61
+ }
62
+
63
+ function maxDeposit(args) {
64
+ const cap = args.max_usdg ?? 1;
65
+ if (typeof cap !== "number" || !Number.isFinite(cap) || cap <= 0) {
66
+ throw new Error("max_usdg must be a positive number of USDG.");
67
+ }
68
+ return Math.round(cap * 1e6);
53
69
  }
54
70
 
55
71
  const PROOF_FEED = process.env.PRISM_PROOF_URL ?? "https://prismnetwork.tech/api/proof";
56
72
 
57
73
  async function publicJson(url, what) {
58
- const response = await fetch(url, { headers: { accept: "application/json" } });
74
+ const response = await fetch(url, {
75
+ headers: { accept: "application/json" },
76
+ signal: AbortSignal.timeout(10_000),
77
+ });
59
78
  if (!response.ok) throw new Error(`prism ${what} unavailable (${response.status})`);
60
- return response.json();
79
+ const body = await response.json().catch(() => null);
80
+ if (body === null) throw new Error(`prism ${what} answered with something that is not JSON`);
81
+ return body;
61
82
  }
62
83
 
63
84
  const leases = new Map();
64
- const usdg = (micros) => `${(Number(micros) / 1e6).toFixed(6)} USDG`;
85
+ // null in, null out: a missing price must never render as 0.000000 USDG.
86
+ const usdg = (micros) =>
87
+ micros == null || !Number.isFinite(Number(micros)) ? null : `${(Number(micros) / 1e6).toFixed(6)} USDG`;
65
88
 
66
89
  function sweepExpiredLeases() {
67
90
  const now = Date.now();
@@ -111,7 +134,7 @@ const TOOLS = [
111
134
  inputSchema: {
112
135
  type: "object",
113
136
  properties: {
114
- limit: { type: "integer", description: "Max receipts to return (default 10)." },
137
+ limit: { type: "integer", description: "Max receipts to return (default 10, max 50)." },
115
138
  },
116
139
  },
117
140
  },
@@ -122,17 +145,29 @@ const TOOLS = [
122
145
  },
123
146
  {
124
147
  name: "prism_batch_run",
125
- description: "Fund a lease that runs one command with no interactive access at all: the node executes it and reports the signed output. Matches only suppliers at trust class 'isolated' or above, so it can find no supplier when none is online prefer prism_lease_and_run for broad availability. Output is capped at 64 KiB per stream.",
148
+ description: "Fund a lease that runs one command with no interactive access at all: the node executes it and reports the signed output. Matches only suppliers at trust class 'isolated' or above, so it can find no supplier when none is online; prefer prism_lease_and_run for broad availability. Output is capped at 64 KiB per stream.",
126
149
  inputSchema: {
127
150
  type: "object",
128
151
  properties: {
129
152
  command: { type: "string", description: "Shell command to run (max 8 KiB)." },
130
153
  duration_seconds: { type: "integer", description: "Paid window in seconds (default 900, max 21600). A command still running at the end is killed and reported exit 124." },
131
154
  min_vram_mib: { type: "integer", description: "Minimum GPU memory in MiB (default 16000)." },
155
+ max_usdg: { type: "number", description: "Hard cap on the USDG this lease may cost (default 1). Raise it deliberately for longer leases." },
132
156
  },
133
157
  required: ["command"],
134
158
  },
135
159
  },
160
+ {
161
+ name: "prism_batch_result",
162
+ description: "Read the output of a batch lease by lease_id, once its node has reported. Use it to recover a result after prism_batch_run timed out.",
163
+ inputSchema: {
164
+ type: "object",
165
+ properties: {
166
+ lease_id: { type: "integer", description: "The lease_id from prism_batch_run's output or error." },
167
+ },
168
+ required: ["lease_id"],
169
+ },
170
+ },
136
171
  {
137
172
  name: "prism_lease_and_run",
138
173
  description: "Lease a GPU, run one shell command on it, and return the output. The lease stays alive (use prism_run for more commands, prism_end_lease to release). Prefer this for a single command; use prism_lease when you'll run several.",
@@ -147,6 +182,7 @@ const TOOLS = [
147
182
  enum: TRUST_CLASSES,
148
183
  description: "Refuse suppliers below this trust class (default 'open'). Raise it for anything the host operator must not read.",
149
184
  },
185
+ max_usdg: { type: "number", description: "Hard cap on the USDG this lease may cost (default 1). Raise it deliberately for longer leases." },
150
186
  },
151
187
  required: ["command"],
152
188
  },
@@ -164,6 +200,7 @@ const TOOLS = [
164
200
  enum: TRUST_CLASSES,
165
201
  description: "Refuse suppliers below this trust class (default 'open'). Raise it for anything the host operator must not read.",
166
202
  },
203
+ max_usdg: { type: "number", description: "Hard cap on the USDG this lease may cost (default 1). Raise it deliberately for longer leases." },
167
204
  },
168
205
  },
169
206
  },
@@ -191,7 +228,7 @@ const TOOLS = [
191
228
  },
192
229
  {
193
230
  name: "prism_vault_store",
194
- description: "Store private data a card, an identity document, an API credential encrypted under a key derived from this agent's wallet on this machine. Prism receives ciphertext only and cannot read it. Use this instead of writing a secret into a workspace or a file. Returns an item_id; the value is not recoverable without the wallet.",
231
+ description: "Store private data (a card, an identity document, an API credential) encrypted under a key derived from this agent's wallet on this machine. Prism receives ciphertext only and cannot read it. Use this instead of writing a secret into a workspace or a file. Returns an item_id; the value is not recoverable without the wallet.",
195
232
  inputSchema: {
196
233
  type: "object",
197
234
  properties: {
@@ -213,7 +250,7 @@ const TOOLS = [
213
250
  },
214
251
  {
215
252
  name: "prism_vault_read",
216
- description: "Decrypt and return one vault item, in this process, using the wallet-derived key. The plaintext exists only here do not echo it into a leased workspace, a log, or a message.",
253
+ description: "Decrypt and return one vault item, in this process, using the wallet-derived key. The plaintext exists only here; do not echo it into a leased workspace, a log, or a message.",
217
254
  inputSchema: {
218
255
  type: "object",
219
256
  properties: { item_id: { type: "string", description: "The item_id from prism_vault_store or prism_vault_list." } },
@@ -250,12 +287,16 @@ async function handle(name, args) {
250
287
  }
251
288
  if (name === "prism_list_gpus") {
252
289
  const minTrust = args.min_trust ?? "open";
290
+ if (!TRUST_CLASSES.includes(minTrust)) {
291
+ throw new Error(`min_trust must be one of ${TRUST_CLASSES.join(", ")}`);
292
+ }
253
293
  let offers;
254
294
  if (agent) {
255
- await ensureAuth();
256
295
  offers = await agent.offers({ minTrust });
257
296
  } else {
258
- offers = await publicOffers(minTrust);
297
+ const url = new URL("/v1/offers", PUBLIC_API);
298
+ url.searchParams.set("min_trust", minTrust);
299
+ offers = await publicJson(url, "offers");
259
300
  }
260
301
  return {
261
302
  available: offers.length,
@@ -265,6 +306,9 @@ async function handle(name, args) {
265
306
  price_per_second: usdg(o.rate_per_second),
266
307
  price_per_hour: usdg(o.rate_per_second * 3600),
267
308
  trust: o.trust_class,
309
+ // A staker-only offer will not match a wallet without the stake, so an
310
+ // unstaked renter should budget from the non-staker rows.
311
+ ...(o.staker_only ? { staker_only: true } : {}),
268
312
  })),
269
313
  };
270
314
  }
@@ -278,21 +322,27 @@ async function handle(name, args) {
278
322
  sourced_low_per_hour: usdg(g.sourced_low_micros_per_hour),
279
323
  sourced_median_per_hour: usdg(g.sourced_median_micros_per_hour),
280
324
  sourced_high_per_hour: usdg(g.sourced_high_micros_per_hour),
281
- settled_mean_per_hour: g.settled_mean_micros_per_hour == null ? null : usdg(g.settled_mean_micros_per_hour),
325
+ settled_mean_per_hour: usdg(g.settled_mean_micros_per_hour),
282
326
  settled_leases: g.settled_leases,
283
327
  })),
284
328
  };
285
329
  }
286
330
  if (name === "prism_receipts") {
287
331
  const feed = await publicJson(PROOF_FEED, "proof feed");
288
- const limit = Number.isInteger(args.limit) && args.limit > 0 ? args.limit : 10;
332
+ if (args.limit !== undefined && (!Number.isInteger(args.limit) || args.limit <= 0)) {
333
+ throw new Error("limit must be a positive integer (max 50)");
334
+ }
335
+ const limit = Math.min(args.limit ?? 10, 50);
289
336
  return {
290
337
  generated_at: feed.generated_at,
291
338
  receipts: (feed.receipts ?? []).slice(0, limit).map((r) => ({
339
+ // The feed numbers leases per escrow deployment, so lease_id repeats
340
+ // across deployments; receipt_id is the unique handle.
341
+ receipt_id: r.receipt_id,
292
342
  lease_id: r.lease_id,
293
343
  outcome: r.outcome,
294
344
  gpu_model: r.gpu_model,
295
- trust: r.trust_class,
345
+ trust: r.trust_class ?? null,
296
346
  runtime_seconds: r.runtime_seconds,
297
347
  charged: usdg(r.charged_base_units),
298
348
  refunded: usdg(r.refunded_base_units),
@@ -302,7 +352,6 @@ async function handle(name, args) {
302
352
  }
303
353
  if (name === "prism_leases") {
304
354
  requireWallet(name);
305
- await ensureAuth();
306
355
  const all = await agent.leases();
307
356
  return {
308
357
  total: all.length,
@@ -320,13 +369,14 @@ async function handle(name, args) {
320
369
  };
321
370
  }
322
371
  if (name === "prism_batch_run") {
323
- if (!args.command) throw new Error("command is required");
372
+ requireCommand(args.command);
324
373
  requireWallet(name);
325
- await ensureAuth();
374
+ const cap = maxDeposit(args);
326
375
  const batch = await agent.lease({
327
376
  image: IMAGE,
328
377
  durationSeconds: args.duration_seconds ?? 900,
329
378
  minVramMib: args.min_vram_mib ?? 16000,
379
+ maxDeposit: cap,
330
380
  command: args.command,
331
381
  });
332
382
  return {
@@ -338,35 +388,54 @@ async function handle(name, args) {
338
388
  truncated: batch.result?.truncated ?? false,
339
389
  };
340
390
  }
391
+ if (name === "prism_batch_result") {
392
+ requireWallet(name, "reads this wallet's leases");
393
+ const id = leaseId(args.lease_id);
394
+ return { lease_id: id, result: await agent.result(id) };
395
+ }
341
396
  if (name === "prism_lease_and_run" || name === "prism_lease") {
342
- if (name === "prism_lease_and_run" && !args.command) throw new Error("command is required");
397
+ if (name === "prism_lease_and_run") requireCommand(args.command);
343
398
  requireWallet(name);
344
- await ensureAuth();
399
+ const cap = maxDeposit(args);
345
400
  sweepExpiredLeases();
346
401
  const lease = await agent.lease({
347
402
  image: IMAGE,
348
403
  durationSeconds: args.duration_seconds ?? 900,
349
404
  minVramMib: args.min_vram_mib ?? 16000,
405
+ maxDeposit: cap,
350
406
  minTrustClass: args.min_trust_class ?? "open",
351
407
  });
352
408
  leases.set(lease.leaseId, lease);
353
409
  const summary = {
354
410
  lease_id: lease.leaseId,
411
+ funding_tx: lease.fundingHash,
355
412
  ssh: { host: lease.access.ssh_host, port: lease.access.ssh_port, user: lease.access.ssh_user },
356
413
  trust: lease.quote?.trust_class ?? "open",
357
414
  expires_at: lease.access.expires_at,
358
415
  };
359
416
  if (name === "prism_lease") return summary;
360
- const out = await agent.run(lease, args.command);
361
- return { ...summary, command: args.command, exit_code: out.code, stdout: out.stdout, stderr: out.stderr };
417
+ // The lease is paid for by this point; an SSH failure must still hand the
418
+ // caller everything it bought.
419
+ try {
420
+ const out = await agent.run(lease, args.command);
421
+ return { ...summary, command: args.command, exit_code: out.code, stdout: out.stdout, stderr: out.stderr };
422
+ } catch (err) {
423
+ return {
424
+ ...summary,
425
+ error: `the lease is funded but the command could not run: ${err?.message ?? err}`,
426
+ next: `the lease stays open; try prism_run with lease_id ${lease.leaseId}, or prism_end_lease`,
427
+ };
428
+ }
362
429
  }
363
430
  if (name === "prism_run") {
364
- if (!args.command) throw new Error("command is required");
431
+ requireCommand(args.command);
365
432
  const id = leaseId(args.lease_id);
366
433
  const lease = leases.get(id);
367
434
  if (!lease) throw new Error(`no active lease ${id} in this session`);
435
+ const timeoutSeconds = args.timeout_seconds ?? 120;
368
436
  const out = await agent.run(lease, args.command, {
369
- timeoutMs: (args.timeout_seconds ?? 120) * 1000,
437
+ timeoutMs: timeoutSeconds * 1000,
438
+ connectRetries: 6,
370
439
  });
371
440
  return { lease_id: id, exit_code: out.code, stdout: out.stdout, stderr: out.stderr };
372
441
  }
@@ -380,14 +449,13 @@ async function handle(name, args) {
380
449
  return { lease_id: id, released: Boolean(lease) };
381
450
  }
382
451
  if (name.startsWith("prism_vault_")) return handleVault(name, args);
383
- throw new Error(`unknown tool ${name}`);
452
+ throw new Error(`unknown tool ${name}. Valid tools: ${TOOLS.map((t) => t.name).join(", ")}`);
384
453
  }
385
454
 
386
455
  // The vault key is derived here from the wallet signature and stays in this
387
456
  // process. Nothing in this function sends a key or a plaintext to Prism.
388
457
  async function handleVault(name, args) {
389
- const vault = requireWallet(name).vault;
390
- await ensureAuth();
458
+ const vault = requireWallet(name, "derives the vault key from the wallet").vault;
391
459
  if (!vault.unlocked) await vault.unlock();
392
460
 
393
461
  if (name === "prism_vault_store") {
@@ -433,23 +501,26 @@ async function handleVault(name, args) {
433
501
  throw new Error(`unknown tool ${name}`);
434
502
  }
435
503
 
436
- let authPromise = null;
437
- function ensureAuth() {
438
- authPromise ??= agent.authenticate().catch((err) => {
439
- authPromise = null;
440
- throw err;
441
- });
442
- return authPromise;
443
- }
444
-
445
- const server = new Server({ name: "prism", version: "0.5.0" }, { capabilities: { tools: {} } });
504
+ const server = new Server({ name: "prism", version: "0.6.0" }, { capabilities: { tools: {} } });
446
505
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
447
506
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
448
507
  try {
449
508
  const result = await handle(request.params.name, request.params.arguments ?? {});
450
509
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
451
510
  } catch (err) {
452
- return { isError: true, content: [{ type: "text", text: `error: ${err.message ?? err}` }] };
511
+ const body = err?.body ?? {};
512
+ const detail = [
513
+ body.cause,
514
+ body.hint,
515
+ body.required != null ? `required ${body.required}` : null,
516
+ body.max != null ? `cap ${body.max}` : null,
517
+ body.lease_id != null ? `lease_id ${body.lease_id}` : null,
518
+ body.funding_hash ? `funding_tx ${body.funding_hash}` : null,
519
+ ]
520
+ .filter(Boolean)
521
+ .join("; ");
522
+ const text = `error: ${err?.message ?? err}${detail ? ` (${detail})` : ""}`;
523
+ return { isError: true, content: [{ type: "text", text }] };
453
524
  }
454
525
  });
455
526