@paidfor/mcp 0.6.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/link.js ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Linking a wallet to a paidfor account from the command line, as a device flow: `POST /v1/team/link` returns a short
3
+ * code a person checks and approves in the browser, and `POST /v1/team/link/poll` returns the team key once, after the
4
+ * approval. The device code and the team key are secrets: they are returned to the caller and never put in a message.
5
+ */
6
+ import { assertKeySafeBase } from "./wallet.js";
7
+ /** A non-empty string. */
8
+ const str = (v) => typeof v === "string" && v.length > 0;
9
+ /** POSTs JSON and reads the JSON answer; follows no redirect, gives up after 15 seconds. */
10
+ async function postJson(fetchFn, url, body, client) {
11
+ const res = await fetchFn(url, {
12
+ method: "POST",
13
+ headers: { "Content-Type": "application/json", Accept: "application/json", "X-Paidfor-Client": client },
14
+ body: JSON.stringify(body),
15
+ redirect: "error",
16
+ signal: AbortSignal.timeout(15_000),
17
+ });
18
+ const json = (await res.json().catch(() => ({})));
19
+ return { status: res.status, json: json && typeof json === "object" ? json : {} };
20
+ }
21
+ /** Starts a link. Throws an Error whose message is safe to show (never a code or a key) when it cannot. */
22
+ export async function startLink(o) {
23
+ const base = o.baseUrl.replace(/\/$/, "");
24
+ assertKeySafeBase(base);
25
+ let r;
26
+ try {
27
+ r = await postJson(o.fetchFn ?? fetch, `${base}/v1/team/link`, { address: o.address, label: o.label, client: o.client }, o.client);
28
+ }
29
+ catch (e) {
30
+ throw new Error(`could not reach ${base}: ${e instanceof Error ? e.message : String(e)}`);
31
+ }
32
+ if (r.status === 429)
33
+ throw new Error("paidfor is taking too many link requests right now (HTTP 429)");
34
+ if (r.status === 503)
35
+ throw new Error("paidfor cannot link accounts right now (HTTP 503)");
36
+ if (r.status !== 201 && r.status !== 200)
37
+ throw new Error(`paidfor answered HTTP ${r.status}${str(r.json.error) ? `: ${r.json.error.slice(0, 200)}` : ""}`);
38
+ const j = r.json;
39
+ const interval = typeof j.interval === "number" && Number.isFinite(j.interval) && j.interval > 0 ? j.interval : 5;
40
+ if (!str(j.user_code) || !str(j.device_code) || !str(j.verify_url) || !str(j.expires_at) || Number.isNaN(Date.parse(j.expires_at))) {
41
+ throw new Error("paidfor's answer to the link request could not be read");
42
+ }
43
+ return { user_code: j.user_code, device_code: j.device_code, verify_url: j.verify_url, expires_at: j.expires_at, interval };
44
+ }
45
+ /**
46
+ * Polls until the link is approved, denied, expired or already used, or `expires_at` passes. A network error or a 5xx
47
+ * is tried again on the next tick; a 429 slows the polling down. `immediate` asks once before the first wait (a link
48
+ * resumed from an earlier run); `stopAt` (epoch ms) gives up with { kind: "pending" } when the next poll would be later.
49
+ */
50
+ export async function pollLink(o) {
51
+ const base = o.baseUrl.replace(/\/$/, "");
52
+ const fetchFn = o.fetchFn ?? fetch;
53
+ const sleep = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
54
+ const now = o.now ?? Date.now;
55
+ const deadline = Date.parse(o.start.expires_at);
56
+ let interval = o.start.interval;
57
+ let first = true;
58
+ const stillPending = { kind: "pending", message: "not approved yet" };
59
+ // Whether to wait for another tick, or stop here because the window closes first.
60
+ /** Whether another poll fits before stopAt. */
61
+ const goOn = () => o.stopAt === undefined || now() + interval * 1000 <= o.stopAt;
62
+ for (;;) {
63
+ if (!(first && o.immediate))
64
+ await sleep(interval * 1000);
65
+ first = false;
66
+ if (now() >= deadline)
67
+ return { kind: "expired", message: "the code expired before it was approved" };
68
+ let r;
69
+ try {
70
+ r = await postJson(fetchFn, `${base}/v1/team/link/poll`, { device_code: o.start.device_code }, o.client);
71
+ }
72
+ catch {
73
+ if (!goOn())
74
+ return stillPending;
75
+ continue; // try again on the next tick, until the deadline
76
+ }
77
+ if (r.status === 410)
78
+ return { kind: "used", message: "this link was already used" };
79
+ if (r.status === 429) {
80
+ interval += 5;
81
+ if (!goOn())
82
+ return stillPending;
83
+ continue;
84
+ }
85
+ if (r.status >= 500) {
86
+ if (!goOn())
87
+ return stillPending;
88
+ continue;
89
+ }
90
+ if (r.status !== 200)
91
+ return { kind: "failed", message: `paidfor answered HTTP ${r.status}${str(r.json.error) ? `: ${r.json.error.slice(0, 200)}` : ""}` };
92
+ switch (r.json.status) {
93
+ case "pending":
94
+ if (!goOn())
95
+ return stillPending;
96
+ continue;
97
+ case "approved":
98
+ if (!str(r.json.team_key))
99
+ return { kind: "failed", message: "paidfor approved the link but sent no key" };
100
+ return { kind: "approved", team_key: r.json.team_key, team_name: str(r.json.team_name) ? r.json.team_name : "your account" };
101
+ case "denied":
102
+ return { kind: "denied", message: "the link was denied in the browser" };
103
+ case "expired":
104
+ return { kind: "expired", message: "the code expired before it was approved" };
105
+ default:
106
+ return { kind: "failed", message: "paidfor's answer could not be read" };
107
+ }
108
+ }
109
+ }
package/dist/pay.d.ts CHANGED
@@ -9,7 +9,7 @@ export declare const BODY_LIMIT_BYTES: number;
9
9
  /** When to reuse an intent_id: a retry of the same purchase only. A date-only id (observed: "bitcoin-price-2026-11-01" chosen in three fresh sessions) replays instead of buying. */
