@paidfor/mcp 0.5.0 → 0.7.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/dist/team.d.ts ADDED
@@ -0,0 +1,80 @@
1
+ import type { Receipt } from "@paidfor/x402-safe-fetch";
2
+ export declare const AUTHORIZE_TIMEOUT_MS = 10000;
3
+ export declare const REPORT_TIMEOUT_MS = 5000;
4
+ export type AuthorizeBody = {
5
+ wallet: string;
6
+ intent_id: string;
7
+ resource_url: string;
8
+ pay_to: string;
9
+ amount_atomic: string;
10
+ network: string;
11
+ asset: string;
12
+ scheme: string;
13
+ protocol: "x402";
14
+ };
15
+ export type TeamDecision = {
16
+ kind: "approve";
17
+ auth_ref: string;
18
+ valid_before: number;
19
+ } | {
20
+ kind: "decline";
21
+ reason: string;
22
+ message: string;
23
+ retry_after?: number;
24
+ } | {
25
+ kind: "needs_approval";
26
+ approve_url: string;
27
+ expires_at: string;
28
+ message: string;
29
+ } | {
30
+ kind: "already_settled";
31
+ settlement_ref: string;
32
+ outcome: string;
33
+ message: string;
34
+ } | {
35
+ kind: "failed";
36
+ message: string;
37
+ };
38
+ export type OutcomeBody = {
39
+ wallet: string;
40
+ intent_id: string;
41
+ auth_ref: string;
42
+ paid: boolean;
43
+ settlement_ref?: string;
44
+ outcome?: string;
45
+ http_status?: number;
46
+ response_sha256?: string;
47
+ };
48
+ /** sent: the server has it. queued: kept on disk, sent again before the next payment. rejected: the server refused it for good. */
49
+ export type ReportState = "sent" | "queued" | "rejected";
50
+ /** What to report for a payment that was approved, from its receipt. */
51
+ export declare function outcomeFor(r: Receipt, wallet: string, authRef: string): OutcomeBody;
52
+ /** Exported for tests: a 200's body as a decision. Anything not exactly one of the contract's answers is "failed". */
53
+ export declare function readDecision(b: unknown): TeamDecision;
54
+ export declare class TeamClient {
55
+ private readonly base;
56
+ private readonly key;
57
+ private readonly queueDir;
58
+ private readonly fetchFn;
59
+ private readonly warn;
60
+ private readonly retryDelayMs;
61
+ /** The key travels in a header. Plain http is for a server on this machine only. */
62
+ private readonly keySafe;
63
+ constructor(o: {
64
+ baseUrl: string;
65
+ teamKey: string;
66
+ dir: string;
67
+ fetch?: typeof globalThis.fetch;
68
+ warn?: (m: string) => void;
69
+ retryDelayMs?: number;
70
+ });
71
+ private post;
72
+ /** Never throws: anything but a 200 with a readable decision is { kind: "failed" }. */
73
+ authorize(body: AuthorizeBody): Promise<TeamDecision>;
74
+ /** One try. "retry" means the server could not take it now; a 4xx will never be taken, so it is not kept. */
75
+ private send;
76
+ private queueFile;
77
+ report(body: OutcomeBody): Promise<ReportState>;
78
+ /** Send what earlier reports left behind. Stops at the first one the server cannot take now. Never throws. */
79
+ flush(): Promise<void>;
80
+ }
package/dist/team.js ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Team mode: the two calls pay_x402 makes around a payment (docs/team-api.md). `authorize` asks before signing and
3
+ * turns anything but a clean decision into "failed", which pays nothing. `report` says what happened afterwards; a
4
+ * report the server could not take is kept on disk and sent again, because an approval nobody reports keeps counting
5
+ * against the budgets. Only pay.ts imports this module.
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ import { mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ export const AUTHORIZE_TIMEOUT_MS = 10_000;
11
+ export const REPORT_TIMEOUT_MS = 5_000;
12
+ /** The words the server takes for a paid outcome. */
13
+ const PAID_OUTCOMES = ["ok", "non_delivery", "wrong_goods", "overcharge", "duplicate", "paid_no_response"];
14
+ /** What to report for a payment that was approved, from its receipt. */
15
+ export function outcomeFor(r, wallet, authRef) {
16
+ const base = { wallet, intent_id: r.intent_id, auth_ref: authRef };
17
+ // Nothing left this machine, or the seller says its settlement failed: the money did not move.
18
+ if (!r.paid || r.settlement.parsed?.success === false)
19
+ return { ...base, paid: false };
20
+ return {
21
+ ...base,
22
+ paid: true,
23
+ // With no transaction hash the signed nonce is what finds the settlement on chain.
24
+ settlement_ref: r.tx_hash ?? r.nonce ?? authRef,
25
+ outcome: r.reported_outcome && PAID_OUTCOMES.includes(r.reported_outcome) ? r.reported_outcome : "non_delivery",
26
+ ...(r.response ? { http_status: r.response.status } : {}),
27
+ ...(r.response?.sha256 ? { response_sha256: r.response.sha256 } : {}),
28
+ };
29
+ }
30
+ const str = (v) => typeof v === "string" && v.length > 0;
31
+ function failedStatus(status, body) {
32
+ const said = body && typeof body === "object" && str(body.error) ? ` ${body.error.slice(0, 300)}` : "";
33
+ if (status === 401)
34
+ return { kind: "failed", message: `paidfor refused the team key; check PAIDFOR_TEAM_KEY.${said}` };
35
+ if (status === 404)
36
+ return { kind: "failed", message: `this wallet is not registered to the team; run: paidfor-mcp wallet register.${said}` };
37
+ return { kind: "failed", message: `paidfor answered HTTP ${status}.${said}` };
38
+ }
39
+ /** Exported for tests: a 200's body as a decision. Anything not exactly one of the contract's answers is "failed". */
40
+ export function readDecision(b) {
41
+ const bad = { kind: "failed", message: "paidfor's answer could not be read." };
42
+ if (!b || typeof b !== "object")
43
+ return bad;
44
+ const d = b;
45
+ const message = str(d.message) ? d.message : "";
46
+ switch (d.decision) {
47
+ case "approve":
48
+ return str(d.auth_ref) && typeof d.valid_before === "number" ? { kind: "approve", auth_ref: d.auth_ref, valid_before: d.valid_before } : bad;
49
+ case "decline":
50
+ return str(d.reason) ? { kind: "decline", reason: d.reason, message: message || d.reason, ...(typeof d.retry_after === "number" ? { retry_after: d.retry_after } : {}) } : bad;
51
+ case "needs_approval":
52
+ return str(d.approve_url) && str(d.expires_at) ? { kind: "needs_approval", approve_url: d.approve_url, expires_at: d.expires_at, message } : bad;
53
+ case "already_settled":
54
+ return { kind: "already_settled", settlement_ref: str(d.settlement_ref) ? d.settlement_ref : "unknown", outcome: str(d.outcome) ? d.outcome : "unknown", message };
55
+ default:
56
+ return bad;
57
+ }
58
+ }
59
+ export class TeamClient {
60
+ base;
61
+ key;
62
+ queueDir;
63
+ fetchFn;
64
+ warn;
65
+ retryDelayMs;
66
+ /** The key travels in a header. Plain http is for a server on this machine only. */
67
+ keySafe;
68
+ constructor(o) {
69
+ this.base = o.baseUrl.replace(/\/$/, "");
70
+ this.key = o.teamKey;
71
+ this.queueDir = join(o.dir, "team-outcomes");
72
+ this.fetchFn = o.fetch ?? globalThis.fetch;
73
+ this.warn = o.warn ?? (() => undefined);
74
+ this.retryDelayMs = o.retryDelayMs ?? 250;
75
+ const u = new URL(this.base);
76
+ this.keySafe = u.protocol === "https:" || ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname);
77
+ }
78
+ post(path, body, timeoutMs) {
79
+ return this.fetchFn(`${this.base}${path}`, {
80
+ method: "POST",
81
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.key}` },
82
+ body: JSON.stringify(body),
83
+ redirect: "error", // the key goes to the configured origin and nowhere else
84
+ signal: AbortSignal.timeout(timeoutMs),
85
+ });
86
+ }
87
+ /** Never throws: anything but a 200 with a readable decision is { kind: "failed" }. */
88
+ async authorize(body) {
89
+ if (!this.keySafe)
90
+ return { kind: "failed", message: `refusing to send the team key to ${this.base}; use https.` };
91
+ let res;
92
+ try {
93
+ res = await this.post("/v1/team/authorize", body, AUTHORIZE_TIMEOUT_MS);
94
+ }
95
+ catch {
96
+ return { kind: "failed", message: "paidfor could not be reached." };
97
+ }
98
+ let parsed = null;
99
+ try {
100
+ parsed = await res.json();
101
+ }
102
+ catch {
103
+ /* an unreadable body is handled below */
104
+ }
105
+ return res.status === 200 ? readDecision(parsed) : failedStatus(res.status, parsed);
106
+ }
107
+ /** One try. "retry" means the server could not take it now; a 4xx will never be taken, so it is not kept. */
108
+ async send(body) {
109
+ if (!this.keySafe)
110
+ return "retry";
111
+ try {
112
+ const res = await this.post("/v1/team/outcome", body, REPORT_TIMEOUT_MS);
113
+ void res.body?.cancel().catch(() => undefined);
114
+ if (res.ok)
115
+ return "sent";
116
+ if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) {
117
+ this.warn(`paidfor-mcp: the team outcome for intent ${body.intent_id} was refused with HTTP ${res.status}`);
118
+ return "rejected";
119
+ }
120
+ return "retry";
121
+ }
122
+ catch {
123
+ return "retry";
124
+ }
125
+ }
126
+ queueFile(body) {
127
+ return join(this.queueDir, `${createHash("sha256").update(`${body.intent_id}\n${body.auth_ref}`).digest("hex").slice(0, 40)}.json`);
128
+ }
129
+ async report(body) {
130
+ let r = await this.send(body);
131
+ if (r === "retry") {
132
+ await new Promise((d) => setTimeout(d, this.retryDelayMs));
133
+ r = await this.send(body);
134
+ }
135
+ if (r !== "retry")
136
+ return r;
137
+ try {
138
+ await mkdir(this.queueDir, { recursive: true, mode: 0o700 });
139
+ const file = this.queueFile(body);
140
+ await writeFile(`${file}.tmp`, JSON.stringify(body), { mode: 0o600 });
141
+ await rename(`${file}.tmp`, file);
142
+ }
143
+ catch (e) {
144
+ this.warn(`paidfor-mcp: could not keep the team outcome for intent ${body.intent_id}: ${e instanceof Error ? e.message : String(e)}`);
145
+ }
146
+ return "queued";
147
+ }
148
+ /** Send what earlier reports left behind. Stops at the first one the server cannot take now. Never throws. */
149
+ async flush() {
150
+ let names;
151
+ try {
152
+ names = (await readdir(this.queueDir)).filter((n) => n.endsWith(".json"));
153
+ }
154
+ catch {
155
+ return; // nothing queued yet
156
+ }
157
+ for (const name of names) {
158
+ const file = join(this.queueDir, name);
159
+ try {
160
+ const r = await this.send(JSON.parse(await readFile(file, "utf8")));
161
+ if (r === "retry")
162
+ return;
163
+ await unlink(file);
164
+ }
165
+ catch (e) {
166
+ this.warn(`paidfor-mcp: could not resend ${name}: ${e instanceof Error ? e.message : String(e)}`);
167
+ }
168
+ }
169
+ }
170
+ }
package/dist/wallet.d.ts CHANGED
@@ -26,8 +26,17 @@ export declare function newWallet(home?: string): Promise<{
26
26
  export declare const defaultKeyFile: (home?: string) => string;
27
27
  /** paidfor refused or could not answer a registration. The message never includes a key. */
28
28
  export declare class RegisterError extends Error {
29
+ readonly status?: number | undefined;
29
30
  name: string;
31
+ /** Builds the error; `status` is paidfor's HTTP status when it answered (401: the team key was refused). */
32
+ constructor(message: string, status?: number | undefined);
30
33
  }
34
+ /**
35
+ * Team keys and link codes travel to paidfor in headers and bodies. Plain http is for a server on this machine only;
36
+ * anything else is refused before a request is made.
37
+ */
38
+ export declare function assertKeySafeBase(base: string): void;
39
+ /** What registerWallet reports: the address and whether it was already there, moved or starts paused. */
31
40
  export type Registered = {
32
41
  address: `0x${string}`;
33
42
  already: boolean;
@@ -46,6 +55,7 @@ export declare function registerWallet(o: {
46
55
  env?: Env;
47
56
  home?: string;
48
57
  fetchFn?: typeof fetch;
58
+ link?: string;
49
59
  }): Promise<Registered>;
50
60
  export type Limits = {
51
61
  maxPerCallUsd: number;
package/dist/wallet.js CHANGED
@@ -76,7 +76,23 @@ export async function newWallet(home = homedir()) {
76
76
  export const defaultKeyFile = (home = homedir()) => join(paidforDir(home), "wallet.key");
77
77
  /** paidfor refused or could not answer a registration. The message never includes a key. */
78
78
  export class RegisterError extends Error {
79
+ status;
79
80
  name = "RegisterError";
81
+ /** Builds the error; `status` is paidfor's HTTP status when it answered (401: the team key was refused). */
82
+ constructor(message, status) {
83
+ super(message);
84
+ this.status = status;
85
+ }
86
+ }
87
+ /**
88
+ * Team keys and link codes travel to paidfor in headers and bodies. Plain http is for a server on this machine only;
89
+ * anything else is refused before a request is made.
90
+ */
91
+ export function assertKeySafeBase(base) {
92
+ const u = new URL(base);
93
+ if (u.protocol !== "https:" && !["localhost", "127.0.0.1", "[::1]"].includes(u.hostname)) {
94
+ throw new RegisterError(`refusing to send a team key over ${u.protocol.replace(":", "")} to ${u.hostname}; use https`);
95
+ }
80
96
  }
81
97
  /**
82
98
  * `paidfor-mcp wallet register`: proves to a paidfor team that this wallet is the agent's, by signing the challenge
@@ -97,11 +113,8 @@ export async function registerWallet(o) {
97
113
  throw none;
98
114
  const f = o.fetchFn ?? fetch;
99
115
  const base = o.baseUrl.replace(/\/$/, "");
100
- // The team key travels in a header. Plain http is for a server on this machine only.
101
- const u = new URL(base);
102
- if (u.protocol !== "https:" && !["localhost", "127.0.0.1", "[::1]"].includes(u.hostname)) {
103
- throw new RegisterError(`refusing to send a team key over ${u.protocol.replace(":", "")} to ${u.hostname}; use https`);
104
- }
116
+ assertKeySafeBase(base);
117
+ /** POSTs to paidfor with the team key; a non-2xx answer is a RegisterError carrying the status. */
105
118
  const post = async (path, body) => {
106
119
  let res;
107
120
  try {
@@ -112,14 +125,16 @@ export async function registerWallet(o) {
112
125
  }
113
126
  const j = (await res.json().catch(() => ({})));
114
127
  if (!res.ok)
115
- throw new RegisterError(`paidfor answered ${res.status}${typeof j.error === "string" ? `: ${j.error}` : ""}`);
128
+ throw new RegisterError(`paidfor answered ${res.status}${typeof j.error === "string" ? `: ${j.error}` : ""}`, res.status);
116
129
  return j;
117
130
  };
118
131
  const c = await post("/v1/team/wallets/challenge", { address: loaded.account.address });
119
132
  if (typeof c.message !== "string" || typeof c.token !== "string")
120
133
  throw new RegisterError("paidfor did not return a challenge to sign");
121
134
  const signature = await loaded.account.signMessage({ message: c.message });
122
- const r = await post("/v1/team/wallets", { message: c.message, token: c.token, signature, label: o.label ?? "" });
135
+ // `link`: the device code of a link a person approved in the browser for this address (`paidfor-mcp init`), so the
136
+ // server can start the wallet unpaused. It is a secret, like the team key, and goes only in this body.
137
+ const r = await post("/v1/team/wallets", { message: c.message, token: c.token, signature, label: o.label ?? "", ...(o.link ? { link: o.link } : {}) });
123
138
  const w = (r.wallet ?? {});
124
139
  return { address: (w.address ?? loaded.account.address), already: r.already === true, moved: r.moved === true, paused: w.rules?.paused === true };
125
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paidfor/mcp",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "mcpName": "dev.paidfor/paidfor",
5
5
  "description": "MCP server for the paidfor.dev x402 seller index: check_x402_seller, list_reliable_sellers and recent_changes, plus pay_x402 (stdio, your own wallet)",
6
6
  "license": "MIT",
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@modelcontextprotocol/sdk": "^1.32.2",
28
- "@paidfor/x402-safe-fetch": "0.2.0",
28
+ "@paidfor/x402-safe-fetch": "0.4.0",
29
29
  "viem": "^2.57.13",
30
30
  "zod": "^4.0.0"
31
31
  },