@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.
- package/README.md +78 -52
- package/dist/chain.js +1 -1
- package/dist/commands/mandate.js +564 -131
- package/dist/commands/pay.js +16 -7
- package/dist/help.js +119 -0
- package/dist/index.js +52 -96
- package/dist/keystore.js +6 -10
- package/dist/mcp.js +59 -39
- package/package.json +7 -7
- package/skills/metal-agent/SKILL.md +53 -27
package/dist/commands/pay.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
117
|
-
|
|
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 (
|
|
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 {
|
|
12
|
-
import {
|
|
13
|
-
|
|
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
|
-
"
|
|
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(
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
|
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${
|
|
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
|
|
20
|
-
export const
|
|
21
|
-
/** The
|
|
22
|
-
export
|
|
23
|
-
|
|
24
|
-
|
|
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 {
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
mandate:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
50
|
-
await
|
|
51
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
}
|