@metalntwx/metal-agent 0.2.0 → 0.3.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 +65 -39
- package/dist/chain.js +1 -1
- package/dist/commands/fund.js +28 -14
- package/dist/commands/key.js +2 -2
- package/dist/commands/mandate.js +616 -142
- package/dist/commands/pay.js +86 -13
- package/dist/help.js +20 -19
- package/dist/index.js +40 -14
- package/dist/keystore.js +30 -18
- package/dist/mcp.js +273 -31
- package/package.json +6 -6
- package/skills/metal-agent/SKILL.md +93 -51
package/dist/mcp.js
CHANGED
|
@@ -1,35 +1,131 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
|
+
import { stat } from "node:fs/promises";
|
|
5
|
+
import { BaseError, RpcRequestError, erc20Abi, getAddress, isAddress } from "viem";
|
|
4
6
|
import { z } from "zod";
|
|
5
|
-
import { target } from "./chain.js";
|
|
7
|
+
import { rpcClient, target } from "./chain.js";
|
|
6
8
|
import { fund } from "./commands/fund.js";
|
|
7
|
-
import { parseMax, pay, payUnderMandate } from "./commands/pay.js";
|
|
9
|
+
import { PaymentInDoubt, PaymentRefused, parseMax, pay, payUnderMandate } from "./commands/pay.js";
|
|
8
10
|
import { status } from "./commands/status.js";
|
|
9
|
-
import { loadMandate, mandateAgentSigner } from "./commands/mandate.js";
|
|
10
|
-
import {
|
|
11
|
-
import { USDZ, formatUsdz } from "@metalntwx/metal-pay";
|
|
12
|
-
import { redactUrls } from "@metalntwx/metal-pay/mandates";
|
|
11
|
+
import { loadMandate, mandateAgentSigner, mandatePull, mandateStatus, mandateVerify, parseAddress, parsePaymentId, parsePullAmount, parseTxHash, } from "./commands/mandate.js";
|
|
12
|
+
import { defaultKeyPath, pendingPullsPath, readKey, readTrustedIssuer } from "./keystore.js";
|
|
13
|
+
import { USDZ, accountFactoryFor, formatUsdz, metalNetwork, rpcUrlFor, usdzBaseUnits } from "@metalntwx/metal-pay";
|
|
14
|
+
import { describeRefusal, redactUrls, refusalOf } from "@metalntwx/metal-pay/mandates";
|
|
13
15
|
/** The authority `metal-agent mcp` serves under, from its options. */
|
|
14
16
|
export function mcpAuthority(options) {
|
|
15
17
|
if (options.mandate !== undefined) {
|
|
16
18
|
if (options.key !== undefined || options.max !== undefined) {
|
|
17
|
-
throw new Error("mcp --mandate takes neither --key nor --max: the mandate sets the limit, and the agent
|
|
19
|
+
throw new Error("mcp --mandate takes neither --key nor --max: the mandate sets the limit, and the agent signs with its mandate key");
|
|
18
20
|
}
|
|
19
|
-
return { kind: "mandate", credentialPath: options.mandate,
|
|
21
|
+
return { kind: "mandate", credentialPath: options.mandate, ...(options["mandate-key-file"] !== undefined && { mandateKeyFile: options["mandate-key-file"] }) };
|
|
20
22
|
}
|
|
21
|
-
if (options["
|
|
22
|
-
throw new Error("--
|
|
23
|
-
return { kind: "key", keyPath: options.key ??
|
|
23
|
+
if (options["mandate-key-file"] !== undefined)
|
|
24
|
+
throw new Error("--mandate-key-file goes with --mandate");
|
|
25
|
+
return { kind: "key", keyPath: options.key ?? defaultKeyPath(), maxAmount: parseMax(options.max, "--max") };
|
|
24
26
|
}
|
|
25
27
|
const usdz = (units) => `${formatUsdz(units)} ${USDZ.symbol}`;
|
|
26
28
|
const network = z.enum(["devnet", "testnet"]).describe("Which Metal network; there is no default");
|
|
27
|
-
const rpcUrl = z
|
|
28
|
-
.string()
|
|
29
|
-
.url()
|
|
30
|
-
.optional()
|
|
31
|
-
.describe("JSON-RPC endpoint; on devnet the network's default when omitted. On testnet, status needs an endpoint of the user's own, created after signing up in the Metal console");
|
|
29
|
+
const rpcUrl = (onTestnet) => z.string().url().optional().describe(`JSON-RPC endpoint; on devnet the network's default when omitted. ${onTestnet}`);
|
|
32
30
|
const RPC_HOW = "pass rpcUrl or set METAL_RPC_URL in the server's environment";
|
|
31
|
+
/** A running server's environment is the user's to change, and takes effect when it restarts. */
|
|
32
|
+
const SERVER_RPC_HOW = "tell the user to set METAL_RPC_URL in this server's environment and restart the server";
|
|
33
|
+
/**
|
|
34
|
+
* Checks that the server's endpoint for `on` serves `on`'s network: a balance,
|
|
35
|
+
* a pull's outcome or a verification read on another chain is not this one's.
|
|
36
|
+
*/
|
|
37
|
+
async function assertServerChain(on) {
|
|
38
|
+
const id = await rpcClient(on, SERVER_RPC_HOW).getChainId();
|
|
39
|
+
if (id === on.network.chainId)
|
|
40
|
+
return;
|
|
41
|
+
const other = ["devnet", "testnet"].find((name) => metalNetwork(name)?.chainId === id);
|
|
42
|
+
throw new Error(`the server's endpoint is on chain ${id}, not ${on.network.name}'s ${on.network.chainId}: ${other === undefined ? "" : `pass network ${other}, or `}tell the user to set METAL_RPC_URL to a ${on.network.name} endpoint and restart the server`);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The network a mandate tool reads, through the server's own endpoint and
|
|
46
|
+
* facilitator, checked to serve that network: a pull's outcome and a
|
|
47
|
+
* verification are only as true as the chain they are read from, so the
|
|
48
|
+
* model names no endpoint, as it names no mandate.
|
|
49
|
+
*/
|
|
50
|
+
async function mandateTarget(net) {
|
|
51
|
+
const on = target(net);
|
|
52
|
+
const resolved = { ...on, rpcUrl: rpcUrlFor(on.network, on.rpcUrl, SERVER_RPC_HOW) };
|
|
53
|
+
await assertServerChain(resolved);
|
|
54
|
+
return resolved;
|
|
55
|
+
}
|
|
56
|
+
/** A mandate's limit or state refused a payment: limits and dates are mandate_status's to show, and the user's to change. */
|
|
57
|
+
const MANDATE_REFUSED_NEXT = "next: nothing was paid; mandate_status shows the mandate's limits and state: a start date or a window clears with time, and a limit, or a revoked or unbound mandate, needs the user";
|
|
58
|
+
/** The server keeps the credential it started with, so only the user moves it to the one that replaced or renewed it. */
|
|
59
|
+
const REVOKED_NEXT = "next: nothing more is paid under it; tell the user, who restarts this server with --mandate naming the credential that replaced or renewed it";
|
|
60
|
+
/** What a model does after pay is refused, which paid nothing: a mandate's refusal as its limits and state say, any other the user's to judge. */
|
|
61
|
+
function payRefusedNext(reason, underMandate) {
|
|
62
|
+
const refusal = underMandate && reason !== undefined ? refusalOf(reason) : undefined;
|
|
63
|
+
if (refusal === "Revoked")
|
|
64
|
+
return REVOKED_NEXT;
|
|
65
|
+
if (refusal !== undefined)
|
|
66
|
+
return MANDATE_REFUSED_NEXT;
|
|
67
|
+
return "next: nothing was paid; tell the user the reason before paying this URL again";
|
|
68
|
+
}
|
|
69
|
+
/** What a model does after a payment that may have settled, found by the nonce the call printed: under a mandate mandate_verify lists it, under a key only the user can look it up. */
|
|
70
|
+
function payInDoubtNext(nonce, underMandate) {
|
|
71
|
+
return underMandate
|
|
72
|
+
? `next: call mandate_verify and look for nonce ${nonce}: if it is listed the payment settled, so do not pay again`
|
|
73
|
+
: `next: do not pay this URL again before the user has checked whether nonce ${nonce} settled`;
|
|
74
|
+
}
|
|
75
|
+
/** Runs pay, ending a refusal, or a payment that may have settled, with what the model does next. */
|
|
76
|
+
async function payOrNext(print, underMandate, run) {
|
|
77
|
+
let nonce;
|
|
78
|
+
try {
|
|
79
|
+
await run((line) => {
|
|
80
|
+
if (line.startsWith("nonce "))
|
|
81
|
+
nonce = line.slice("nonce ".length);
|
|
82
|
+
print(line);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error instanceof PaymentRefused) {
|
|
87
|
+
print(`error: ${redactUrls(error.message)}`);
|
|
88
|
+
print(payRefusedNext(error.reason, underMandate));
|
|
89
|
+
return "failed";
|
|
90
|
+
}
|
|
91
|
+
if (!(error instanceof PaymentInDoubt) || nonce === undefined)
|
|
92
|
+
throw error;
|
|
93
|
+
print(`error: ${redactUrls(error.message)}`);
|
|
94
|
+
print(payInDoubtNext(nonce, underMandate));
|
|
95
|
+
return "failed";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** What a model does after a pull's outcome, in the tool's own terms, from the call's own arguments; a paid pull needs nothing more. */
|
|
99
|
+
function pullNext(outcome, call) {
|
|
100
|
+
switch (outcome) {
|
|
101
|
+
case "pending":
|
|
102
|
+
return `next: call mandate_pull again with to ${call.to}, amount ${call.amount} and paymentId ${JSON.stringify(call.paymentId)}: it sends the same payout, which pays at most once, or reports how it ended; do not change the paymentId or the amount to retry it`;
|
|
103
|
+
case "held":
|
|
104
|
+
return "next: the payee's receive policy holds this payment in escrow, so the payee was not paid; it counts against the budget, so do not send it again; the payee, or the authority its policy names, releases it";
|
|
105
|
+
case "refused":
|
|
106
|
+
return MANDATE_REFUSED_NEXT;
|
|
107
|
+
case "paid":
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** The balance the testnet faucet tops an address up to: 100 *USDZ, as grant_target in apps/testnet-faucet/src/lib.rs sets it. */
|
|
112
|
+
const FAUCET_GRANT = BigInt(usdzBaseUnits("100"));
|
|
113
|
+
/** The most an issuer's public key file is read at: a few hundred bytes of JSON. */
|
|
114
|
+
const ISSUER_FILE_MAX = 64 * 1024;
|
|
115
|
+
const ASK_ISSUERS = "ask the user for the issuer public key files they trust";
|
|
116
|
+
/** The model names these files, so one that is not an issuer's is named without what it holds, and a device or a large file is not read. */
|
|
117
|
+
async function readIssuerFile(path) {
|
|
118
|
+
const file = await stat(path).catch((error) => {
|
|
119
|
+
throw error.code === "ENOENT" ? new Error(`no file at ${path}; ${ASK_ISSUERS}`) : error;
|
|
120
|
+
});
|
|
121
|
+
if (!file.isFile())
|
|
122
|
+
throw new Error(`${path} is not a regular file, so not an issuer's public key file; ${ASK_ISSUERS}`);
|
|
123
|
+
if (file.size > ISSUER_FILE_MAX)
|
|
124
|
+
throw new Error(`${path} is over 64 KiB, so not an issuer's public key file; ${ASK_ISSUERS}`);
|
|
125
|
+
await readTrustedIssuer(path).catch((error) => {
|
|
126
|
+
throw error instanceof SyntaxError ? new Error(`${path} is not JSON, so not an issuer's public key file; ${ASK_ISSUERS}`) : error;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
33
129
|
export function createMcpServer(authority) {
|
|
34
130
|
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
35
131
|
const server = new McpServer({ name: "metal-agent", version });
|
|
@@ -41,17 +137,21 @@ export function createMcpServer(authority) {
|
|
|
41
137
|
headers: z.record(z.string(), z.string()).optional().describe("Request headers, by name"),
|
|
42
138
|
};
|
|
43
139
|
const BUY_RPC = "To buy Metal RPC, POST a JSON-RPC body to the RPC URL; the rpc-aas-endpoint header is the endpoint the payment bought, so send further RPC calls there without paying again.";
|
|
140
|
+
const AGAIN = "Each call signs a new payment, so calling pay again pays again; a call that times out or returns no answer may still have paid:";
|
|
44
141
|
if (authority.kind === "mandate") {
|
|
45
142
|
server.registerTool("pay", {
|
|
46
|
-
description: `Request a URL and pay its x402 challenge in *USDZ on Metal under this server's mandate: the payment comes from the mandate's account, signed by the
|
|
143
|
+
description: `Request a URL and pay its x402 challenge in *USDZ on Metal under this server's mandate: the payment comes from the mandate's account, signed by the mandate key, within the limits its owner set, and a payment outside them is refused with the limit named. Only the owner can change the limits. Returns the settlement transaction hash, the response headers and the body. ${AGAIN} check before calling again, as mandate_verify lists this mandate's payments with their nonces.`,
|
|
47
144
|
// Strict: a key or limit the model names is refused, not dropped.
|
|
48
145
|
inputSchema: z.object(request).strict(),
|
|
49
|
-
}, ({ url, network: net, method, body, headers }) => collect((print) =>
|
|
146
|
+
}, ({ url, network: net, method, body, headers }) => collect(async (print) => {
|
|
147
|
+
await assertMandateKey(authority);
|
|
148
|
+
return payOrNext(print, true, (print) => payUnderMandate(target(net), authority, url, print, { method, body, headers }));
|
|
149
|
+
}));
|
|
50
150
|
}
|
|
51
151
|
else {
|
|
52
152
|
const limit = usdz(authority.maxAmount);
|
|
53
153
|
server.registerTool("pay", {
|
|
54
|
-
description: `Request a URL and pay its x402 challenge in *USDZ on Metal with this server's key, refusing before signing any offer above ${limit}. Returns the settlement transaction hash, the response headers and the body. ${BUY_RPC}`,
|
|
154
|
+
description: `Request a URL and pay its x402 challenge in *USDZ on Metal with this server's key, refusing before signing any offer above ${limit}. Returns the settlement transaction hash, the response headers and the body. ${AGAIN} tell the user before calling again. ${BUY_RPC}`,
|
|
55
155
|
inputSchema: z
|
|
56
156
|
.object({
|
|
57
157
|
...request,
|
|
@@ -67,40 +167,182 @@ export function createMcpServer(authority) {
|
|
|
67
167
|
throw new Error(`max can only lower this server's limit of ${limit} per payment; its --max sets the limit`);
|
|
68
168
|
}
|
|
69
169
|
const raise = `the server's limit is ${limit}, set by its --max`;
|
|
70
|
-
|
|
170
|
+
const key = await readKey(authority.keyPath).catch((error) => {
|
|
171
|
+
if (error.code !== "ENOENT")
|
|
172
|
+
throw error;
|
|
173
|
+
// The default key file the model can create; one the user named with --key is theirs to restore, as a new key there would hold nothing.
|
|
174
|
+
if (!isDefaultKey(authority.keyPath)) {
|
|
175
|
+
throw new Error(`no key at ${authority.keyPath}, the key file this server's --key names: tell the user to restore it, or to restart the server with --key naming another`);
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`no key at ${authority.keyPath}, the key this server pays with: create it with \`metal-agent key create --out ${authority.keyPath}\`, or restart the server with --key naming a key file`);
|
|
178
|
+
});
|
|
179
|
+
return payOrNext(print, false, (print) => pay(target(net), key, url, { maxAmount, raise }, print, { method, body, headers }));
|
|
71
180
|
}));
|
|
72
181
|
}
|
|
73
182
|
server.registerTool("fund", {
|
|
74
|
-
description: "Fund an address with *USDZ. On devnet, from the devnet's pre-funded principal account; on testnet, from the public faucet, which sets the amount itself and reports an address that already holds a full grant as funded."
|
|
183
|
+
description: "Fund an address with *USDZ. On devnet, from the devnet's pre-funded principal account; on testnet, from the public faucet, which sets the amount itself and reports an address that already holds a full grant as funded. " +
|
|
184
|
+
"On testnet with the server's METAL_RPC_URL, an address that already holds about a full grant is reported funded and the faucet is not asked.",
|
|
75
185
|
inputSchema: {
|
|
76
186
|
address: z.string().regex(/^0x[0-9a-fA-F]{40}$/),
|
|
77
187
|
network,
|
|
78
188
|
amount: z.string().optional().describe("Decimal *USDZ, devnet only; 10 by default"),
|
|
79
|
-
rpcUrl,
|
|
189
|
+
rpcUrl: rpcUrl("On testnet unused: the faucet funds the address"),
|
|
80
190
|
},
|
|
81
|
-
}, ({ address, network: net, amount, rpcUrl: rpc }) => collect((print) =>
|
|
191
|
+
}, ({ address, network: net, amount, rpcUrl: rpc }) => collect(async (print) => {
|
|
192
|
+
const on = target(net, { rpcUrl: rpc });
|
|
193
|
+
if (on.network.name === "testnet") {
|
|
194
|
+
if (amount !== undefined)
|
|
195
|
+
throw new Error("on testnet the faucet sets the amount; leave out amount");
|
|
196
|
+
// The faucet tops an address up to a full grant, so one that holds about that much would spend a grant on the difference.
|
|
197
|
+
// Only the server's endpoint is read; a balance it cannot read, or on another chain, leaves the faucet to decide.
|
|
198
|
+
const server = target(net);
|
|
199
|
+
if (server.rpcUrl !== undefined && isAddress(address)) {
|
|
200
|
+
const balance = await assertServerChain(server)
|
|
201
|
+
.then(() => rpcClient(server).readContract({ address: server.nusd, abi: erc20Abi, functionName: "balanceOf", args: [address] }))
|
|
202
|
+
.catch(() => undefined);
|
|
203
|
+
if (balance !== undefined && balance * 100n >= FAUCET_GRANT * 99n) {
|
|
204
|
+
print(`${getAddress(address)} is funded: it holds ${usdz(balance)}, about a full faucet grant, so the faucet was not asked`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// `key import` writes the CLI's key file, which this server may not pay with.
|
|
210
|
+
await fund(on, address, amount, print, fetch, false);
|
|
211
|
+
}));
|
|
82
212
|
server.registerTool("status", {
|
|
83
213
|
description: "Show a settled transfer from chain data: receipt status, block, submitter, gas, and the MIP20 transfer it carried.",
|
|
84
|
-
inputSchema: {
|
|
214
|
+
inputSchema: {
|
|
215
|
+
txHash: z.string().regex(/^0x[0-9a-fA-F]{64}$/),
|
|
216
|
+
network,
|
|
217
|
+
rpcUrl: rpcUrl("On testnet, status needs an endpoint of the user's own, created after signing up in the Metal console; left out, the server's METAL_RPC_URL is used when it is set"),
|
|
218
|
+
},
|
|
85
219
|
}, ({ txHash, network: net, rpcUrl: rpc }) => collect((print) => status(target(net, { rpcUrl: rpc }), txHash, print, RPC_HOW)));
|
|
220
|
+
if (authority.kind === "mandate") {
|
|
221
|
+
const { credentialPath, mandateKeyFile } = authority;
|
|
222
|
+
server.registerTool("mandate_pull", {
|
|
223
|
+
description: "Pay an address from this server's mandate's account with no seller, such as a refund or a payout: signed by the mandate key, within the limits its owner set, and submitted by the facilitator, which pays the gas. " +
|
|
224
|
+
"One payment, one paymentId, such as the refund's or the invoice's id: never call with another paymentId for a payment an earlier call sent, and an error after a pending answer does not mean nothing was sent. " +
|
|
225
|
+
"A paymentId recorded as paid or held in a transaction the chain does not hold, as after a chain reset, fails and sends nothing: ask the user before paying it again under another paymentId. " +
|
|
226
|
+
"Returns `outcome paid`, `refused`, `pending` or `held`, with the transaction when there is one, and on any outcome but paid a `next:` line saying what to do.",
|
|
227
|
+
inputSchema: z
|
|
228
|
+
.object({
|
|
229
|
+
to: z.string().regex(/^0x[0-9a-fA-F]{40}$/).describe("The address to pay"),
|
|
230
|
+
amount: z.string().describe("Decimal *USDZ to the cent, such as 2.50"),
|
|
231
|
+
paymentId: z.string().min(1).describe("Your id for this payment, such as the refund's or the invoice's; a retry passes the same one"),
|
|
232
|
+
network,
|
|
233
|
+
})
|
|
234
|
+
.strict(),
|
|
235
|
+
}, ({ to, amount, paymentId, network: net }) => collect(async (print) => {
|
|
236
|
+
const on = await mandateTarget(net);
|
|
237
|
+
const args = {
|
|
238
|
+
credentialPath,
|
|
239
|
+
mandateKeyFile,
|
|
240
|
+
to: parseAddress(to, "to"),
|
|
241
|
+
amount: parsePullAmount(amount, "amount"),
|
|
242
|
+
paymentId: parsePaymentId(paymentId, "paymentId"),
|
|
243
|
+
keyPath: undefined,
|
|
244
|
+
recordRefusal: false,
|
|
245
|
+
};
|
|
246
|
+
await assertMandateKey(authority);
|
|
247
|
+
let revoked = false;
|
|
248
|
+
const outcome = await mandatePull(on, args, (line) => {
|
|
249
|
+
if (line === `reason ${describeRefusal("Revoked")}`)
|
|
250
|
+
revoked = true;
|
|
251
|
+
print(line);
|
|
252
|
+
});
|
|
253
|
+
const next = outcome === "refused" && revoked ? REVOKED_NEXT : pullNext(outcome, { to: args.to, amount, paymentId });
|
|
254
|
+
if (next !== undefined)
|
|
255
|
+
print(next);
|
|
256
|
+
if (outcome === "refused")
|
|
257
|
+
return "failed";
|
|
258
|
+
}));
|
|
259
|
+
server.registerTool("mandate_status", {
|
|
260
|
+
description: "Show this server's mandate: the limits its owner set (payees, the most per payment, the budget, the most payments when capped, its start and expiry), whether it is bound on the owner's account and, once bound, whether it is revoked, `spent <amount> of <budget>` and `payments`. Call it to see what the mandate has left, before a payment or after a refusal.",
|
|
261
|
+
inputSchema: z.object({ network }).strict(),
|
|
262
|
+
}, ({ network: net }) => collect(async (print) => {
|
|
263
|
+
const on = await mandateTarget(net);
|
|
264
|
+
const state = await mandateStatus(on, credentialPath, accountFactoryFor(on.network, undefined, "run mandate status from the CLI with --factory"), print);
|
|
265
|
+
if (state === "unbound")
|
|
266
|
+
print("next: every payment is refused until its owner binds it, creating the account first when account-type is none; tell the user");
|
|
267
|
+
if (state === "revoked")
|
|
268
|
+
print(REVOKED_NEXT);
|
|
269
|
+
}));
|
|
270
|
+
server.registerTool("mandate_verify", {
|
|
271
|
+
description: "Check this server's mandate and every payment and refusal under it from the chain alone, or one of this mandate's payments by txHash, with the signed order pay printed for it as signedOffer. " +
|
|
272
|
+
"Returns four `step` lines, one line per payment or refusal found, and `verified yes` or `verified no`. Fails naming an event under the mandate this SDK does not decode, as an account of another version emits.",
|
|
273
|
+
inputSchema: z
|
|
274
|
+
.object({
|
|
275
|
+
issuers: z.array(z.string()).min(1).describe("The issuer public key files the user trusts, such as the owner's <key>.issuer.public.json: ask the user for them"),
|
|
276
|
+
txHash: z.string().regex(/^0x[0-9a-fA-F]{64}$/).optional().describe("One transaction to check instead of every payment"),
|
|
277
|
+
signedOffer: z.string().optional().describe("The signed order pay printed for that transaction's payment, checked against it; needs txHash"),
|
|
278
|
+
network,
|
|
279
|
+
})
|
|
280
|
+
.strict(),
|
|
281
|
+
}, ({ issuers, txHash, signedOffer, network: net }) => collect(async (print) => {
|
|
282
|
+
if (signedOffer !== undefined && txHash === undefined)
|
|
283
|
+
throw new Error("signedOffer goes with txHash, the payment it was made for");
|
|
284
|
+
const on = await mandateTarget(net);
|
|
285
|
+
for (const path of issuers)
|
|
286
|
+
await readIssuerFile(path);
|
|
287
|
+
const args = {
|
|
288
|
+
accountFactory: accountFactoryFor(on.network, undefined, "run mandate verify from the CLI with --factory"),
|
|
289
|
+
credentialPath,
|
|
290
|
+
issuerPaths: issuers,
|
|
291
|
+
tx: txHash === undefined ? undefined : parseTxHash(txHash, "txHash"),
|
|
292
|
+
signedOffer,
|
|
293
|
+
};
|
|
294
|
+
if ((await mandateVerify(on, args, print)) === "failed")
|
|
295
|
+
return "failed";
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
86
298
|
return server;
|
|
87
299
|
}
|
|
300
|
+
/** The server's mandate key, checked when it started and read again by each call: one gone since is the user's to restore. */
|
|
301
|
+
async function assertMandateKey({ credentialPath, mandateKeyFile }) {
|
|
302
|
+
const mandate = await loadMandate(credentialPath);
|
|
303
|
+
await mandateAgentSigner(mandate, credentialPath, mandateKeyFile).catch(() => {
|
|
304
|
+
throw new Error(`the server's mandate key, ${mandateKeyFile ?? `beside ${credentialPath}`}, cannot be read as this mandate's: tell the user to put it back, or to restart the server with --mandate-key-file naming it`);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
/** Whether `path` is the agent's default key file; an empty HOME names none. */
|
|
308
|
+
function isDefaultKey(path) {
|
|
309
|
+
try {
|
|
310
|
+
return path === defaultKeyPath();
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** Runs a command for a tool, returning the lines it printed; a command that ends `failed`, or throws, is the tool's error. */
|
|
88
317
|
async function collect(run) {
|
|
89
318
|
const lines = [];
|
|
90
319
|
try {
|
|
91
|
-
await run((line) => lines.push(line));
|
|
92
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
320
|
+
const failed = (await run((line) => lines.push(line))) === "failed";
|
|
321
|
+
return { content: [{ type: "text", text: lines.join("\n") }], ...(failed && { isError: true }) };
|
|
93
322
|
}
|
|
94
323
|
catch (error) {
|
|
324
|
+
// A chain library's message ends in its version and the request; its short message says what failed, and the node's own words lead,
|
|
325
|
+
// as the library's short message for a node's error reads as the caller's mistake.
|
|
326
|
+
const node = error instanceof BaseError ? error.walk((e) => e instanceof RpcRequestError) : null;
|
|
327
|
+
const message = node instanceof RpcRequestError
|
|
328
|
+
? `the node answered: ${node.details}`
|
|
329
|
+
: error instanceof BaseError
|
|
330
|
+
? `${error.shortMessage}${error.details ? ` Details: ${error.details}` : ""}`
|
|
331
|
+
: error instanceof Error
|
|
332
|
+
? error.message
|
|
333
|
+
: String(error);
|
|
95
334
|
// The result goes to the model: an endpoint's URL in it would hand over the endpoint's credential.
|
|
96
|
-
lines.push(`error: ${redactUrls(
|
|
335
|
+
lines.push(`error: ${redactUrls(message)}`);
|
|
97
336
|
return { content: [{ type: "text", text: lines.join("\n") }], isError: true };
|
|
98
337
|
}
|
|
99
338
|
}
|
|
100
|
-
/** Serves over stdio. A mandate and its
|
|
101
|
-
* it, so fund and status work without one. */
|
|
339
|
+
/** Serves over stdio. A mandate, its mandate key and the home its pulls are kept under are checked first, so a wrong path fails the host's
|
|
340
|
+
* launch; a key is read when pay needs it, so fund and status work without one. */
|
|
102
341
|
export async function serveMcp(authority) {
|
|
103
|
-
if (authority.kind === "mandate")
|
|
104
|
-
|
|
342
|
+
if (authority.kind === "mandate") {
|
|
343
|
+
const mandate = await loadMandate(authority.credentialPath);
|
|
344
|
+
await mandateAgentSigner(mandate, authority.credentialPath, authority.mandateKeyFile);
|
|
345
|
+
pendingPullsPath(mandate.reference);
|
|
346
|
+
}
|
|
105
347
|
await createMcpServer(authority).connect(new StdioServerTransport());
|
|
106
348
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metalntwx/metal-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The `metal-agent` CLI and MCP server: create or import
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "The `metal-agent` CLI and MCP server: create or import the agent's key, fund it on Metal devnet or testnet, pay x402 offers in *USDZ, look up a settlement, and set spending limits for an agent that Metal enforces.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@metalntwx/metal-ts": "^0.3.2",
|
|
22
|
-
"@metalntwx/metal-pay": "^0.
|
|
23
|
-
"@modelcontextprotocol/sdk": "1.
|
|
24
|
-
"@x402/core": "2.
|
|
22
|
+
"@metalntwx/metal-pay": "^0.3.1",
|
|
23
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
24
|
+
"@x402/core": "2.28.0",
|
|
25
25
|
"viem": "^2.54.6",
|
|
26
26
|
"zod": "^3.25.76"
|
|
27
27
|
},
|
|
28
28
|
"dependencyRationale": {
|
|
29
|
-
"@metalntwx/metal-ts": "Funds
|
|
29
|
+
"@metalntwx/metal-ts": "Funds a key on devnet with a MIP20 transfer.",
|
|
30
30
|
"@metalntwx/metal-pay": "The one-call x402 payment clients, mandates, and the Metal network constants.",
|
|
31
31
|
"@modelcontextprotocol/sdk": "The stdio MCP server behind `metal-agent mcp`.",
|
|
32
32
|
"@x402/core": "Decodes the `payment-response` header a paid request returns.",
|