@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 +26 -0
- package/package.json +3 -2
- package/policy.mjs +117 -0
- package/server.mjs +63 -2
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.
|
|
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.
|
|
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")
|
|
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.
|
|
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 {
|