@metalntwx/metal-agent 0.1.1 → 0.3.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.
@@ -59,7 +59,7 @@ export async function pay(target, account, url, limit, print, request = {}) {
59
59
  */
60
60
  export async function payUnderMandate(target, payer, url, print, request = {}) {
61
61
  const mandate = await loadMandate(payer.credentialPath);
62
- const signDigest = await mandateAgentSigner(mandate, payer.agentKeyPath);
62
+ const signDigest = await mandateAgentSigner(mandate, payer.credentialPath, payer.mandateKeyFile);
63
63
  const paying = createMandatePaymentClient({ credential: mandate.credential, signDigest, network: target.network });
64
64
  print(`mandate ${paying.reference}`);
65
65
  await paidRequest(target, { ...paying, payer: paying.principal }, url, print, request);
@@ -102,6 +102,9 @@ async function paidRequest(target, { fetchWithPayment, client, payer, raise }, u
102
102
  const how = raise !== undefined && aborted[1].endsWith("limit per payment") ? `; ${raise}` : "";
103
103
  throw new Error(`payment refused before signing: ${aborted[1]}${how}`);
104
104
  }
105
+ if (signed !== undefined) {
106
+ throw new Error(`${error instanceof Error ? error.message : String(error)}; a payment was signed and may have settled; look it up by its nonce before paying again`);
107
+ }
105
108
  throw error;
106
109
  });
107
110
  const body = await response.text();
@@ -113,14 +116,17 @@ async function paidRequest(target, { fetchWithPayment, client, payer, raise }, u
113
116
  const priced = offer ? `${formatUsdz(offer.amount)} ${USDZ.symbol} on ${offer.network}` : "no offer";
114
117
  throw new Error(`payment refused: ${describeRefusalReason(required.error)} (offer: ${priced})`);
115
118
  }
