@paidfor/mcp 0.2.1 → 0.3.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 +42 -2
- package/dist/http.d.ts +1 -0
- package/dist/http.js +49 -0
- package/dist/index.js +2 -2
- package/dist/pay.d.ts +49 -0
- package/dist/pay.js +166 -0
- package/dist/rules.d.ts +33 -0
- package/dist/rules.js +41 -0
- package/dist/server.d.ts +12 -4
- package/dist/server.js +47 -16
- package/dist/stdio.js +46 -3
- package/dist/wallet.d.ts +41 -0
- package/dist/wallet.js +119 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -4,14 +4,17 @@ MCP server for [paidfor.dev](https://paidfor.dev), the x402 seller failure index
|
|
|
4
4
|
|
|
5
5
|
**No account, no key.** Everything that reads the index or contributes data (the site, the API, the MCP tools, the client, telemetry and the changes feed) needs no account and no key, now and later. An email address is asked for only to deliver alerts or to join a waitlist, as a confirmation link, never a profile.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Three read tools on every transport, plus two paying tools on stdio only when a wallet is configured:
|
|
8
8
|
|
|
9
9
|
- `check_x402_seller({ url })`: verdict (`avoid`, `caution`, `ok`, `unknown`) and one sentence for an x402 resource URL or seller domain, with the seller's paid-call count, failure rate and 95% interval, the resource's listed price, modal quote and max overcharge ratio when known, and links to the seller page and raw evidence.
|
|
10
10
|
- `list_reliable_sellers({ category?, limit? })`: zero-failure sellers with at least 30 paid calls.
|
|
11
|
+
- `recent_changes({ host?, since? })`: what changed, newest first (verdicts, new failures, moved quotes, pay-to addresses, replies, the operator series maturing or disagreeing); called when the user asks what changed or before relying on a verdict older than a day. Reads `GET https://paidfor.dev/v1/changes`.
|
|
12
|
+
- `pay_x402({ url, method?, headers?, body?, intent_id?, max_usd? })` (stdio, wallet configured): pays through [`@paidfor/x402-safe-fetch`](https://www.npmjs.com/package/@paidfor/x402-safe-fetch) with your own wallet. See "Let your agent pay, safely" below.
|
|
13
|
+
- `wallet_status()` (stdio, wallet configured): the paying address, its USDC balance on Base, the limits and today's spend. Never the key.
|
|
11
14
|
|
|
12
15
|
On initialize the server sends `instructions` telling the client to call `check_x402_seller` before any x402 payment or retry, unprompted, and how to act on each verdict; the text is `INSTRUCTIONS` in `src/server.ts`, shared by every transport. Per-client setup: https://paidfor.dev/install.
|
|
13
16
|
|
|
14
|
-
|
|
17
|
+
The read tools use the public JSON API (`GET https://paidfor.dev/v1/check`, `/v1/sellers`, `/v1/changes`). No keys, no rate limit for now.
|
|
15
18
|
|
|
16
19
|
## Hosted (Streamable HTTP)
|
|
17
20
|
|
|
@@ -35,6 +38,43 @@ Both read the public JSON API (`GET https://paidfor.dev/v1/check`, `GET https://
|
|
|
35
38
|
}
|
|
36
39
|
```
|
|
37
40
|
|
|
41
|
+
## Let your agent pay, safely (stdio only)
|
|
42
|
+
|
|
43
|
+
`pay_x402` pays an x402 seller with **your own wallet**, through `@paidfor/x402-safe-fetch` 0.2.0: it checks the seller on paidfor first and refuses `avoid` sellers, refuses quotes over 1.1x the listed price, caps the amount per call and per day, never pays twice for the same `intent_id` (a retry returns the stored result), keeps receipts in `~/.paidfor/receipts/`, and reports the outcome with its proof of payment (payer, nonce, pay-to, tx hash) to `https://paidfor.dev/v1/telemetry`, where it is checked on Base. It returns the status, the body (truncated at 64 KB) and a receipt with the amount, pay-to, tx hash and basescan link, the seller verdict at purchase, the validator results and the `verification_url` that shows whether the index verified it.
|
|
44
|
+
|
|
45
|
+
**Use a dedicated wallet holding a few dollars, never your main wallet.** There is no custody and no account: the key stays on your machine, is read only to sign, and is never logged, returned by a tool or sent anywhere.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npx -y @paidfor/mcp wallet new
|
|
49
|
+
# prints the address and: fund this with a few USDC on Base; x402 payments on Base are gasless for the buyer, no ETH needed
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
That writes `~/.paidfor/wallet.key` with mode 600 (it never overwrites an existing key). Send a few USDC on Base to the address, then point the server at the key:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"mcpServers": {
|
|
57
|
+
"paidfor": {
|
|
58
|
+
"command": "npx",
|
|
59
|
+
"args": ["-y", "@paidfor/mcp"],
|
|
60
|
+
"env": { "PAIDFOR_WALLET_KEY_FILE": "/Users/you/.paidfor/wallet.key" }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Verify by asking the agent to call `wallet_status`.
|
|
67
|
+
|
|
68
|
+
| env | default | |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `PAIDFOR_WALLET_KEY_FILE` | | path to the key file; refused if group- or world-readable (`chmod 600`) |
|
|
71
|
+
| `PAIDFOR_WALLET_KEY` | | the key inline; discouraged, use the file |
|
|
72
|
+
| `PAIDFOR_MAX_PER_CALL_USD` | `0.50` | most one call may pay; `max_usd` can lower it, never raise it |
|
|
73
|
+
| `PAIDFOR_DAILY_CAP_USD` | `5` | most all calls may pay per UTC day (tracked in `~/.paidfor/spend.json`) |
|
|
74
|
+
| `BASE_RPC_URL` | `https://mainnet.base.org` | used only by `wallet_status` for the balance |
|
|
75
|
+
|
|
76
|
+
Without a key (or with a misconfigured one, reported on stderr) the stdio server is the read-only server. The hosted server at `mcp.paidfor.dev` and the site's `/mcp` route never register `pay_x402` or `wallet_status`, by design: they never hold a key.
|
|
77
|
+
|
|
38
78
|
## Run the HTTP service yourself
|
|
39
79
|
|
|
40
80
|
This folder is a standalone Node project (own `package.json` and `pnpm-lock.yaml`):
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone Streamable HTTP service (Railway / Fly / any Node host). Stateless: one
|
|
3
|
+
* transport per request. GET /health for the platform health check.
|
|
4
|
+
*/
|
|
5
|
+
import { createServer as createHttpServer } from "node:http";
|
|
6
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
7
|
+
import { createServer, TOOL_NAMES, VERSION } from "./server.js";
|
|
8
|
+
const port = Number(process.env.PORT ?? 3402);
|
|
9
|
+
const counts = new Map();
|
|
10
|
+
const bump = (k) => counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
11
|
+
const CORS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", "Access-Control-Allow-Headers": "*" };
|
|
12
|
+
createHttpServer(async (req, res) => {
|
|
13
|
+
const path = (req.url ?? "/").split("?")[0];
|
|
14
|
+
if (req.method === "OPTIONS") {
|
|
15
|
+
res.writeHead(204, CORS);
|
|
16
|
+
res.end();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (path === "/health") {
|
|
20
|
+
res.writeHead(200, { "Content-Type": "text/plain", ...CORS });
|
|
21
|
+
res.end("ok");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (path === "/usage") {
|
|
25
|
+
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
26
|
+
res.end(JSON.stringify(Object.fromEntries(counts)));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (path === "/" || path === "/mcp") {
|
|
30
|
+
if (req.method === "GET" && !(req.headers.accept ?? "").includes("text/event-stream")) {
|
|
31
|
+
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
32
|
+
res.end(JSON.stringify({ name: "paidfor", version: VERSION, transport: "streamable-http", tools: TOOL_NAMES, usage: "POST JSON-RPC 2.0 to this URL" }));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
bump("request");
|
|
36
|
+
for (const [k, v] of Object.entries(CORS))
|
|
37
|
+
res.setHeader(k, v);
|
|
38
|
+
const server = createServer({ onToolCall: bump });
|
|
39
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
40
|
+
res.on("close", () => { void transport.close(); void server.close(); });
|
|
41
|
+
await server.connect(transport);
|
|
42
|
+
await transport.handleRequest(req, res);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
res.writeHead(404, CORS);
|
|
46
|
+
res.end();
|
|
47
|
+
}).listen(port, () => {
|
|
48
|
+
console.log(`paidfor MCP (streamable http) listening on :${port}, reading ${process.env.PAIDFOR_URL ?? "https://paidfor.dev"}`);
|
|
49
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { createServer as createHttpServer } from "node:http";
|
|
12
12
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
13
|
-
import { createServer, DEFAULT_BASE, TOOL_NAMES, VERSION } from "./server.js";
|
|
13
|
+
import { CLIENT_HEADER, createServer, DEFAULT_BASE, TOOL_NAMES, VERSION } from "./server.js";
|
|
14
14
|
const base = (process.env.PAIDFOR_API_BASE ?? process.env.PAIDFOR_URL ?? DEFAULT_BASE).replace(/\/$/, "");
|
|
15
15
|
const port = Number(process.env.PORT ?? 3000);
|
|
16
16
|
const host = process.env.HOST ?? "0.0.0.0";
|
|
@@ -21,7 +21,7 @@ const noteGeneratedAt = (g) => {
|
|
|
21
21
|
if (g)
|
|
22
22
|
dataGeneratedAt = g;
|
|
23
23
|
};
|
|
24
|
-
fetch(`${base}/v1/summary`, { headers: { Accept: "application/json", "User-Agent": `paidfor-mcp/${VERSION}
|
|
24
|
+
fetch(`${base}/v1/summary`, { headers: { Accept: "application/json", "User-Agent": `paidfor-mcp/${VERSION}`, "X-Paidfor-Client": CLIENT_HEADER } })
|
|
25
25
|
.then((r) => (r.ok ? r.json() : null))
|
|
26
26
|
.then((j) => noteGeneratedAt(j?.generated_at))
|
|
27
27
|
.catch(() => undefined);
|
package/dist/pay.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { type Receipt, type SafeNetwork } from "@paidfor/x402-safe-fetch";
|
|
3
|
+
import { type Limits, type LoadedWallet } from "./wallet.js";
|
|
4
|
+
export declare const PAY_TOOL_NAMES: readonly ["pay_x402", "wallet_status"];
|
|
5
|
+
export declare const BODY_LIMIT_BYTES: number;
|
|
6
|
+
export declare const PAY_INSTRUCTIONS: string;
|
|
7
|
+
/** The stdio server's instructions with a wallet configured: the pay paragraph goes in before the no-account promise, which stays last. */
|
|
8
|
+
export declare const INSTRUCTIONS_WITH_PAY: string;
|
|
9
|
+
export declare const PAY_DESCRIPTION: string;
|
|
10
|
+
export declare const WALLET_STATUS_DESCRIPTION: string;
|
|
11
|
+
export type PayOptions = {
|
|
12
|
+
wallet: LoadedWallet;
|
|
13
|
+
limits: Limits;
|
|
14
|
+
/** Site origin; telemetry goes to `${baseUrl}/v1/telemetry`. */
|
|
15
|
+
baseUrl: string;
|
|
16
|
+
/** Default ~/.paidfor (receipts in ./receipts, today's spend in ./spend.json). */
|
|
17
|
+
dir?: string;
|
|
18
|
+
/** Tests only: a local network and fetch. Default Base mainnet and globalThis.fetch. */
|
|
19
|
+
network?: SafeNetwork;
|
|
20
|
+
fetch?: typeof globalThis.fetch;
|
|
21
|
+
/** wallet_status balance; default reads USDC.balanceOf on Base over BASE_RPC_URL or https://mainnet.base.org. */
|
|
22
|
+
balanceOf?: (address: `0x${string}`) => Promise<bigint>;
|
|
23
|
+
};
|
|
24
|
+
/** The receipt as pay_x402 reports it: what was paid, to whom, the proof, and how to check it. */
|
|
25
|
+
export declare function receiptSummary(r: Receipt): {
|
|
26
|
+
outcome: import("@paidfor/x402-safe-fetch").Outcome;
|
|
27
|
+
paid: boolean;
|
|
28
|
+
replayed: boolean;
|
|
29
|
+
intent_id: string;
|
|
30
|
+
amount_usdc: string;
|
|
31
|
+
pay_to: string | null;
|
|
32
|
+
tx_hash: string | null;
|
|
33
|
+
basescan_url: string | null;
|
|
34
|
+
seller: {
|
|
35
|
+
host: string | null;
|
|
36
|
+
verdict_at_purchase: import("@paidfor/x402-safe-fetch").Verdict;
|
|
37
|
+
verdict_text: string | null;
|
|
38
|
+
seller_url: string | null;
|
|
39
|
+
};
|
|
40
|
+
validators: Partial<Record<import("@paidfor/x402-safe-fetch").ValidatorName, import("@paidfor/x402-safe-fetch").ValidatorResult>>;
|
|
41
|
+
validator_failures: import("@paidfor/x402-safe-fetch").ValidatorName[];
|
|
42
|
+
reported_outcome: import("@paidfor/x402-safe-fetch").TelemetryOutcome | null;
|
|
43
|
+
verification_url: string | null;
|
|
44
|
+
};
|
|
45
|
+
export declare function truncateBody(body: string): {
|
|
46
|
+
body: string;
|
|
47
|
+
note: string | null;
|
|
48
|
+
};
|
|
49
|
+
export declare function registerPayTools(server: McpServer, o: PayOptions): Promise<void>;
|
package/dist/pay.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pay_x402 and wallet_status: the stdio-only tools that buy through @paidfor/x402-safe-fetch with the
|
|
3
|
+
* user's own wallet and report the verified outcome to the index. Only stdio.ts imports this module;
|
|
4
|
+
* the hosted service (index.ts) and the site's /mcp route never do, because they never hold a key.
|
|
5
|
+
*/
|
|
6
|
+
import { mkdir } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { PaymentRefusedError, SafeFetchError, safeFetch } from "@paidfor/x402-safe-fetch";
|
|
9
|
+
import { createPublicClient, erc20Abi, http } from "viem";
|
|
10
|
+
import { base as baseChain } from "viem/chains";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { INSTRUCTIONS, NO_ACCOUNT_PROMISE } from "./server.js";
|
|
13
|
+
import { SpendLedger, fromMicro, paidforDir, toMicro } from "./wallet.js";
|
|
14
|
+
export const PAY_TOOL_NAMES = ["pay_x402", "wallet_status"];
|
|
15
|
+
export const BODY_LIMIT_BYTES = 64 * 1024;
|
|
16
|
+
const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
|
|
17
|
+
/** The client's smallest positive cap: with the daily budget spent, any quote is refused before signing. */
|
|
18
|
+
const MIN_CAP_USD = 0.000001;
|
|
19
|
+
export const PAY_INSTRUCTIONS = "If pay_x402 is available, use it for every x402 purchase instead of any other way of paying; it checks the seller first, refuses avoid sellers, caps the amount, " +
|
|
20
|
+
"never pays twice for the same intent, and reports the verified outcome to the index. Reuse intent_id when retrying the same purchase. " +
|
|
21
|
+
"Tell the user the amount and the seller verdict before the first purchase from a new seller. " +
|
|
22
|
+
"pay_x402 signs with the user's own wallet on this machine; the key never leaves it.";
|
|
23
|
+
/** The stdio server's instructions with a wallet configured: the pay paragraph goes in before the no-account promise, which stays last. */
|
|
24
|
+
export const INSTRUCTIONS_WITH_PAY = INSTRUCTIONS.replace(`\n${NO_ACCOUNT_PROMISE}`, `\n${PAY_INSTRUCTIONS}\n${NO_ACCOUNT_PROMISE}`);
|
|
25
|
+
export const PAY_DESCRIPTION = "Call this to pay for any x402 request (an HTTP 402 asking for USDC on Base) instead of paying any other way. " +
|
|
26
|
+
"It checks the seller on paidfor first, refuses avoid sellers and quotes over 1.1x the listed price, caps the amount per call and per day, " +
|
|
27
|
+
"never pays twice for the same intent_id (a retry returns the stored result), and reports the verified outcome to the index. " +
|
|
28
|
+
"Returns the response status and body (64 KB max) and a receipt: amount, pay-to, tx hash with a basescan link, seller verdict, validator results and verification_url. " +
|
|
29
|
+
"On refusal it returns the reason and the seller page and pays nothing.";
|
|
30
|
+
export const WALLET_STATUS_DESCRIPTION = "Call this when the user asks which wallet pays, how much USDC it holds, or how much of today's cap is left, and before a first purchase. " +
|
|
31
|
+
"Returns the address, the USDC balance on Base, the per-call and daily limits and today's spend. Never returns the key.";
|
|
32
|
+
const text = (s, isError = false) => ({ content: [{ type: "text", text: s }], ...(isError ? { isError: true } : {}) });
|
|
33
|
+
const json = (o) => JSON.stringify(o, null, 2);
|
|
34
|
+
/** The receipt as pay_x402 reports it: what was paid, to whom, the proof, and how to check it. */
|
|
35
|
+
export function receiptSummary(r) {
|
|
36
|
+
const paid = r.replayed ? r.original ?? r : r;
|
|
37
|
+
const tx = paid.tx_hash;
|
|
38
|
+
return {
|
|
39
|
+
outcome: r.outcome,
|
|
40
|
+
paid: r.paid,
|
|
41
|
+
replayed: r.replayed,
|
|
42
|
+
intent_id: r.intent_id,
|
|
43
|
+
amount_usdc: paid.amount_usdc,
|
|
44
|
+
pay_to: paid.quote?.pay_to ?? null,
|
|
45
|
+
tx_hash: tx,
|
|
46
|
+
basescan_url: tx ? `https://basescan.org/tx/${tx}` : null,
|
|
47
|
+
seller: { host: r.seller.host, verdict_at_purchase: r.seller.verdict, verdict_text: r.seller.verdict_text, seller_url: r.seller.seller_url },
|
|
48
|
+
validators: r.validators,
|
|
49
|
+
validator_failures: r.validator_failures,
|
|
50
|
+
reported_outcome: paid.reported_outcome,
|
|
51
|
+
verification_url: paid.verification_url,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function truncateBody(body) {
|
|
55
|
+
const buf = Buffer.from(body, "utf8");
|
|
56
|
+
if (buf.byteLength <= BODY_LIMIT_BYTES)
|
|
57
|
+
return { body, note: null };
|
|
58
|
+
// Cut on a character boundary at or under the limit.
|
|
59
|
+
const cut = new TextDecoder("utf-8", { fatal: false }).decode(buf.subarray(0, BODY_LIMIT_BYTES)).replace(/�$/, "");
|
|
60
|
+
return { body: cut, note: `body truncated at 64 KB of ${buf.byteLength} bytes` };
|
|
61
|
+
}
|
|
62
|
+
function defaultBalanceOf(address) {
|
|
63
|
+
const client = createPublicClient({ chain: baseChain, transport: http(process.env.BASE_RPC_URL || "https://mainnet.base.org") });
|
|
64
|
+
return client.readContract({ address: USDC_BASE, abi: erc20Abi, functionName: "balanceOf", args: [address] });
|
|
65
|
+
}
|
|
66
|
+
export async function registerPayTools(server, o) {
|
|
67
|
+
const dir = o.dir ?? paidforDir();
|
|
68
|
+
const receiptsDir = join(dir, "receipts");
|
|
69
|
+
await mkdir(receiptsDir, { recursive: true, mode: 0o700 });
|
|
70
|
+
const ledger = new SpendLedger(join(dir, "spend.json"));
|
|
71
|
+
const base = o.baseUrl.replace(/\/$/, "");
|
|
72
|
+
const address = o.wallet.account.address;
|
|
73
|
+
const logger = { warn: (m) => void process.stderr.write(`${m}\n`) };
|
|
74
|
+
server.registerTool("pay_x402", {
|
|
75
|
+
title: "Pay an x402 seller safely",
|
|
76
|
+
description: PAY_DESCRIPTION,
|
|
77
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
78
|
+
inputSchema: {
|
|
79
|
+
url: z.string().url().describe("The x402 resource URL"),
|
|
80
|
+
method: z.string().min(1).max(10).optional().describe("HTTP method, default GET"),
|
|
81
|
+
headers: z.record(z.string(), z.string()).optional().describe("Request headers"),
|
|
82
|
+
body: z.string().optional().describe("Request body, as text"),
|
|
83
|
+
intent_id: z.string().min(1).max(200).optional().describe("Reuse the same id when retrying the same purchase; a retry returns the stored result without paying again"),
|
|
84
|
+
max_usd: z.number().positive().optional().describe("Most to pay for this call in USD; capped by PAIDFOR_MAX_PER_CALL_USD"),
|
|
85
|
+
},
|
|
86
|
+
}, async ({ url, method, headers, body, intent_id, max_usd }) => {
|
|
87
|
+
const spent = await ledger.spentMicro();
|
|
88
|
+
const leftMicro = toMicro(o.limits.dailyCapUsd) - spent;
|
|
89
|
+
const perCall = Math.min(max_usd ?? o.limits.maxPerCallUsd, o.limits.maxPerCallUsd);
|
|
90
|
+
const dailyBinds = leftMicro / 1e6 < perCall;
|
|
91
|
+
const cap = Math.max(Math.min(perCall, leftMicro / 1e6), MIN_CAP_USD);
|
|
92
|
+
const limits = {
|
|
93
|
+
max_per_call_usd: o.limits.maxPerCallUsd,
|
|
94
|
+
this_call_cap_usd: Number(cap.toFixed(6)),
|
|
95
|
+
daily_cap_usd: o.limits.dailyCapUsd,
|
|
96
|
+
spent_today_usdc: fromMicro(spent),
|
|
97
|
+
...(max_usd !== undefined && max_usd > o.limits.maxPerCallUsd ? { note: `max_usd ${max_usd} is above PAIDFOR_MAX_PER_CALL_USD; capped at ${o.limits.maxPerCallUsd}` } : {}),
|
|
98
|
+
};
|
|
99
|
+
const sf = safeFetch(o.wallet.account, {
|
|
100
|
+
maxPerCallUsd: cap,
|
|
101
|
+
maxOverListedRatio: 1.1,
|
|
102
|
+
refuseVerdicts: ["avoid"],
|
|
103
|
+
indexUrl: `${base}/v1`,
|
|
104
|
+
telemetry: "verified",
|
|
105
|
+
receiptsDir,
|
|
106
|
+
logger,
|
|
107
|
+
...(o.network ? { network: o.network } : {}),
|
|
108
|
+
...(o.fetch ? { fetch: o.fetch } : {}),
|
|
109
|
+
});
|
|
110
|
+
try {
|
|
111
|
+
const res = await sf(url, { method: method ?? "GET", headers, body }, { intentId: intent_id });
|
|
112
|
+
const r = res.receipt;
|
|
113
|
+
if (r.paid && !r.replayed)
|
|
114
|
+
await ledger.add(toMicro(r.amount_usdc));
|
|
115
|
+
const { body: out, note } = truncateBody(await res.text());
|
|
116
|
+
const head = r.replayed
|
|
117
|
+
? `Replayed the stored result for intent ${r.intent_id}; nothing was paid again.`
|
|
118
|
+
: r.paid
|
|
119
|
+
? `Paid ${r.amount_usdc} USDC to ${r.quote?.pay_to}; the seller answered HTTP ${res.status} (${r.outcome}).`
|
|
120
|
+
: `No payment was needed; the server answered HTTP ${res.status}.`;
|
|
121
|
+
return text(`${head}\n\n${json({ status: res.status, receipt: receiptSummary(r), limits, ...(note ? { body_note: note } : {}) })}\n\n--- response body ---\n${out}`);
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
if (e instanceof PaymentRefusedError) {
|
|
125
|
+
const why = e.code === "refused_max_per_call" && dailyBinds
|
|
126
|
+
? `refused: today's cap of ${o.limits.dailyCapUsd} USD (PAIDFOR_DAILY_CAP_USD) leaves ${fromMicro(Math.max(leftMicro, 0))} USDC. ${e.message}`
|
|
127
|
+
: e.code === "refused_max_per_call"
|
|
128
|
+
? `refused: the quote is above this call's cap of ${cap} USD (max_usd, capped by PAIDFOR_MAX_PER_CALL_USD ${o.limits.maxPerCallUsd}). ${e.message}`
|
|
129
|
+
: `refused: ${e.message}`;
|
|
130
|
+
return text(`${why}\nNothing was paid.\n\n${json({ refused: e.code, paid: false, seller_url: e.sellerUrl ?? e.receipt?.seller.seller_url ?? null, intent_id: e.receipt?.intent_id ?? intent_id ?? null, limits })}`);
|
|
131
|
+
}
|
|
132
|
+
if (e instanceof SafeFetchError) {
|
|
133
|
+
return text(`${e.code}: ${e.message}\n\n${json({ error: e.code, paid: e.receipt?.paid ?? false, receipt: e.receipt ? receiptSummary(e.receipt) : null, limits })}`, true);
|
|
134
|
+
}
|
|
135
|
+
return text(`error: ${e instanceof Error ? e.message : String(e)}`, true);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
server.registerTool("wallet_status", {
|
|
139
|
+
title: "Show the paying wallet",
|
|
140
|
+
description: WALLET_STATUS_DESCRIPTION,
|
|
141
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
142
|
+
inputSchema: {},
|
|
143
|
+
}, async () => {
|
|
144
|
+
const spent = await ledger.spentMicro();
|
|
145
|
+
let usdc = null;
|
|
146
|
+
let balance_error = null;
|
|
147
|
+
try {
|
|
148
|
+
usdc = fromMicro(Number(await (o.balanceOf ?? defaultBalanceOf)(address)));
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
balance_error = e instanceof Error ? e.message.split("\n")[0] : String(e);
|
|
152
|
+
}
|
|
153
|
+
const s = {
|
|
154
|
+
address,
|
|
155
|
+
network: "eip155:8453",
|
|
156
|
+
usdc_balance: usdc,
|
|
157
|
+
...(balance_error ? { balance_error } : {}),
|
|
158
|
+
basescan_url: `https://basescan.org/address/${address}`,
|
|
159
|
+
key_source: o.wallet.source === "file" ? "PAIDFOR_WALLET_KEY_FILE" : "PAIDFOR_WALLET_KEY (discouraged; prefer a key file)",
|
|
160
|
+
max_per_call_usd: o.limits.maxPerCallUsd,
|
|
161
|
+
daily_cap_usd: o.limits.dailyCapUsd,
|
|
162
|
+
spent_today_usdc: fromMicro(spent),
|
|
163
|
+
};
|
|
164
|
+
return text(`${usdc === null ? "Balance unavailable" : `${usdc} USDC on Base`} at ${address}.\n\n${json(s)}`);
|
|
165
|
+
});
|
|
166
|
+
}
|
package/dist/rules.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one copy of the rules an agent follows: when to check, what each verdict means, and never paying twice.
|
|
3
|
+
* The server instructions and the tool descriptions (server.ts) and the skill at paidfor.dev/skill.md (lib/skill.ts on
|
|
4
|
+
* the site) are all generated from these constants, so the three copies cannot drift.
|
|
5
|
+
*
|
|
6
|
+
* Changing any text here changes the skill: bump RULES_REVISION (the skill's `skill-revision`) and update RULES_SHA;
|
|
7
|
+
* lib/skill.test.ts fails until both move together.
|
|
8
|
+
*/
|
|
9
|
+
export declare const RULES_REVISION = "2026-10-29";
|
|
10
|
+
/** When to call check_x402_seller (GET /v1/check), in order. */
|
|
11
|
+
export declare const CHECK_TRIGGERS: readonly ["before any x402 payment", "before retrying a request that answered HTTP 402", "whenever the user is about to buy an API call with USDC on Base or names an API they intend to pay per call"];
|
|
12
|
+
/** When to call list_reliable_sellers (GET /v1/sellers?zero_failures=1). */
|
|
13
|
+
export declare const CHOOSE_TRIGGER = "when choosing among sellers for a task";
|
|
14
|
+
/** When to call recent_changes (GET /v1/changes). */
|
|
15
|
+
export declare const CHANGES_TRIGGER = "when the user asks what changed, or before relying on a verdict older than a day";
|
|
16
|
+
/** What to do for each verdict. */
|
|
17
|
+
export declare const VERDICT_RULES: {
|
|
18
|
+
readonly avoid: "do not pay an \"avoid\" seller and say why with the evidence link";
|
|
19
|
+
readonly caution: "before paying a \"caution\" seller tell the user the failure rate";
|
|
20
|
+
readonly ok: "\"ok\" needs no comment";
|
|
21
|
+
readonly unknown: "\"unknown\" means no data, say so and proceed only on the user's say-so";
|
|
22
|
+
};
|
|
23
|
+
export declare const NEVER_PAY_TWICE = "Never pay the same seller twice for one task because of a timeout or error without checking first.";
|
|
24
|
+
export declare const SENDS_ONLY_URL = "These tools read a public index, need no key, and send nothing but the URL being checked.";
|
|
25
|
+
/** sha256 of the JSON of every constant above (lib/skill.test.ts recomputes it). */
|
|
26
|
+
export declare const RULES_SHA = "7258b901f6577a02dfe997416d3de61e122f5da21476eb686dd1c1a9379bffc6";
|
|
27
|
+
/** "a, b, and c" */
|
|
28
|
+
export declare function listAnd(xs: readonly string[]): string;
|
|
29
|
+
/** "a, b, or c" */
|
|
30
|
+
export declare function listOr(xs: readonly string[]): string;
|
|
31
|
+
export declare const cap: (s: string) => string;
|
|
32
|
+
/** The verdict rules as one sentence (server instructions, skill). */
|
|
33
|
+
export declare const VERDICT_SENTENCE: string;
|
package/dist/rules.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one copy of the rules an agent follows: when to check, what each verdict means, and never paying twice.
|
|
3
|
+
* The server instructions and the tool descriptions (server.ts) and the skill at paidfor.dev/skill.md (lib/skill.ts on
|
|
4
|
+
* the site) are all generated from these constants, so the three copies cannot drift.
|
|
5
|
+
*
|
|
6
|
+
* Changing any text here changes the skill: bump RULES_REVISION (the skill's `skill-revision`) and update RULES_SHA;
|
|
7
|
+
* lib/skill.test.ts fails until both move together.
|
|
8
|
+
*/
|
|
9
|
+
export const RULES_REVISION = "2026-10-29";
|
|
10
|
+
/** When to call check_x402_seller (GET /v1/check), in order. */
|
|
11
|
+
export const CHECK_TRIGGERS = [
|
|
12
|
+
"before any x402 payment",
|
|
13
|
+
"before retrying a request that answered HTTP 402",
|
|
14
|
+
"whenever the user is about to buy an API call with USDC on Base or names an API they intend to pay per call",
|
|
15
|
+
];
|
|
16
|
+
/** When to call list_reliable_sellers (GET /v1/sellers?zero_failures=1). */
|
|
17
|
+
export const CHOOSE_TRIGGER = "when choosing among sellers for a task";
|
|
18
|
+
/** When to call recent_changes (GET /v1/changes). */
|
|
19
|
+
export const CHANGES_TRIGGER = "when the user asks what changed, or before relying on a verdict older than a day";
|
|
20
|
+
/** What to do for each verdict. */
|
|
21
|
+
export const VERDICT_RULES = {
|
|
22
|
+
avoid: 'do not pay an "avoid" seller and say why with the evidence link',
|
|
23
|
+
caution: 'before paying a "caution" seller tell the user the failure rate',
|
|
24
|
+
ok: '"ok" needs no comment',
|
|
25
|
+
unknown: '"unknown" means no data, say so and proceed only on the user\'s say-so',
|
|
26
|
+
};
|
|
27
|
+
export const NEVER_PAY_TWICE = "Never pay the same seller twice for one task because of a timeout or error without checking first.";
|
|
28
|
+
export const SENDS_ONLY_URL = "These tools read a public index, need no key, and send nothing but the URL being checked.";
|
|
29
|
+
/** sha256 of the JSON of every constant above (lib/skill.test.ts recomputes it). */
|
|
30
|
+
export const RULES_SHA = "7258b901f6577a02dfe997416d3de61e122f5da21476eb686dd1c1a9379bffc6";
|
|
31
|
+
/** "a, b, and c" */
|
|
32
|
+
export function listAnd(xs) {
|
|
33
|
+
return xs.length < 2 ? (xs[0] ?? "") : `${xs.slice(0, -1).join(", ")}, and ${xs.at(-1)}`;
|
|
34
|
+
}
|
|
35
|
+
/** "a, b, or c" */
|
|
36
|
+
export function listOr(xs) {
|
|
37
|
+
return xs.length < 2 ? (xs[0] ?? "") : `${xs.slice(0, -1).join(", ")}, or ${xs.at(-1)}`;
|
|
38
|
+
}
|
|
39
|
+
export const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
40
|
+
/** The verdict rules as one sentence (server instructions, skill). */
|
|
41
|
+
export const VERDICT_SENTENCE = `Follow the verdict: ${[VERDICT_RULES.avoid, VERDICT_RULES.caution, VERDICT_RULES.ok, VERDICT_RULES.unknown].join("; ")}.`;
|
package/dist/server.d.ts
CHANGED
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The paidfor MCP server. One factory, three transports: stdio (npx @paidfor/mcp),
|
|
3
3
|
* the hosted Streamable HTTP service (index.ts) and the Next.js route at /mcp on the site.
|
|
4
|
-
*
|
|
4
|
+
* The read tools are thin readers of the public JSON API; the verdict logic lives on the site.
|
|
5
|
+
* The paying tools (pay.ts) are added by stdio.ts alone, and only when a wallet is configured.
|
|
5
6
|
*/
|
|
6
7
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
-
export
|
|
8
|
+
export * from "./rules.js";
|
|
9
|
+
export declare const VERSION = "0.3.1";
|
|
10
|
+
/** Sent on every request to the index so usage can be counted by channel (never anything else). */
|
|
11
|
+
export declare const CLIENT_HEADER = "mcp/0.3.1";
|
|
8
12
|
export declare const DEFAULT_BASE = "https://paidfor.dev";
|
|
9
|
-
export declare const TOOL_NAMES: readonly ["check_x402_seller", "list_reliable_sellers"];
|
|
13
|
+
export declare const TOOL_NAMES: readonly ["check_x402_seller", "list_reliable_sellers", "recent_changes"];
|
|
10
14
|
/**
|
|
11
15
|
* The no-account promise, word for word wherever it appears: the server instructions (last line),
|
|
12
16
|
* this package's README, /methodology, /install.md and /privacy on the site.
|
|
13
17
|
*/
|
|
14
18
|
export declare const NO_ACCOUNT_PROMISE: string;
|
|
15
|
-
/** Sent on initialize by every transport (stdio, the hosted service, the site's /mcp route), so clients call the tools unprompted.
|
|
19
|
+
/** Sent on initialize by every transport (stdio, the hosted service, the site's /mcp route), so clients call the tools unprompted.
|
|
20
|
+
* Generated from rules.ts, like the tool descriptions below and the skill at paidfor.dev/skill.md. */
|
|
16
21
|
export declare const INSTRUCTIONS: string;
|
|
17
22
|
export declare const CHECK_DESCRIPTION: string;
|
|
18
23
|
export declare const LIST_DESCRIPTION: string;
|
|
24
|
+
export declare const CHANGES_DESCRIPTION: string;
|
|
19
25
|
export type ServerOptions = {
|
|
20
26
|
/** Site origin to read from. Defaults to PAIDFOR_API_BASE (or the older PAIDFOR_URL) or https://paidfor.dev. */
|
|
21
27
|
baseUrl?: string;
|
|
@@ -23,5 +29,7 @@ export type ServerOptions = {
|
|
|
23
29
|
onToolCall?: (tool: string) => void;
|
|
24
30
|
/** Called with the export's generated_at from every successful response (the hosted service shows it on /health). */
|
|
25
31
|
onResponse?: (generated_at: string) => void;
|
|
32
|
+
/** Replaces INSTRUCTIONS; stdio.ts passes INSTRUCTIONS_WITH_PAY when it registers pay_x402. */
|
|
33
|
+
instructions?: string;
|
|
26
34
|
};
|
|
27
35
|
export declare function createServer(opts?: ServerOptions): McpServer;
|
package/dist/server.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The paidfor MCP server. One factory, three transports: stdio (npx @paidfor/mcp),
|
|
3
3
|
* the hosted Streamable HTTP service (index.ts) and the Next.js route at /mcp on the site.
|
|
4
|
-
*
|
|
4
|
+
* The read tools are thin readers of the public JSON API; the verdict logic lives on the site.
|
|
5
|
+
* The paying tools (pay.ts) are added by stdio.ts alone, and only when a wallet is configured.
|
|
5
6
|
*/
|
|
6
7
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
8
|
import { z } from "zod";
|
|
8
|
-
|
|
9
|
+
import { CHANGES_TRIGGER, CHECK_TRIGGERS, CHOOSE_TRIGGER, NEVER_PAY_TWICE, SENDS_ONLY_URL, VERDICT_SENTENCE, cap, listAnd, listOr } from "./rules.js";
|
|
10
|
+
export * from "./rules.js";
|
|
11
|
+
export const VERSION = "0.3.1";
|
|
12
|
+
/** Sent on every request to the index so usage can be counted by channel (never anything else). */
|
|
13
|
+
export const CLIENT_HEADER = `mcp/${VERSION}`;
|
|
9
14
|
export const DEFAULT_BASE = "https://paidfor.dev";
|
|
10
|
-
export const TOOL_NAMES = ["check_x402_seller", "list_reliable_sellers"];
|
|
15
|
+
export const TOOL_NAMES = ["check_x402_seller", "list_reliable_sellers", "recent_changes"];
|
|
11
16
|
const CATEGORIES = ["inference", "data", "scraping", "compute", "other"];
|
|
12
17
|
/**
|
|
13
18
|
* The no-account promise, word for word wherever it appears: the server instructions (last line),
|
|
@@ -15,32 +20,36 @@ const CATEGORIES = ["inference", "data", "scraping", "compute", "other"];
|
|
|
15
20
|
*/
|
|
16
21
|
export const NO_ACCOUNT_PROMISE = "Everything that reads the index or contributes data (the site, the API, the MCP tools, the client, telemetry and the changes feed) needs no account and no key, now and later. " +
|
|
17
22
|
"An email address is asked for only to deliver alerts or to join a waitlist, as a confirmation link, never a profile.";
|
|
18
|
-
/** Sent on initialize by every transport (stdio, the hosted service, the site's /mcp route), so clients call the tools unprompted.
|
|
23
|
+
/** Sent on initialize by every transport (stdio, the hosted service, the site's /mcp route), so clients call the tools unprompted.
|
|
24
|
+
* Generated from rules.ts, like the tool descriptions below and the skill at paidfor.dev/skill.md. */
|
|
19
25
|
export const INSTRUCTIONS = "paidfor rates x402 sellers by paying them and verifying delivery. " +
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"
|
|
26
|
+
`${cap(listAnd(CHECK_TRIGGERS))}, call check_x402_seller on the URL or seller domain first, without being asked. ` +
|
|
27
|
+
`${VERDICT_SENTENCE} ` +
|
|
28
|
+
`${cap(CHOOSE_TRIGGER)}, prefer results from list_reliable_sellers. ` +
|
|
29
|
+
`${cap(CHANGES_TRIGGER)}, call recent_changes. ` +
|
|
30
|
+
`${NEVER_PAY_TWICE} ` +
|
|
31
|
+
SENDS_ONLY_URL +
|
|
32
|
+
"\n" +
|
|
27
33
|
NO_ACCOUNT_PROMISE;
|
|
28
|
-
export const CHECK_DESCRIPTION =
|
|
34
|
+
export const CHECK_DESCRIPTION = `Call this ${listOr(CHECK_TRIGGERS)}. ` +
|
|
29
35
|
"Returns a verdict (avoid, caution, ok, unknown), one sentence to act on, the seller's paid-call count and failure rate with a 95% interval, " +
|
|
30
36
|
"listed price, modal quote and max overcharge ratio when known, and links to the seller page and raw evidence.";
|
|
31
|
-
export const LIST_DESCRIPTION =
|
|
37
|
+
export const LIST_DESCRIPTION = `Call this ${CHOOSE_TRIGGER}, or when the user asks for a safe or reliable API. ` +
|
|
32
38
|
"Returns sellers with zero failures across at least 30 paid calls, most-called first; optional category filter (inference, data, scraping, compute, other).";
|
|
33
|
-
|
|
39
|
+
export const CHANGES_DESCRIPTION = `Call this ${CHANGES_TRIGGER}. ` +
|
|
40
|
+
"Returns recent changes, newest first: verdict changes, new failures by type, moved quotes, changed pay-to addresses, sellers added or reaching 30 paid calls, published seller replies, " +
|
|
41
|
+
"the operator-reported series maturing or disagreeing with ours; optional host filter and since (ISO time).";
|
|
42
|
+
/** The read tools only read the public index; the directories ask for these hints. */
|
|
34
43
|
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
35
44
|
async function getJson(url) {
|
|
36
|
-
const res = await fetch(url, { headers: { Accept: "application/json", "User-Agent": `paidfor-mcp/${VERSION}
|
|
45
|
+
const res = await fetch(url, { headers: { Accept: "application/json", "User-Agent": `paidfor-mcp/${VERSION}`, "X-Paidfor-Client": CLIENT_HEADER } });
|
|
37
46
|
if (!res.ok)
|
|
38
47
|
throw new Error(`${url} returned HTTP ${res.status}`);
|
|
39
48
|
return (await res.json());
|
|
40
49
|
}
|
|
41
50
|
export function createServer(opts = {}) {
|
|
42
51
|
const base = (opts.baseUrl ?? process.env.PAIDFOR_API_BASE ?? process.env.PAIDFOR_URL ?? DEFAULT_BASE).replace(/\/$/, "");
|
|
43
|
-
const server = new McpServer({ name: "paidfor", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
52
|
+
const server = new McpServer({ name: "paidfor", version: VERSION }, { instructions: opts.instructions ?? INSTRUCTIONS });
|
|
44
53
|
server.registerTool("check_x402_seller", {
|
|
45
54
|
title: "Check an x402 seller before paying",
|
|
46
55
|
description: CHECK_DESCRIPTION,
|
|
@@ -87,5 +96,27 @@ export function createServer(opts = {}) {
|
|
|
87
96
|
const head = `${rows.length} sellers with zero failures across at least 30 paid calls${category ? ` in ${category}` : ""} (index generated ${r.generated_at}).`;
|
|
88
97
|
return { content: [{ type: "text", text: `${head}\n\n${JSON.stringify(rows, null, 2)}` }] };
|
|
89
98
|
});
|
|
99
|
+
server.registerTool("recent_changes", {
|
|
100
|
+
title: "Recent changes to x402 sellers",
|
|
101
|
+
description: CHANGES_DESCRIPTION,
|
|
102
|
+
annotations: READ_ONLY,
|
|
103
|
+
inputSchema: {
|
|
104
|
+
host: z.string().min(1).optional().describe("Only this seller, a bare domain such as api.example.com"),
|
|
105
|
+
since: z.string().min(1).optional().describe("Only changes after this ISO 8601 time"),
|
|
106
|
+
},
|
|
107
|
+
}, async ({ host, since }) => {
|
|
108
|
+
opts.onToolCall?.("recent_changes");
|
|
109
|
+
const q = new URLSearchParams();
|
|
110
|
+
if (host)
|
|
111
|
+
q.set("host", host);
|
|
112
|
+
if (since)
|
|
113
|
+
q.set("since", since);
|
|
114
|
+
const r = await getJson(`${base}/v1/changes${q.size ? `?${q}` : ""}`);
|
|
115
|
+
const rows = r.events.slice(0, 100);
|
|
116
|
+
const head = r.count
|
|
117
|
+
? `${r.count} changes${host ? ` for ${host}` : ""}${since ? ` since ${since}` : ""}, newest first${r.count > rows.length ? ` (first ${rows.length} shown)` : ""}.`
|
|
118
|
+
: `No changes${host ? ` for ${host}` : ""}${since ? ` since ${since}` : ""}. The index is diffed hourly.`;
|
|
119
|
+
return { content: [{ type: "text", text: `${head}\n\n${JSON.stringify(rows, null, 2)}` }] };
|
|
120
|
+
});
|
|
90
121
|
return server;
|
|
91
122
|
}
|
package/dist/stdio.js
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `npx @paidfor/mcp`: the stdio server. With PAIDFOR_WALLET_KEY_FILE (or PAIDFOR_WALLET_KEY) set it also
|
|
4
|
+
* registers pay_x402 and wallet_status; without one it is the same read-only server as the hosted one.
|
|
5
|
+
* `paidfor-mcp wallet new` writes a fresh key to ~/.paidfor/wallet.key (mode 600) and prints the address.
|
|
6
|
+
*/
|
|
2
7
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
-
import { createServer } from "./server.js";
|
|
4
|
-
|
|
5
|
-
|
|
8
|
+
import { DEFAULT_BASE, createServer } from "./server.js";
|
|
9
|
+
import { FUND_SENTENCE, WalletConfigError, loadWallet, newWallet, readLimits } from "./wallet.js";
|
|
10
|
+
const [cmd, sub] = process.argv.slice(2);
|
|
11
|
+
if (cmd === "wallet") {
|
|
12
|
+
if (sub !== "new") {
|
|
13
|
+
process.stderr.write("usage: paidfor-mcp wallet new\n");
|
|
14
|
+
process.exit(2);
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const { address, file } = await newWallet();
|
|
18
|
+
process.stdout.write(`${address}\n${FUND_SENTENCE}\n`);
|
|
19
|
+
process.stderr.write(`key written to ${file} (mode 600); set PAIDFOR_WALLET_KEY_FILE=${file} in your MCP client config\n`);
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
process.stderr.write(`paidfor-mcp: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const baseUrl = (process.env.PAIDFOR_API_BASE ?? process.env.PAIDFOR_URL ?? DEFAULT_BASE).replace(/\/$/, "");
|
|
28
|
+
let pay = null;
|
|
29
|
+
let limits = null;
|
|
30
|
+
try {
|
|
31
|
+
pay = await loadWallet();
|
|
32
|
+
if (pay)
|
|
33
|
+
limits = readLimits();
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
// A wallet that is misconfigured never pays: start read-only and say why on stderr (never the key).
|
|
37
|
+
pay = null;
|
|
38
|
+
process.stderr.write(`paidfor-mcp: pay_x402 disabled: ${e instanceof WalletConfigError ? e.message : "the wallet key could not be loaded"}\n`);
|
|
39
|
+
}
|
|
40
|
+
if (pay && limits) {
|
|
41
|
+
const { INSTRUCTIONS_WITH_PAY, registerPayTools } = await import("./pay.js");
|
|
42
|
+
const server = createServer({ baseUrl, instructions: INSTRUCTIONS_WITH_PAY });
|
|
43
|
+
await registerPayTools(server, { wallet: pay, limits, baseUrl });
|
|
44
|
+
await server.connect(new StdioServerTransport());
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
await createServer({ baseUrl }).connect(new StdioServerTransport());
|
|
48
|
+
}
|
package/dist/wallet.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type PrivateKeyAccount } from "viem/accounts";
|
|
2
|
+
export declare const FUND_SENTENCE = "fund this with a few USDC on Base; x402 payments on Base are gasless for the buyer, no ETH needed";
|
|
3
|
+
export declare const DEFAULT_MAX_PER_CALL_USD = 0.5;
|
|
4
|
+
export declare const DEFAULT_DAILY_CAP_USD = 5;
|
|
5
|
+
export type Env = Record<string, string | undefined>;
|
|
6
|
+
/** ~/.paidfor, resolved from HOME at call time. */
|
|
7
|
+
export declare const paidforDir: (home?: string) => string;
|
|
8
|
+
export declare class WalletConfigError extends Error {
|
|
9
|
+
name: string;
|
|
10
|
+
}
|
|
11
|
+
export type LoadedWallet = {
|
|
12
|
+
account: PrivateKeyAccount;
|
|
13
|
+
source: "file" | "env";
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The configured wallet, or null when neither PAIDFOR_WALLET_KEY_FILE nor PAIDFOR_WALLET_KEY is set.
|
|
17
|
+
* A key file readable by group or others is refused. Error messages never include the key.
|
|
18
|
+
*/
|
|
19
|
+
export declare function loadWallet(env?: Env): Promise<LoadedWallet | null>;
|
|
20
|
+
/** `paidfor-mcp wallet new`: a fresh key in ~/.paidfor/wallet.key, mode 600. Never overwrites an existing key. */
|
|
21
|
+
export declare function newWallet(home?: string): Promise<{
|
|
22
|
+
address: `0x${string}`;
|
|
23
|
+
file: string;
|
|
24
|
+
}>;
|
|
25
|
+
export type Limits = {
|
|
26
|
+
maxPerCallUsd: number;
|
|
27
|
+
dailyCapUsd: number;
|
|
28
|
+
};
|
|
29
|
+
/** PAIDFOR_MAX_PER_CALL_USD (default 0.50) and PAIDFOR_DAILY_CAP_USD (default 5). */
|
|
30
|
+
export declare function readLimits(env?: Env): Limits;
|
|
31
|
+
/** USDC has 6 decimals; the ledger keeps integer micro-USDC so sums never drift. */
|
|
32
|
+
export declare const toMicro: (usd: number | string) => number;
|
|
33
|
+
export declare const fromMicro: (m: number) => string;
|
|
34
|
+
/** What pay_x402 has spent today (UTC), in ~/.paidfor/spend.json. Only sent authorizations count; replays do not. */
|
|
35
|
+
export declare class SpendLedger {
|
|
36
|
+
readonly file: string;
|
|
37
|
+
constructor(file: string);
|
|
38
|
+
static today(now?: Date): string;
|
|
39
|
+
spentMicro(now?: Date): Promise<number>;
|
|
40
|
+
add(micro: number, now?: Date): Promise<number>;
|
|
41
|
+
}
|
package/dist/wallet.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The buyer's own wallet for pay_x402 (stdio only). No custody and no account: the key is read from a
|
|
3
|
+
* file the user controls (or, discouraged, an env var), used to sign locally, and never logged, returned
|
|
4
|
+
* by a tool or sent anywhere. Spending limits and today's running total live next to it in ~/.paidfor.
|
|
5
|
+
*/
|
|
6
|
+
import { mkdir, open, readFile, stat, writeFile, rename } from "node:fs/promises";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
|
|
10
|
+
export const FUND_SENTENCE = "fund this with a few USDC on Base; x402 payments on Base are gasless for the buyer, no ETH needed";
|
|
11
|
+
export const DEFAULT_MAX_PER_CALL_USD = 0.5;
|
|
12
|
+
export const DEFAULT_DAILY_CAP_USD = 5;
|
|
13
|
+
/** ~/.paidfor, resolved from HOME at call time. */
|
|
14
|
+
export const paidforDir = (home = homedir()) => join(home, ".paidfor");
|
|
15
|
+
export class WalletConfigError extends Error {
|
|
16
|
+
name = "WalletConfigError";
|
|
17
|
+
}
|
|
18
|
+
const KEY_RE = /^(0x)?[0-9a-fA-F]{64}$/;
|
|
19
|
+
function parseKey(raw, where) {
|
|
20
|
+
const k = raw.trim();
|
|
21
|
+
if (!KEY_RE.test(k))
|
|
22
|
+
throw new WalletConfigError(`${where} does not hold a 32-byte hex private key`);
|
|
23
|
+
return (k.startsWith("0x") ? k : `0x${k}`);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The configured wallet, or null when neither PAIDFOR_WALLET_KEY_FILE nor PAIDFOR_WALLET_KEY is set.
|
|
27
|
+
* A key file readable by group or others is refused. Error messages never include the key.
|
|
28
|
+
*/
|
|
29
|
+
export async function loadWallet(env = process.env) {
|
|
30
|
+
const file = env.PAIDFOR_WALLET_KEY_FILE?.trim();
|
|
31
|
+
if (file) {
|
|
32
|
+
let st;
|
|
33
|
+
try {
|
|
34
|
+
st = await stat(file);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new WalletConfigError(`PAIDFOR_WALLET_KEY_FILE ${file} cannot be read`);
|
|
38
|
+
}
|
|
39
|
+
if (!st.isFile())
|
|
40
|
+
throw new WalletConfigError(`PAIDFOR_WALLET_KEY_FILE ${file} is not a file`);
|
|
41
|
+
if (process.platform !== "win32" && (st.mode & 0o077) !== 0) {
|
|
42
|
+
throw new WalletConfigError(`refused: ${file} is readable by group or others (mode ${(st.mode & 0o777).toString(8)}); run chmod 600 ${file}`);
|
|
43
|
+
}
|
|
44
|
+
return { account: privateKeyToAccount(parseKey(await readFile(file, "utf8"), `PAIDFOR_WALLET_KEY_FILE ${file}`)), source: "file" };
|
|
45
|
+
}
|
|
46
|
+
const inline = env.PAIDFOR_WALLET_KEY;
|
|
47
|
+
if (inline && inline.trim())
|
|
48
|
+
return { account: privateKeyToAccount(parseKey(inline, "PAIDFOR_WALLET_KEY")), source: "env" };
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
/** `paidfor-mcp wallet new`: a fresh key in ~/.paidfor/wallet.key, mode 600. Never overwrites an existing key. */
|
|
52
|
+
export async function newWallet(home = homedir()) {
|
|
53
|
+
const dir = paidforDir(home);
|
|
54
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
55
|
+
const file = join(dir, "wallet.key");
|
|
56
|
+
const key = generatePrivateKey();
|
|
57
|
+
let fh;
|
|
58
|
+
try {
|
|
59
|
+
fh = await open(file, "wx", 0o600);
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
if (e.code === "EEXIST")
|
|
63
|
+
throw new WalletConfigError(`${file} already exists; not overwriting it`);
|
|
64
|
+
throw e;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
await fh.writeFile(key + "\n", "utf8");
|
|
68
|
+
await fh.chmod(0o600);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
await fh.close();
|
|
72
|
+
}
|
|
73
|
+
return { address: privateKeyToAccount(key).address, file };
|
|
74
|
+
}
|
|
75
|
+
function positive(env, name, dflt) {
|
|
76
|
+
const raw = env[name]?.trim();
|
|
77
|
+
if (!raw)
|
|
78
|
+
return dflt;
|
|
79
|
+
const n = Number(raw);
|
|
80
|
+
if (!(Number.isFinite(n) && n > 0))
|
|
81
|
+
throw new WalletConfigError(`${name} must be a positive number of USD, got ${JSON.stringify(raw)}`);
|
|
82
|
+
return n;
|
|
83
|
+
}
|
|
84
|
+
/** PAIDFOR_MAX_PER_CALL_USD (default 0.50) and PAIDFOR_DAILY_CAP_USD (default 5). */
|
|
85
|
+
export function readLimits(env = process.env) {
|
|
86
|
+
return {
|
|
87
|
+
maxPerCallUsd: positive(env, "PAIDFOR_MAX_PER_CALL_USD", DEFAULT_MAX_PER_CALL_USD),
|
|
88
|
+
dailyCapUsd: positive(env, "PAIDFOR_DAILY_CAP_USD", DEFAULT_DAILY_CAP_USD),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** USDC has 6 decimals; the ledger keeps integer micro-USDC so sums never drift. */
|
|
92
|
+
export const toMicro = (usd) => Math.round(Number(usd) * 1e6);
|
|
93
|
+
export const fromMicro = (m) => (m / 1e6).toFixed(6);
|
|
94
|
+
/** What pay_x402 has spent today (UTC), in ~/.paidfor/spend.json. Only sent authorizations count; replays do not. */
|
|
95
|
+
export class SpendLedger {
|
|
96
|
+
file;
|
|
97
|
+
constructor(file) {
|
|
98
|
+
this.file = file;
|
|
99
|
+
}
|
|
100
|
+
static today(now = new Date()) {
|
|
101
|
+
return now.toISOString().slice(0, 10);
|
|
102
|
+
}
|
|
103
|
+
async spentMicro(now = new Date()) {
|
|
104
|
+
try {
|
|
105
|
+
const j = JSON.parse(await readFile(this.file, "utf8"));
|
|
106
|
+
return j.day === SpendLedger.today(now) && Number.isFinite(j.spent_micro) ? Number(j.spent_micro) : 0;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async add(micro, now = new Date()) {
|
|
113
|
+
const total = (await this.spentMicro(now)) + micro;
|
|
114
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
115
|
+
await writeFile(tmp, JSON.stringify({ day: SpendLedger.today(now), spent_micro: total, spent_usdc: fromMicro(total) }) + "\n", { mode: 0o600 });
|
|
116
|
+
await rename(tmp, this.file);
|
|
117
|
+
return total;
|
|
118
|
+
}
|
|
119
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paidfor/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"mcpName": "io.github.sarmadahsan/paidfor",
|
|
5
|
-
"description": "MCP server for the paidfor.dev x402 seller index: check_x402_seller and
|
|
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",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"bin": {
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
27
|
+
"@paidfor/x402-safe-fetch": "0.2.0",
|
|
28
|
+
"viem": "^2.57.13",
|
|
27
29
|
"zod": "^4.0.0"
|
|
28
30
|
},
|
|
29
31
|
"devDependencies": {
|