10
10
  export declare const INTENT_RULE: string;
11
11
  export declare const PAY_INSTRUCTIONS: string;
12
- /** The stdio server's instructions with a wallet configured: the pay paragraph goes in before the no-account promise, which stays last. */
12
+ /** The stdio server's instructions with a wallet configured: the pay paragraph goes last. */
13
13
  export declare const INSTRUCTIONS_WITH_PAY: string;
14
14
  export declare const PAY_TITLE = "Fetch a paid (x402) URL, paying with your wallet";
15
15
  export declare const PAY_ALIAS_TITLE = "Fetch a paid URL (same as pay_x402)";
package/dist/pay.js CHANGED
@@ -10,7 +10,7 @@ import { PaymentRefusedError, SafeFetchError, safeFetch } from "@paidfor/x402-sa
10
10
  import { createPublicClient, erc20Abi, http } from "viem";
11
11
  import { base as baseChain } from "viem/chains";
12
12
  import { z } from "zod";
13
- import { INSTRUCTIONS, NO_ACCOUNT_PROMISE, PAID_URL_SIGNALS, PAY_ROUTING_RULE, PLAIN_FETCH_FAILS } from "./server.js";
13
+ import { INSTRUCTIONS, PAID_URL_SIGNALS, PAY_ROUTING_RULE, PLAIN_FETCH_FAILS } from "./server.js";
14
14
  import { TeamClient, outcomeFor } from "./team.js";
15
15
  import { SpendLedger, fromMicro, paidforDir, toMicro } from "./wallet.js";
16
16
  export const PAY_TOOL_NAMES = ["pay_x402", "fetch_paid_url", "wallet_status"];
@@ -29,8 +29,8 @@ export const PAY_INSTRUCTIONS = `${PAY_ROUTING_RULE} ` +
29
29
  `${INTENT_RULE} ` +
30
30
  "Tell the user the amount and the seller verdict before the first purchase from a new seller. " +
31
31
  "pay_x402 signs with the user's own wallet on this machine; the key never leaves it.";