116
- if (response.status === 402 && paymentResponse) {
117
- throw new Error(`payment refused at settlement: ${settleRefusal(decodePaymentResponseHeader(paymentResponse))}`);
119
+ const settlement = paymentResponse ? decodePaymentResponseHeader(paymentResponse) : undefined;
120
+ if (settlement !== undefined && !settlement.success)
121
+ throw new Error(`payment refused at settlement: ${settleRefusal(settlement)}`);
122
+ // A redirect is reported, not an error: the payment may have settled, and `location` says where it points.
123
+ if (response.status >= 400) {
124
+ // A seller that fails after the payment was signed may have settled it first.
125
+ const doubt = signed !== undefined && response.status >= 500 ? "; a payment was signed and may have settled; look it up by its nonce before paying again" : "";
126
+ throw new Error(`${url} answered ${response.status}: ${body}${doubt}`);
118
127
  }
119
- if (!response.ok)
120
- throw new Error(`${url} answered ${response.status}: ${body}`);
121
128
  print(`status ${response.status}`);
122
- if (paymentResponse) {
123
- const settlement = decodePaymentResponseHeader(paymentResponse);
129
+ if (settlement !== undefined) {
124
130
  print(`payer ${settlement.payer ?? payer}`);
125
131
  if (signed) {
126
132
  print(`payee ${signed.payTo}`);
@@ -131,6 +137,9 @@ async function paidRequest(target, { fetchWithPayment, client, payer, raise }, u
131
137
  if (target.explorerUrl)
132
138
  print(`explorer ${target.explorerUrl}/tx/${settlement.transaction}`);
133
139
  }
140
+ else if (signed !== undefined) {
141
+ print("payment unconfirmed (signed, but the seller returned no settlement; look it up by its nonce)");
142
+ }
134
143
  else {
135
144
  print("payment none (the resource did not ask for one)");
136
145
  }
package/dist/help.js ADDED
@@ -0,0 +1,119 @@
1
+ import { DEFAULT_KEY_PATH } from "./keystore.js";
2
+ const NETWORK = "--network devnet|testnet";
3
+ const COMMANDS = [
4
+ {
5
+ name: "key create",
6
+ 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.`,
8
+ },
9
+ {
10
+ name: "key import",
11
+ usage: "metal-agent key import <file|hex> [--out <file>]",
12
+ about: "Loads a key someone funded for you: a hex key, a file holding one, or a key file. Prints the address, never the key.",
13
+ },
14
+ {
15
+ name: "fund",
16
+ usage: `metal-agent fund <address> --network devnet [--amount <usdz>]\nmetal-agent fund <address> --network testnet`,
17
+ about: "On testnet, asks the public faucet, which sets the amount. On devnet, moves *USDZ from the devnet's pre-funded account, 10 by default.",
18
+ },
19
+ {
20
+ name: "pay",
21
+ usage: `metal-agent pay <url> ${NETWORK} [--key <file>] [--max <usdz>]\n [--method <method>] [--body <text>] [--header 'name: value']...\nmetal-agent pay <url> --mandate <credential> ${NETWORK} [--mandate-key-file <file>]`,
22
+ about: "Requests the URL and pays its x402 offer in *USDZ. With --key, the agent pays from its own key and refuses, before signing, any offer above --max (1 *USDZ by default). With --mandate, it pays from the owner's account within the mandate's limits, signed by the .key.json file in the credential's directory whose key the credential names, unless --mandate-key-file names another. --method (GET by default, POST with a --body), --body (application/json unless a --header names a content type) and each --header are sent again with the payment.",
23
+ },
24
+ {
25
+ name: "status",
26
+ usage: `metal-agent status <txHash> ${NETWORK} [--rpc-url <url>]`,
27
+ about: "Shows a settlement from chain data: status, block, submitter, gas, and the transfer it carried. On testnet it needs an RPC endpoint of your own.",
28
+ },
29
+ {
30
+ name: "mcp",
31
+ 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.",
33
+ },
34
+ {
35
+ name: "account create",
36
+ 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.",
38
+ },
39
+ {
40
+ name: "mandate credential create",
41
+ 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.",
43
+ },
44
+ {
45
+ name: "mandate bind",
46
+ usage: `metal-agent mandate bind --credential <file> --key <owner key file> ${NETWORK} [--issuer <file>]... [--signer-key <file>]...`,
47
+ about: "Binds the credential on the owner's account, which then pays under it. The account's owners sign: --key pays the gas and is the sole signer unless repeated --signer-key names the owners' keys. --issuer names the issuers whose credentials the owner accepts, the <key>.issuer.public.json beside the owner's key by default.",
48
+ },
49
+ {
50
+ name: "mandate replace",
51
+ usage: `metal-agent mandate replace --credential <file> --replaces <file> --key <owner key file> ${NETWORK}\n [--issuer <file>]... [--signer-key <file>]...`,
52
+ about: "Binds the credential in place of the mandate --replaces names, which is revoked in the same transaction. What was spent carries over into the new budget.",
53
+ },
54
+ {
55
+ name: "mandate renew",
56
+ usage: `metal-agent mandate renew --credential <file> --replaces <file> --key <owner key file> ${NETWORK}\n [--issuer <file>]... [--signer-key <file>]...`,
57
+ about: "Binds the credential as a fresh mandate and revokes the one --replaces names, in one transaction. The new budget starts with nothing spent; this is how a budget starts over, since it never resets on its own.",
58
+ },
59
+ {
60
+ name: "mandate revoke",
61
+ usage: `metal-agent mandate revoke --credential <file> --key <owner key file> ${NETWORK} [--signer-key <file>]...`,
62
+ about: "Revokes the mandate: every later payment under it is refused.",
63
+ },
64
+ {
65
+ name: "mandate status",
66
+ usage: `metal-agent mandate status --credential <file> ${NETWORK}`,
67
+ about: "Shows the mandate's limits and where it stands: bound or not, revoked or not, what is spent of the budget and how many payments were made.",
68
+ },
69
+ {
70
+ 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.",
73
+ },
74
+ {
75
+ name: "mandate verify",
76
+ 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.",
78
+ },
79
+ ];
80
+ const SHARED = `Chain commands also take --rpc-url and --faucet-url, and mandate pull
81
+ --facilitator-url, which override METAL_RPC_URL, METAL_FAUCET_URL and
82
+ METAL_FACILITATOR_URL, which override each network's defaults.
83
+ On testnet, status, account create and the mandate commands other than
84
+ credential create need an RPC endpoint of your own:
85
+ sign up in the Metal console and create one. account create and mandate bind,
86
+ replace, renew, revoke, status and verify take --factory <address> when the network's MetalAccountFactory is
87
+ not the one metal-agent knows.
88
+
89
+ metal-agent <command> --help explains one command.`;
90
+ /** The whole command list, or the commands the given words name, with what each does. */
91
+ export function helpFor(words) {
92
+ // The longest run of leading words that names a command or a group: arguments after it are not part of the name.
93
+ for (let n = words.length; n > 0; n--) {
94
+ const asked = words.slice(0, n).join(" ");
95
+ const exact = COMMANDS.find((c) => c.name === asked);
96
+ if (exact)
97
+ return `${exact.usage}\n\n${wrap(exact.about)}`;
98
+ const group = COMMANDS.filter((c) => c.name.startsWith(`${asked} `));
99
+ if (group.length > 0)
100
+ return group.map((c) => `${c.usage}\n\n${wrap(c.about)}`).join("\n\n");
101
+ }
102
+ return `metal-agent: pay x402 sellers on Metal, and set spending limits Metal enforces for an agent\n\n${COMMANDS.map((c) => c.usage).join("\n")}\n\n${SHARED}`;
103
+ }
104
+ function wrap(text, width = 78) {
105
+ const lines = [];
106
+ let line = "";
107
+ for (const word of text.split(" ")) {
108
+ if (line !== "" && line.length + 1 + word.length > width) {
109
+ lines.push(line);
110
+ line = word;
111
+ }
112
+ else {
113
+ line = line === "" ? word : `${line} ${word}`;
114
+ }
115
+ }
116
+ if (line !== "")
117
+ lines.push(line);
118
+ return lines.join("\n");
119
+ }
package/dist/index.js CHANGED
@@ -5,82 +5,12 @@ import { redactUrls } from "@metalntwx/metal-pay/mandates";
5
5
  import { target } from "./chain.js";
6
6
  import { fund } from "./commands/fund.js";
7
7
  import { keyCreate, keyImport } from "./commands/key.js";
8
- import { credentialCreate, accountCreate, mandateBind, mandatePull, mandateReplace, mandateRevoke, mandateStatus, mandateVerify, parseAddress, parseCents, parseCount, parseTxHash, parsePullAmount, } from "./commands/mandate.js";
8
+ import { credentialCreate, accountCreate, mandateBind, mandatePull, mandateRenew, mandateReplace, mandateRevoke, mandateStatus, mandateVerify, parseAddress, parseCents, parseCount, parsePayee, parseTxHash, parsePullAmount, } from "./commands/mandate.js";
9
9
  import { parseHeaders, parseMax, pay, payUnderMandate } from "./commands/pay.js";
10
10
  import { status } from "./commands/status.js";
11
- import { DEFAULT_ISSUER_KEY_PATH, DEFAULT_KEY_PATH, agentKeyPath, gasKeyPath, publicKeyPath, readKey } from "./keystore.js";
12
- import { serveMcp } from "./mcp.js";
13
- const USAGE = `metal-agent: pay x402 sellers on Metal
14
-
15
- metal-agent key create [--out <file>]
16
- metal-agent key import <file|hex> [--out <file>]
17
- metal-agent fund <address> --network devnet [--amount <usdz>]
18
- metal-agent fund <address> --network testnet from the public faucet, which sets the amount
19
- metal-agent pay <url> --network devnet|testnet [--key <file>] [--max <usdz>]
20
- [--method <method>] [--body <text>] [--header 'name: value']...
21
- metal-agent pay <url> --mandate <credential> --network devnet|testnet [--agent-key <file>]
22
- metal-agent status <txHash> --network devnet|testnet [--rpc-url <url>]
23
- metal-agent mcp serve pay, fund and status as MCP tools over stdio
24
-
25
- Spending limits an account's owner sets for an agent, which Metal enforces:
26
-
27
- metal-agent account create --out <owner key file to create> | --key <owner key file> --network devnet|testnet
28
-
29
- metal-agent mandate credential create --principal <address> --cap <usdz> --budget <usdz> --out <file>
30
- --network devnet|testnet [--payee <address>]... [--expires-in <seconds>] [--max-occurrences <n>]
31
- [--description <text>] [--agent-key <file>] [--issuer-key <file>]
32
- metal-agent mandate bind --credential <file> --key <owner key file> --network devnet|testnet [--issuer <file>]...
33
- metal-agent mandate replace --credential <file> --replaces <file> --key <owner key file> --network devnet|testnet
34
- [--issuer <file>]...
35
- metal-agent mandate revoke --credential <file> --key <owner key file> --network devnet|testnet
36
- metal-agent mandate status --credential <file> --network devnet|testnet
37
- metal-agent mandate pull --credential <file> --to <address> --amount <usdz> --network devnet|testnet
38
- [--key <file>] [--agent-key <file>]
39
- metal-agent mandate verify --credential <file> --network devnet|testnet [--tx <hash> [--signed-offer <text>]]
40
- [--issuer <file>]...
41
-
42
- Chain commands also take --rpc-url and --faucet-url, which override
43
- METAL_RPC_URL and METAL_FAUCET_URL, which override each network's defaults:
44
- the RPC from @metalntwx/metal-pay, the faucet from the shipped networks.json.
45
- status reads the chain: on devnet the network default applies; on testnet,
46
- sign up in the Metal console, create an endpoint, and pass it.
47
-
48
- pay refuses, before signing, any offer above --max *USDZ (1 by default). It
49
- sends --method (GET by default, POST with a --body), --body (as
50
- application/json unless a --header names a content type) and each --header,
51
- and sends them again with the payment.
52
-
53
- A mandate lets an agent pay from its owner's account (the principal) with a
54
- key of its own, within limits: the most per payment (--cap), a budget for
55
- the mandate's whole life (--budget), an expiry (--expires-in, 30 days by
56
- default) and, when --payee names any, the addresses it may pay; with none
57
- named it may pay any address. credential create writes those into a
58
- credential file, <out>.json, and the agent's keys beside it: its mandate key,
59
- <out>.agent-key.json, and the key that pays for its pulls, <out>.gas-key.json;
60
- --agent-key names a mandate key the agent already has instead, with its gas
61
- key beside that. The issuer keys that sign the credential are created on
62
- first use. account create names the owner's Metal account, a smart account
63
- the owner's key controls, created on chain by the first bind, and its address
64
- is --principal. bind, replace and
65
- revoke are signed by the account's quorum: --key pays gas and is the default
66
- sole signer; repeated --signer-key supplies EOA quorum signers. The account
67
- factory is the network's; --factory names another on any of these commands.
68
- Keep owner keys apart from the agent's keys. pay --mandate
69
- pays from the principal's account, signed by the agent's key; a payment
70
- outside the mandate is refused on chain and the refusal names the limit.
71
- pull makes a payment with no seller, such as moving funds to an allowed
72
- address; the --key account, the agent's gas key by default, submits it and
73
- pays its gas, and a refused pull exits 1. verify checks the mandate and every
74
- payment under it from the chain alone, or one --tx: the binding, the
75
- credential against it and under its issuer, and each payment as the agent
76
- key's own signed per-payment mandate, rebuilt from its transaction; with
77
- --signed-offer, the offer the agent signed for that payment, as pay printed
78
- it, against the payment. A check that fails exits 1. A seller that asks for
79
- the agent's signed order gets it with the payment; pay prints it. Amounts under a mandate are whole cents. The mandate
80
- commands read and write the chain, so on testnet they need an endpoint.
81
-
82
- The key file defaults to ${DEFAULT_KEY_PATH}, the agent's keys to the files
83
- beside its credential, and the issuer keys to ${DEFAULT_ISSUER_KEY_PATH}.`;
11
+ import { helpFor } from "./help.js";
12
+ import { DEFAULT_KEY_PATH, issuerKeyPath, publicKeyPath, readKey } from "./keystore.js";
13
+ import { mcpAuthority, serveMcp } from "./mcp.js";
84
14
  const print = (line) => console.log(line);
85
15
  async function main(argv) {
86
16
  const { values, positionals } = parseArgs({
@@ -90,6 +20,7 @@ async function main(argv) {
90
20
  network: { type: "string" },
91
21
  "rpc-url": { type: "string" },
92
22
  "faucet-url": { type: "string" },
23
+ "facilitator-url": { type: "string" },
93
24
  key: { type: "string" },
94
25
  factory: { type: "string" },
95
26
  "signer-key": { type: "string", multiple: true },
@@ -103,29 +34,36 @@ async function main(argv) {
103
34
  credential: { type: "string" },
104
35
  replaces: { type: "string" },
105
36
  issuer: { type: "string", multiple: true },
106
- "agent-key": { type: "string" },
37
+ "mandate-key-file": { type: "string" },
107
38
  "issuer-key": { type: "string" },
108
39
  principal: { type: "string" },
109
40
  payee: { type: "string", multiple: true },
110
41
  cap: { type: "string" },
111
42
  budget: { type: "string" },
112
43
  "expires-in": { type: "string" },
44
+ "starts-in": { type: "string" },
113
45
  "max-occurrences": { type: "string" },
114
46
  tx: { type: "string" },
115
47
  "signed-offer": { type: "string" },
116
48
  description: { type: "string" },
117
49
  to: { type: "string" },
50
+ "record-refusal": { type: "boolean" },
118
51
  help: { type: "boolean", short: "h" },
119
52
  },
120
53
  });
121
54
  const [command, sub] = positionals;
55
+ const extra = positionals.slice(argumentCount(command, sub));
56
+ if (extra.length > 0) {
57
+ throw new Error(`unexpected argument${extra.length > 1 ? "s" : ""} ${extra.map((a) => JSON.stringify(a)).join(" ")}; quote a value that holds a space`);
58
+ }
122
59
  if (values.help || command === undefined || command === "help") {
123
- print(USAGE);
60
+ print(helpFor(command === "help" ? positionals.slice(1) : positionals.slice(0, 3)));
124
61
  return command === undefined && !values.help ? 2 : 0;
125
62
  }
126
63
  const on = () => target(values.network, {
127
64
  rpcUrl: values["rpc-url"],
128
65
  faucetUrl: values["faucet-url"],
66
+ facilitatorUrl: values["facilitator-url"],
129
67
  });
130
68
  switch (command) {
131
69
  case "key":
@@ -143,12 +81,14 @@ async function main(argv) {
143
81
  const request = { method: values.method, body: values.body, headers: parseHeaders(values.header, "--header") };
144
82
  if (values.mandate !== undefined) {
145
83
  if (values.max !== undefined || values.key !== undefined) {
146
- throw new Error("pay --mandate takes neither --max nor --key: the mandate sets the limit, and the agent's key is --agent-key");
84
+ throw new Error("pay --mandate takes neither --max nor --key: the mandate sets the limit, and the agent signs with its mandate key");
147
85
  }
148
- const payer = { credentialPath: values.mandate, agentKeyPath: values["agent-key"] ?? agentKeyPath(values.mandate) };
86
+ const payer = { credentialPath: values.mandate, mandateKeyFile: values["mandate-key-file"] };
149
87
  await payUnderMandate(on(), payer, sub, print, request);
150
88
  return 0;
151
89
  }
90
+ if (values["mandate-key-file"] !== undefined)
91
+ throw new Error("--mandate-key-file goes with --mandate");
152
92
  const limit = { maxAmount: parseMax(values.max, "--max"), raise: "pass --max to allow it" };
153
93
  await pay(on(), await readKey(values.key ?? DEFAULT_KEY_PATH), sub, limit, print, request);
154
94
  return 0;
@@ -157,7 +97,7 @@ async function main(argv) {
157
97
  if (sub !== "create")
158
98
  throw new Error(`unknown account subcommand "${sub ?? ""}"; try: account create`);
159
99
  if ((values.key === undefined) === (values.out === undefined))
160
- throw new Error("account create needs --out, the owner key file to create, or --key, one that exists");
100
+ throw new Error("account create needs --out, the owner key file, created when it does not exist, or --key, one that exists");
161
101
  const factory = accountFactoryFor(on().network, values.factory === undefined ? undefined : parseAddress(values.factory, "--factory"), "pass --factory");
162
102
  await accountCreate(on(), { accountFactory: factory, ...(values.key === undefined ? { out: values.out } : { keyPath: values.key }) }, print);
163
103
  return 0;
@@ -167,40 +107,46 @@ async function main(argv) {
167
107
  const OWNER_KEY = "--key, the account owner's key file";
168
108
  const need = (value, option) => {
169
109
  if (value === undefined)
170
- throw new Error(`mandate ${sub ?? ""} needs ${option}`);
110
+ throw new Error(`mandate ${sub === "credential" ? "credential create" : (sub ?? "")} needs ${option}`);
171
111
  return value;
172
112
  };
173
113
  const accountFactory = () => accountFactoryFor(on().network, values.factory === undefined ? undefined : parseAddress(values.factory, "--factory"), "pass --factory");
174
114
  const owner = () => ({ accountFactory: accountFactory(), signerPaths: values["signer-key"] ?? [] });
175
- const issuerPaths = values.issuer ?? [publicKeyPath(DEFAULT_ISSUER_KEY_PATH)];
115
+ // The issuers the owner accepts: theirs, beside their key, unless --issuer names others.
116
+ const ownerIssuers = () => values.issuer ?? [publicKeyPath(issuerKeyPath(need(values.key, OWNER_KEY)))];
176
117
  if (sub === "credential" && positionals[2] === "create") {
177
- const payees = (values.payee ?? []).map((payee) => parseAddress(payee, "--payee"));
118
+ const payees = (values.payee ?? []).map((payee) => parsePayee(payee, "--payee"));
119
+ // The owner's key must be there: a mistyped path would put new issuer keys beside nothing.
120
+ if (values["issuer-key"] === undefined && values.key !== undefined)
121
+ await readKey(values.key);
178
122
  await credentialCreate(on(), {
179
123
  principal: parseAddress(values.principal, "--principal"),
180
124
  payees,
181
125
  cap: parseCents(values.cap, "--cap"),
182
126
  budget: parseCents(values.budget, "--budget"),
183
127
  expiresIn: parseCount(values["expires-in"], "--expires-in") ?? 30 * 86_400,
128
+ startsIn: parseCount(values["starts-in"], "--starts-in"),
184
129
  maxOccurrences: parseCount(values["max-occurrences"], "--max-occurrences"),
185
130
  description: values.description ?? "A mandate issued by metal-agent",
186
131
  out: need(values.out, "--out"),
187
- agentKeyPath: values["agent-key"],
188
- issuerKeyPath: values["issuer-key"] ?? DEFAULT_ISSUER_KEY_PATH,
132
+ mandateKeyFile: values["mandate-key-file"],
133
+ // The keys that sign the owner's credentials are as private as the owner's key: they live beside it.
134
+ issuerKeyPath: values["issuer-key"] ?? issuerKeyPath(need(values.key, "--key, the account owner's key file, beside which the keys that sign its credentials live, or --issuer-key")),
189
135
  }, print);
190
136
  }
191
137
  else if (sub === "bind") {
192
- const args = { ...owner(), credentialPath: need(values.credential, "--credential"), issuerPaths, keyPath: need(values.key, OWNER_KEY) };
138
+ const args = { ...owner(), credentialPath: need(values.credential, "--credential"), issuerPaths: ownerIssuers(), keyPath: need(values.key, OWNER_KEY) };
193
139
  await mandateBind(on(), args, print);
194
140
  }
195
- else if (sub === "replace") {
141
+ else if (sub === "replace" || sub === "renew") {
196
142
  const args = {
197
143
  credentialPath: need(values.credential, "--credential"),
198
144
  replacesPath: need(values.replaces, "--replaces"),
199
- issuerPaths,
145
+ issuerPaths: ownerIssuers(),
200
146
  ...owner(),
201
147
  keyPath: need(values.key, OWNER_KEY),
202
148
  };
203
- await mandateReplace(on(), args, print);
149
+ await (sub === "replace" ? mandateReplace : mandateRenew)(on(), args, print);
204
150
  }
205
151
  else if (sub === "revoke") {
206
152
  await mandateRevoke(on(), { ...owner(), credentialPath: need(values.credential, "--credential"), keyPath: need(values.key, OWNER_KEY) }, print);
@@ -209,24 +155,26 @@ async function main(argv) {
209
155
  await mandateStatus(on(), need(values.credential, "--credential"), accountFactory(), print);
210
156
  }
211
157
  else if (sub === "pull") {
212
- const credentialPath = need(values.credential, "--credential");
213
- const agentKey = values["agent-key"] ?? agentKeyPath(credentialPath);
214
158
  const args = {
215
- credentialPath,
216
- agentKeyPath: agentKey,
159
+ credentialPath: need(values.credential, "--credential"),
160
+ mandateKeyFile: values["mandate-key-file"],
217
161
  to: parseAddress(values.to, "--to"),
218
162
  amount: parsePullAmount(values.amount, "--amount"),
219
- keyPath: values.key ?? gasKeyPath(agentKey),
163
+ keyPath: values.key,
164
+ recordRefusal: values["record-refusal"] === true,
220
165
  };
221
166
  return (await mandatePull(on(), args, print)) === "paid" ? 0 : 1;
222
167
  }
223
168
  else if (sub === "verify") {
224
169
  const tx = values.tx === undefined ? undefined : parseTxHash(values.tx, "--tx");
170
+ const issuerPaths = values.issuer ?? [];
171
+ if (issuerPaths.length === 0)
172
+ throw new Error("mandate verify needs --issuer: the issuer-public-file credential create printed, or another issuer you trust");
225
173
  const args = { accountFactory: accountFactory(), credentialPath: need(values.credential, "--credential"), issuerPaths, tx, signedOffer: values["signed-offer"] };
226
174
  return (await mandateVerify(on(), args, print)) === "verified" ? 0 : 1;
227
175
  }
228
176
  else {
229
- throw new Error(`unknown mandate subcommand "${sub ?? ""}"; try: credential create, bind, replace, revoke, status, pull, verify`);
177
+ throw new Error(`unknown mandate subcommand "${sub ?? ""}"; try: credential create, bind, replace, renew, revoke, status, pull, verify`);
230
178
  }
231
179
  return 0;
232
180
  }
@@ -234,13 +182,21 @@ async function main(argv) {
234
182
  await status(on(), sub, print);
235
183
  return 0;
236
184
  case "mcp":
237
- await serveMcp();
185
+ await serveMcp(mcpAuthority(values));
238
186
  await new Promise(() => { });
239
187
  return 0;
240
188
  default:
241
- throw new Error(`unknown command "${command}"\n\n${USAGE}`);
189
+ throw new Error(`unknown command "${command}"\n\n${helpFor([])}`);
242
190
  }
243
191
  }
192
+ /** How many words a command takes before its options: `key import <file|hex>` and `mandate credential create` three, `mcp` one, the other commands two; any number for `help` and for a command the dispatch then names unknown. */
193
+ function argumentCount(command, sub) {
194
+ if ((command === "key" && sub === "import") || (command === "mandate" && sub === "credential"))
195
+ return 3;
196
+ if (command === "mcp")
197
+ return 1;
198
+ return ["key", "fund", "pay", "account", "mandate", "status"].includes(command ?? "") ? 2 : Infinity;
199
+ }
244
200
  main(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
245
201
  // An endpoint's URL can carry its credential, and this output often lands in an agent's transcript.
246
202
  console.error(`error: ${redactUrls(error instanceof Error ? error.message : String(error))}`);
package/dist/keystore.js CHANGED
@@ -9,21 +9,17 @@ export function isPrivateKey(value) {
9
9
  return typeof value === "string" && isHex(value) && value.length === 66;
10
10
  }
11
11
  export const DEFAULT_KEY_PATH = join(homedir(), ".metal-agent", "key.json");
12
- /** The keys that sign a credential `mandate credential create` issues. */
13
- export const DEFAULT_ISSUER_KEY_PATH = join(homedir(), ".metal-agent", "issuer.json");
14
12
  /** `foo.json` to `foo.<suffix>.json`: a file that belongs beside another. */
15
13
  function beside(path, suffix) {
16
14
  const ext = extname(path);
17
15
  return `${path.slice(0, path.length - ext.length)}.${suffix}${ext}`;
18
16
  }
19
- /** The P-256 key an agent signs payments with under the mandate at `credentialPath`: `mandate.json` to `mandate.agent-key.json`. */
20
- export const agentKeyPath = (credentialPath) => beside(credentialPath, "agent-key");
21
- /** The funded key that submits the agent's direct pulls and pays their gas, beside its mandate key: `mandate.agent-key.json` to `mandate.gas-key.json`, `shared.json` to `shared.gas-key.json`. */
22
- export function gasKeyPath(agentKeyPath) {
23
- const ext = extname(agentKeyPath);
24
- const stem = agentKeyPath.slice(0, agentKeyPath.length - ext.length);
25
- return `${stem.endsWith(".agent-key") ? stem.slice(0, -".agent-key".length) : stem}.gas-key${ext}`;
26
- }
17
+ /** The mandate key `credential create` writes beside the credential at `credentialPath`: `mandate.json` to `mandate.key.json`. */
18
+ 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`;
21
+ /** 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
+ export const issuerKeyPath = (ownerKeyPath) => beside(ownerKeyPath, "issuer");
27
23
  /** The secp256k1 key at `path`, generated and written there first when the file does not exist. */
28
24
  export async function readOrCreateKey(path) {
29
25
  if (await exists(path))
package/dist/mcp.js CHANGED
@@ -6,8 +6,23 @@ import { target } from "./chain.js";
6
6
  import { fund } from "./commands/fund.js";
7
7
  import { parseMax, pay, payUnderMandate } from "./commands/pay.js";
8
8
  import { status } from "./commands/status.js";
9
- import { DEFAULT_KEY_PATH, agentKeyPath, readKey } from "./keystore.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";
10
12
  import { redactUrls } from "@metalntwx/metal-pay/mandates";
13
+ /** The authority `metal-agent mcp` serves under, from its options. */
14
+ export function mcpAuthority(options) {
15
+ if (options.mandate !== undefined) {
16
+ 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 signs with its mandate key");
18
+ }
19
+ return { kind: "mandate", credentialPath: options.mandate, ...(options["mandate-key-file"] !== undefined && { mandateKeyFile: options["mandate-key-file"] }) };
20
+ }
21
+ if (options["mandate-key-file"] !== undefined)
22
+ throw new Error("--mandate-key-file goes with --mandate");
23
+ return { kind: "key", keyPath: options.key ?? DEFAULT_KEY_PATH, maxAmount: parseMax(options.max, "--max") };
24
+ }
25
+ const usdz = (units) => `${formatUsdz(units)} ${USDZ.symbol}`;
11
26
  const network = z.enum(["devnet", "testnet"]).describe("Which Metal network; there is no default");
12
27
  const rpcUrl = z
13
28
  .string()
@@ -15,45 +30,46 @@ const rpcUrl = z
15
30
  .optional()
16
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");
17
32
  const RPC_HOW = "pass rpcUrl or set METAL_RPC_URL in the server's environment";
18
- export function createMcpServer() {
33
+ export function createMcpServer(authority) {
19
34
  const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
20
35
  const server = new McpServer({ name: "metal-agent", version });
21
- server.registerTool("pay", {
22
- description: "Request a URL and pay its x402 challenge in *USDZ on Metal with the agent's key. Returns the settlement transaction hash, the response headers and the body. With mandate, the payment comes from the mandate's account within the limits its owner set, and a payment outside them is refused on chain with the limit named. 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.",
23
- inputSchema: {
24
- url: z.string().url(),
25
- network,
26
- key: z.string().optional().describe(`Agent key file; ${DEFAULT_KEY_PATH} by default`),
27
- max: z
28
- .string()
29
- .optional()
30
- .describe("The most this payment may cost, in decimal *USDZ; 1 by default. A higher offer is refused before signing"),
31
- mandate: z
32
- .string()
33
- .optional()
34
- .describe("Credential file of a mandate to pay under. The mandate's limits apply in place of key and max, and only its owner can change them"),
35
- agentKey: z
36
- .string()
37
- .optional()
38
- .describe("The agent's mandate key file; the <credential>.agent-key.json beside the credential by default"),
39
- method: z.string().optional().describe("HTTP method; GET by default, POST with a body"),
40
- body: z.string().optional().describe("Request body, sent as application/json unless headers names a content type"),
41
- headers: z.record(z.string(), z.string()).optional().describe("Request headers, by name"),
42
- },
43
- }, ({ url, network: net, key, max, mandate, agentKey, method, body, headers }) => collect(async (print) => {
44
- const request = { method, body, headers };
45
- if (mandate !== undefined) {
46
- if (max !== undefined || key !== undefined) {
47
- throw new Error("with mandate, pass neither max nor key: the mandate sets the limit, and the agent's key is agentKey");
36
+ const request = {
37
+ url: z.string().url(),
38
+ network,
39
+ method: z.string().optional().describe("HTTP method; GET by default, POST with a body"),
40
+ body: z.string().optional().describe("Request body, sent as application/json unless headers names a content type"),
41
+ headers: z.record(z.string(), z.string()).optional().describe("Request headers, by name"),
42
+ };
43
+ 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.";
44
+ if (authority.kind === "mandate") {
45
+ 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}`,
47
+ // Strict: a key or limit the model names is refused, not dropped.
48
+ inputSchema: z.object(request).strict(),
49
+ }, ({ url, network: net, method, body, headers }) => collect((print) => payUnderMandate(target(net), authority, url, print, { method, body, headers })));
50
+ }
51
+ else {
52
+ const limit = usdz(authority.maxAmount);
53
+ 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}`,
55
+ inputSchema: z
56
+ .object({
57
+ ...request,
58
+ max: z
59
+ .string()
60
+ .optional()
61
+ .describe(`A lower limit for this payment, in decimal *USDZ; it can only lower the server's ${limit}`),
62
+ })
63
+ .strict(),
64
+ }, ({ url, network: net, max, method, body, headers }) => collect(async (print) => {
65
+ const maxAmount = max === undefined ? authority.maxAmount : parseMax(max, "max");
66
+ if (maxAmount > authority.maxAmount) {
67
+ throw new Error(`max can only lower this server's limit of ${limit} per payment; its --max sets the limit`);
48
68
  }
49
- const payer = { credentialPath: mandate, agentKeyPath: agentKey ?? agentKeyPath(mandate) };
50
- await payUnderMandate(target(net), payer, url, print, request);
51
- return;
52
- }
53
- const maxAmount = parseMax(max, "max");
54
- const account = await readKey(key ?? DEFAULT_KEY_PATH);
55
- await pay(target(net), account, url, { maxAmount, raise: "raise max to allow it" }, print, request);
56
- }));
69
+ 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 });
71
+ }));
72
+ }
57
73
  server.registerTool("fund", {
58
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.",
59
75
  inputSchema: {
@@ -81,6 +97,10 @@ async function collect(run) {
81
97
  return { content: [{ type: "text", text: lines.join("\n") }], isError: true };
82
98
  }
83
99
  }
84
- export async function serveMcp() {
85
- await createMcpServer().connect(new StdioServerTransport());
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. */
102
+ export async function serveMcp(authority) {
103
+ if (authority.kind === "mandate")
104
+ await mandateAgentSigner(await loadMandate(authority.credentialPath), authority.credentialPath, authority.mandateKeyFile);
105
+ await createMcpServer(authority).connect(new StdioServerTransport());
86
106
  }