@prismnetwork/mcp 0.9.4 → 0.10.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 CHANGED
@@ -106,6 +106,32 @@ share the ledger file.
106
106
  None of this is the real limit. Fund a dedicated wallet with what you are
107
107
  willing to lose: that balance is what survives a bug in everything above.
108
108
 
109
+ ## Spend policy
110
+
111
+ The limits above cap how much an agent spends. A spend policy decides whether a
112
+ given lease has a reason you accept. Set `PRISM_SPEND_POLICY` to the policy as
113
+ JSON, or to the path of a JSON file:
114
+
115
+ ```json
116
+ {
117
+ "policy_id": "gpu-v1",
118
+ "allow": ["fine_tune", "benchmark"],
119
+ "require": ["needs_gpu"],
120
+ "minimums": { "needs_gpu": 0.8 }
121
+ }
122
+ ```
123
+
124
+ Every lease tool then takes a `decision`: the action, what made the call, and
125
+ typed answers with a confidence from 0 to 1. A decision that misses the policy
126
+ is refused with every reason before anything is quoted, funded or counted
127
+ against the budget. One that passes is hashed into the escrow deposit, so the
128
+ lease on chain carries a reference only that decision reproduces. `prism_budget`
129
+ shows the policy in force. Without `PRISM_SPEND_POLICY` a decision is optional
130
+ and still binds when given.
131
+
132
+ The binding shows a decision existed before the money moved and met your rules.
133
+ It does not show the decision was sound.
134
+
109
135
  Tools that spend are annotated `destructiveHint` and carry
110
136
  `anthropic/requiresUserInteraction`, so Claude Code asks before every one of
111
137
  them even in modes that otherwise approve tools automatically.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prismnetwork/mcp",
3
- "version": "0.9.4",
3
+ "version": "0.10.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
  "budget.mjs",
12
+ "policy.mjs",
12
13
  "server.mjs",
13
14
  "README.md"
14
15
  ],
@@ -18,7 +19,7 @@
18
19
  "dependencies": {
19
20
  "@modelcontextprotocol/sdk": "^1.0.0",
20
21
  "@phala/dcap-qvl": "^0.6.1",
21
- "@prismnetwork/agent-sdk": "^0.7.8",
22
+ "@prismnetwork/agent-sdk": "^0.7.12",
22
23
  "jose": "^6",
23
24
  "viem": "^2"
24
25
  },
