@metalntwx/metal-agent 0.3.0 → 0.3.2

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/dist/help.js CHANGED
@@ -1,10 +1,9 @@
1
- import { DEFAULT_KEY_PATH } from "./keystore.js";
2
1
  const NETWORK = "--network devnet|testnet";
3
2
  const COMMANDS = [
4
3
  {
5
4
  name: "key create",
6
5
  usage: "metal-agent key create [--out <file>]",
7
- about: `Generates the agent's key and writes it to a key file only its owner can read, ${DEFAULT_KEY_PATH} unless --out names another. It never overwrites a key file.`,
6
+ about: `Generates the agent's key and writes it to a key file only its owner can read, ~/.metal-agent/key.json unless --out names another. It never overwrites a key file.`,
8
7
  },
9
8
  {
10
9
  name: "key import",
@@ -29,17 +28,17 @@ const COMMANDS = [
29
28
  {
30
29
  name: "mcp",
31
30
  usage: "metal-agent mcp [--key <file>] [--max <usdz>]\nmetal-agent mcp --mandate <credential> [--mandate-key-file <file>]",
32
- about: "Serves pay, fund and status as MCP tools over stdio. What pay spends is fixed here, by whoever starts the server: its key and --max, or one mandate. The model calling the tools reads pages and offers anyone can write, so it can choose no other key, no mandate and no higher limit; under a key, a max it passes can only lower the server's.",
31
+ about: "Serves pay, fund and status as MCP tools over stdio; under a mandate, mandate_pull, mandate_status and mandate_verify too, which read the chain through the server's METAL_RPC_URL, or devnet's default endpoint. What pay and mandate_pull spend is set here, by whoever starts the server: a key file and --max, or one mandate's credential file, which each call reads. The model calling the tools reads pages and offers anyone can write, so it can choose no other key, no mandate and no higher limit; under a key, a max it passes can only lower the server's.",
33
32
  },
34
33
  {
35
34
  name: "account create",
36
35
  usage: `metal-agent account create --out <owner key file> | --key <owner key file> ${NETWORK}`,
37
- about: "Creates the owner's Metal account, a smart account the owner's key controls, and prints its address, the --principal of the owner's credentials. The owner's key pays the gas; one that holds none is funded first, from the faucet on testnet. An account that exists is named, and nothing is sent. Keep the owner's key where no agent can read it.",
36
+ about: "Creates the owner's Metal account, a smart account the owner's key controls, and prints its address, the --principal of the owner's credentials. The owner's key pays the gas; one that holds too little is funded first, from the faucet on testnet. When a later step fails, such as a faucet refusal, the error names the key file, and the same command continues from it. An account that exists is named, and nothing is sent. Keep the owner's key where no agent can read it.",
38
37
  },
39
38
  {
40
39
  name: "mandate credential create",
41
40
  usage: `metal-agent mandate credential create --principal <address> --cap <usdz> --budget <usdz> --out <file>\n --key <owner key file> | --issuer-key <file> ${NETWORK}\n [--payee <address>[,<name>[,<website>]]]... [--expires-in <seconds>] [--starts-in <seconds>] [--max-occurrences <n>]\n [--description <text>] [--mandate-key-file <file>]`,
42
- about: "Writes the limits an agent pays within into a credential file: the most per payment (--cap), the most in total over the mandate's whole life (--budget, which never resets), an expiry (--expires-in, 30 days by default), a start (--starts-in, at once by default) and, when --payee names any, the addresses it may pay, each with an optional name and website. Amounts are in *USDZ, to the cent. The mandate key the agent signs with is written beside the credential, mandate.key.json for --out mandate.json, unless --mandate-key-file names one that exists, as for a replacement. The keys that sign the credential are the owner's: they live beside the owner's key file, <key>.issuer.json, created on first use, unless --issuer-key names their file.",
41
+ about: "Writes the limits an agent pays within into a credential file: the most per payment (--cap), the most in total over the mandate's whole life (--budget, which never resets), an expiry (--expires-in, 30 days by default), a start (--starts-in, by default 00:00 UTC on the day of issue) and, when --payee names any, the addresses it may pay, each with an optional name and website. Amounts are in *USDZ, to the cent. The mandate key the agent signs with is written beside the credential, mandate.key.json for --out mandate.json, unless --mandate-key-file names one that exists, as for a replacement. The keys that sign the credential are the owner's: they live beside the owner's key file, <key>.issuer.json, created on first use, unless --issuer-key names their file.",
43
42
  },
44
43
  {
45
44
  name: "mandate bind",
@@ -68,13 +67,13 @@ const COMMANDS = [
68
67
  },
69
68
  {
70
69
  name: "mandate pull",
71
- usage: `metal-agent mandate pull --credential <file> --to <address> --amount <usdz> ${NETWORK}\n [--key <file>] [--mandate-key-file <file>] [--record-refusal]`,
72
- about: "Pays an address the mandate allows, with no seller. The facilitator submits it and pays its gas, unless --key names a funded key to submit it. A payout the mandate would refuse is reported and not sent, and the command exits 1; with --key, --record-refusal sends it so the refusal is recorded on chain. The outcome is read from the chain: paid, refused, held in the payee's escrow, or pending. A pending pull is kept beside the credential, and running the same command again sends that same pull, which pays at most once.",
70
+ usage: `metal-agent mandate pull --credential <file> --to <address> --amount <usdz> ${NETWORK}\n [--payment-id <id>] [--key <file>] [--mandate-key-file <file>] [--record-refusal]`,
71
+ about: "Pays an address the mandate allows, with no seller. The facilitator submits it and pays its gas, unless --key names a funded key to submit it. A payout the mandate would refuse is reported and not sent, and the command exits 1; with --key, --record-refusal sends it so the refusal is recorded on chain. The outcome is read from the chain: paid (exit 0), refused (exit 1), held in the payee's escrow (exit 4), or pending (exit 3); an error also exits 1. Each pull is kept under ~/.metal-agent/pulls, and running the same command again sends a pending pull again, which pays at most once. --payment-id names the payment, such as a refund's id: two payouts of one amount to one payee are two payments under two ids, an id whose payout was paid or held reports it again, and one the account refused may be sent with other values. Without it, a payout is known by its payee and amount. Either is known only under this home directory and this credential: a retry from another machine, or under a credential that renewed or replaced it, signs a new payout.",
73
72
  },
74
73
  {
75
74
  name: "mandate verify",
76
75
  usage: `metal-agent mandate verify --credential <file> --issuer <file>... ${NETWORK} [--tx <hash> [--signed-offer <text>]]`,
77
- about: "Checks the mandate and every payment under it from the chain alone, or one --tx: the binding, the credential against it and under an --issuer you trust, and each payment as the mandate key's signed per-payment mandate. With --signed-offer, the order the agent signed for that payment, as pay printed it. A check that fails exits 1.",
76
+ about: "Checks the mandate and every payment under it from the chain alone, or one --tx: the binding, the credential against it and under an --issuer you trust, and each payment as the mandate key's signed per-payment mandate. With --signed-offer, the order the agent signed for that payment, as pay printed it. A check that fails exits 1, as does an event under the mandate the SDK does not decode.",
78
77
  },
79
78
  ];
80
79
  const SHARED = `Chain commands also take --rpc-url and --faucet-url, and mandate pull
@@ -86,7 +85,8 @@ sign up in the Metal console and create one. account create and mandate bind,
86
85
  replace, renew, revoke, status and verify take --factory <address> when the network's MetalAccountFactory is
87
86
  not the one metal-agent knows.
88
87
 
89
- metal-agent <command> --help explains one command.`;
88
+ metal-agent <command> --help explains one command.
89
+ metal-agent --version prints the version.`;
90
90
  /** The whole command list, or the commands the given words name, with what each does. */
91
91
  export function helpFor(words) {
92
92
  // The longest run of leading words that names a command or a group: arguments after it are not part of the name.
package/dist/index.js CHANGED
@@ -1,17 +1,20 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
2
3
  import { parseArgs } from "node:util";
3
4
  import { accountFactoryFor } from "@metalntwx/metal-pay";
4
5
  import { redactUrls } from "@metalntwx/metal-pay/mandates";
5
6
  import { target } from "./chain.js";
6
7
  import { fund } from "./commands/fund.js";
7
8
  import { keyCreate, keyImport } from "./commands/key.js";
8
- import { credentialCreate, accountCreate, mandateBind, mandatePull, mandateRenew, mandateReplace, mandateRevoke, mandateStatus, mandateVerify, parseAddress, parseCents, parseCount, parsePayee, parseTxHash, parsePullAmount, } from "./commands/mandate.js";
9
+ import { credentialCreate, accountCreate, mandateBind, mandatePull, mandateRenew, mandateReplace, mandateRevoke, mandateStatus, mandateVerify, parseAddress, parseCents, parseCount, parsePayee, parseTxHash, parsePaymentId, parsePullAmount, } from "./commands/mandate.js";
9
10
  import { parseHeaders, parseMax, pay, payUnderMandate } from "./commands/pay.js";
10
11
  import { status } from "./commands/status.js";
11
12
  import { helpFor } from "./help.js";
12
- import { DEFAULT_KEY_PATH, issuerKeyPath, publicKeyPath, readKey } from "./keystore.js";
13
+ import { defaultKeyPath, issuerKeyPath, publicKeyPath, readKey } from "./keystore.js";
13
14
  import { mcpAuthority, serveMcp } from "./mcp.js";
14
15
  const print = (line) => console.log(line);
16
+ /** `mandate pull`'s exit code per outcome: a wrapper that retries on `pending` alone sends the same pull, and never pays a `held` one into escrow again. */
17
+ const PULL_EXIT = { paid: 0, refused: 1, pending: 3, held: 4 };
15
18
  async function main(argv) {
16
19
  const { values, positionals } = parseArgs({
17
20
  args: argv,
@@ -47,10 +50,16 @@ async function main(argv) {
47
50
  "signed-offer": { type: "string" },
48
51
  description: { type: "string" },
49
52
  to: { type: "string" },
53
+ "payment-id": { type: "string" },
50
54
  "record-refusal": { type: "boolean" },
51
55
  help: { type: "boolean", short: "h" },
56
+ version: { type: "boolean" },
52
57
  },
53
58
  });
59
+ if (values.version) {
60
+ print(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
61
+ return 0;
62
+ }
54
63
  const [command, sub] = positionals;
55
64
  const extra = positionals.slice(argumentCount(command, sub));
56
65
  if (extra.length > 0) {
@@ -90,7 +99,7 @@ async function main(argv) {
90
99
  if (values["mandate-key-file"] !== undefined)
91
100
  throw new Error("--mandate-key-file goes with --mandate");
92
101
  const limit = { maxAmount: parseMax(values.max, "--max"), raise: "pass --max to allow it" };
93
- await pay(on(), await readKey(values.key ?? DEFAULT_KEY_PATH), sub, limit, print, request);
102
+ await pay(on(), await readKey(values.key ?? defaultKeyPath()), sub, limit, print, request);
94
103
  return 0;
95
104
  }
96
105
  case "account": {
@@ -160,10 +169,11 @@ async function main(argv) {
160
169
  mandateKeyFile: values["mandate-key-file"],
161
170
  to: parseAddress(values.to, "--to"),
162
171
  amount: parsePullAmount(values.amount, "--amount"),
172
+ paymentId: parsePaymentId(values["payment-id"], "--payment-id"),
163
173
  keyPath: values.key,
164
174
  recordRefusal: values["record-refusal"] === true,
165
175
  };
166
- return (await mandatePull(on(), args, print)) === "paid" ? 0 : 1;
176
+ return PULL_EXIT[await mandatePull(on(), args, print)];
167
177
  }
168
178
  else if (sub === "verify") {
169
179
  const tx = values.tx === undefined ? undefined : parseTxHash(values.tx, "--tx");
package/dist/keystore.js CHANGED
@@ -1,14 +1,22 @@
1
- import { createECDH } from "node:crypto";
2
- import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { createECDH, randomUUID } from "node:crypto";
2
+ import { access, chmod, link, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
- import { dirname, extname, join } from "node:path";
4
+ import { dirname, extname, isAbsolute, join } from "node:path";
5
5
  import { createEs256Key, jwkThumbprint, parseP256Jwk } from "@metalntwx/metal-pay/mandates";
6
6
  import { isHex } from "viem";
7
7
  import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
8
8
  export function isPrivateKey(value) {
9
9
  return typeof value === "string" && isHex(value) && value.length === 66;
10
10
  }
11
- export const DEFAULT_KEY_PATH = join(homedir(), ".metal-agent", "key.json");
11
+ /** `~/.metal-agent`, where metal-agent keeps its own files. An empty HOME names no directory, and they would land under whichever one the command runs in. */
12
+ function agentHome() {
13
+ const home = homedir();
14
+ if (!isAbsolute(home))
15
+ throw new Error(`HOME is ${home === "" ? "empty" : `${JSON.stringify(home)}, not an absolute path`}; set it to your home directory, where metal-agent keeps its key and its pulls`);
16
+ return join(home, ".metal-agent");
17
+ }
18
+ /** The agent's key file when no option names one: `~/.metal-agent/key.json`. */
19
+ export const defaultKeyPath = () => join(agentHome(), "key.json");
12
20
  /** `foo.json` to `foo.<suffix>.json`: a file that belongs beside another. */
13
21
  function beside(path, suffix) {
14
22
  const ext = extname(path);
@@ -16,8 +24,8 @@ function beside(path, suffix) {
16
24
  }
17
25
  /** The mandate key `credential create` writes beside the credential at `credentialPath`: `mandate.json` to `mandate.key.json`. */
18
26
  export const mandateKeyPath = (credentialPath) => beside(credentialPath, "key");
19
- /** The directory of pulls `mandate pull` sent without an outcome yet, beside the credential at `credentialPath`: `mandate.json` to `mandate.pulls`. */
20
- export const pendingPullsPath = (credentialPath) => `${credentialPath.slice(0, credentialPath.length - extname(credentialPath).length)}.pulls`;
27
+ /** The directory of the pulls `mandate pull` sends under the mandate `reference`, one directory per payment: `~/.metal-agent/pulls/<reference>`. */
28
+ export const pendingPullsPath = (reference) => join(agentHome(), "pulls", reference);
21
29
  /** The keys that sign the owner's credentials, beside the owner's key file and as private as it: `owner-key.json` to `owner-key.issuer.json`. */
22
30
  export const issuerKeyPath = (ownerKeyPath) => beside(ownerKeyPath, "issuer");
23
31
  /** The secp256k1 key at `path`, generated and written there first when the file does not exist. */
@@ -29,14 +37,22 @@ export async function readOrCreateKey(path) {
29
37
  await writeKey(path, { address, privateKey });
30
38
  return { address, created: true };
31
39
  }
40
+ /** Writes a key file that does not exist: whole, beside it, then linked into place, so a failed write leaves no part-written key for a later run to read. */
32
41
  export async function writeKey(path, key) {
33
42
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
34
- await writeFile(path, `${JSON.stringify(key, null, 2)}\n`, { mode: 0o600, flag: "wx" }).catch((error) => {
35
- if (error.code === "EEXIST")
36
- throw new Error(`${path} already holds a key; pass --out <file> to write another`);
37
- throw error;
38
- });
39
- await chmod(path, 0o600);
43
+ const draft = `${path}.${randomUUID()}.tmp`;
44
+ try {
45
+ await writeFile(draft, `${JSON.stringify(key, null, 2)}\n`, { mode: 0o600, flag: "wx" });
46
+ await chmod(draft, 0o600);
47
+ await link(draft, path).catch((error) => {
48
+ if (error.code === "EEXIST")
49
+ throw new Error(`${path} already holds a key; pass --out <file> to write another`);
50
+ throw error;
51
+ });
52
+ }
53
+ finally {
54
+ await rm(draft, { force: true });
55
+ }
40
56
  }
41
57
  export async function readKey(path) {
42
58
  const parsed = JSON.parse(await readFile(path, "utf8"));
package/dist/mcp.js CHANGED
@@ -1,15 +1,17 @@
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 { DEFAULT_KEY_PATH, readKey } from "./keystore.js";
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) {
@@ -20,16 +22,110 @@ export function mcpAuthority(options) {
20
22
  }
21
23
  if (options["mandate-key-file"] !== undefined)
22
24
  throw new Error("--mandate-key-file goes with --mandate");
23
- return { kind: "key", keyPath: options.key ?? DEFAULT_KEY_PATH, maxAmount: parseMax(options.max, "--max") };
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 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. ${BUY_RPC}`,
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) => payUnderMandate(target(net), authority, url, print, { method, body, headers })));
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
- await pay(target(net), await readKey(authority.keyPath), url, { maxAmount, raise }, print, { method, body, headers });
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) => fund(target(net, { rpcUrl: rpc }), address, amount, 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: { txHash: z.string().regex(/^0x[0-9a-fA-F]{64}$/), network, rpcUrl },
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(error instanceof Error ? error.message : String(error))}`);
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 mandate key are checked first, so a wrong path fails the host's launch; a key is read when pay needs
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
- await mandateAgentSigner(await loadMandate(authority.credentialPath), authority.credentialPath, authority.mandateKeyFile);
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@metalntwx/metal-agent",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
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": {
@@ -19,7 +19,7 @@
19
19
  ],
20
20
  "dependencies": {
21
21
  "@metalntwx/metal-ts": "^0.3.2",
22
- "@metalntwx/metal-pay": "^0.3.0",
22
+ "@metalntwx/metal-pay": "^0.3.2",
23
23
  "@modelcontextprotocol/sdk": "1.32.1",
24
24
  "@x402/core": "2.28.0",
25
25
  "viem": "^2.54.6",