32
- /** The stdio server's instructions with a wallet configured: the pay paragraph goes in before the no-account promise, which stays last. */
33
- export const INSTRUCTIONS_WITH_PAY = INSTRUCTIONS.replace(`\n${NO_ACCOUNT_PROMISE}`, `\n${PAY_INSTRUCTIONS}\n${NO_ACCOUNT_PROMISE}`);
32
+ /** The stdio server's instructions with a wallet configured: the pay paragraph goes last. */
33
+ export const INSTRUCTIONS_WITH_PAY = `${INSTRUCTIONS}\n${PAY_INSTRUCTIONS}`;
34
34
  export const PAY_TITLE = "Fetch a paid (x402) URL, paying with your wallet";
35
35
  export const PAY_ALIAS_TITLE = "Fetch a paid URL (same as pay_x402)";
36
36
  export const PAY_DESCRIPTION = "Fetches a paid (x402) URL end to end: sends the request, handles the HTTP 402 and pays the quoted USDC on Base with the user's own wallet under the configured caps, and returns the response body. " +
@@ -0,0 +1,25 @@
1
+ /** The saved link: what `link` needs to poll it, register the wallet and write the configs. */
2
+ export type PendingLink = {
3
+ v: 1;
4
+ device_code: string;
5
+ user_code: string;
6
+ verify_url: string;
7
+ expires_at: string;
8
+ interval: number;
9
+ base_url: string;
10
+ label: string;
11
+ key_file: string;
12
+ address: string;
13
+ clients: string[];
14
+ /** Set once the link was approved but the registration failed: the key handed over, kept for the retry. */
15
+ team_key?: string;
16
+ team_name?: string;
17
+ };
18
+ /** Where the record lives: ~/.paidfor/link-pending.json. */
19
+ export declare const pendingPath: (home: string) => string;
20
+ /** The saved record, or null when there is none or it is unreadable or malformed. Never throws. */
21
+ export declare function readPending(home: string): Promise<PendingLink | null>;
22
+ /** Saves the record atomically (dir 700, file 600), replacing any earlier one. Returns the file's path. */
23
+ export declare function writePending(home: string, rec: Omit<PendingLink, "v">): Promise<string>;
24
+ /** Removes the record; a record already gone is fine. */
25
+ export declare function deletePending(home: string): Promise<void>;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * A link started by `init --yes --link` (or `link --yes`) that nobody waited for: an agent's shell returns output only
3
+ * when the command exits, so the command prints the approval link, saves this record and exits, and a later
4
+ * `paidfor-mcp link` resumes it. ~/.paidfor/link-pending.json (dir 700, file 600, atomic write) is the only place the
5
+ * device code is ever written. A record that cannot be read is treated as absent and replaced.
6
+ *
7
+ * A record that carries `team_key` means "approved, registration owed": the browser approval went through and the key
8
+ * was handed over (once), but registering the wallet failed. `link` retries the registration with that key instead of
9
+ * asking for a new approval. Such a record does not expire with the link: the key does not.
10
+ */
11
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { paidforDir } from "./wallet.js";
14
+ /** Where the record lives: ~/.paidfor/link-pending.json. */
15
+ export const pendingPath = (home) => join(paidforDir(home), "link-pending.json");
16
+ /** A non-empty string. */
17
+ const str = (v) => typeof v === "string" && v.length > 0;
18
+ /** The saved record, or null when there is none or it is unreadable or malformed. Never throws. */
19
+ export async function readPending(home) {
20
+ try {
21
+ const j = JSON.parse(await readFile(pendingPath(home), "utf8"));
22
+ if (!j || typeof j !== "object")
23
+ return null;
24
+ const ok = str(j.device_code) &&
25
+ str(j.user_code) &&
26
+ str(j.verify_url) &&
27
+ str(j.expires_at) &&
28
+ !Number.isNaN(Date.parse(j.expires_at)) &&
29
+ typeof j.interval === "number" &&
30
+ j.interval > 0 &&
31
+ str(j.base_url) &&
32
+ typeof j.label === "string" &&
33
+ str(j.key_file) &&
34
+ str(j.address) &&
35
+ Array.isArray(j.clients) &&
36
+ j.clients.every((c) => typeof c === "string") &&
37
+ (j.team_key === undefined || str(j.team_key)) &&
38
+ (j.team_name === undefined || typeof j.team_name === "string");
39
+ return ok ? j : null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ /** Saves the record atomically (dir 700, file 600), replacing any earlier one. Returns the file's path. */
46
+ export async function writePending(home, rec) {
47
+ const dir = paidforDir(home);
48
+ await mkdir(dir, { recursive: true, mode: 0o700 });
49
+ await chmod(dir, 0o700).catch(() => undefined);
50
+ const file = pendingPath(home);
51
+ const tmp = `${file}.${process.pid}.tmp`;
52
+ try {
53
+ await writeFile(tmp, JSON.stringify({ v: 1, ...rec }) + "\n", { mode: 0o600 });
54
+ await chmod(tmp, 0o600);
55
+ await rename(tmp, file);
56
+ }
57
+ catch (e) {
58
+ await unlink(tmp).catch(() => undefined);
59
+ throw e;
60
+ }
61
+ return file;
62
+ }
63
+ /** Removes the record; a record already gone is fine. */
64
+ export async function deletePending(home) {
65
+ await unlink(pendingPath(home)).catch(() => undefined);
66
+ }
package/dist/rules.js CHANGED
@@ -34,7 +34,7 @@ export const RULES_SHA = "7258b901f6577a02dfe997416d3de61e122f5da21476eb686dd1c1
34
34
  */
35
35
  export const REPORT_TRIGGER = "after your agent pays an x402 seller with any wallet";
36
36
  export const REPORT_DESCRIPTION = "After your agent pays an x402 seller with any wallet, report what came back. Needs the resource URL, the settlement transaction hash and the outcome. " +
37
- "Free, no account, no key; the report counts only with on-chain proof.";
37
+ "Free; the report counts only with on-chain proof.";
38
38
  /** The outcomes a reporter can name, in the index's words. */
39
39
  export const REPORT_OUTCOMES = {
40
40
  ok: "the response was usable for its purpose",
package/dist/server.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { REPORT_OUTCOMES } from "./rules.js";
3
3
  export * from "./rules.js";
4
- export declare const VERSION = "0.4.1";
4
+ export declare const VERSION = "0.7.0";
5
5
  /** Sent on every request to the index so usage can be counted by channel (never anything else). */
6
- export declare const CLIENT_HEADER = "mcp/0.4.1";
6
+ export declare const CLIENT_HEADER = "mcp/0.7.0";
7
7
  /** What the hosted service (index.ts) sends instead, so the index can count hosted and stdio use apart. */
8
- export declare const HOSTED_CLIENT_HEADER = "mcp-hosted/0.4.1";
8
+ export declare const HOSTED_CLIENT_HEADER = "mcp-hosted/0.7.0";
9
9
  /**
10
10
  * Which client product a hosted session comes from, read from `clientInfo.name` on initialize and nothing else from the
11
11
  * handshake: a product name, never a user. The hosted service appends it to its header (`mcp-hosted/<version>+claude`),
@@ -17,11 +17,6 @@ export declare function clientTag(name: unknown): ClientTag;
17
17
  export declare const taggedClientHeader: (tag: ClientTag | null) => string;
18
18
  export declare const DEFAULT_BASE = "https://paidfor.dev";
19
19
  export declare const TOOL_NAMES: readonly ["check_x402_seller", "list_reliable_sellers", "recent_changes", "report_x402_payment"];
20
- /**
21
- * The no-account promise, word for word wherever it appears: the server instructions (last line),
22
- * this package's README, /methodology, /install.md and /privacy on the site.
23
- */
24
- export declare const NO_ACCOUNT_PROMISE: string;
25
20
  /** Sent on initialize by every transport (stdio, the hosted service, the site's /mcp route), so clients call the tools unprompted.
26
21
  * Generated from rules.ts, like the tool descriptions below and the skill at paidfor.dev/skill.md. */
27
22
  export declare const INSTRUCTIONS: string;
package/dist/server.js CHANGED
@@ -10,7 +10,7 @@ import { ListPromptsRequestSchema, ListResourcesRequestSchema, ListResourceTempl
10
10
  import { z } from "zod";
11
11
  import { CHANGES_TRIGGER, CHECK_TRIGGERS, CHOOSE_TRIGGER, NEVER_PAY_TWICE, REPORT_DESCRIPTION, REPORT_OUTCOMES, REPORT_SENDS, SENDS_ONLY_URL, VERDICT_SENTENCE, cap, listAnd, listOr } from "./rules.js";
12
12
  export * from "./rules.js";
13
- export const VERSION = "0.4.1";
13
+ export const VERSION = "0.7.0";
14
14
  /** Sent on every request to the index so usage can be counted by channel (never anything else). */
15
15
  export const CLIENT_HEADER = `mcp/${VERSION}`;
16
16
  /** What the hosted service (index.ts) sends instead, so the index can count hosted and stdio use apart. */
@@ -35,12 +35,6 @@ export const taggedClientHeader = (tag) => (tag ? `${HOSTED_CLIENT_HEADER}+${tag
35
35
  export const DEFAULT_BASE = "https://paidfor.dev";
36
36
  export const TOOL_NAMES = ["check_x402_seller", "list_reliable_sellers", "recent_changes", "report_x402_payment"];
37
37
  const CATEGORIES = ["inference", "data", "scraping", "compute", "other"];
38
- /**
39
- * The no-account promise, word for word wherever it appears: the server instructions (last line),
40
- * this package's README, /methodology, /install.md and /privacy on the site.
41
- */
42
- 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. " +
43
- "An email address is asked for only to deliver alerts or to join a waitlist, as a confirmation link, never a profile.";
44
38
  /** Sent on initialize by every transport (stdio, the hosted service, the site's /mcp route), so clients call the tools unprompted.
45
39
  * Generated from rules.ts, like the tool descriptions below and the skill at paidfor.dev/skill.md. */
46
40
  export const INSTRUCTIONS = "paidfor rates x402 sellers by paying them and verifying delivery. " +
@@ -50,9 +44,7 @@ export const INSTRUCTIONS = "paidfor rates x402 sellers by paying them and verif
50
44
  `${cap(CHANGES_TRIGGER)}, call recent_changes. ` +
51
45
  `${NEVER_PAY_TWICE} ` +
52
46
  `${SENDS_ONLY_URL} ` +
53
- `After any x402 payment made with any wallet outside this server, call report_x402_payment with the resource URL, the settlement transaction hash and the outcome; it moves no money and sends only ${REPORT_SENDS}.` +
54
- "\n" +
55
- NO_ACCOUNT_PROMISE;
47
+ `After any x402 payment made with any wallet outside this server, call report_x402_payment with the resource URL, the settlement transaction hash and the outcome; it moves no money and sends only ${REPORT_SENDS}.`;
56
48
  /**
57
49
  * What the hosted transports (index.ts, the site's /mcp route) send instead of INSTRUCTIONS: the same facts, described
58
50
  * rather than directed, because the Connectors Directory review rejects text that tells Claude how to behave or to call
@@ -64,8 +56,7 @@ export const HOSTED_INSTRUCTIONS = "paidfor rates x402 sellers by paying them an
64
56
  "list_reliable_sellers returns sellers with zero failures across at least 30 paid calls, for choosing among sellers. " +
65
57
  "recent_changes returns what changed in the index, which is diffed hourly. " +
66
58
  `${SENDS_ONLY_URL} ` +
67
- `report_x402_payment records what came back after a payment the user's own wallet already made, from the resource URL, the settlement transaction hash and the outcome; it moves no money, sends only ${REPORT_SENDS}, and the report counts only once the transaction is verified as a USDC transfer on Base.\n` +
68
- NO_ACCOUNT_PROMISE;
59
+ `report_x402_payment records what came back after a payment the user's own wallet already made, from the resource URL, the settlement transaction hash and the outcome; it moves no money, sends only ${REPORT_SENDS}, and the report counts only once the transaction is verified as a USDC transfer on Base.`;
69
60
  /** The first sentence of each read tool description: when to call it. The manifest and the setup page list these as they are. */
70
61
  export const TOOL_TRIGGERS = {
71
62
  check_x402_seller: `Call this ${listOr(CHECK_TRIGGERS)}.`,
@@ -88,7 +79,7 @@ export const SERVER_INFO = {
88
79
  title: "paidfor: x402 seller reliability",
89
80
  version: VERSION,
90
81
  description: "Helps an agent decide whether to pay an x402 API seller on Base: verdicts, failure rates and evidence from real paid calls, " +
91
- "a list of sellers that have never failed, and a feed of recent changes. No account or API key needed.",
82
+ "a list of sellers that have never failed, and a feed of recent changes.",
92
83
  websiteUrl: DEFAULT_BASE,
93
84
  icons: [{ src: `${DEFAULT_BASE}/icon.png`, mimeType: "image/png", sizes: ["512x512"] }],
94
85
  };
package/dist/setup.d.ts CHANGED
@@ -6,6 +6,9 @@ export declare const MANIFEST_URL = "https://paidfor.dev/.well-known/mcp.json";
6
6
  export declare const AGENT_SETUP = "Tell your agent to read https://paidfor.dev/skill.md";
7
7
  /** What the hosted transports put in X-Paidfor-Client on every call to the index (taggedClientHeader: mcp-hosted/<version>+<client tag>). */
8
8
  export declare const CLIENT_HEADER_NOTE = "Every call the server makes to the index carries X-Paidfor-Client: mcp-hosted/<version>+<client>, so use can be counted by channel; <client> is your client product (claude, chatgpt, cursor or other, from its name on initialize), never you.";
9
+ /** The one-command install (`paidfor-mcp init`): detects the clients, offers a wallet and a link to an account, writes the config. */
10
+ export declare const INIT_COMMAND: (pkg: string) => string;
11
+ /** The Claude Code command that adds the hosted server, user scope. */
9
12
  export declare const claudeCodeCommand: (url: string) => string;
10
13
  export declare const cursorServer: (url: string) => {
11
14
  url: string;
@@ -43,6 +46,5 @@ export declare const MANIFEST_META: {
43
46
  skill_url: string;
44
47
  privacy_url: string;
45
48
  auth: string;
46
- no_account: string;
47
49
  };
48
50
  };
package/dist/setup.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * /install), the HTML page a browser gets from GET on the MCP URL, and the `_meta` block of the manifest at
4
4
  * paidfor.dev/.well-known/mcp.json. All of it is generated from server.ts and rules.ts, like the tool descriptions.
5
5
  */
6
- import { DEFAULT_BASE, HOSTED_INSTRUCTIONS, NO_ACCOUNT_PROMISE, TOOL_NAMES, TOOL_TRIGGERS, VERSION } from "./server.js";
6
+ import { DEFAULT_BASE, HOSTED_INSTRUCTIONS, TOOL_NAMES, TOOL_TRIGGERS, VERSION } from "./server.js";
7
7
  export const HOSTED_URL = "https://mcp.paidfor.dev/mcp";
8
8
  export const SETUP_URL = `${DEFAULT_BASE}/install`;
9
9
  export const SKILL_URL = `${DEFAULT_BASE}/skill.md`;
@@ -12,6 +12,9 @@ export const MANIFEST_URL = `${DEFAULT_BASE}/.well-known/mcp.json`;
12
12
  export const AGENT_SETUP = `Tell your agent to read ${SKILL_URL}`;
13
13
  /** What the hosted transports put in X-Paidfor-Client on every call to the index (taggedClientHeader: mcp-hosted/<version>+<client tag>). */
14
14
  export const CLIENT_HEADER_NOTE = "Every call the server makes to the index carries X-Paidfor-Client: mcp-hosted/<version>+<client>, so use can be counted by channel; <client> is your client product (claude, chatgpt, cursor or other, from its name on initialize), never you.";
15
+ /** The one-command install (`paidfor-mcp init`): detects the clients, offers a wallet and a link to an account, writes the config. */
16
+ export const INIT_COMMAND = (pkg) => `npx -y ${pkg} init`;
17
+ /** The Claude Code command that adds the hosted server, user scope. */
15
18
  export const claudeCodeCommand = (url) => `claude mcp add --transport http --scope user paidfor ${url}`;
16
19
  export const cursorServer = (url) => ({ url });
17
20
  export const cursorDeepLink = (url) => `cursor://anysphere.cursor-deeplink/mcp/install?name=paidfor&config=${encodeURIComponent(Buffer.from(JSON.stringify(cursorServer(url)), "utf8").toString("base64"))}`;
@@ -57,7 +60,7 @@ export function setupPageHtml({ endpoint, statusUrl }) {
57
60
  return `<!doctype html>
58
61
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
59
62
  <title>paidfor MCP server</title>
60
- <meta name="description" content="Set up the paidfor MCP server: check an x402 seller before your agent pays. No account, no key.">
63
+ <meta name="description" content="Set up the paidfor MCP server: check an x402 seller before your agent pays.">
61
64
  <link rel="icon" href="${DEFAULT_BASE}/icon.png"><link rel="alternate" type="application/json" href="${esc(MANIFEST_URL)}">
62
65
  <style>${CSS}</style></head>
63
66
  <body><main>
@@ -91,8 +94,6 @@ ${pre(cursorConfig(HOSTED_URL))}
91
94
  <ul>${tools}</ul>
92
95
  <details><summary>Server instructions, as sent on initialize</summary><p>${esc(HOSTED_INSTRUCTIONS)}</p></details>
93
96
 
94
- <h2>No account, no key</h2>
95
- <p>${esc(NO_ACCOUNT_PROMISE)}</p>
96
97
  <p class="muted">${esc(CLIENT_HEADER_NOTE)}</p>
97
98
 
98
99
  <footer class="muted"><a href="${MANIFEST_URL}">Manifest</a> · <a href="${DEFAULT_BASE}/api">API</a> · <a href="${PRIVACY_URL}">Privacy</a> · <a href="${esc(statusUrl)}">Status JSON</a></footer>
@@ -113,6 +114,5 @@ export const MANIFEST_META = {
113
114
  skill_url: SKILL_URL,
114
115
  privacy_url: PRIVACY_URL,
115
116
  auth: "none",
116
- no_account: NO_ACCOUNT_PROMISE,
117
117
  },
118
118
  };
package/dist/stdio.js CHANGED
@@ -2,6 +2,7 @@
2
2
  /**
3
3
  * `npx @paidfor/mcp`: the stdio server. With PAIDFOR_WALLET_KEY_FILE (or PAIDFOR_WALLET_KEY) set it also
4
4
  * registers pay_x402 and wallet_status; without one it is the same read-only server as the hosted one.
5
+ * `paidfor-mcp init` does the whole install: finds the MCP clients, offers a wallet and a link to an account, writes the config.
5
6
  * `paidfor-mcp wallet new` writes a fresh key to ~/.paidfor/wallet.key (mode 600) and prints the address.
6
7
  * `paidfor-mcp wallet register` proves that wallet to a paidfor team (PAIDFOR_TEAM_KEY). With PAIDFOR_TEAM_KEY also set on
7
8
  * the server, pay_x402 asks the team's rules before every payment.
@@ -11,6 +12,18 @@ import { DEFAULT_BASE, createServer } from "./server.js";
11
12
  import { FUND_SENTENCE, WalletConfigError, loadWallet, newWallet, readLimits, registerWallet } from "./wallet.js";
12
13
  const [cmd, sub, ...rest] = process.argv.slice(2);
13
14
  const baseUrl = (process.env.PAIDFOR_API_BASE ?? process.env.PAIDFOR_URL ?? DEFAULT_BASE).replace(/\/$/, "");
15
+ if (cmd === "init" || cmd === "link") {
16
+ // The one-command install (init.ts); `link` is its link, register and config steps alone.
17
+ const { realDeps, runInit } = await import("./init.js");
18
+ const args = process.argv.slice(3);
19
+ try {
20
+ process.exit(await runInit(args, realDeps(), cmd));
21
+ }
22
+ catch (e) {
23
+ process.stderr.write(`paidfor-mcp: ${e instanceof Error ? e.message : String(e)}\n`);
24
+ process.exit(1);
25
+ }
26
+ }
14
27
  if (cmd === "wallet") {
15
28
  const usage = "usage: paidfor-mcp wallet new | PAIDFOR_TEAM_KEY=pft_... paidfor-mcp wallet register [--label \"research agent\"]\n";
16
29
  try {
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.6.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",