package/policy.mjs ADDED
@@ -0,0 +1,117 @@
1
+ // The operator's spending rules for leases. The budget caps how much an agent
2
+ // may spend; the policy decides whether a given spend has a reason the operator
3
+ // accepts. The agent states its reason as a decision, the SDK checks it against
4
+ // this policy before anything is quoted, and only the decision's hash leaves
5
+ // the machine, bound into the escrow deposit.
6
+ import { readFileSync } from "node:fs";
7
+ import { answer, decision as makeDecision, decisionDigest, refusals } from "@prismnetwork/agent-sdk/decision";
8
+
9
+ export class PolicyError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "PolicyError";
13
+ }
14
+ }
15
+
16
+ const strings = (value, field) => {
17
+ if (value === undefined) return [];
18
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string" || !v.trim())) {
19
+ throw new PolicyError(`PRISM_SPEND_POLICY: ${field} must be a list of names`);
20
+ }
21
+ return value;
22
+ };
23
+
24
+ /// PRISM_SPEND_POLICY holds the policy as JSON, or the path to a JSON file.
25
+ /// Unset means no policy: a decision is optional and, when given, still binds.
26
+ export function readPolicy(raw = process.env.PRISM_SPEND_POLICY) {
27
+ if (raw === undefined || raw.trim() === "") return null;
28
+ let text = raw.trim();
29
+ if (!text.startsWith("{")) {
30
+ try {
31
+ text = readFileSync(text, "utf8");
32
+ } catch (err) {
33
+ throw new PolicyError(`PRISM_SPEND_POLICY: cannot read ${raw}: ${err.code ?? err.message}`);
34
+ }
35
+ }
36
+ let parsed;
37
+ try {
38
+ parsed = JSON.parse(text);
39
+ } catch {
40
+ throw new PolicyError("PRISM_SPEND_POLICY is not valid JSON");
41
+ }
42
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
43
+ throw new PolicyError("PRISM_SPEND_POLICY must be a JSON object");
44
+ }
45
+ const policyId = parsed.policy_id;
46
+ if (typeof policyId !== "string" || !policyId.trim()) {
47
+ throw new PolicyError("PRISM_SPEND_POLICY needs a policy_id, so every decision records which rules admitted it");
48
+ }
49
+ const minimums = parsed.minimums ?? {};
50
+ if (typeof minimums !== "object" || Array.isArray(minimums)) {
51
+ throw new PolicyError("PRISM_SPEND_POLICY: minimums must map an answer name to a confidence floor");
52
+ }
53
+ for (const [name, floor] of Object.entries(minimums)) {
54
+ if (typeof floor !== "number" || !(floor >= 0 && floor <= 1)) {
55
+ throw new PolicyError(`PRISM_SPEND_POLICY: the floor for ${name} must be a number from 0 to 1`);
56
+ }
57
+ }
58
+ return {
59
+ policyId,
60
+ allow: strings(parsed.allow, "allow"),
61
+ require: strings(parsed.require, "require"),
62
+ minimums,
63
+ };
64
+ }
65
+
66
+ /// What prism_budget shows, so an agent can shape its decision before it asks.
67
+ export function describePolicy(policy) {
68
+ if (!policy) return { spend_policy: "none: leases need no stated reason" };
69
+ return {
70
+ spend_policy: {
71
+ policy_id: policy.policyId,
72
+ allowed_actions: policy.allow.length ? policy.allow : "any",
73
+ required_answers: policy.require,
74
+ confidence_floors: policy.minimums,
75
+ },
76
+ };
77
+ }
78
+
79
+ /// The decision a tool call carries, in the SDK's form. Malformed input is the
80
+ /// caller's mistake and says which field; a missing decision under a policy is
81
+ /// refused with what the policy needs.
82
+ export function decisionFrom(input, policy) {
83
+ if (input === undefined || input === null) {
84
+ if (!policy) return null;
85
+ const needs = [
86
+ policy.allow.length ? `action one of ${policy.allow.join(", ")}` : "an action",
87
+ ...policy.require.map((n) => `an answer for ${n}`),
88
+ ...Object.entries(policy.minimums).map(([n, f]) => `${n} with confidence at least ${f}`),
89
+ ];
90
+ throw new PolicyError(
91
+ `the operator's spend policy ${policy.policyId} requires a decision with this lease: ${needs.join("; ")}. Nothing was quoted or funded.`,
92
+ );
93
+ }
94
+ if (typeof input !== "object") throw new PolicyError("decision must be an object with action, source and answers");
95
+ const answers = (input.answers ?? []).map((a) => {
96
+ if (!a || typeof a.name !== "string") throw new PolicyError("each decision answer needs a name");
97
+ return answer(a.name, a.value ?? null, a.confidence ?? null);
98
+ });
99
+ return makeDecision({
100
+ action: input.action,
101
+ source: input.source,
102
+ answers,
103
+ policyId: input.policy_id ?? policy?.policyId ?? null,
104
+ });
105
+ }
106
+
107
+ /// Checked here, before the spend is booked against the budget, so a refused
108
+ /// decision neither costs anything nor holds capacity.
109
+ export function authorised(policy, d) {
110
+ if (!policy) return;
111
+ const reasons = refusals(policy, d);
112
+ if (reasons.length) {
113
+ throw new PolicyError(`the spend policy ${policy.policyId} refused this lease: ${reasons.join("; ")}. Nothing was quoted or funded.`);
114
+ }
115
+ }
116
+
117
+ export { decisionDigest };
package/server.mjs CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  verifyConfidential,
16
16
  } from "@prismnetwork/agent-sdk";
17
17
  import { BudgetError, SpendLedger, callCeiling, readBudget, recordSpend, stripUnexpanded } from "./budget.mjs";
