@forestrie/mcp-register 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +126 -2
- package/bin/mcp-register.mjs +2 -0
- package/dist/core/base64.d.ts +7 -0
- package/dist/core/base64.d.ts.map +1 -0
- package/dist/core/base64.js +20 -0
- package/dist/core/base64.js.map +1 -0
- package/dist/core/delegation.d.ts +56 -0
- package/dist/core/delegation.d.ts.map +1 -0
- package/dist/core/delegation.js +130 -0
- package/dist/core/delegation.js.map +1 -0
- package/dist/core/grant.d.ts +41 -0
- package/dist/core/grant.d.ts.map +1 -0
- package/dist/core/grant.js +78 -0
- package/dist/core/grant.js.map +1 -0
- package/dist/core/hex.d.ts +4 -0
- package/dist/core/hex.d.ts.map +1 -0
- package/dist/core/hex.js +18 -0
- package/dist/core/hex.js.map +1 -0
- package/dist/core/index.d.ts +28 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +21 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/issue.d.ts +38 -0
- package/dist/core/issue.d.ts.map +1 -0
- package/dist/core/issue.js +135 -0
- package/dist/core/issue.js.map +1 -0
- package/dist/core/limits.d.ts +9 -0
- package/dist/core/limits.d.ts.map +1 -0
- package/dist/core/limits.js +9 -0
- package/dist/core/limits.js.map +1 -0
- package/dist/core/scrapi.d.ts +66 -0
- package/dist/core/scrapi.d.ts.map +1 -0
- package/dist/core/scrapi.js +174 -0
- package/dist/core/scrapi.js.map +1 -0
- package/dist/core/signer.d.ts +24 -0
- package/dist/core/signer.d.ts.map +1 -0
- package/dist/core/signer.js +2 -0
- package/dist/core/signer.js.map +1 -0
- package/dist/core/statement.d.ts +26 -0
- package/dist/core/statement.d.ts.map +1 -0
- package/dist/core/statement.js +70 -0
- package/dist/core/statement.js.map +1 -0
- package/dist/core/version.d.ts +23 -0
- package/dist/core/version.d.ts.map +1 -0
- package/dist/core/version.js +23 -0
- package/dist/core/version.js.map +1 -0
- package/dist/core/x402.d.ts +99 -0
- package/dist/core/x402.d.ts.map +1 -0
- package/dist/core/x402.js +202 -0
- package/dist/core/x402.js.map +1 -0
- package/dist/net/delegate.d.ts +56 -0
- package/dist/net/delegate.d.ts.map +1 -0
- package/dist/net/delegate.js +182 -0
- package/dist/net/delegate.js.map +1 -0
- package/dist/net/http.d.ts +17 -0
- package/dist/net/http.d.ts.map +1 -0
- package/dist/net/http.js +52 -0
- package/dist/net/http.js.map +1 -0
- package/dist/net/index.d.ts +14 -0
- package/dist/net/index.d.ts.map +1 -0
- package/dist/net/index.js +13 -0
- package/dist/net/index.js.map +1 -0
- package/dist/net/write.d.ts +55 -0
- package/dist/net/write.d.ts.map +1 -0
- package/dist/net/write.js +46 -0
- package/dist/net/write.js.map +1 -0
- package/dist/node/cli.d.ts +8 -0
- package/dist/node/cli.d.ts.map +1 -0
- package/dist/node/cli.js +143 -0
- package/dist/node/cli.js.map +1 -0
- package/dist/node/grant-store.d.ts +49 -0
- package/dist/node/grant-store.d.ts.map +1 -0
- package/dist/node/grant-store.js +95 -0
- package/dist/node/grant-store.js.map +1 -0
- package/dist/node/grant-tools.d.ts +29 -0
- package/dist/node/grant-tools.d.ts.map +1 -0
- package/dist/node/grant-tools.js +498 -0
- package/dist/node/grant-tools.js.map +1 -0
- package/dist/node/ledger.d.ts +26 -0
- package/dist/node/ledger.d.ts.map +1 -0
- package/dist/node/ledger.js +78 -0
- package/dist/node/ledger.js.map +1 -0
- package/dist/node/payer.d.ts +48 -0
- package/dist/node/payer.d.ts.map +1 -0
- package/dist/node/payer.js +170 -0
- package/dist/node/payer.js.map +1 -0
- package/dist/node/resolve-input.d.ts +19 -0
- package/dist/node/resolve-input.d.ts.map +1 -0
- package/dist/node/resolve-input.js +76 -0
- package/dist/node/resolve-input.js.map +1 -0
- package/dist/node/server.d.ts +47 -0
- package/dist/node/server.d.ts.map +1 -0
- package/dist/node/server.js +212 -0
- package/dist/node/server.js.map +1 -0
- package/dist/node/signer-file.d.ts +34 -0
- package/dist/node/signer-file.d.ts.map +1 -0
- package/dist/node/signer-file.js +183 -0
- package/dist/node/signer-file.js.map +1 -0
- package/dist/node/store.d.ts +40 -0
- package/dist/node/store.d.ts.map +1 -0
- package/dist/node/store.js +117 -0
- package/dist/node/store.js.map +1 -0
- package/dist/node/text.d.ts +29 -0
- package/dist/node/text.d.ts.map +1 -0
- package/dist/node/text.js +124 -0
- package/dist/node/text.js.map +1 -0
- package/dist/node/tools.d.ts +118 -0
- package/dist/node/tools.d.ts.map +1 -0
- package/dist/node/tools.js +548 -0
- package/dist/node/tools.js.map +1 -0
- package/package.json +108 -3
- package/src/core/base64.ts +18 -0
- package/src/core/delegation.ts +239 -0
- package/src/core/grant.ts +127 -0
- package/src/core/hex.ts +18 -0
- package/src/core/index.ts +84 -0
- package/src/core/issue.ts +209 -0
- package/src/core/limits.ts +8 -0
- package/src/core/scrapi.ts +236 -0
- package/src/core/signer.ts +23 -0
- package/src/core/statement.ts +133 -0
- package/src/core/version.ts +25 -0
- package/src/core/x402.ts +314 -0
- package/src/net/delegate.ts +274 -0
- package/src/net/http.ts +71 -0
- package/src/net/index.ts +26 -0
- package/src/net/write.ts +124 -0
- package/src/node/cli.ts +170 -0
- package/src/node/grant-store.ts +154 -0
- package/src/node/grant-tools.ts +707 -0
- package/src/node/ledger.ts +98 -0
- package/src/node/payer.ts +241 -0
- package/src/node/resolve-input.ts +96 -0
- package/src/node/server.ts +332 -0
- package/src/node/signer-file.ts +251 -0
- package/src/node/store.ts +175 -0
- package/src/node/text.ts +153 -0
- package/src/node/tools.ts +810 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the payer backend has committed, per UTC day:
|
|
3
|
+
* `<home>/payments/<YYYY-MM-DD>.jsonl`, one line per signed authorisation.
|
|
4
|
+
*
|
|
5
|
+
* An entry is written BEFORE the payment is sent: once signed, an
|
|
6
|
+
* authorisation can be settled whether or not this server ever sees
|
|
7
|
+
* canopy's answer, so the daily cap counts every authorisation signed, not
|
|
8
|
+
* only the ones known to have settled. The cap is approximate under
|
|
9
|
+
* concurrency: two calls can both pass the check before either is written.
|
|
10
|
+
*/
|
|
11
|
+
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { StoreError } from "./store.js";
|
|
14
|
+
|
|
15
|
+
export interface LedgerEntry {
|
|
16
|
+
at: string;
|
|
17
|
+
network: string;
|
|
18
|
+
asset: string;
|
|
19
|
+
amount: string;
|
|
20
|
+
payTo: string;
|
|
21
|
+
from: string;
|
|
22
|
+
nonce: string;
|
|
23
|
+
/** The child log whose grant the payment is for. */
|
|
24
|
+
logId: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class SpendLedger {
|
|
28
|
+
constructor(readonly root: string) {}
|
|
29
|
+
|
|
30
|
+
private file(day: string): string {
|
|
31
|
+
return join(this.root, "payments", `${day}.jsonl`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static day(now: Date): string {
|
|
35
|
+
return now.toISOString().slice(0, 10);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
entries(now: Date): LedgerEntry[] {
|
|
39
|
+
let text: string;
|
|
40
|
+
try {
|
|
41
|
+
text = readFileSync(this.file(SpendLedger.day(now)), "utf8");
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
44
|
+
throw new StoreError(
|
|
45
|
+
"store_failed",
|
|
46
|
+
`cannot read the spend ledger: ${(err as Error).message}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const out: LedgerEntry[] = [];
|
|
50
|
+
for (const line of text.split("\n")) {
|
|
51
|
+
if (line.trim() === "") continue;
|
|
52
|
+
try {
|
|
53
|
+
out.push(JSON.parse(line) as LedgerEntry);
|
|
54
|
+
} catch {
|
|
55
|
+
// Refuse rather than guess: an unreadable line could be a payment.
|
|
56
|
+
throw new StoreError(
|
|
57
|
+
"corrupt_record",
|
|
58
|
+
`the spend ledger ${this.file(SpendLedger.day(now))} has a line that is not JSON; fix or remove it before paying again`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Atomic units committed today on this network and asset. Each nonce
|
|
67
|
+
* counts once: a retry re-signs a grant's payment with the same nonce,
|
|
68
|
+
* and at most one authorisation per nonce can ever settle.
|
|
69
|
+
*/
|
|
70
|
+
spentToday(now: Date, network: string, asset: string): bigint {
|
|
71
|
+
let total = 0n;
|
|
72
|
+
const seen = new Set<string>();
|
|
73
|
+
for (const e of this.entries(now)) {
|
|
74
|
+
if (e.network !== network || e.asset.toLowerCase() !== asset) continue;
|
|
75
|
+
const key = `${e.from.toLowerCase()} ${e.nonce.toLowerCase()}`;
|
|
76
|
+
if (seen.has(key)) continue;
|
|
77
|
+
seen.add(key);
|
|
78
|
+
total += BigInt(e.amount);
|
|
79
|
+
}
|
|
80
|
+
return total;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
record(now: Date, entry: LedgerEntry): void {
|
|
84
|
+
try {
|
|
85
|
+
mkdirSync(join(this.root, "payments"), { recursive: true, mode: 0o700 });
|
|
86
|
+
appendFileSync(
|
|
87
|
+
this.file(SpendLedger.day(now)),
|
|
88
|
+
JSON.stringify(entry) + "\n",
|
|
89
|
+
{ mode: 0o600 },
|
|
90
|
+
);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
throw new StoreError(
|
|
93
|
+
"store_failed",
|
|
94
|
+
`cannot write the spend ledger: ${(err as Error).message}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The payer backend: what pays canopy's 402 when register_grant meets one.
|
|
3
|
+
*
|
|
4
|
+
* Separate from the log key and the authority key (D6): those are ES256 and
|
|
5
|
+
* hold no funds; the payer is an EVM account that holds testnet USDC and
|
|
6
|
+
* signs only standard x402 authorisations (EIP-3009
|
|
7
|
+
* `transferWithAuthorization`, the "exact" scheme). The default backend is
|
|
8
|
+
* a viem account loaded from a local key file. An embedder passes its own
|
|
9
|
+
* `PayerBackend` (a hosted wallet) to `createServer({ payer })`.
|
|
10
|
+
*
|
|
11
|
+
* The default backend builds the x402 v2 payment payload exactly as the
|
|
12
|
+
* upstream client does (`@x402/evm`'s `createEIP3009Payload`), with one
|
|
13
|
+
* difference: the nonce is the one register_grant derives from the grant
|
|
14
|
+
* (`grantPaymentNonce`), where upstream draws a random one and offers no way
|
|
15
|
+
* to supply it. A derived nonce is what makes paying twice for one grant
|
|
16
|
+
* impossible rather than merely avoided. `test/node/payer.test.ts` checks
|
|
17
|
+
* the payload against the upstream client's, field for field.
|
|
18
|
+
*
|
|
19
|
+
* The payer key never crosses the MCP boundary: no tool takes or returns it,
|
|
20
|
+
* the file is refused if it is a symlink or readable by others, and no
|
|
21
|
+
* `{path}` input may read it. The caps are configuration
|
|
22
|
+
* (`readPaymentCaps`), never tool arguments.
|
|
23
|
+
*/
|
|
24
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
25
|
+
import { getAddress } from "viem";
|
|
26
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
27
|
+
import {
|
|
28
|
+
DEFAULT_PAY_ASSET,
|
|
29
|
+
DEFAULT_PAY_NETWORK,
|
|
30
|
+
type PaymentCaps,
|
|
31
|
+
type PaymentOption,
|
|
32
|
+
type PaymentRequired,
|
|
33
|
+
} from "../core/x402.js";
|
|
34
|
+
|
|
35
|
+
export interface PayerBackend {
|
|
36
|
+
/** The paying account, `0x…`: public, safe to report. */
|
|
37
|
+
readonly address: string;
|
|
38
|
+
/** Where the key lives, for humans: never key material. */
|
|
39
|
+
readonly description: string;
|
|
40
|
+
/**
|
|
41
|
+
* Sign a payment for exactly `option` (already checked against the caps)
|
|
42
|
+
* and return the `X-PAYMENT` header value: base64 of the x402 v2 payment
|
|
43
|
+
* payload. The authorisation MUST carry `nonce`; register_grant refuses
|
|
44
|
+
* to send one that does not.
|
|
45
|
+
*/
|
|
46
|
+
pay(request: PayRequest): Promise<string>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface PayRequest {
|
|
50
|
+
required: PaymentRequired;
|
|
51
|
+
option: PaymentOption;
|
|
52
|
+
/** The EIP-3009 nonce, `0x` + 64 hex, derived from the grant. */
|
|
53
|
+
nonce: string;
|
|
54
|
+
/** Seconds since the epoch; validBefore is this plus maxTimeoutSeconds. */
|
|
55
|
+
nowSeconds: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class PayerError extends Error {
|
|
59
|
+
constructor(
|
|
60
|
+
readonly code: "payer_unavailable" | "payer_misconfigured",
|
|
61
|
+
message: string,
|
|
62
|
+
) {
|
|
63
|
+
super(message);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const PRIVATE_KEY = /^0x[0-9a-fA-F]{64}$/;
|
|
68
|
+
const ATOMIC = /^[0-9]{1,78}$/;
|
|
69
|
+
const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Load an EVM private key (`0x` + 64 hex, one line) from `path`. Refused,
|
|
73
|
+
* as the log key is, when the path is a symlink or the file is accessible to
|
|
74
|
+
* group or others.
|
|
75
|
+
*/
|
|
76
|
+
export function openKeyFilePayer(path: string): PayerBackend {
|
|
77
|
+
let stat;
|
|
78
|
+
try {
|
|
79
|
+
stat = lstatSync(path);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw new PayerError(
|
|
82
|
+
"payer_unavailable",
|
|
83
|
+
`cannot open the payer key file ${path}: ${(err as Error).message}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
87
|
+
throw new PayerError(
|
|
88
|
+
"payer_unavailable",
|
|
89
|
+
`the payer key path ${path} is not a regular file (a symlink or directory is refused)`,
|
|
90
|
+
);
|
|
91
|
+
if ((stat.mode & 0o077) !== 0)
|
|
92
|
+
throw new PayerError(
|
|
93
|
+
"payer_unavailable",
|
|
94
|
+
`the payer key file ${path} is accessible to other users (mode ${(stat.mode & 0o777).toString(8)}); chmod 600 it`,
|
|
95
|
+
);
|
|
96
|
+
const text = readFileSync(path, "utf8").trim();
|
|
97
|
+
if (!PRIVATE_KEY.test(text))
|
|
98
|
+
throw new PayerError(
|
|
99
|
+
"payer_unavailable",
|
|
100
|
+
`the payer key file ${path} must hold one EVM private key: 0x followed by 64 hex digits`,
|
|
101
|
+
);
|
|
102
|
+
return evmPayer(text as `0x${string}`, `local payer key file ${path}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const TRANSFER_WITH_AUTHORIZATION = {
|
|
106
|
+
TransferWithAuthorization: [
|
|
107
|
+
{ name: "from", type: "address" },
|
|
108
|
+
{ name: "to", type: "address" },
|
|
109
|
+
{ name: "value", type: "uint256" },
|
|
110
|
+
{ name: "validAfter", type: "uint256" },
|
|
111
|
+
{ name: "validBefore", type: "uint256" },
|
|
112
|
+
{ name: "nonce", type: "bytes32" },
|
|
113
|
+
],
|
|
114
|
+
} as const;
|
|
115
|
+
|
|
116
|
+
/** An EIP-3009 "exact" payer over a viem account. */
|
|
117
|
+
export function evmPayer(
|
|
118
|
+
privateKey: `0x${string}`,
|
|
119
|
+
description: string,
|
|
120
|
+
): PayerBackend {
|
|
121
|
+
const account = privateKeyToAccount(privateKey);
|
|
122
|
+
return {
|
|
123
|
+
address: account.address,
|
|
124
|
+
description,
|
|
125
|
+
pay: async ({ required, option, nonce, nowSeconds }) => {
|
|
126
|
+
const chainId = Number(option.network.split(":")[1]);
|
|
127
|
+
const name = option.extra?.["name"];
|
|
128
|
+
const version = option.extra?.["version"];
|
|
129
|
+
if (!/^eip155:[0-9]+$/.test(option.network))
|
|
130
|
+
throw new Error(`not an EVM network: ${option.network}`);
|
|
131
|
+
if (typeof name !== "string" || typeof version !== "string")
|
|
132
|
+
throw new Error(
|
|
133
|
+
`the payment requirements lack the EIP-712 domain (extra.name, extra.version) for asset ${option.asset}`,
|
|
134
|
+
);
|
|
135
|
+
if (!/^0x[0-9a-f]{64}$/.test(nonce))
|
|
136
|
+
throw new Error(`nonce must be 0x + 64 lowercase hex`);
|
|
137
|
+
const authorization = {
|
|
138
|
+
from: account.address,
|
|
139
|
+
to: getAddress(option.payTo),
|
|
140
|
+
value: option.amount,
|
|
141
|
+
validAfter: "0",
|
|
142
|
+
validBefore: String(nowSeconds + (option.maxTimeoutSeconds ?? 300)),
|
|
143
|
+
nonce,
|
|
144
|
+
};
|
|
145
|
+
const signature = await account.signTypedData({
|
|
146
|
+
domain: {
|
|
147
|
+
name,
|
|
148
|
+
version,
|
|
149
|
+
chainId,
|
|
150
|
+
verifyingContract: getAddress(option.asset),
|
|
151
|
+
},
|
|
152
|
+
types: TRANSFER_WITH_AUTHORIZATION,
|
|
153
|
+
primaryType: "TransferWithAuthorization",
|
|
154
|
+
message: {
|
|
155
|
+
from: authorization.from,
|
|
156
|
+
to: authorization.to,
|
|
157
|
+
value: BigInt(authorization.value),
|
|
158
|
+
validAfter: 0n,
|
|
159
|
+
validBefore: BigInt(authorization.validBefore),
|
|
160
|
+
nonce: nonce as `0x${string}`,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
// The x402 v2 PaymentPayload, in the upstream client's field order.
|
|
164
|
+
const payload = {
|
|
165
|
+
x402Version: 2,
|
|
166
|
+
payload: { authorization, signature },
|
|
167
|
+
...(required.resource !== undefined
|
|
168
|
+
? { resource: required.resource }
|
|
169
|
+
: {}),
|
|
170
|
+
accepted: option,
|
|
171
|
+
};
|
|
172
|
+
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The caps, from configuration. Both amount caps are required once a payer
|
|
179
|
+
* is configured: a payer with no cap is refused, never defaulted.
|
|
180
|
+
*
|
|
181
|
+
* - `MCP_REGISTER_PAY_MAX_PER_CALL`, `MCP_REGISTER_PAY_MAX_PER_DAY`: atomic
|
|
182
|
+
* units of the asset (USDC has 6 decimals: 10000 is $0.01);
|
|
183
|
+
* - `MCP_REGISTER_PAY_NETWORK` (default Base Sepolia, `eip155:84532`) and
|
|
184
|
+
* `MCP_REGISTER_PAY_ASSET` (default Circle's USDC on Base Sepolia);
|
|
185
|
+
* - `MCP_REGISTER_PAY_TO`: optional, the only payee;
|
|
186
|
+
* - `MCP_REGISTER_PAY_ALLOW_MAINNET=1`: pay on a network that is not a
|
|
187
|
+
* testnet. Off by default.
|
|
188
|
+
*/
|
|
189
|
+
export function readPaymentCaps(
|
|
190
|
+
env: Record<string, string | undefined>,
|
|
191
|
+
): PaymentCaps {
|
|
192
|
+
const misconfigured = (message: string) =>
|
|
193
|
+
new PayerError("payer_misconfigured", message);
|
|
194
|
+
const amount = (name: string): bigint => {
|
|
195
|
+
const raw = env[name]?.trim();
|
|
196
|
+
if (!raw)
|
|
197
|
+
throw misconfigured(
|
|
198
|
+
`${name} is required when a payer is configured: the server pays nothing without a cap`,
|
|
199
|
+
);
|
|
200
|
+
if (!ATOMIC.test(raw))
|
|
201
|
+
throw misconfigured(
|
|
202
|
+
`${name} must be a whole number of atomic units (got ${raw})`,
|
|
203
|
+
);
|
|
204
|
+
return BigInt(raw);
|
|
205
|
+
};
|
|
206
|
+
const maxPerCallAtomic = amount("MCP_REGISTER_PAY_MAX_PER_CALL");
|
|
207
|
+
const maxPerDayAtomic = amount("MCP_REGISTER_PAY_MAX_PER_DAY");
|
|
208
|
+
const network =
|
|
209
|
+
env["MCP_REGISTER_PAY_NETWORK"]?.trim() || DEFAULT_PAY_NETWORK;
|
|
210
|
+
if (!/^eip155:[0-9]+$/.test(network))
|
|
211
|
+
throw misconfigured(
|
|
212
|
+
`MCP_REGISTER_PAY_NETWORK must be an EVM CAIP-2 id such as eip155:84532 (got ${network})`,
|
|
213
|
+
);
|
|
214
|
+
const asset = (
|
|
215
|
+
env["MCP_REGISTER_PAY_ASSET"]?.trim() || DEFAULT_PAY_ASSET
|
|
216
|
+
).toLowerCase();
|
|
217
|
+
if (!ADDRESS.test(asset))
|
|
218
|
+
throw misconfigured(
|
|
219
|
+
`MCP_REGISTER_PAY_ASSET must be a 0x-prefixed 20-byte address (got ${asset})`,
|
|
220
|
+
);
|
|
221
|
+
if (
|
|
222
|
+
env["MCP_REGISTER_PAY_ASSET"] === undefined &&
|
|
223
|
+
network !== DEFAULT_PAY_NETWORK
|
|
224
|
+
)
|
|
225
|
+
throw misconfigured(
|
|
226
|
+
`MCP_REGISTER_PAY_ASSET is required with MCP_REGISTER_PAY_NETWORK=${network}: the default asset is USDC on ${DEFAULT_PAY_NETWORK} only`,
|
|
227
|
+
);
|
|
228
|
+
const payTo = env["MCP_REGISTER_PAY_TO"]?.trim();
|
|
229
|
+
if (payTo && !ADDRESS.test(payTo))
|
|
230
|
+
throw misconfigured(
|
|
231
|
+
`MCP_REGISTER_PAY_TO must be a 0x-prefixed 20-byte address (got ${payTo})`,
|
|
232
|
+
);
|
|
233
|
+
return {
|
|
234
|
+
network,
|
|
235
|
+
asset,
|
|
236
|
+
maxPerCallAtomic,
|
|
237
|
+
maxPerDayAtomic,
|
|
238
|
+
...(payTo ? { payTo } : {}),
|
|
239
|
+
allowMainnet: env["MCP_REGISTER_PAY_ALLOW_MAINNET"] === "1",
|
|
240
|
+
};
|
|
241
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `node:fs` boundary. Base64 in, or a path read from disk — nothing
|
|
3
|
+
* else in `src/node` reads a file. Every core and net call in this package
|
|
4
|
+
* takes `Uint8Array`, never a path and never a base64 string; this module
|
|
5
|
+
* is where the MCP wire form of "bytes" (`{ base64 } | { path }`) becomes
|
|
6
|
+
* one, for `tools.ts` to pass on.
|
|
7
|
+
*
|
|
8
|
+
* Validates aggressively rather than trusting a malformed input to fail
|
|
9
|
+
* cleanly downstream: a bad base64 string that `Buffer.from` decodes
|
|
10
|
+
* anyway (silently dropping what it does not understand) would otherwise
|
|
11
|
+
* surface many calls later as a confusing "genesis document malformed" or
|
|
12
|
+
* "receipt malformed" — a typo dressed up as a verification failure.
|
|
13
|
+
* Mirrors `@forestrie/mcp-verify`'s `src/node/resolve-input.ts`. The wire
|
|
14
|
+
* field is `b64`, as the verifier names it; `base64` is accepted as an
|
|
15
|
+
* alias, permanently, because this package documented that name first
|
|
16
|
+
* and a caller who learned either server's shape must not be turned away
|
|
17
|
+
* by the other (plan-2609-08 U2).
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
|
|
21
|
+
export class InputError extends Error {
|
|
22
|
+
readonly code = "invalid_input" as const;
|
|
23
|
+
constructor(message: string) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "InputError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Bytes cross the MCP boundary as base64 — `b64` (the verifier's name) or
|
|
30
|
+
* its alias `base64` — and stdio additionally accepts a path. */
|
|
31
|
+
export type BytesInput =
|
|
32
|
+
{ b64: string } | { base64: string } | { path: string };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Strict base64. `Buffer.from(s, "base64")` silently ignores anything it
|
|
36
|
+
* does not understand, so a plainly wrong string decodes to a few
|
|
37
|
+
* plausible bytes rather than failing here, where the message can still
|
|
38
|
+
* say which field was wrong.
|
|
39
|
+
*/
|
|
40
|
+
function decodeBase64(
|
|
41
|
+
value: string,
|
|
42
|
+
what: string,
|
|
43
|
+
key: "b64" | "base64",
|
|
44
|
+
): Uint8Array {
|
|
45
|
+
const trimmed = value.trim();
|
|
46
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(trimmed) || trimmed.length === 0) {
|
|
47
|
+
throw new InputError(
|
|
48
|
+
`${what}.${key} is not valid base64 (standard alphabet, optional '=' padding)`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
const bytes = new Uint8Array(Buffer.from(trimmed, "base64"));
|
|
52
|
+
if (bytes.length === 0) {
|
|
53
|
+
throw new InputError(`${what}.${key} decoded to 0 bytes`);
|
|
54
|
+
}
|
|
55
|
+
// Round-trip guard: catches truncated / mis-padded input that Buffer would
|
|
56
|
+
// otherwise accept by discarding the incomplete trailing group.
|
|
57
|
+
if (
|
|
58
|
+
Buffer.from(bytes).toString("base64").replace(/=+$/, "") !==
|
|
59
|
+
trimmed.replace(/=+$/, "")
|
|
60
|
+
) {
|
|
61
|
+
throw new InputError(
|
|
62
|
+
`${what}.${key} is not canonical base64 (it does not round-trip)`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return bytes;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Resolve one `{ b64 } | { base64 } | { path }` input to bytes. Throws `InputError`
|
|
69
|
+
* — never a bare `Error` — so a caller can tell "your input was malformed"
|
|
70
|
+
* from a programming error and turn it into a `structuredContent.problem`
|
|
71
|
+
* rather than an uncaught exception. */
|
|
72
|
+
export function resolveBytes(input: BytesInput, what = "input"): Uint8Array {
|
|
73
|
+
if ("b64" in input) {
|
|
74
|
+
return decodeBase64(input.b64, what, "b64");
|
|
75
|
+
}
|
|
76
|
+
if ("base64" in input) {
|
|
77
|
+
return decodeBase64(input.base64, what, "base64");
|
|
78
|
+
}
|
|
79
|
+
if (input.path.trim() === "") {
|
|
80
|
+
throw new InputError(`${what}.path is empty`);
|
|
81
|
+
}
|
|
82
|
+
let bytes: Uint8Array;
|
|
83
|
+
try {
|
|
84
|
+
bytes = new Uint8Array(readFileSync(input.path));
|
|
85
|
+
} catch (err) {
|
|
86
|
+
throw new InputError(
|
|
87
|
+
`cannot read ${what}.path '${input.path}': ${
|
|
88
|
+
err instanceof Error ? err.message : String(err)
|
|
89
|
+
}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (bytes.length === 0) {
|
|
93
|
+
throw new InputError(`${what}.path '${input.path}' is empty (0 bytes)`);
|
|
94
|
+
}
|
|
95
|
+
return bytes;
|
|
96
|
+
}
|