@forum-labs/payfetch 1.0.0 → 1.0.1

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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # payfetch
2
2
 
3
+ [![smithery badge](https://smithery.ai/badge/forum-labs/payfetch)](https://smithery.ai/servers/forum-labs/payfetch)
4
+
3
5
  payfetch lets an AI agent fetch a URL and, when the server answers HTTP 402 (the
4
6
  x402 payment protocol), pay for it automatically, but only within a spending policy
5
7
  you control. It is non-custodial: you bring your own wallet, the key stays on your
@@ -122,8 +124,8 @@ Pay for real with `paid_fetch`:
122
124
  ```
123
125
 
124
126
  `maxAmountUsd` tightens the per-call cap for this one call. It can only lower the
125
- limit, never raise it. If the price is above your approval threshold, a human is
126
- asked to confirm (see Approvals). The result carries the response body, the payment
127
+ limit, never raise it. If the price is above your approval threshold, approval is
128
+ required first (see Approvals). The result carries the response body, the payment
127
129
  outcome and transaction reference, any warnings, and a `receiptId`.
128
130
 
129
131
  ## The spending policy
@@ -172,8 +174,11 @@ allow", and it never widens future authority.
172
174
  - `elicit` (default): the client prompts a human with the host, resource, amount,
173
175
  network and asset, guard results, and today's remaining budgets. They approve once
174
176
  or deny. The prompt times out after 120 seconds and is then treated as a denial.
175
- Some MCP clients cannot service an elicitation prompt (Claude Desktop cancels it
176
- immediately; Claude Code supports it). payfetch tells apart a real human "deny"
177
+ Some MCP clients cannot service an elicitation prompt: as of Claude Code v2.1.198
178
+ and current Claude Desktop, neither does (Claude Code does not advertise the
179
+ elicitation capability; Claude Desktop advertises it but cancels the prompt
180
+ immediately). When a client adds elicitation support, the prompt works with no
181
+ payfetch change. payfetch tells apart a real human "deny"
177
182
  from a client that simply cannot ask, and it never treats "cannot ask" as a silent
178
183
  denial. When a payment is blocked only because the client cannot elicit, the tool
179
184
  result says so and names the ways to allow it.
@@ -373,18 +378,20 @@ Defaults, from `{dataDir}/config.json`, schema `p3f.policy.v1`:
373
378
  | `caps.dailyUsd` | `2.00` | Max per UTC day. |
374
379
  | `caps.perHostDailyUsd` | `1.00` | Max per host per UTC day. |
375
380
  | `caps.totalUsd` | `null` | Optional lifetime cap. |
376
- | `approval.thresholdUsd` | `0.10` | Above this, a human approves. |
381
+ | `approval.thresholdUsd` | `0.10` | Above this, approval is required. |
377
382
  | `approval.mode` | `"elicit"` | `elicit`, `queue`, or `deny`. |
378
383
  | `approval.elicitFallback` | `"deny"` | Used when the client cannot elicit. Fail-closed. |
379
384
  | `approval.preApprovedUpToUsd` | `null` | No-dialog ceiling for above-threshold payments. |
380
385
  | `approval.preApprovedHosts` | `[]` | Hosts pre-approved to auto-pay above threshold. |
381
386
  | `guards.trust.enabled` | `true` | The default-on trust check. |
382
387
  | `guards.trust.mode` | `"advisory"` | `advisory` warns; `enforce` blocks. |
388
+ | `guards.trust.minScore` | `null` | Minimum acceptable TrustScore; below it the guard blocks or warns. `null` uses verdict-based blocking only, and it is ignored when the API returns a null score. |
383
389
  | `guards.trust.blockVerdicts` | `["unreliable"]` | Verdicts that block or warn. |
384
390
  | `guards.trust.blockUnrated` | `false` | `unrated` passes by default. |
385
391
  | `guards.trust.onUnavailable` | `"block"` | Enforce-mode behavior when the guard cannot answer. |
386
392
  | `guards.trust.dailyBudgetUsd` | `0` | 0 means free tier only. |
387
393
  | `guards.safety.enabled` | `false` | Token safety screen; needs `tokenAddress`. |
394
+ | `guards.safety.mode` | `"enforce"` | `advisory` warns; `enforce` blocks. Applies only when the safety guard is enabled. |
388
395
  | `guards.safety.depth` | `"basic"` | `deep` is always paid. |
389
396
  | `guards.safety.blockVerdicts` | `["danger"]` | Token verdicts that block. |
390
397
  | `guards.safety.blockDeployerVerdicts` | `["serial_rugger"]` | Deployer verdicts, deep only. |
@@ -140,4 +140,5 @@ export declare function classifyFromParts(status: number, settlement: {
140
140
  success: boolean;
141
141
  transaction?: string;
142
142
  } | null, _quote: PaymentQuote): TerminalClass;
143
+ export declare function applyJsonContentTypeDefault(headers: Record<string, string>, body: string | null): Record<string, string>;
143
144
  export declare function stripReservedHeaders(headers: RequestInit["headers"]): Record<string, string>;
@@ -155,8 +155,8 @@ export class PayfetchEngine {
155
155
  const policy = pol.policy;
156
156
  const caps = this.capsOf(policy);
157
157
  const method = (init.method ?? "GET").toUpperCase();
158
- const userHeaders = stripReservedHeaders(init.headers);
159
158
  const body = typeof init.body === "string" ? init.body : null;
159
+ const userHeaders = applyJsonContentTypeDefault(stripReservedHeaders(init.headers), body);
160
160
  const responseMode = opts.responseMode ?? "inline";
161
161
  const dry = quoteMode || Boolean(opts.dryRun);
162
162
  const io = this.transportIo();
@@ -739,6 +739,24 @@ function preApprovedNote(policy, host, quote) {
739
739
  function microUsd(x) {
740
740
  return Number.isFinite(x) ? Math.round(x * 1_000_000) : Number.POSITIVE_INFINITY;
741
741
  }
742
+ export function applyJsonContentTypeDefault(headers, body) {
743
+ if (body === null || body.length === 0)
744
+ return headers;
745
+ for (const k of Object.keys(headers)) {
746
+ if (k.toLowerCase() === "content-type")
747
+ return headers;
748
+ }
749
+ let parsed;
750
+ try {
751
+ parsed = JSON.parse(body);
752
+ }
753
+ catch {
754
+ return headers;
755
+ }
756
+ if (typeof parsed !== "object" || parsed === null)
757
+ return headers;
758
+ return { ...headers, "Content-Type": "application/json" };
759
+ }
742
760
  export function stripReservedHeaders(headers) {
743
761
  const reserved = new Set([
744
762
  X_PAYMENT_HEADER.toLowerCase(),
@@ -56,3 +56,6 @@ export { adaptFetch, parseIpv4 };
56
56
  export type { Decision, SpendStatus, FetchOpts };
57
57
  export type { Policy } from "./core/policy.js";
58
58
  export type { Receipt } from "./core/ledger.js";
59
+ export { buildFromEnv, realConfigIo } from "./config.js";
60
+ export type { ConfigIo, EnvRecord } from "./config.js";
61
+ export { policyLockNotice, paymentRejectedHint } from "./mcp/tools.js";
package/dist/src/index.js CHANGED
@@ -149,3 +149,5 @@ function createPinnedHttpClient(resolve, policy) {
149
149
  return client;
150
150
  }
151
151
  export { adaptFetch, parseIpv4 };
152
+ export { buildFromEnv, realConfigIo } from "./config.js";
153
+ export { policyLockNotice, paymentRejectedHint } from "./mcp/tools.js";
@@ -7,11 +7,11 @@ export declare const TOOL_NAMES: {
7
7
  readonly APPROVE_PENDING: "approve_pending";
8
8
  };
9
9
  export declare const TOOL_DESCRIPTIONS: {
10
- readonly paid_fetch: "Fetch a URL, automatically paying if it requires payment (HTTP 402, x402 protocol) — within the operator's spending policy. Free URLs are fetched normally at no cost. Use payment_quote first if you only want to know the price. Payments above the operator's approval threshold will ask the human for confirmation.";
11
- readonly payment_quote: "Check what a paid URL costs and whether the current spending policy would allow paying it, WITHOUT paying. Returns the price, payment terms, trust-check results, and the policy decision.";
12
- readonly spend_status: "Show today's agent spending: totals, remaining budgets overall and per host, active holds, and recent payments.";
13
- readonly list_receipts: "Query the local payment receipt ledger (audit trail). Filter by time, host, or outcome.";
14
- readonly approve_pending: "List or resolve payments waiting for human approval (queue mode). Approving grants a one-time re-run permission for that exact payment.";
10
+ readonly paid_fetch: "Fetch a URL, automatically paying if it requires payment (HTTP 402, x402 protocol) — within the operator's spending policy. Free URLs are fetched normally at no cost. Use payment_quote first if you only want to know the price. Payments above the operator's approval threshold will ask the human for confirmation. Spending policy is operator-owned config; no tool can widen it.";
11
+ readonly payment_quote: "Check what a paid URL costs and whether the current spending policy would allow paying it, WITHOUT paying. Returns the price, payment terms, trust-check results, and the policy decision. Spending policy is operator-owned config; no tool can widen it.";
12
+ readonly spend_status: "Show today's agent spending: totals, remaining budgets overall and per host, active holds, and recent payments. Spending policy is operator-owned config; no tool can widen it.";
13
+ readonly list_receipts: "Query the local payment receipt ledger (audit trail). Filter by time, host, or outcome. Spending policy is operator-owned config; no tool can widen it.";
14
+ readonly approve_pending: "List or resolve payments waiting for human approval (queue mode). Approving grants a one-time re-run permission for that exact payment. It only resolves payments already queued for approval; it cannot change spending policy — no payfetch tool can widen operator-owned limits.";
15
15
  };
16
16
  export type JsonSchema = {
17
17
  type: "object";
@@ -31,6 +31,7 @@ export type ToolDefinition = {
31
31
  };
32
32
  export declare const PAYFETCH_TOOLS: readonly ToolDefinition[];
33
33
  export declare function policyLockNotice(dataDir: string): string;
34
+ export declare function paymentRejectedHint(status: number): string;
34
35
  export declare class UnknownToolError extends Error {
35
36
  readonly toolName: string;
36
37
  constructor(toolName: string);
@@ -8,11 +8,11 @@ export const TOOL_NAMES = {
8
8
  APPROVE_PENDING: "approve_pending",
9
9
  };
10
10
  export const TOOL_DESCRIPTIONS = {
11
- paid_fetch: "Fetch a URL, automatically paying if it requires payment (HTTP 402, x402 protocol) — within the operator's spending policy. Free URLs are fetched normally at no cost. Use payment_quote first if you only want to know the price. Payments above the operator's approval threshold will ask the human for confirmation.",
12
- payment_quote: "Check what a paid URL costs and whether the current spending policy would allow paying it, WITHOUT paying. Returns the price, payment terms, trust-check results, and the policy decision.",
13
- spend_status: "Show today's agent spending: totals, remaining budgets overall and per host, active holds, and recent payments.",
14
- list_receipts: "Query the local payment receipt ledger (audit trail). Filter by time, host, or outcome.",
15
- approve_pending: "List or resolve payments waiting for human approval (queue mode). Approving grants a one-time re-run permission for that exact payment.",
11
+ paid_fetch: "Fetch a URL, automatically paying if it requires payment (HTTP 402, x402 protocol) — within the operator's spending policy. Free URLs are fetched normally at no cost. Use payment_quote first if you only want to know the price. Payments above the operator's approval threshold will ask the human for confirmation. Spending policy is operator-owned config; no tool can widen it.",
12
+ payment_quote: "Check what a paid URL costs and whether the current spending policy would allow paying it, WITHOUT paying. Returns the price, payment terms, trust-check results, and the policy decision. Spending policy is operator-owned config; no tool can widen it.",
13
+ spend_status: "Show today's agent spending: totals, remaining budgets overall and per host, active holds, and recent payments. Spending policy is operator-owned config; no tool can widen it.",
14
+ list_receipts: "Query the local payment receipt ledger (audit trail). Filter by time, host, or outcome. Spending policy is operator-owned config; no tool can widen it.",
15
+ approve_pending: "List or resolve payments waiting for human approval (queue mode). Approving grants a one-time re-run permission for that exact payment. It only resolves payments already queued for approval; it cannot change spending policy — no payfetch tool can widen operator-owned limits.",
16
16
  };
17
17
  const LIST_RECEIPTS_DEFAULT_LIMIT = 50;
18
18
  const LIST_RECEIPTS_MAX_LIMIT = 200;
@@ -26,7 +26,11 @@ export const PAID_FETCH_INPUT_SCHEMA = {
26
26
  method: { enum: [...HTTP_METHOD_ENUM], default: "GET" },
27
27
  headers: { type: "object", additionalProperties: { type: "string" } },
28
28
  body: { type: "string" },
29
- maxAmountUsd: { type: "number", minimum: 0 },
29
+ maxAmountUsd: {
30
+ type: "number",
31
+ minimum: 0,
32
+ description: "Tightens the per-call cap for this one call. Can only LOWER the limit, never raise it.",
33
+ },
30
34
  dryRun: { type: "boolean", default: false },
31
35
  responseMode: { enum: [...RESPONSE_MODE_ENUM], default: "inline" },
32
36
  tokenAddress: { type: "string" },
@@ -103,6 +107,12 @@ export const PAYFETCH_TOOLS = [
103
107
  export function policyLockNotice(dataDir) {
104
108
  return `Spending limits and lists are set by the operator in ${dataDir}/config.json — they cannot be changed from this session.`;
105
109
  }
110
+ export function paymentRejectedHint(status) {
111
+ return (`The server rejected the paid request (HTTP ${status}) — this usually means the request ` +
112
+ `was malformed, so check the request headers and body (for example, a JSON body needs a ` +
113
+ `Content-Type: application/json header). This does NOT confirm settlement: you were ` +
114
+ `charged only if a settlement transaction is shown.`);
115
+ }
106
116
  export class UnknownToolError extends Error {
107
117
  toolName;
108
118
  constructor(toolName) {
@@ -201,6 +211,11 @@ async function mapPaidFetchResult(dataDir, receipt, response, responseMode) {
201
211
  (receipt.notes.includes("elicit_unsupported") || receipt.notes.includes("elicit_cancelled"))) {
202
212
  denied.approvalGuidance = elicitBlockedGuidance(dataDir);
203
213
  }
214
+ if (receipt.outcome === "payment_rejected") {
215
+ const st = receipt.http?.status ?? null;
216
+ if (st !== null && st >= 400 && st < 500)
217
+ denied.hint = paymentRejectedHint(st);
218
+ }
204
219
  return denied;
205
220
  }
206
221
  function elicitBlockedGuidance(dataDir) {
@@ -2,8 +2,8 @@
2
2
  "manifest_version": "0.2",
3
3
  "name": "payfetch",
4
4
  "display_name": "payfetch",
5
- "version": "1.0.0",
6
- "description": "Buy-side paying-fetch client: fetch URLs and pay HTTP 402 (x402) paywalls automatically, under operator-owned spending policy (caps, allow/deny lists, human approval, trust checks, receipts). Non-custodial bring your own wallet.",
5
+ "version": "1.0.1",
6
+ "description": "Buy-side paying-fetch client: fetch URLs and pay HTTP 402 (x402) paywalls automatically, under operator-owned spending policy (caps, allow/deny lists, human approval, trust checks, receipts). Non-custodial \u2014 bring your own wallet.",
7
7
  "long_description": "payfetch lets an AI agent fetch a URL and, if it returns HTTP 402 (x402 protocol), pay for it automatically WITHIN a policy the operator controls: per-call / per-day / per-host / lifetime spend caps, host allow/deny lists, a human-approval threshold, a default-ON trust check against the Forum Labs trust API, and an immutable local receipt for every attempt. The agent can never raise its own limits. Keys are never transmitted or logged. Directory submission is not offered (payment connectors are policy-blocked); this bundle is for MANUAL install only.",
8
8
  "author": {
9
9
  "name": "Forum Labs"
@@ -13,7 +13,9 @@
13
13
  "entry_point": "server/payfetch-mcp.mjs",
14
14
  "mcp_config": {
15
15
  "command": "node",
16
- "args": ["${__dirname}/server/payfetch-mcp.mjs"],
16
+ "args": [
17
+ "${__dirname}/server/payfetch-mcp.mjs"
18
+ ],
17
19
  "env": {
18
20
  "PAYFETCH_PRIVATE_KEY": "${user_config.private_key}",
19
21
  "PAYFETCH_KEY_FILE": "${user_config.key_file}",
@@ -29,24 +31,39 @@
29
31
  }
30
32
  },
31
33
  "tools": [
32
- { "name": "paid_fetch", "description": "Fetch a URL, paying an HTTP 402 (x402) paywall automatically within policy." },
33
- { "name": "payment_quote", "description": "Check what a paid URL costs and whether policy would allow paying it, without paying." },
34
- { "name": "spend_status", "description": "Show today's spending: totals, remaining budgets, holds, recent payments." },
35
- { "name": "list_receipts", "description": "Query the local payment receipt ledger (audit trail)." },
36
- { "name": "approve_pending", "description": "List or resolve payments waiting for human approval (queue mode)." }
34
+ {
35
+ "name": "paid_fetch",
36
+ "description": "Fetch a URL, paying an HTTP 402 (x402) paywall automatically within policy."
37
+ },
38
+ {
39
+ "name": "payment_quote",
40
+ "description": "Check what a paid URL costs and whether policy would allow paying it, without paying."
41
+ },
42
+ {
43
+ "name": "spend_status",
44
+ "description": "Show today's spending: totals, remaining budgets, holds, recent payments."
45
+ },
46
+ {
47
+ "name": "list_receipts",
48
+ "description": "Query the local payment receipt ledger (audit trail)."
49
+ },
50
+ {
51
+ "name": "approve_pending",
52
+ "description": "List or resolve payments waiting for human approval (queue mode)."
53
+ }
37
54
  ],
38
55
  "user_config": {
39
56
  "private_key": {
40
57
  "type": "string",
41
58
  "title": "Wallet private key (0x-hex)",
42
- "description": "Signer option 1 of 3. A 0x-hex EVM private key. SECRET stored in the OS keychain, never transmitted or logged. Use a DEDICATED low-balance wallet. Set exactly ONE signer option.",
59
+ "description": "Signer option 1 of 3. A 0x-hex EVM private key. SECRET \u2014 stored in the OS keychain, never transmitted or logged. Use a DEDICATED low-balance wallet. Set exactly ONE signer option.",
43
60
  "sensitive": true,
44
61
  "required": false
45
62
  },
46
63
  "key_file": {
47
64
  "type": "file",
48
65
  "title": "Wallet key file",
49
- "description": "Signer option 1 (alt): path to a file containing a 0x-hex private key. REFUSED at startup if the file is group/world-readable chmod 600 it.",
66
+ "description": "Signer option 1 (alt): path to a file containing a 0x-hex private key. REFUSED at startup if the file is group/world-readable \u2014 chmod 600 it.",
50
67
  "required": false
51
68
  },
52
69
  "cdp_api_key_id": {
@@ -59,14 +76,14 @@
59
76
  "cdp_api_key_secret": {
60
77
  "type": "string",
61
78
  "title": "CDP API key secret",
62
- "description": "Coinbase CDP API key secret. SECRET stored in the OS keychain.",
79
+ "description": "Coinbase CDP API key secret. SECRET \u2014 stored in the OS keychain.",
63
80
  "sensitive": true,
64
81
  "required": false
65
82
  },
66
83
  "cdp_wallet_secret": {
67
84
  "type": "string",
68
85
  "title": "CDP wallet secret",
69
- "description": "Coinbase CDP wallet secret. SECRET stored in the OS keychain.",
86
+ "description": "Coinbase CDP wallet secret. SECRET \u2014 stored in the OS keychain.",
70
87
  "sensitive": true,
71
88
  "required": false
72
89
  },
@@ -91,7 +108,7 @@
91
108
  "approver": {
92
109
  "type": "string",
93
110
  "title": "Approval authority (set to 1 to enable)",
94
- "description": "Set to 1 to grant this session approval authority. NOTE: for safety, the server REFUSES TO START if this is set together with a queue-capable approval mode (approval.mode 'queue', or 'elicit' with elicitFallback 'queue') the agent must not approve its own payments. The supported human gate is elicit-to-human. Blank = list-only.",
111
+ "description": "Set to 1 to grant this session approval authority. NOTE: for safety, the server REFUSES TO START if this is set together with a queue-capable approval mode (approval.mode 'queue', or 'elicit' with elicitFallback 'queue') \u2014 the agent must not approve its own payments. The supported human gate is elicit-to-human. Blank = list-only.",
95
112
  "required": false
96
113
  },
97
114
  "via": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forum-labs/payfetch",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "mcpName": "io.github.forum-labs/payfetch",
5
5
  "description": "Buy-side x402/HTTP-402 paying-fetch client for AI agents, under operator-owned spending policy (caps, allow/deny, human approval, trust checks, local receipts). Non-custodial.",
6
6
  "keywords": [
@@ -67,6 +67,6 @@
67
67
  "@modelcontextprotocol/sdk": "1.29.0",
68
68
  "@x402/core": "2.17.0",
69
69
  "undici": "8.5.0",
70
- "viem": "2.54.1"
70
+ "viem": "2.54.6"
71
71
  }
72
72
  }