18
+ import { authorised, decisionDigest, decisionFrom, describePolicy, readPolicy } from "./policy.mjs";
18
19
 
19
20
  stripUnexpanded(process.env);
20
21
 
@@ -64,6 +65,26 @@ try {
64
65
  console.error(`prism mcp: ${budgetProblem}`);
65
66
  }
66
67
 
68
+ // Same rule as the budget: a policy the operator got wrong stops leasing rather
69
+ // than letting every lease through unchecked.
70
+ let policy = null;
71
+ let policyProblem = null;
72
+ try {
73
+ policy = readPolicy();
74
+ } catch (err) {
75
+ policyProblem = err?.message ?? String(err);
76
+ console.error(`prism mcp: ${policyProblem}`);
77
+ }
78
+
79
+ /// The decision this lease carries, checked against the operator's policy
80
+ /// before anything is booked, quoted or funded.
81
+ function leaseDecision(tool, args) {
82
+ if (policyProblem) throw new Error(`${tool} is disabled until the spend policy is fixed: ${policyProblem}`);
83
+ const d = decisionFrom(args.decision, policy);
84
+ if (d) authorised(policy, d);
85
+ return d;
86
+ }
87
+
67
88
  function requireWallet(tool, reason = "spends money") {
68
89
  if (!agent) {
69
90
  throw new Error(
@@ -211,6 +232,33 @@ const spends = {
211
232
  _meta: { "anthropic/requiresUserInteraction": true },
212
233
  };
213
234
 
235
+ // Why a lease is being funded. Only its hash leaves the machine, bound into the
236
+ // escrow deposit, so whoever holds the decision can prove it came first.
237
+ const DECISION_SCHEMA = {
238
+ type: "object",
239
+ description:
240
+ "Why this lease is being funded. Required when the operator set a spend policy (see prism_budget), and the lease is refused before anything is quoted if the decision does not meet it. Only a hash of it leaves this machine, recorded with the escrow deposit.",
241
+ properties: {
242
+ action: { type: "string", description: "What the spend is for, e.g. 'fine_tune' or 'benchmark'." },
243
+ source: { type: "string", description: "What made the call: a model name, a rule, or 'operator'." },
244
+ answers: {
245
+ type: "array",
246
+ description: "Typed answers behind the decision. confidence (0 to 1) is what a policy floor reads.",
247
+ items: {
248
+ type: "object",
249
+ properties: {
250
+ name: { type: "string", description: "Lowercase identifier, e.g. 'needs_gpu'." },
251
+ value: { description: "The answer: an option, a score, or a probability." },
252
+ confidence: { type: "number", minimum: 0, maximum: 1 },
253
+ },
254
+ required: ["name", "value"],
255
+ },
256
+ },
257
+ policy_id: { type: "string", description: "The policy this decision was made under; defaults to the operator's." },
258
+ },
259
+ required: ["action", "source"],
260
+ };
261
+
214
262
  const TOOLS = [
215
263
  {
216
264
  name: "prism_budget",
@@ -272,6 +320,7 @@ const TOOLS = [
272
320
  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." },
273
321
  min_vram_mib: { type: "integer", description: "Minimum GPU memory in MiB (default 16000)." },
274
322
  max_usdg: { type: "number", description: "Cost ceiling for this lease in USDG. It lowers the operator's PRISM_MAX_USDG and cannot raise it; omitted, that ceiling applies. See prism_budget." },
323
+ decision: DECISION_SCHEMA,
275
324
  },
276
325
  required: ["command"],
277
326
  },
@@ -371,6 +420,7 @@ const TOOLS = [
371
420
  description: "Refuse suppliers below this trust class (default 'open'). Raise it for anything the host operator must not read.",
372
421
  },
373
422
  max_usdg: { type: "number", description: "Cost ceiling for this lease in USDG. It lowers the operator's PRISM_MAX_USDG and cannot raise it; omitted, that ceiling applies. See prism_budget." },
423
+ decision: DECISION_SCHEMA,
374
424
  },
375
425
  required: ["command"],
376
426
  },
@@ -391,6 +441,7 @@ const TOOLS = [
391
441
  description: "Refuse suppliers below this trust class (default 'open'). Raise it for anything the host operator must not read.",
392
442
  },
393
443
  max_usdg: { type: "number", description: "Cost ceiling for this lease in USDG. It lowers the operator's PRISM_MAX_USDG and cannot raise it; omitted, that ceiling applies. See prism_budget." },
444
+ decision: DECISION_SCHEMA,
394
445
  },
395
446
  },
