@paidfor/mcp 0.4.0 → 0.6.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/README.md +36 -1
- package/dist/pay.d.ts +31 -1
- package/dist/pay.js +111 -27
- package/dist/rules.d.ts +10 -0
- package/dist/rules.js +13 -0
- package/dist/server.d.ts +3 -3
- package/dist/server.js +1 -1
- package/dist/stdio.js +38 -12
- package/dist/team.d.ts +80 -0
- package/dist/team.js +170 -0
- package/dist/wallet.d.ts +25 -0
- package/dist/wallet.js +51 -0
- package/package.json +4 -2
- package/dist/http.d.ts +0 -1
- package/dist/http.js +0 -49
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ The read tools use the public JSON API (`GET https://paidfor.dev/v1/check`, `/v1
|
|
|
45
45
|
|
|
46
46
|
## Let your agent pay, safely (stdio only)
|
|
47
47
|
|
|
48
|
-
`pay_x402` pays an x402 seller with **your own wallet**, through `@paidfor/x402-safe-fetch` 0.
|
|
48
|
+
`pay_x402` pays an x402 seller with **your own wallet**, through `@paidfor/x402-safe-fetch` 0.4.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.
|
|
49
49
|
|
|
50
50
|
**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.
|
|
51
51
|
|
|
@@ -70,6 +70,24 @@ That writes `~/.paidfor/wallet.key` with mode 600 (it never overwrites an existi
|
|
|
70
70
|
|
|
71
71
|
Verify by asking the agent to call `wallet_status`.
|
|
72
72
|
|
|
73
|
+
**Put the wallet under a team's rules (0.5.0).** A paidfor team account (`https://paidfor.dev/team`) sets budgets and rules on a wallet. To register this wallet to your team, make a team key on that page and run:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
PAIDFOR_TEAM_KEY=pft_... npx -y @paidfor/mcp wallet register --label "research agent"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
It asks paidfor for a challenge, signs it with the wallet's key (the key is sent nowhere) and sends the signature back. The wallet is `PAIDFOR_WALLET_KEY_FILE` or `PAIDFOR_WALLET_KEY` when set, else `~/.paidfor/wallet.key`; if the MCP client is configured with a key file elsewhere, set `PAIDFOR_WALLET_KEY_FILE` to that same path for this command too, or it registers the default wallet instead. A wallet registered with a key starts paused: open the team page, check its limits, untick Paused and save. The team key is read from the environment only, never from an argument. Written into the command it stays in your shell history; to keep it out, `read -rs PAIDFOR_TEAM_KEY && export PAIDFOR_TEAM_KEY` first and run the command without the prefix. Registering does not configure the server: `pay_x402` still needs `PAIDFOR_WALLET_KEY_FILE` in the MCP client config, as above.
|
|
80
|
+
|
|
81
|
+
**Ask the team before each payment (0.6.0).** Registering alone does not put payments under the team's rules. Give the server the team key too:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
"env": { "PAIDFOR_WALLET_KEY_FILE": "/Users/you/.paidfor/wallet.key", "PAIDFOR_TEAM_KEY": "pft_..." }
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
With `PAIDFOR_TEAM_KEY` set, `pay_x402` asks `POST /v1/team/authorize` after its own checks and before it signs, and the local caps still apply. It pays only on an approval, and signs with the approval's reference as the EIP-3009 nonce and its deadline as `validBefore`, so the payment can be matched to the ledger. If the server is unreachable or answers anything but a clear decision, nothing is paid. A decline comes back with the team's reason. A payment waiting for a person comes back with the link to approve it and the `intent_id` to call again with. After a payment the outcome goes to `POST /v1/team/outcome`; a report the server cannot take is kept in `~/.paidfor/team-outcomes/` and sent before the next payment. The key sits in your MCP client's config, so treat that file as a secret. The key cannot change rules, keys or approvals. The agent holds its own wallet key here, so a payment made some other way is not stopped. Stdio only: the hosted server never pays.
|
|
88
|
+
|
|
89
|
+
**Routing a paid URL (0.4.1).** The model should reach for `pay_x402` whenever a URL is paid, metered, pay-per-call, x402, priced in USDC, said to charge a fee, or has answered 402 to any other fetch, even when the client also has a generic web fetch tool and the user never says how to pay. The tool is described as the way to fetch such a URL end to end (it sends the request, handles the 402, pays under the caps and returns the body), the description says a plain fetch fails on these URLs with 402, and the stdio server adds the same rule to its `instructions` only when the paying tools are registered; the hosted server's text is unchanged. `eval/routing.mjs` measures the first-call rate per model and prompt shape (`pnpm eval:routing`; results by date in `eval/routing-<date>.md`). `PAIDFOR_FETCH_ALIAS=1` also registers the same handler as `fetch_paid_url`, for clients that route by a fetch-shaped name; the eval compares both and the alias is off by default.
|
|
90
|
+
|
|
73
91
|
| env | default | |
|
|
74
92
|
|---|---|---|
|
|
75
93
|
| `PAIDFOR_WALLET_KEY_FILE` | | path to the key file; refused if group- or world-readable (`chmod 600`) |
|
|
@@ -77,6 +95,8 @@ Verify by asking the agent to call `wallet_status`.
|
|
|
77
95
|
| `PAIDFOR_MAX_PER_CALL_USD` | `0.50` | most one call may pay; `max_usd` can lower it, never raise it |
|
|
78
96
|
| `PAIDFOR_DAILY_CAP_USD` | `5` | most all calls may pay per UTC day (tracked in `~/.paidfor/spend.json`) |
|
|
79
97
|
| `BASE_RPC_URL` | `https://mainnet.base.org` | used only by `wallet_status` for the balance |
|
|
98
|
+
| `PAIDFOR_TEAM_KEY` | | with a wallet: ask the team's rules before each payment and report the outcome (`https` only, or a server on this machine); also used by `wallet register` |
|
|
99
|
+
| `PAIDFOR_FETCH_ALIAS` | off | `1` also registers `pay_x402` as `fetch_paid_url` (same handler, schema and caps) |
|
|
80
100
|
|
|
81
101
|
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.
|
|
82
102
|
|
|
@@ -104,6 +124,14 @@ Usage is counted by Manufact's gateway (every `tools/call` with client and sessi
|
|
|
104
124
|
|
|
105
125
|
`pnpm build && pnpm eval` runs ten user messages that never mention paidfor through the Anthropic API (`ANTHROPIC_API_KEY` from the environment or an untracked `eval/.env`; model `claude-sonnet-5-5` or `EVAL_MODEL`) with the server's tools and instructions attached, by default with the tools deferred as Claude Code presents them, and prints which ones called the right tool first. Results by date in `eval/RESULTS.md`.
|
|
106
126
|
|
|
127
|
+
## Routing eval
|
|
128
|
+
|
|
129
|
+
`pnpm build && pnpm eval:routing` (`eval/routing.mjs`) runs paid-URL prompts that never mention paidfor, wallets or tool names through the Anthropic API on `claude-sonnet-5-5` and `claude-opus-5-5`, five trials each, with every tool simulated and nothing paid. Two scenarios: the default `hosted` is the primary adoption path, the hosted server's four tools (no wallet) next to the user's own `web_fetch` and a third-party `x402_pay`, measuring how often the model checks the seller before its first payment attempt, refuses an avoid seller, and reports a payment with the right transaction hash; `EVAL_SCENARIO=stdio` is the stdio server with a wallet next to a generic `web_fetch`, measuring how often the first fetch of a paid URL goes to `pay_x402`. Variants compare wordings (`EVAL_VARIANTS`); the hosted text is frozen by the listings, so its directive variant is a what-if. Results by date in `eval/routing-<date>.md`.
|
|
130
|
+
|
|
131
|
+
## Tests
|
|
132
|
+
|
|
133
|
+
`pnpm test` builds and runs `test/*.test.mjs` with Node's test runner: `hosted-snapshot` fails on any change to the hosted server's tool list, instructions or empty resources/prompts lists (the ChatGPT app review rescans them daily; regenerate on purpose with `UPDATE_SNAPSHOT=1`), `pay-wording` checks the `pay_x402` framing, guarantees, annotations and the alias, `pay-replay` the replay line and the `intent_id` guidance against a local seller, `pay-team` team mode against a local seller and a scripted server. The site's `vitest` suite (`test/mcp-pay.test.ts` at the repo root) covers the paying tools end to end against a local seller, and `test/mcp-team.test.ts` runs team mode against the real server code.
|
|
134
|
+
|
|
107
135
|
## Claude Desktop extension
|
|
108
136
|
|
|
109
137
|
`pnpm mcpb` bundles the stdio server with esbuild, writes `manifest.json` at the package version, validates and packs it with `@anthropic-ai/mcpb`, and copies it to the site's `public/paidfor.mcpb` (served at https://paidfor.dev/paidfor.mcpb).
|
|
@@ -111,3 +139,10 @@ Usage is counted by Manufact's gateway (every `tools/call` with client and sessi
|
|
|
111
139
|
## Registry
|
|
112
140
|
|
|
113
141
|
`server.json` is the Official MCP Registry entry (`dev.paidfor/paidfor`, matching `mcpName` in `package.json` from 0.3.3) and the manifest the site serves at https://paidfor.dev/.well-known/mcp.json and `/mcp.json`. Its `_meta` is `MANIFEST_META` from `src/setup.ts`; the site test fails when they differ. Publish npm first, then `mcp-publisher login dns --domain paidfor.dev` and `mcp-publisher publish` (checklist in `docs/listings.md` at the repo root).
|
|
142
|
+
|
|
143
|
+
## Changelog
|
|
144
|
+
|
|
145
|
+
- **0.6.0** (unpublished): team mode. With `PAIDFOR_TEAM_KEY` set, `pay_x402` asks the team before signing, fails closed, signs the approval's nonce and deadline and reports the outcome. Needs `@paidfor/x402-safe-fetch` 0.4.0. The hosted server's tools and instructions are unchanged.
|
|
146
|
+
- **0.5.0** (unpublished): `paidfor-mcp wallet register` registers the configured wallet to a paidfor team (`PAIDFOR_TEAM_KEY`), signing the team's challenge locally; new test `wallet-register.test.mjs`. The hosted server's tools and instructions are unchanged.
|
|
147
|
+
- **0.4.1** (unpublished): `pay_x402` is framed as the way to fetch a paid (x402) URL end to end, its description says a generic fetch fails on such URLs with 402 and that any URL called paid, metered, x402 or that answered 402 goes to it with the same method, headers and body; the stdio server with a wallet adds the same routing rule to its instructions (`PAY_ROUTING_RULE` in `rules.ts`). A replayed result (same `intent_id`) now opens with the age and time of the stored result and says to call again with a new id for a new purchase; the description, the schema and the stdio rules say to reuse an `intent_id` only when retrying the same purchase after an error or timeout and to omit it otherwise (a date-only id chosen in three fresh sessions replayed instead of buying). Optional `fetch_paid_url` alias (`PAIDFOR_FETCH_ALIAS=1`). New `pnpm test` (hosted-surface snapshot, wording) and `pnpm eval:routing`. The hosted server's tools and instructions are unchanged.
|
|
148
|
+
- **0.4.0**: `report_x402_payment` on every transport; `mcpName` `dev.paidfor/paidfor`.
|
package/dist/pay.d.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { type Receipt, type SafeNetwork } from "@paidfor/x402-safe-fetch";
|
|
3
|
+
import { type TeamDecision } from "./team.js";
|
|
3
4
|
import { type Limits, type LoadedWallet } from "./wallet.js";
|
|
4
|
-
export declare const PAY_TOOL_NAMES: readonly ["pay_x402", "wallet_status"];
|
|
5
|
+
export declare const PAY_TOOL_NAMES: readonly ["pay_x402", "fetch_paid_url", "wallet_status"];
|
|
6
|
+
/** The second name for the pay_x402 handler, registered only with PayOptions.fetchAlias (PAIDFOR_FETCH_ALIAS=1 on stdio). */
|
|
7
|
+
export declare const PAY_ALIAS = "fetch_paid_url";
|
|
5
8
|
export declare const BODY_LIMIT_BYTES: number;
|
|
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
|
+
export declare const INTENT_RULE: string;
|
|
6
11
|
export declare const PAY_INSTRUCTIONS: string;
|
|
7
12
|
/** The stdio server's instructions with a wallet configured: the pay paragraph goes in before the no-account promise, which stays last. */
|
|
8
13
|
export declare const INSTRUCTIONS_WITH_PAY: string;
|
|
14
|
+
export declare const PAY_TITLE = "Fetch a paid (x402) URL, paying with your wallet";
|
|
15
|
+
export declare const PAY_ALIAS_TITLE = "Fetch a paid URL (same as pay_x402)";
|
|
9
16
|
export declare const PAY_DESCRIPTION: string;
|
|
17
|
+
/** The alias carries the whole description, so a client that shows tools by name routes to either; the last sentence stops double calls. */
|
|
18
|
+
export declare const PAY_ALIAS_DESCRIPTION: string;
|
|
10
19
|
export declare const WALLET_STATUS_DESCRIPTION: string;
|
|
11
20
|
export type PayOptions = {
|
|
12
21
|
wallet: LoadedWallet;
|
|
@@ -20,6 +29,12 @@ export type PayOptions = {
|
|
|
20
29
|
fetch?: typeof globalThis.fetch;
|
|
21
30
|
/** wallet_status balance; default reads USDC.balanceOf on Base over BASE_RPC_URL or https://mainnet.base.org. */
|
|
22
31
|
balanceOf?: (address: `0x${string}`) => Promise<bigint>;
|
|
32
|
+
/** Also register the same handler as fetch_paid_url (PAY_ALIAS). Off by default; stdio turns it on with PAIDFOR_FETCH_ALIAS=1. */
|
|
33
|
+
fetchAlias?: boolean;
|
|
34
|
+
/** PAIDFOR_TEAM_KEY: ask the team's rules before every payment and report the outcome. Stdio only. */
|
|
35
|
+
teamKey?: string;
|
|
36
|
+
/** Tests only: how long to wait before the second try of an outcome report. */
|
|
37
|
+
teamRetryDelayMs?: number;
|
|
23
38
|
};
|
|
24
39
|
/** The receipt as pay_x402 reports it: what was paid, to whom, the proof, and how to check it. */
|
|
25
40
|
export declare function receiptSummary(r: Receipt): {
|
|
@@ -27,6 +42,7 @@ export declare function receiptSummary(r: Receipt): {
|
|
|
27
42
|
paid: boolean;
|
|
28
43
|
replayed: boolean;
|
|
29
44
|
intent_id: string;
|
|
45
|
+
stored_at: string | null;
|
|
30
46
|
amount_usdc: string;
|
|
31
47
|
pay_to: string | null;
|
|
32
48
|
tx_hash: string | null;
|
|
@@ -42,6 +58,20 @@ export declare function receiptSummary(r: Receipt): {
|
|
|
42
58
|
reported_outcome: import("@paidfor/x402-safe-fetch").TelemetryOutcome | null;
|
|
43
59
|
verification_url: string | null;
|
|
44
60
|
};
|
|
61
|
+
/** "14 minutes", "3 hours", "2 days", "less than a minute". */
|
|
62
|
+
export declare function describeAge(ms: number): string;
|
|
63
|
+
/** The first line of a replayed pay_x402 result: the age and time of the stored result, so a second genuine purchase is not mistaken for a retry. */
|
|
64
|
+
export declare function replayHead(r: Receipt, now?: number): string;
|
|
65
|
+
/** What pay_x402 says when the team's rules, not the seller or the local caps, stop a payment. Nothing was paid in any of these. */
|
|
66
|
+
export declare function teamRefusal(d: Exclude<TeamDecision, {
|
|
67
|
+
kind: "approve";
|
|
68
|
+
}>, intentId: string, limits: unknown): {
|
|
69
|
+
isError?: boolean | undefined;
|
|
70
|
+
content: {
|
|
71
|
+
type: "text";
|
|
72
|
+
text: string;
|
|
73
|
+
}[];
|
|
74
|
+
};
|
|
45
75
|
export declare function truncateBody(body: string): {
|
|
46
76
|
body: string;
|
|
47
77
|
note: string | null;
|
package/dist/pay.js
CHANGED
|
@@ -3,30 +3,45 @@
|
|
|
3
3
|
* user's own wallet and report the verified outcome to the index. Only stdio.ts imports this module;
|
|
4
4
|
* the hosted service (index.ts) and the site's /mcp route never do, because they never hold a key.
|
|
5
5
|
*/
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
6
7
|
import { mkdir } from "node:fs/promises";
|
|
7
8
|
import { join } from "node:path";
|
|
8
9
|
import { PaymentRefusedError, SafeFetchError, safeFetch } from "@paidfor/x402-safe-fetch";
|
|
9
10
|
import { createPublicClient, erc20Abi, http } from "viem";
|
|
10
11
|
import { base as baseChain } from "viem/chains";
|
|
11
12
|
import { z } from "zod";
|
|
12
|
-
import { INSTRUCTIONS, NO_ACCOUNT_PROMISE } from "./server.js";
|
|
13
|
+
import { INSTRUCTIONS, NO_ACCOUNT_PROMISE, PAID_URL_SIGNALS, PAY_ROUTING_RULE, PLAIN_FETCH_FAILS } from "./server.js";
|
|
14
|
+
import { TeamClient, outcomeFor } from "./team.js";
|
|
13
15
|
import { SpendLedger, fromMicro, paidforDir, toMicro } from "./wallet.js";
|
|
14
|
-
export const PAY_TOOL_NAMES = ["pay_x402", "wallet_status"];
|
|
16
|
+
export const PAY_TOOL_NAMES = ["pay_x402", "fetch_paid_url", "wallet_status"];
|
|
17
|
+
/** The second name for the pay_x402 handler, registered only with PayOptions.fetchAlias (PAIDFOR_FETCH_ALIAS=1 on stdio). */
|
|
18
|
+
export const PAY_ALIAS = "fetch_paid_url";
|
|
15
19
|
export const BODY_LIMIT_BYTES = 64 * 1024;
|
|
16
20
|
const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
|
|
17
21
|
/** The client's smallest positive cap: with the daily budget spent, any quote is refused before signing. */
|
|
18
22
|
const MIN_CAP_USD = 0.000001;
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
/** 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. */
|
|
24
|
+
export const INTENT_RULE = "Reuse an intent_id only when retrying the same purchase after an error or timeout; for a new purchase omit it (the tool mints one) or use a fresh id with a random suffix, never an id made only of the task and the date. " +
|
|
25
|
+
"A replayed result says so in its first line with the age of the stored result: tell the user it is a replay, and call again with a new intent_id if a new purchase was meant.";
|
|
26
|
+
export const PAY_INSTRUCTIONS = `${PAY_ROUTING_RULE} ` +
|
|
27
|
+
"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, " +
|
|
28
|
+
"never pays twice for the same intent, and reports the verified outcome to the index by itself (report_x402_payment is not needed after it). " +
|
|
29
|
+
`${INTENT_RULE} ` +
|
|
21
30
|
"Tell the user the amount and the seller verdict before the first purchase from a new seller. " +
|
|
22
31
|
"pay_x402 signs with the user's own wallet on this machine; the key never leaves it.";
|
|
23
32
|
/** The stdio server's instructions with a wallet configured: the pay paragraph goes in before the no-account promise, which stays last. */
|
|
24
33
|
export const INSTRUCTIONS_WITH_PAY = INSTRUCTIONS.replace(`\n${NO_ACCOUNT_PROMISE}`, `\n${PAY_INSTRUCTIONS}\n${NO_ACCOUNT_PROMISE}`);
|
|
25
|
-
export const
|
|
26
|
-
|
|
27
|
-
|
|
34
|
+
export const PAY_TITLE = "Fetch a paid (x402) URL, paying with your wallet";
|
|
35
|
+
export const PAY_ALIAS_TITLE = "Fetch a paid URL (same as pay_x402)";
|
|
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. " +
|
|
37
|
+
`${PLAIN_FETCH_FAILS.charAt(0).toUpperCase()}${PLAIN_FETCH_FAILS.slice(1)}; ` +
|
|
38
|
+
`any URL the user calls ${PAID_URL_SIGNALS}, and any URL that returned 402 to any other fetch, goes here instead, with the same method, headers and body. ` +
|
|
39
|
+
"Before paying it checks the seller on paidfor and refuses avoid sellers and quotes over 1.1x the listed price, caps the amount per call and per day, " +
|
|
40
|
+
"never pays twice for the same intent_id (a retry with the same id returns the stored result and says how old it is; omit intent_id for a new purchase), and reports the verified outcome to the index by itself. " +
|
|
28
41
|
"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.";
|
|
42
|
+
"On refusal it returns the reason and the seller page and pays nothing. A URL that answers without a 402 is returned as is, nothing paid.";
|
|
43
|
+
/** The alias carries the whole description, so a client that shows tools by name routes to either; the last sentence stops double calls. */
|
|
44
|
+
export const PAY_ALIAS_DESCRIPTION = `${PAY_DESCRIPTION} This is the same tool as pay_x402 under a second name; call one of them, not both.`;
|
|
30
45
|
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
46
|
"Returns the address, the USDC balance on Base, the per-call and daily limits and today's spend. Never returns the key.";
|
|
32
47
|
const text = (s, isError = false) => ({ content: [{ type: "text", text: s }], ...(isError ? { isError: true } : {}) });
|
|
@@ -40,6 +55,7 @@ export function receiptSummary(r) {
|
|
|
40
55
|
paid: r.paid,
|
|
41
56
|
replayed: r.replayed,
|
|
42
57
|
intent_id: r.intent_id,
|
|
58
|
+
stored_at: r.replayed ? (r.original?.finished_at ?? null) : null,
|
|
43
59
|
amount_usdc: paid.amount_usdc,
|
|
44
60
|
pay_to: paid.quote?.pay_to ?? null,
|
|
45
61
|
tx_hash: tx,
|
|
@@ -51,6 +67,38 @@ export function receiptSummary(r) {
|
|
|
51
67
|
verification_url: paid.verification_url,
|
|
52
68
|
};
|
|
53
69
|
}
|
|
70
|
+
/** "14 minutes", "3 hours", "2 days", "less than a minute". */
|
|
71
|
+
export function describeAge(ms) {
|
|
72
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
73
|
+
const unit = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
74
|
+
if (s < 60)
|
|
75
|
+
return "less than a minute";
|
|
76
|
+
if (s < 3600)
|
|
77
|
+
return unit(Math.floor(s / 60), "minute");
|
|
78
|
+
if (s < 86400)
|
|
79
|
+
return unit(Math.floor(s / 3600), "hour");
|
|
80
|
+
return unit(Math.floor(s / 86400), "day");
|
|
81
|
+
}
|
|
82
|
+
/** The first line of a replayed pay_x402 result: the age and time of the stored result, so a second genuine purchase is not mistaken for a retry. */
|
|
83
|
+
export function replayHead(r, now = Date.now()) {
|
|
84
|
+
const at = r.original?.finished_at ?? r.original?.started_at ?? null;
|
|
85
|
+
const when = at && Number.isFinite(Date.parse(at)) ? `stored ${describeAge(now - Date.parse(at))} ago (${at})` : "stored earlier (time unknown, the original receipt file is gone)";
|
|
86
|
+
return `Replayed the result ${when} for intent ${r.intent_id}; nothing was paid again. If this is a new purchase, call again with a new intent_id.`;
|
|
87
|
+
}
|
|
88
|
+
/** What pay_x402 says when the team's rules, not the seller or the local caps, stop a payment. Nothing was paid in any of these. */
|
|
89
|
+
export function teamRefusal(d, intentId, limits) {
|
|
90
|
+
const res = (head, detail, isError = false) => text(`${head}\n\n${json({ ...detail, paid: false, intent_id: intentId, limits })}`, isError);
|
|
91
|
+
switch (d.kind) {
|
|
92
|
+
case "decline":
|
|
93
|
+
return res(`Declined by your team's rules: ${d.message}\nNothing was paid.`, { refused: "team_decline", reason: d.reason, ...(d.retry_after !== undefined ? { retry_after: d.retry_after } : {}) });
|
|
94
|
+
case "needs_approval":
|
|
95
|
+
return res(`A person has to approve this payment before it can be made. Nothing was paid.\nGive the user this link: ${d.approve_url}\nAfter they approve it, call pay_x402 again with intent_id "${intentId}". The request waits until ${d.expires_at}.${d.message ? ` ${d.message}` : ""}`, { refused: "team_needs_approval", approve_url: d.approve_url, expires_at: d.expires_at });
|
|
96
|
+
case "already_settled":
|
|
97
|
+
return res(`This purchase was already paid (settlement ${d.settlement_ref}, outcome ${d.outcome}); nothing was paid again. If this is a new purchase, call again with a new intent_id.`, { refused: "team_already_settled", settlement_ref: d.settlement_ref, outcome: d.outcome });
|
|
98
|
+
case "failed":
|
|
99
|
+
return res(`The team's rules could not be checked, so nothing was paid: ${d.message}\nCalling again with intent_id "${intentId}" is safe.`, { refused: "team_authorize_failed" }, true);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
54
102
|
export function truncateBody(body) {
|
|
55
103
|
const buf = Buffer.from(body, "utf8");
|
|
56
104
|
if (buf.byteLength <= BODY_LIMIT_BYTES)
|
|
@@ -71,19 +119,18 @@ export async function registerPayTools(server, o) {
|
|
|
71
119
|
const base = o.baseUrl.replace(/\/$/, "");
|
|
72
120
|
const address = o.wallet.account.address;
|
|
73
121
|
const logger = { warn: (m) => void process.stderr.write(`${m}\n`) };
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}, async ({ url, method, headers, body, intent_id, max_usd }) => {
|
|
122
|
+
const team = o.teamKey ? new TeamClient({ baseUrl: base, teamKey: o.teamKey, dir, ...(o.fetch ? { fetch: o.fetch } : {}), warn: logger.warn, ...(o.teamRetryDelayMs !== undefined ? { retryDelayMs: o.teamRetryDelayMs } : {}) }) : null;
|
|
123
|
+
if (team)
|
|
124
|
+
void team.flush();
|
|
125
|
+
const payInput = {
|
|
126
|
+
url: z.string().url().describe("The URL to fetch and, if it answers 402, pay for"),
|
|
127
|
+
method: z.string().min(1).max(10).optional().describe("HTTP method, default GET"),
|
|
128
|
+
headers: z.record(z.string(), z.string()).optional().describe("Request headers"),
|
|
129
|
+
body: z.string().optional().describe("Request body, as text"),
|
|
130
|
+
intent_id: z.string().min(1).max(200).optional().describe("Omit for a new purchase (one is minted). Reuse the same id only when retrying the same purchase after an error or timeout: a retry returns the stored result, with its age, without paying again. Never a date-only id."),
|
|
131
|
+
max_usd: z.number().positive().optional().describe("Most to pay for this call in USD; capped by PAIDFOR_MAX_PER_CALL_USD"),
|
|
132
|
+
};
|
|
133
|
+
const pay = async ({ url, method, headers, body, intent_id, max_usd }) => {
|
|
87
134
|
const spent = await ledger.spentMicro();
|
|
88
135
|
const leftMicro = toMicro(o.limits.dailyCapUsd) - spent;
|
|
89
136
|
const perCall = Math.min(max_usd ?? o.limits.maxPerCallUsd, o.limits.maxPerCallUsd);
|
|
@@ -96,6 +143,20 @@ export async function registerPayTools(server, o) {
|
|
|
96
143
|
spent_today_usdc: fromMicro(spent),
|
|
97
144
|
...(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
145
|
};
|
|
146
|
+
// Team mode needs the id before the first call: a person's approval is claimed by asking again with the same one.
|
|
147
|
+
const intentId = intent_id ?? (team ? randomUUID() : undefined);
|
|
148
|
+
const asked = {};
|
|
149
|
+
/** Say what happened to an approved payment. Undefined when the server approved nothing. */
|
|
150
|
+
const settle = async (r) => {
|
|
151
|
+
const d = asked.decision;
|
|
152
|
+
if (!team || d?.kind !== "approve")
|
|
153
|
+
return undefined;
|
|
154
|
+
if (!r) {
|
|
155
|
+
logger.warn(`paidfor-mcp: no receipt for approved intent ${intentId}, so no outcome was reported; the approval keeps counting against the budgets`);
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
return team.report(outcomeFor(r, address, d.auth_ref));
|
|
159
|
+
};
|
|
99
160
|
const sf = safeFetch(o.wallet.account, {
|
|
100
161
|
maxPerCallUsd: cap,
|
|
101
162
|
maxOverListedRatio: 1.1,
|
|
@@ -106,35 +167,57 @@ export async function registerPayTools(server, o) {
|
|
|
106
167
|
logger,
|
|
107
168
|
...(o.network ? { network: o.network } : {}),
|
|
108
169
|
...(o.fetch ? { fetch: o.fetch } : {}),
|
|
170
|
+
...(team
|
|
171
|
+
? {
|
|
172
|
+
authorize: async (q) => {
|
|
173
|
+
const d = await team.authorize({ wallet: q.payer, intent_id: q.intentId, resource_url: q.url, pay_to: q.payTo, amount_atomic: q.amountAtomic, network: q.network, asset: q.asset, scheme: q.scheme, protocol: "x402" });
|
|
174
|
+
asked.decision = d;
|
|
175
|
+
return d.kind === "approve" ? { approve: true, nonce: d.auth_ref, validBefore: d.valid_before } : { approve: false, message: d.message };
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
: {}),
|
|
109
179
|
});
|
|
110
180
|
try {
|
|
111
|
-
|
|
181
|
+
// Owed reports go first: until the server has them it answers the same purchase with pending_settlement.
|
|
182
|
+
await team?.flush();
|
|
183
|
+
const res = await sf(url, { method: method ?? "GET", headers, body }, { intentId });
|
|
112
184
|
const r = res.receipt;
|
|
113
185
|
if (r.paid && !r.replayed)
|
|
114
186
|
await ledger.add(toMicro(r.amount_usdc));
|
|
187
|
+
const teamReport = await settle(r);
|
|
115
188
|
const { body: out, note } = truncateBody(await res.text());
|
|
116
189
|
const head = r.replayed
|
|
117
|
-
?
|
|
190
|
+
? replayHead(r)
|
|
118
191
|
: r.paid
|
|
119
192
|
? `Paid ${r.amount_usdc} USDC to ${r.quote?.pay_to}; the seller answered HTTP ${res.status} (${r.outcome}).`
|
|
120
193
|
: `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}`);
|
|
194
|
+
return text(`${head}\n\n${json({ status: res.status, receipt: receiptSummary(r), limits, ...(teamReport ? { team_outcome_report: teamReport } : {}), ...(note ? { body_note: note } : {}) })}\n\n--- response body ---\n${out}`);
|
|
122
195
|
}
|
|
123
196
|
catch (e) {
|
|
197
|
+
if (asked.decision && asked.decision.kind !== "approve" && e instanceof PaymentRefusedError && e.code === "refused_authorize") {
|
|
198
|
+
return teamRefusal(asked.decision, e.receipt?.intent_id ?? intentId, limits);
|
|
199
|
+
}
|
|
200
|
+
const teamReport = await settle(e instanceof SafeFetchError ? e.receipt : undefined);
|
|
201
|
+
const reported = teamReport ? { team_outcome_report: teamReport } : {};
|
|
124
202
|
if (e instanceof PaymentRefusedError) {
|
|
125
203
|
const why = e.code === "refused_max_per_call" && dailyBinds
|
|
126
204
|
? `refused: today's cap of ${o.limits.dailyCapUsd} USD (PAIDFOR_DAILY_CAP_USD) leaves ${fromMicro(Math.max(leftMicro, 0))} USDC. ${e.message}`
|
|
127
205
|
: e.code === "refused_max_per_call"
|
|
128
206
|
? `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
207
|
: `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 ??
|
|
208
|
+
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 ?? intentId ?? null, limits, ...reported })}`);
|
|
131
209
|
}
|
|
132
210
|
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);
|
|
211
|
+
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, ...reported })}`, true);
|
|
134
212
|
}
|
|
135
213
|
return text(`error: ${e instanceof Error ? e.message : String(e)}`, true);
|
|
136
214
|
}
|
|
137
|
-
}
|
|
215
|
+
};
|
|
216
|
+
const payAnnotations = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
|
|
217
|
+
server.registerTool("pay_x402", { title: PAY_TITLE, description: PAY_DESCRIPTION, annotations: { ...payAnnotations, title: PAY_TITLE }, inputSchema: payInput }, pay);
|
|
218
|
+
if (o.fetchAlias) {
|
|
219
|
+
server.registerTool(PAY_ALIAS, { title: PAY_ALIAS_TITLE, description: PAY_ALIAS_DESCRIPTION, annotations: { ...payAnnotations, title: PAY_ALIAS_TITLE }, inputSchema: payInput }, pay);
|
|
220
|
+
}
|
|
138
221
|
server.registerTool("wallet_status", {
|
|
139
222
|
title: "Show the paying wallet",
|
|
140
223
|
description: WALLET_STATUS_DESCRIPTION,
|
|
@@ -160,6 +243,7 @@ export async function registerPayTools(server, o) {
|
|
|
160
243
|
max_per_call_usd: o.limits.maxPerCallUsd,
|
|
161
244
|
daily_cap_usd: o.limits.dailyCapUsd,
|
|
162
245
|
spent_today_usdc: fromMicro(spent),
|
|
246
|
+
...(team ? { team_mode: true } : {}),
|
|
163
247
|
};
|
|
164
248
|
return text(`${usdc === null ? "Balance unavailable" : `${usdc} USDC on Base`} at ${address}.\n\n${json(s)}`);
|
|
165
249
|
});
|
package/dist/rules.d.ts
CHANGED
|
@@ -40,6 +40,16 @@ export declare const REPORT_OUTCOMES: {
|
|
|
40
40
|
};
|
|
41
41
|
/** What the report sends, for the instructions and the skill. */
|
|
42
42
|
export declare const REPORT_SENDS = "the resource URL, the settlement transaction hash, the outcome and the quote if given";
|
|
43
|
+
/**
|
|
44
|
+
* Routing a paid URL to pay_x402 (stdio with a wallet). Kept out of RULES_SHA like the report constants: the skill moved
|
|
45
|
+
* to its `.3` revision when these were added (2026-11-01). The hosted server never carries them (test/hosted-snapshot).
|
|
46
|
+
*/
|
|
47
|
+
/** The words that mark a URL as one to fetch through the paying path, for the tool description, the instructions and the skill. */
|
|
48
|
+
export declare const PAID_URL_SIGNALS = "paid, metered, pay-per-call, x402, priced in USDC, or said to charge a fee per request";
|
|
49
|
+
/** Why a generic fetch is the wrong tool for such a URL, in one clause. */
|
|
50
|
+
export declare const PLAIN_FETCH_FAILS = "a plain fetch cannot pay and fails on these URLs with HTTP 402 Payment Required (or an opaque client error)";
|
|
51
|
+
/** The rule the stdio server adds to its instructions when pay_x402 is registered. */
|
|
52
|
+
export declare const PAY_ROUTING_RULE: string;
|
|
43
53
|
/** "a, b, and c" */
|
|
44
54
|
export declare function listAnd(xs: readonly string[]): string;
|
|
45
55
|
/** "a, b, or c" */
|
package/dist/rules.js
CHANGED
|
@@ -45,6 +45,19 @@ export const REPORT_OUTCOMES = {
|
|
|
45
45
|
};
|
|
46
46
|
/** What the report sends, for the instructions and the skill. */
|
|
47
47
|
export const REPORT_SENDS = "the resource URL, the settlement transaction hash, the outcome and the quote if given";
|
|
48
|
+
/**
|
|
49
|
+
* Routing a paid URL to pay_x402 (stdio with a wallet). Kept out of RULES_SHA like the report constants: the skill moved
|
|
50
|
+
* to its `.3` revision when these were added (2026-11-01). The hosted server never carries them (test/hosted-snapshot).
|
|
51
|
+
*/
|
|
52
|
+
/** The words that mark a URL as one to fetch through the paying path, for the tool description, the instructions and the skill. */
|
|
53
|
+
export const PAID_URL_SIGNALS = "paid, metered, pay-per-call, x402, priced in USDC, or said to charge a fee per request";
|
|
54
|
+
/** Why a generic fetch is the wrong tool for such a URL, in one clause. */
|
|
55
|
+
export const PLAIN_FETCH_FAILS = "a plain fetch cannot pay and fails on these URLs with HTTP 402 Payment Required (or an opaque client error)";
|
|
56
|
+
/** The rule the stdio server adds to its instructions when pay_x402 is registered. */
|
|
57
|
+
export const PAY_ROUTING_RULE = `When a URL is ${PAID_URL_SIGNALS}, or when any fetch of it answered HTTP 402 (or failed with a client error on a URL the user called paid), ` +
|
|
58
|
+
"fetch it with pay_x402 and not with a web fetch or any other fetch tool, even if one is available and even if the user did not ask to pay: " +
|
|
59
|
+
`${PLAIN_FETCH_FAILS}, while pay_x402 sends the request, pays the quote within the caps and returns the body. ` +
|
|
60
|
+
"Pass the same URL, method, headers and body. Never tell the user a paid URL cannot be fetched or paid while pay_x402 is available.";
|
|
48
61
|
/** "a, b, and c" */
|
|
49
62
|
export function listAnd(xs) {
|
|
50
63
|
return xs.length < 2 ? (xs[0] ?? "") : `${xs.slice(0, -1).join(", ")}, and ${xs.at(-1)}`;
|
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.
|
|
4
|
+
export declare const VERSION = "0.4.1";
|
|
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.
|
|
6
|
+
export declare const CLIENT_HEADER = "mcp/0.4.1";
|
|
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.
|
|
8
|
+
export declare const HOSTED_CLIENT_HEADER = "mcp-hosted/0.4.1";
|
|
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`),
|
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.
|
|
13
|
+
export const VERSION = "0.4.1";
|
|
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. */
|
package/dist/stdio.js
CHANGED
|
@@ -3,28 +3,51 @@
|
|
|
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
5
|
* `paidfor-mcp wallet new` writes a fresh key to ~/.paidfor/wallet.key (mode 600) and prints the address.
|
|
6
|
+
* `paidfor-mcp wallet register` proves that wallet to a paidfor team (PAIDFOR_TEAM_KEY). With PAIDFOR_TEAM_KEY also set on
|
|
7
|
+
* the server, pay_x402 asks the team's rules before every payment.
|
|
6
8
|
*/
|
|
7
9
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
10
|
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
|
+
import { FUND_SENTENCE, WalletConfigError, loadWallet, newWallet, readLimits, registerWallet } from "./wallet.js";
|
|
12
|
+
const [cmd, sub, ...rest] = process.argv.slice(2);
|
|
13
|
+
const baseUrl = (process.env.PAIDFOR_API_BASE ?? process.env.PAIDFOR_URL ?? DEFAULT_BASE).replace(/\/$/, "");
|
|
11
14
|
if (cmd === "wallet") {
|
|
12
|
-
|
|
13
|
-
process.stderr.write("usage: paidfor-mcp wallet new\n");
|
|
14
|
-
process.exit(2);
|
|
15
|
-
}
|
|
15
|
+
const usage = "usage: paidfor-mcp wallet new | PAIDFOR_TEAM_KEY=pft_... paidfor-mcp wallet register [--label \"research agent\"]\n";
|
|
16
16
|
try {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
if (sub === "new") {
|
|
18
|
+
const { address, file } = await newWallet();
|
|
19
|
+
process.stdout.write(`${address}\n${FUND_SENTENCE}\n`);
|
|
20
|
+
process.stderr.write(`key written to ${file} (mode 600); set PAIDFOR_WALLET_KEY_FILE=${file} in your MCP client config\n`);
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
if (sub === "register") {
|
|
24
|
+
// The team key comes from the environment, not an argument: it is a secret, and arguments show in process lists.
|
|
25
|
+
const teamKey = process.env.PAIDFOR_TEAM_KEY?.trim();
|
|
26
|
+
if (!teamKey) {
|
|
27
|
+
process.stderr.write(`paidfor-mcp: PAIDFOR_TEAM_KEY is not set. Make a key on ${baseUrl}/team and run: PAIDFOR_TEAM_KEY=pft_... paidfor-mcp wallet register\n`);
|
|
28
|
+
process.exit(2);
|
|
29
|
+
}
|
|
30
|
+
const at = rest.indexOf("--label");
|
|
31
|
+
if (at >= 0 && (rest[at + 1] === undefined || rest[at + 1].startsWith("--"))) {
|
|
32
|
+
process.stderr.write(usage);
|
|
33
|
+
process.exit(2);
|
|
34
|
+
}
|
|
35
|
+
const label = at >= 0 ? rest[at + 1] : "";
|
|
36
|
+
const r = await registerWallet({ baseUrl, teamKey, label });
|
|
37
|
+
process.stdout.write(`${r.address}\n`);
|
|
38
|
+
process.stdout.write(r.already ? "already registered to your team; nothing changed\n" : r.moved ? "registered to your team (it was moved from another team)\n" : "registered to your team\n");
|
|
39
|
+
if (r.paused)
|
|
40
|
+
process.stdout.write(`it starts paused: open ${baseUrl}/team, check its limits, untick Paused and save\n`);
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
process.stderr.write(usage);
|
|
44
|
+
process.exit(2);
|
|
21
45
|
}
|
|
22
46
|
catch (e) {
|
|
23
47
|
process.stderr.write(`paidfor-mcp: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
24
48
|
process.exit(1);
|
|
25
49
|
}
|
|
26
50
|
}
|
|
27
|
-
const baseUrl = (process.env.PAIDFOR_API_BASE ?? process.env.PAIDFOR_URL ?? DEFAULT_BASE).replace(/\/$/, "");
|
|
28
51
|
let pay = null;
|
|
29
52
|
let limits = null;
|
|
30
53
|
try {
|
|
@@ -40,7 +63,10 @@ catch (e) {
|
|
|
40
63
|
if (pay && limits) {
|
|
41
64
|
const { INSTRUCTIONS_WITH_PAY, registerPayTools } = await import("./pay.js");
|
|
42
65
|
const server = createServer({ baseUrl, instructions: INSTRUCTIONS_WITH_PAY });
|
|
43
|
-
|
|
66
|
+
// PAIDFOR_FETCH_ALIAS=1 also registers the handler as fetch_paid_url (eval/routing.mjs measures whether the second name helps).
|
|
67
|
+
// PAIDFOR_TEAM_KEY: every payment asks the team's rules first (docs/team-api.md).
|
|
68
|
+
const teamKey = process.env.PAIDFOR_TEAM_KEY?.trim() || undefined;
|
|
69
|
+
await registerPayTools(server, { wallet: pay, limits, baseUrl, fetchAlias: process.env.PAIDFOR_FETCH_ALIAS === "1", ...(teamKey ? { teamKey } : {}) });
|
|
44
70
|
await server.connect(new StdioServerTransport());
|
|
45
71
|
}
|
|
46
72
|
else {
|
package/dist/team.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Receipt } from "@paidfor/x402-safe-fetch";
|
|
2
|
+
export declare const AUTHORIZE_TIMEOUT_MS = 10000;
|
|
3
|
+
export declare const REPORT_TIMEOUT_MS = 5000;
|
|
4
|
+
export type AuthorizeBody = {
|
|
5
|
+
wallet: string;
|
|
6
|
+
intent_id: string;
|
|
7
|
+
resource_url: string;
|
|
8
|
+
pay_to: string;
|
|
9
|
+
amount_atomic: string;
|
|
10
|
+
network: string;
|
|
11
|
+
asset: string;
|
|
12
|
+
scheme: string;
|
|
13
|
+
protocol: "x402";
|
|
14
|
+
};
|
|
15
|
+
export type TeamDecision = {
|
|
16
|
+
kind: "approve";
|
|
17
|
+
auth_ref: string;
|
|
18
|
+
valid_before: number;
|
|
19
|
+
} | {
|
|
20
|
+
kind: "decline";
|
|
21
|
+
reason: string;
|
|
22
|
+
message: string;
|
|
23
|
+
retry_after?: number;
|
|
24
|
+
} | {
|
|
25
|
+
kind: "needs_approval";
|
|
26
|
+
approve_url: string;
|
|
27
|
+
expires_at: string;
|
|
28
|
+
message: string;
|
|
29
|
+
} | {
|
|
30
|
+
kind: "already_settled";
|
|
31
|
+
settlement_ref: string;
|
|
32
|
+
outcome: string;
|
|
33
|
+
message: string;
|
|
34
|
+
} | {
|
|
35
|
+
kind: "failed";
|
|
36
|
+
message: string;
|
|
37
|
+
};
|
|
38
|
+
export type OutcomeBody = {
|
|
39
|
+
wallet: string;
|
|
40
|
+
intent_id: string;
|
|
41
|
+
auth_ref: string;
|
|
42
|
+
paid: boolean;
|
|
43
|
+
settlement_ref?: string;
|
|
44
|
+
outcome?: string;
|
|
45
|
+
http_status?: number;
|
|
46
|
+
response_sha256?: string;
|
|
47
|
+
};
|
|
48
|
+
/** sent: the server has it. queued: kept on disk, sent again before the next payment. rejected: the server refused it for good. */
|
|
49
|
+
export type ReportState = "sent" | "queued" | "rejected";
|
|
50
|
+
/** What to report for a payment that was approved, from its receipt. */
|
|
51
|
+
export declare function outcomeFor(r: Receipt, wallet: string, authRef: string): OutcomeBody;
|
|
52
|
+
/** Exported for tests: a 200's body as a decision. Anything not exactly one of the contract's answers is "failed". */
|
|
53
|
+
export declare function readDecision(b: unknown): TeamDecision;
|
|
54
|
+
export declare class TeamClient {
|
|
55
|
+
private readonly base;
|
|
56
|
+
private readonly key;
|
|
57
|
+
private readonly queueDir;
|
|
58
|
+
private readonly fetchFn;
|
|
59
|
+
private readonly warn;
|
|
60
|
+
private readonly retryDelayMs;
|
|
61
|
+
/** The key travels in a header. Plain http is for a server on this machine only. */
|
|
62
|
+
private readonly keySafe;
|
|
63
|
+
constructor(o: {
|
|
64
|
+
baseUrl: string;
|
|
65
|
+
teamKey: string;
|
|
66
|
+
dir: string;
|
|
67
|
+
fetch?: typeof globalThis.fetch;
|
|
68
|
+
warn?: (m: string) => void;
|
|
69
|
+
retryDelayMs?: number;
|
|
70
|
+
});
|
|
71
|
+
private post;
|
|
72
|
+
/** Never throws: anything but a 200 with a readable decision is { kind: "failed" }. */
|
|
73
|
+
authorize(body: AuthorizeBody): Promise<TeamDecision>;
|
|
74
|
+
/** One try. "retry" means the server could not take it now; a 4xx will never be taken, so it is not kept. */
|
|
75
|
+
private send;
|
|
76
|
+
private queueFile;
|
|
77
|
+
report(body: OutcomeBody): Promise<ReportState>;
|
|
78
|
+
/** Send what earlier reports left behind. Stops at the first one the server cannot take now. Never throws. */
|
|
79
|
+
flush(): Promise<void>;
|
|
80
|
+
}
|
package/dist/team.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team mode: the two calls pay_x402 makes around a payment (docs/team-api.md). `authorize` asks before signing and
|
|
3
|
+
* turns anything but a clean decision into "failed", which pays nothing. `report` says what happened afterwards; a
|
|
4
|
+
* report the server could not take is kept on disk and sent again, because an approval nobody reports keeps counting
|
|
5
|
+
* against the budgets. Only pay.ts imports this module.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
export const AUTHORIZE_TIMEOUT_MS = 10_000;
|
|
11
|
+
export const REPORT_TIMEOUT_MS = 5_000;
|
|
12
|
+
/** The words the server takes for a paid outcome. */
|
|
13
|
+
const PAID_OUTCOMES = ["ok", "non_delivery", "wrong_goods", "overcharge", "duplicate", "paid_no_response"];
|
|
14
|
+
/** What to report for a payment that was approved, from its receipt. */
|
|
15
|
+
export function outcomeFor(r, wallet, authRef) {
|
|
16
|
+
const base = { wallet, intent_id: r.intent_id, auth_ref: authRef };
|
|
17
|
+
// Nothing left this machine, or the seller says its settlement failed: the money did not move.
|
|
18
|
+
if (!r.paid || r.settlement.parsed?.success === false)
|
|
19
|
+
return { ...base, paid: false };
|
|
20
|
+
return {
|
|
21
|
+
...base,
|
|
22
|
+
paid: true,
|
|
23
|
+
// With no transaction hash the signed nonce is what finds the settlement on chain.
|
|
24
|
+
settlement_ref: r.tx_hash ?? r.nonce ?? authRef,
|
|
25
|
+
outcome: r.reported_outcome && PAID_OUTCOMES.includes(r.reported_outcome) ? r.reported_outcome : "non_delivery",
|
|
26
|
+
...(r.response ? { http_status: r.response.status } : {}),
|
|
27
|
+
...(r.response?.sha256 ? { response_sha256: r.response.sha256 } : {}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const str = (v) => typeof v === "string" && v.length > 0;
|
|
31
|
+
function failedStatus(status, body) {
|
|
32
|
+
const said = body && typeof body === "object" && str(body.error) ? ` ${body.error.slice(0, 300)}` : "";
|
|
33
|
+
if (status === 401)
|
|
34
|
+
return { kind: "failed", message: `paidfor refused the team key; check PAIDFOR_TEAM_KEY.${said}` };
|
|
35
|
+
if (status === 404)
|
|
36
|
+
return { kind: "failed", message: `this wallet is not registered to the team; run: paidfor-mcp wallet register.${said}` };
|
|
37
|
+
return { kind: "failed", message: `paidfor answered HTTP ${status}.${said}` };
|
|
38
|
+
}
|
|
39
|
+
/** Exported for tests: a 200's body as a decision. Anything not exactly one of the contract's answers is "failed". */
|
|
40
|
+
export function readDecision(b) {
|
|
41
|
+
const bad = { kind: "failed", message: "paidfor's answer could not be read." };
|
|
42
|
+
if (!b || typeof b !== "object")
|
|
43
|
+
return bad;
|
|
44
|
+
const d = b;
|
|
45
|
+
const message = str(d.message) ? d.message : "";
|
|
46
|
+
switch (d.decision) {
|
|
47
|
+
case "approve":
|
|
48
|
+
return str(d.auth_ref) && typeof d.valid_before === "number" ? { kind: "approve", auth_ref: d.auth_ref, valid_before: d.valid_before } : bad;
|
|
49
|
+
case "decline":
|
|
50
|
+
return str(d.reason) ? { kind: "decline", reason: d.reason, message: message || d.reason, ...(typeof d.retry_after === "number" ? { retry_after: d.retry_after } : {}) } : bad;
|
|
51
|
+
case "needs_approval":
|
|
52
|
+
return str(d.approve_url) && str(d.expires_at) ? { kind: "needs_approval", approve_url: d.approve_url, expires_at: d.expires_at, message } : bad;
|
|
53
|
+
case "already_settled":
|
|
54
|
+
return { kind: "already_settled", settlement_ref: str(d.settlement_ref) ? d.settlement_ref : "unknown", outcome: str(d.outcome) ? d.outcome : "unknown", message };
|
|
55
|
+
default:
|
|
56
|
+
return bad;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export class TeamClient {
|
|
60
|
+
base;
|
|
61
|
+
key;
|
|
62
|
+
queueDir;
|
|
63
|
+
fetchFn;
|
|
64
|
+
warn;
|
|
65
|
+
retryDelayMs;
|
|
66
|
+
/** The key travels in a header. Plain http is for a server on this machine only. */
|
|
67
|
+
keySafe;
|
|
68
|
+
constructor(o) {
|
|
69
|
+
this.base = o.baseUrl.replace(/\/$/, "");
|
|
70
|
+
this.key = o.teamKey;
|
|
71
|
+
this.queueDir = join(o.dir, "team-outcomes");
|
|
72
|
+
this.fetchFn = o.fetch ?? globalThis.fetch;
|
|
73
|
+
this.warn = o.warn ?? (() => undefined);
|
|
74
|
+
this.retryDelayMs = o.retryDelayMs ?? 250;
|
|
75
|
+
const u = new URL(this.base);
|
|
76
|
+
this.keySafe = u.protocol === "https:" || ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname);
|
|
77
|
+
}
|
|
78
|
+
post(path, body, timeoutMs) {
|
|
79
|
+
return this.fetchFn(`${this.base}${path}`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${this.key}` },
|
|
82
|
+
body: JSON.stringify(body),
|
|
83
|
+
redirect: "error", // the key goes to the configured origin and nowhere else
|
|
84
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** Never throws: anything but a 200 with a readable decision is { kind: "failed" }. */
|
|
88
|
+
async authorize(body) {
|
|
89
|
+
if (!this.keySafe)
|
|
90
|
+
return { kind: "failed", message: `refusing to send the team key to ${this.base}; use https.` };
|
|
91
|
+
let res;
|
|
92
|
+
try {
|
|
93
|
+
res = await this.post("/v1/team/authorize", body, AUTHORIZE_TIMEOUT_MS);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return { kind: "failed", message: "paidfor could not be reached." };
|
|
97
|
+
}
|
|
98
|
+
let parsed = null;
|
|
99
|
+
try {
|
|
100
|
+
parsed = await res.json();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* an unreadable body is handled below */
|
|
104
|
+
}
|
|
105
|
+
return res.status === 200 ? readDecision(parsed) : failedStatus(res.status, parsed);
|
|
106
|
+
}
|
|
107
|
+
/** One try. "retry" means the server could not take it now; a 4xx will never be taken, so it is not kept. */
|
|
108
|
+
async send(body) {
|
|
109
|
+
if (!this.keySafe)
|
|
110
|
+
return "retry";
|
|
111
|
+
try {
|
|
112
|
+
const res = await this.post("/v1/team/outcome", body, REPORT_TIMEOUT_MS);
|
|
113
|
+
void res.body?.cancel().catch(() => undefined);
|
|
114
|
+
if (res.ok)
|
|
115
|
+
return "sent";
|
|
116
|
+
if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) {
|
|
117
|
+
this.warn(`paidfor-mcp: the team outcome for intent ${body.intent_id} was refused with HTTP ${res.status}`);
|
|
118
|
+
return "rejected";
|
|
119
|
+
}
|
|
120
|
+
return "retry";
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return "retry";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
queueFile(body) {
|
|
127
|
+
return join(this.queueDir, `${createHash("sha256").update(`${body.intent_id}\n${body.auth_ref}`).digest("hex").slice(0, 40)}.json`);
|
|
128
|
+
}
|
|
129
|
+
async report(body) {
|
|
130
|
+
let r = await this.send(body);
|
|
131
|
+
if (r === "retry") {
|
|
132
|
+
await new Promise((d) => setTimeout(d, this.retryDelayMs));
|
|
133
|
+
r = await this.send(body);
|
|
134
|
+
}
|
|
135
|
+
if (r !== "retry")
|
|
136
|
+
return r;
|
|
137
|
+
try {
|
|
138
|
+
await mkdir(this.queueDir, { recursive: true, mode: 0o700 });
|
|
139
|
+
const file = this.queueFile(body);
|
|
140
|
+
await writeFile(`${file}.tmp`, JSON.stringify(body), { mode: 0o600 });
|
|
141
|
+
await rename(`${file}.tmp`, file);
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
this.warn(`paidfor-mcp: could not keep the team outcome for intent ${body.intent_id}: ${e instanceof Error ? e.message : String(e)}`);
|
|
145
|
+
}
|
|
146
|
+
return "queued";
|
|
147
|
+
}
|
|
148
|
+
/** Send what earlier reports left behind. Stops at the first one the server cannot take now. Never throws. */
|
|
149
|
+
async flush() {
|
|
150
|
+
let names;
|
|
151
|
+
try {
|
|
152
|
+
names = (await readdir(this.queueDir)).filter((n) => n.endsWith(".json"));
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return; // nothing queued yet
|
|
156
|
+
}
|
|
157
|
+
for (const name of names) {
|
|
158
|
+
const file = join(this.queueDir, name);
|
|
159
|
+
try {
|
|
160
|
+
const r = await this.send(JSON.parse(await readFile(file, "utf8")));
|
|
161
|
+
if (r === "retry")
|
|
162
|
+
return;
|
|
163
|
+
await unlink(file);
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
this.warn(`paidfor-mcp: could not resend ${name}: ${e instanceof Error ? e.message : String(e)}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
package/dist/wallet.d.ts
CHANGED
|
@@ -22,6 +22,31 @@ export declare function newWallet(home?: string): Promise<{
|
|
|
22
22
|
address: `0x${string}`;
|
|
23
23
|
file: string;
|
|
24
24
|
}>;
|
|
25
|
+
/** The wallet `wallet new` writes, used by `wallet register` when no key is configured in the environment. */
|
|
26
|
+
export declare const defaultKeyFile: (home?: string) => string;
|
|
27
|
+
/** paidfor refused or could not answer a registration. The message never includes a key. */
|
|
28
|
+
export declare class RegisterError extends Error {
|
|
29
|
+
name: string;
|
|
30
|
+
}
|
|
31
|
+
export type Registered = {
|
|
32
|
+
address: `0x${string}`;
|
|
33
|
+
already: boolean;
|
|
34
|
+
moved: boolean;
|
|
35
|
+
paused: boolean;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* `paidfor-mcp wallet register`: proves to a paidfor team that this wallet is the agent's, by signing the challenge
|
|
39
|
+
* the team's key asks for. The wallet is the configured one, or ~/.paidfor/wallet.key when nothing is configured.
|
|
40
|
+
* The key signs one message and is sent nowhere; the team key authenticates the two calls.
|
|
41
|
+
*/
|
|
42
|
+
export declare function registerWallet(o: {
|
|
43
|
+
baseUrl: string;
|
|
44
|
+
teamKey: string;
|
|
45
|
+
label?: string;
|
|
46
|
+
env?: Env;
|
|
47
|
+
home?: string;
|
|
48
|
+
fetchFn?: typeof fetch;
|
|
49
|
+
}): Promise<Registered>;
|
|
25
50
|
export type Limits = {
|
|
26
51
|
maxPerCallUsd: number;
|
|
27
52
|
dailyCapUsd: number;
|
package/dist/wallet.js
CHANGED
|
@@ -72,6 +72,57 @@ export async function newWallet(home = homedir()) {
|
|
|
72
72
|
}
|
|
73
73
|
return { address: privateKeyToAccount(key).address, file };
|
|
74
74
|
}
|
|
75
|
+
/** The wallet `wallet new` writes, used by `wallet register` when no key is configured in the environment. */
|
|
76
|
+
export const defaultKeyFile = (home = homedir()) => join(paidforDir(home), "wallet.key");
|
|
77
|
+
/** paidfor refused or could not answer a registration. The message never includes a key. */
|
|
78
|
+
export class RegisterError extends Error {
|
|
79
|
+
name = "RegisterError";
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* `paidfor-mcp wallet register`: proves to a paidfor team that this wallet is the agent's, by signing the challenge
|
|
83
|
+
* the team's key asks for. The wallet is the configured one, or ~/.paidfor/wallet.key when nothing is configured.
|
|
84
|
+
* The key signs one message and is sent nowhere; the team key authenticates the two calls.
|
|
85
|
+
*/
|
|
86
|
+
export async function registerWallet(o) {
|
|
87
|
+
const env = { ...(o.env ?? process.env) };
|
|
88
|
+
const none = new WalletConfigError("no wallet to register: run `paidfor-mcp wallet new` first, or set PAIDFOR_WALLET_KEY_FILE");
|
|
89
|
+
if (!env.PAIDFOR_WALLET_KEY_FILE?.trim() && !env.PAIDFOR_WALLET_KEY?.trim()) {
|
|
90
|
+
const file = defaultKeyFile(o.home);
|
|
91
|
+
if (!(await stat(file).catch(() => null)))
|
|
92
|
+
throw none;
|
|
93
|
+
env.PAIDFOR_WALLET_KEY_FILE = file;
|
|
94
|
+
}
|
|
95
|
+
const loaded = await loadWallet(env);
|
|
96
|
+
if (!loaded)
|
|
97
|
+
throw none;
|
|
98
|
+
const f = o.fetchFn ?? fetch;
|
|
99
|
+
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
|
+
}
|
|
105
|
+
const post = async (path, body) => {
|
|
106
|
+
let res;
|
|
107
|
+
try {
|
|
108
|
+
res = await f(`${base}${path}`, { method: "POST", headers: { Authorization: `Bearer ${o.teamKey}`, "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
throw new RegisterError(`could not reach ${base}: ${e instanceof Error ? e.message : String(e)}`);
|
|
112
|
+
}
|
|
113
|
+
const j = (await res.json().catch(() => ({})));
|
|
114
|
+
if (!res.ok)
|
|
115
|
+
throw new RegisterError(`paidfor answered ${res.status}${typeof j.error === "string" ? `: ${j.error}` : ""}`);
|
|
116
|
+
return j;
|
|
117
|
+
};
|
|
118
|
+
const c = await post("/v1/team/wallets/challenge", { address: loaded.account.address });
|
|
119
|
+
if (typeof c.message !== "string" || typeof c.token !== "string")
|
|
120
|
+
throw new RegisterError("paidfor did not return a challenge to sign");
|
|
121
|
+
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 ?? "" });
|
|
123
|
+
const w = (r.wallet ?? {});
|
|
124
|
+
return { address: (w.address ?? loaded.account.address), already: r.already === true, moved: r.moved === true, paused: w.rules?.paused === true };
|
|
125
|
+
}
|
|
75
126
|
function positive(env, name, dflt) {
|
|
76
127
|
const raw = env[name]?.trim();
|
|
77
128
|
if (!raw)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paidfor/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"mcpName": "dev.paidfor/paidfor",
|
|
5
5
|
"description": "MCP server for the paidfor.dev x402 seller index: check_x402_seller, list_reliable_sellers and recent_changes, plus pay_x402 (stdio, your own wallet)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
28
|
-
"@paidfor/x402-safe-fetch": "0.
|
|
28
|
+
"@paidfor/x402-safe-fetch": "0.4.0",
|
|
29
29
|
"viem": "^2.57.13",
|
|
30
30
|
"zod": "^4.0.0"
|
|
31
31
|
},
|
|
@@ -51,6 +51,8 @@
|
|
|
51
51
|
"start": "node dist/index.js",
|
|
52
52
|
"dev": "tsc -p tsconfig.json && node dist/index.js",
|
|
53
53
|
"eval": "node --env-file-if-exists=eval/.env eval/run.mjs",
|
|
54
|
+
"eval:routing": "node --env-file-if-exists=eval/.env eval/routing.mjs",
|
|
55
|
+
"test": "pnpm build && node --test \"test/*.test.mjs\"",
|
|
54
56
|
"mcpb": "node mcpb/build.mjs"
|
|
55
57
|
}
|
|
56
58
|
}
|
package/dist/http.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/http.js
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
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
|
-
});
|