396
447
  ...spends,
@@ -483,7 +534,9 @@ const TOOLS = [
483
534
  ];
484
535
 
485
536
  async function handle(name, args) {
486
- if (name === "prism_budget") return requireLedger(name).status();
537
+ if (name === "prism_budget") {
538
+ return { ...requireLedger(name).status(), ...(policyProblem ? { spend_policy_error: policyProblem } : describePolicy(policy)) };
539
+ }
487
540
  if (name === "prism_wallet") {
488
541
  const b = await requireWallet("prism_wallet").balances();
489
542
  return { address: b.address, usdg: usdg(b.usdg), eth_wei: b.eth };
@@ -574,6 +627,7 @@ async function handle(name, args) {
574
627
  if (name === "prism_batch_run") {
575
628
  requireCommand(args.command);
576
629
  requireWallet(name);
630
+ const decision = leaseDecision(name, args);
577
631
  const cap = maxDeposit(name, args);
578
632
  return spending(name, cap, async () => {
579
633
  const batch = await agent.lease({
@@ -582,6 +636,8 @@ async function handle(name, args) {
582
636
  minVramMib: args.min_vram_mib ?? 16000,
583
637
  maxDeposit: cap,
584
638
  command: args.command,
639
+ decision,
640
+ policy,
585
641
  });
586
642
  return {
587
643
  reference: batch.fundingHash,
@@ -589,6 +645,7 @@ async function handle(name, args) {
589
645
  value: {
590
646
  lease_id: batch.leaseId,
591
647
  funding_tx: batch.fundingHash,
648
+ ...(decision ? { decision_hash: decisionDigest(decision) } : {}),
592
649
  exit_code: batch.result?.exit_code,
593
650
  stdout: batch.result?.stdout,
594
651
  stderr: batch.result?.stderr,
@@ -731,6 +788,7 @@ async function handle(name, args) {
731
788
  if (name === "prism_lease_and_run" || name === "prism_lease") {
732
789
  if (name === "prism_lease_and_run") requireCommand(args.command);
733
790
  requireWallet(name);
791
+ const decision = leaseDecision(name, args);
734
792
  const cap = maxDeposit(name, args);
735
793
  sweepExpiredLeases();
736
794
  const lease = await spending(name, cap, async () => {
@@ -740,6 +798,8 @@ async function handle(name, args) {
740
798
  minVramMib: args.min_vram_mib ?? 16000,
741
799
  maxDeposit: cap,
742
800
  minTrustClass: args.min_trust_class ?? "open",
801
+ decision,
802
+ policy,
743
803
  });
744
804
  return { value: funded, reference: funded.fundingHash, settledMicros: escrowed(funded.quote) };
745
805
  });
@@ -747,6 +807,7 @@ async function handle(name, args) {
747
807
  const summary = {
748
808
  lease_id: lease.leaseId,
749
809
  funding_tx: lease.fundingHash,
810
+ ...(decision ? { decision_hash: decisionDigest(decision) } : {}),
750
811
  // `prism_run` checks this itself. It is in the summary because the
751
812
  // caller is being handed an address they may connect to by hand, and an
752
813
  // address with no key to check is an invitation to accept whatever
@@ -850,7 +911,7 @@ async function handleVault(name, args) {
850
911
  throw new Error(`unknown tool ${name}`);
851
912
  }
852
913
 
853
- const server = new Server({ name: "prism", version: "0.9.0" }, { capabilities: { tools: {} } });
914
+ const server = new Server({ name: "prism", version: "0.10.0" }, { capabilities: { tools: {} } });
854
915
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
855
916
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
856
917
  try {