agent-press-wire-mcp 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dylan Caponi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # Agent Press Wire MCP server
2
+
3
+ An MCP server that lets AI agents (Claude Desktop, Claude Code, Cursor, any MCP client) announce product launches and distribute press releases through [Agent Press Wire](https://agent-press-wire.dylan-caponi.workers.dev). Each call is paid in USDC on Base over [x402](https://x402.org), from **your own wallet**. No account, no API key.
4
+
5
+ ## Tools
6
+
7
+ | Tool | Price | What it does |
8
+ | --- | --- | --- |
9
+ | `announce_launch_newsroom` | $1 USDC | Drafts a 350-450 word AP-style press release from your product URL and publishes it on a public, search-indexed newsroom page. About 20 seconds. |
10
+ | `distribute_press_release` | `tier: "wire"` $99 USDC, `tier: "premium"` $299 USDC | Drafts a 450-600 word release and distributes it over a newswire to 500+ news sites plus 100+ TV and radio network sites. Premium adds AP News, USA Today, Barchart and StreetInsider. Requires `city`, `country`, `contact_email`. Returns an `order_id`. |
11
+ | `get_release_status` | Free | Status and live coverage links for a wire order. |
12
+
13
+ The service only charges when the job succeeds. Failed drafts, rejected topics and wire rejections are not charged.
14
+
15
+ ## Safety: quote mode by default
16
+
17
+ Paid tools **charge real USDC**. Settings that control spending:
18
+
19
+ - `X402_PRIVATE_KEY`: private key of your own Base wallet holding USDC. Without it the server only quotes.
20
+ - `MAX_SPEND_USD`: the most the server may pay for a single call. Default `0`, which means quote only: every paid tool returns the x402 price terms and never pays.
21
+ - `MAX_TOTAL_SPEND_USD`: lifetime cap across all calls and restarts. Defaults to `MAX_SPEND_USD`, so out of the box you get at most one paid call until you raise it. Spend is tracked in a ledger at `~/.agent-press-wire/spend.json` (override the directory with `AGENT_PRESS_WIRE_HOME`). Every signed payment authorization is counted, whatever the HTTP outcome, because the server could still settle it until it expires. If a call failed and your wallet shows no transfer for its nonce after `valid_before`, you can delete that entry from the ledger.
22
+
23
+ Paying takes two calls. The first call (without `confirm_token`, or with `quote_only: true`) returns the price and a `confirm_token`. The agent shows the user the exact price, and after the user approves it calls again with the **same parameters** and that `confirm_token`. Tokens are single use, expire after 10 minutes, and are bound to the endpoint, a hash of the request parameters and the price.
24
+
25
+ A payment only happens when all of these hold:
26
+
27
+ - the key is set, the price is at or below `MAX_SPEND_USD`, and the lifetime total stays at or below `MAX_TOTAL_SPEND_USD`;
28
+ - a valid `confirm_token` was passed and `quote_only` is not true;
29
+ - the server's x402 terms match values pinned in this package: pay to `0xF7887C1fAf0DbAedc80C78F9D396D594B69eba95`, exactly $1 for `/v1/press-release`, $99 for `/v1/wire-release`, $299 for `/v1/wire-release/premium`, USDC on Base mainnet, scheme `exact` with an EIP-3009 transfer authorization, and `maxTimeoutSeconds` of 600 or less. Any mismatch is refused, so a compromised server or a wrong `PRESS_API_URL` cannot redirect funds or raise the price.
30
+
31
+ The per-call cap is enforced twice: by this server before signing, and by the x402 client's own spend controls.
32
+
33
+ If a paid call fails after the authorization was signed, the result says the payment was **not settled per the server** and includes the authorization `nonce`, `valid_before` and payer address. Check your wallet before retrying.
34
+
35
+ Examples: `MAX_SPEND_USD=1 MAX_TOTAL_SPEND_USD=5` allows up to five $1 newsroom releases and only quotes the wire tiers. `MAX_SPEND_USD=99 MAX_TOTAL_SPEND_USD=99` allows one standard wire release. Use a dedicated wallet funded with only what you intend to spend.
36
+
37
+ ## Install
38
+
39
+ Requires Node 20+.
40
+
41
+ ### Claude Code
42
+
43
+ ```bash
44
+ claude mcp add agent-press-wire \
45
+ -e X402_PRIVATE_KEY=0xYOUR_KEY -e MAX_SPEND_USD=1 -e MAX_TOTAL_SPEND_USD=5 \
46
+ -- npx -y agent-press-wire-mcp
47
+ ```
48
+
49
+ Quote only (no wallet): `claude mcp add agent-press-wire -- npx -y agent-press-wire-mcp`
50
+
51
+ ### Claude Desktop
52
+
53
+ Edit `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`, Windows: `%APPDATA%\Claude\claude_desktop_config.json`):
54
+
55
+ ```json
56
+ {
57
+ "mcpServers": {
58
+ "agent-press-wire": {
59
+ "command": "npx",
60
+ "args": ["-y", "agent-press-wire-mcp"],
61
+ "env": {
62
+ "X402_PRIVATE_KEY": "0xYOUR_KEY",
63
+ "MAX_SPEND_USD": "1",
64
+ "MAX_TOTAL_SPEND_USD": "5"
65
+ }
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ ### Cursor
72
+
73
+ Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
74
+
75
+ ```json
76
+ {
77
+ "mcpServers": {
78
+ "agent-press-wire": {
79
+ "command": "npx",
80
+ "args": ["-y", "agent-press-wire-mcp"],
81
+ "env": {
82
+ "X402_PRIVATE_KEY": "0xYOUR_KEY",
83
+ "MAX_SPEND_USD": "1",
84
+ "MAX_TOTAL_SPEND_USD": "5"
85
+ }
86
+ }
87
+ }
88
+ }
89
+ ```
90
+
91
+ Other clients: run `npx -y agent-press-wire-mcp` as a stdio server with the same environment variables.
92
+
93
+ ## Example prompts
94
+
95
+ - "Announce my launch: https://example.com, we just shipped v2 with offline mode."
96
+ - "Write and distribute a press release for https://example.com. We're in Austin, United States, press@example.com."
97
+ - "How much would it cost to get press coverage for my product?" (the agent calls with `quote_only: true`)
98
+ - After the user approves a quoted price, the agent repeats the call with the returned `confirm_token`.
99
+ - "Check the status of press release w_3f9d0a1b2c."
100
+
101
+ ## Quote response
102
+
103
+ ```json
104
+ {
105
+ "mode": "quote",
106
+ "paid": false,
107
+ "reason": "no confirm_token: this was a quote",
108
+ "quote": { "price_usd": 99, "asset": "USDC", "network": "Base mainnet (eip155:8453)", "pay_to": "0xF7887C1fAf0DbAedc80C78F9D396D594B69eba95" },
109
+ "spend": { "spent_total_usd": 0, "max_total_spend_usd": 99, "max_spend_usd": 99 },
110
+ "confirm_token": "ct1.1791069332201.6cba...d563",
111
+ "confirm_token_expires_in_seconds": 600
112
+ }
113
+ ```
114
+
115
+ ## Development
116
+
117
+ ```bash
118
+ npm install
119
+ npm run build
120
+ npm test # offline guard-rail tests against a localhost mock x402 server; never pays
121
+ npm run test:quote # quote-mode smoke test against production; never pays
122
+ npx @modelcontextprotocol/inspector node dist/index.js
123
+ ```
124
+
125
+ `PRESS_API_URL` overrides the API base URL for testing.
126
+
127
+ ## License
128
+
129
+ MIT
package/dist/guard.js ADDED
@@ -0,0 +1,141 @@
1
+ // Payment guard rails for the Agent Press Wire MCP server (audit item M2).
2
+ // Pure, testable pieces: pinned payment terms, confirm tokens, and the persisted spend ledger.
3
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
4
+ import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ export const NETWORK = "eip155:8453"; // Base mainnet
8
+ export const USDC_BASE = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913";
9
+ export const USDC_DECIMALS = 6;
10
+ /** Agent Press Wire's receiving wallet. Hard-coded so a compromised server or PRESS_API_URL cannot redirect funds. */
11
+ export const PAY_TO = "0xF7887C1fAf0DbAedc80C78F9D396D594B69eba95";
12
+ /** Expected price per endpoint, in atomic USDC (6 decimals). Any other amount is refused. */
13
+ export const EXPECTED_ATOMIC = Object.freeze({
14
+ "/v1/press-release": "1000000",
15
+ "/v1/wire-release": "99000000",
16
+ "/v1/wire-release/premium": "299000000",
17
+ });
18
+ export const MAX_TIMEOUT_SECONDS = 600;
19
+ export const CONFIRM_TTL_MS = 10 * 60 * 1000;
20
+ export const usdToAtomic = (usd) => BigInt(Math.round(usd * 10 ** USDC_DECIMALS));
21
+ export const atomicToUsd = (atomic) => Number(BigInt(atomic)) / 10 ** USDC_DECIMALS;
22
+ /** Returns null when the 402 terms match what we expect for this endpoint, else a human-readable reason. */
23
+ export function checkPinnedTerms(path, req) {
24
+ const expected = EXPECTED_ATOMIC[path];
25
+ if (!expected)
26
+ return `no pinned price for endpoint ${path}`;
27
+ if (req.scheme !== "exact")
28
+ return `unexpected x402 scheme ${JSON.stringify(req.scheme)} (expected "exact")`;
29
+ if (req.network !== NETWORK)
30
+ return `unexpected network ${JSON.stringify(req.network)} (expected ${NETWORK})`;
31
+ if (String(req.asset ?? "").toLowerCase() !== USDC_BASE)
32
+ return `unexpected asset ${JSON.stringify(req.asset)} (expected USDC on Base)`;
33
+ const method = req.extra?.assetTransferMethod;
34
+ if (method !== undefined && method !== "eip3009")
35
+ return `unexpected assetTransferMethod ${JSON.stringify(method)} (only eip3009 is allowed)`;
36
+ if (String(req.payTo ?? "").toLowerCase() !== PAY_TO.toLowerCase())
37
+ return `payTo ${JSON.stringify(req.payTo)} does not match the pinned Agent Press Wire wallet ${PAY_TO}`;
38
+ if (String(req.amount) !== expected)
39
+ return `amount ${JSON.stringify(req.amount)} does not match the pinned price ${expected} atomic USDC ($${atomicToUsd(expected)}) for ${path}`;
40
+ const t = req.maxTimeoutSeconds;
41
+ if (typeof t !== "number" || !Number.isInteger(t) || t <= 0 || t > MAX_TIMEOUT_SECONDS)
42
+ return `maxTimeoutSeconds ${JSON.stringify(t)} is outside 1..${MAX_TIMEOUT_SECONDS}`;
43
+ return null;
44
+ }
45
+ // ---------- confirm tokens ----------
46
+ /** Stable JSON: sorted keys, undefined dropped. */
47
+ export function canonicalJson(v) {
48
+ if (v === null || typeof v !== "object")
49
+ return JSON.stringify(v);
50
+ if (Array.isArray(v))
51
+ return `[${v.map(canonicalJson).join(",")}]`;
52
+ const o = v;
53
+ return `{${Object.keys(o)
54
+ .filter((k) => o[k] !== undefined)
55
+ .sort()
56
+ .map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`)
57
+ .join(",")}}`;
58
+ }
59
+ export const paramsHash = (path, body) => createHash("sha256").update(`${path}\n${canonicalJson(body)}`).digest("hex");
60
+ /**
61
+ * Issues and verifies single-use confirm tokens. A token is bound to the endpoint, a hash of the
62
+ * request params and the quoted amount, signed with a per-process random secret, and expires.
63
+ */
64
+ export class ConfirmTokens {
65
+ ttlMs;
66
+ secret;
67
+ used = new Map();
68
+ constructor(ttlMs = CONFIRM_TTL_MS, secret) {
69
+ this.ttlMs = ttlMs;
70
+ this.secret = secret ?? randomBytes(32);
71
+ }
72
+ mac(exp, path, body, amount) {
73
+ return createHmac("sha256", this.secret).update(`${exp}|${path}|${paramsHash(path, body)}|${amount}`).digest("hex");
74
+ }
75
+ issue(path, body, amount, now = Date.now()) {
76
+ const exp = now + this.ttlMs;
77
+ return `ct1.${exp}.${this.mac(exp, path, body, amount)}`;
78
+ }
79
+ /** Returns null if valid (and marks the token used), else the reason it was rejected. */
80
+ consume(token, path, body, amount, now = Date.now()) {
81
+ for (const [t, exp] of this.used)
82
+ if (exp < now)
83
+ this.used.delete(t);
84
+ const m = /^ct1\.(\d{13,})\.([0-9a-f]{64})$/.exec(token.trim());
85
+ if (!m)
86
+ return "confirm_token is malformed";
87
+ const exp = Number(m[1]);
88
+ const want = Buffer.from(this.mac(exp, path, body, amount), "hex");
89
+ const got = Buffer.from(m[2], "hex");
90
+ if (want.length !== got.length || !timingSafeEqual(want, got))
91
+ return "confirm_token does not match this endpoint, these exact parameters and this price (or was issued by another server process)";
92
+ if (exp < now)
93
+ return "confirm_token has expired";
94
+ if (this.used.has(m[2]))
95
+ return "confirm_token was already used";
96
+ this.used.set(m[2], exp);
97
+ return null;
98
+ }
99
+ }
100
+ export const ledgerDir = () => process.env.AGENT_PRESS_WIRE_HOME || join(homedir(), ".agent-press-wire");
101
+ export const ledgerPath = (dir = ledgerDir()) => join(dir, "spend.json");
102
+ /** Loads the ledger. Missing file means empty. A corrupt file throws, so the caller fails closed. */
103
+ export function loadLedger(dir = ledgerDir()) {
104
+ const p = ledgerPath(dir);
105
+ if (!existsSync(p))
106
+ return { version: 1, entries: [] };
107
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
108
+ if (parsed?.version !== 1 || !Array.isArray(parsed.entries))
109
+ throw new Error(`spend ledger ${p} is not a valid version 1 ledger`);
110
+ for (const e of parsed.entries)
111
+ BigInt(e.amount_atomic); // throws on garbage
112
+ return parsed;
113
+ }
114
+ export function saveLedger(ledger, dir = ledgerDir()) {
115
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
116
+ const p = ledgerPath(dir);
117
+ const tmp = `${p}.${process.pid}.tmp`;
118
+ writeFileSync(tmp, JSON.stringify(ledger, null, 2) + "\n", { mode: 0o600 });
119
+ renameSync(tmp, p);
120
+ }
121
+ /**
122
+ * Every signed authorization counts toward the total, whatever the HTTP outcome, because the
123
+ * server holds a settleable authorization until valid_before. Remove an entry by hand only after
124
+ * confirming on-chain that it never settled.
125
+ */
126
+ export const totalSpentAtomic = (ledger) => ledger.entries.reduce((s, e) => s + BigInt(e.amount_atomic), 0n);
127
+ /** Returns null if paying `amount` keeps the lifetime total within the cap, else the reason. */
128
+ export function checkTotalCap(ledger, amountAtomic, maxTotalUsd) {
129
+ const spent = totalSpentAtomic(ledger);
130
+ const cap = usdToAtomic(maxTotalUsd);
131
+ if (spent + amountAtomic > cap)
132
+ return `paying $${atomicToUsd(amountAtomic)} would bring total spend to $${atomicToUsd(spent + amountAtomic)}, over MAX_TOTAL_SPEND_USD=${maxTotalUsd} (already spent $${atomicToUsd(spent)} per ${ledgerPath()})`;
133
+ return null;
134
+ }
135
+ /** Serializes ledger check + write within this process so concurrent calls cannot both pass the cap. */
136
+ let chain = Promise.resolve();
137
+ export function withLedgerLock(fn) {
138
+ const run = chain.then(fn, fn);
139
+ chain = run.catch(() => undefined);
140
+ return run;
141
+ }
package/dist/index.js ADDED
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env node
2
+ // Agent Press Wire MCP server (stdio).
3
+ // Lets AI agents announce launches and distribute press releases via
4
+ // https://agent-press-wire.dylan-caponi.workers.dev, paying per call in USDC on Base over x402.
5
+ //
6
+ // Safety (all must hold before anything is signed):
7
+ // X402_PRIVATE_KEY set, quoted price <= MAX_SPEND_USD (default 0 = quote only),
8
+ // lifetime total <= MAX_TOTAL_SPEND_USD (persisted ledger), 402 terms match the pinned
9
+ // payTo / price / asset / network, maxTimeoutSeconds <= 600, and a valid confirm_token
10
+ // from a prior quote for the same endpoint and parameters.
11
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
+ import { x402Client, x402HTTPClient } from "@x402/fetch";
14
+ import { ExactEvmScheme } from "@x402/evm/exact/client";
15
+ import { privateKeyToAccount } from "viem/accounts";
16
+ import { z } from "zod";
17
+ import { NETWORK, USDC_BASE, PAY_TO, CONFIRM_TTL_MS, ConfirmTokens, atomicToUsd, checkPinnedTerms, checkTotalCap, ledgerPath, loadLedger, saveLedger, totalSpentAtomic, usdToAtomic, withLedgerLock, } from "./guard.js";
18
+ const VERSION = "0.1.0";
19
+ const BASE_URL = (process.env.PRESS_API_URL || "https://agent-press-wire.dylan-caponi.workers.dev").replace(/\/+$/, "");
20
+ const TIMEOUT_MS = 180_000;
21
+ function envUsd(name, fallback) {
22
+ const raw = process.env[name];
23
+ if (raw === undefined || raw.trim() === "")
24
+ return fallback;
25
+ const v = Number(raw);
26
+ return Number.isFinite(v) && v > 0 ? v : 0;
27
+ }
28
+ const maxSpendUsd = () => envUsd("MAX_SPEND_USD", 0);
29
+ /** Lifetime cap across all calls and restarts. Defaults to MAX_SPEND_USD (one paid call's worth). */
30
+ const maxTotalSpendUsd = () => envUsd("MAX_TOTAL_SPEND_USD", maxSpendUsd());
31
+ function walletKey() {
32
+ const raw = (process.env.X402_PRIVATE_KEY || "").trim();
33
+ if (!raw)
34
+ return null;
35
+ const k = raw.startsWith("0x") ? raw : `0x${raw}`;
36
+ if (!/^0x[0-9a-fA-F]{64}$/.test(k))
37
+ throw new Error("X402_PRIVATE_KEY must be a 32-byte hex private key");
38
+ return k;
39
+ }
40
+ const ok = (obj) => ({ content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] });
41
+ const fail = (obj) => ({ ...ok(obj), isError: true });
42
+ async function readBody(res) {
43
+ const text = await res.text();
44
+ try {
45
+ return JSON.parse(text);
46
+ }
47
+ catch {
48
+ return text.slice(0, 2000);
49
+ }
50
+ }
51
+ function pickUsdcOnBase(pr) {
52
+ return pr.accepts.find((r) => r.network === NETWORK && String(r.asset).toLowerCase() === USDC_BASE);
53
+ }
54
+ const tokens = new ConfirmTokens();
55
+ function updateLedgerEntry(nonce, ts, patch) {
56
+ try {
57
+ const l = loadLedger();
58
+ const e = l.entries.find((x) => x.ts === ts && x.nonce === nonce);
59
+ if (e)
60
+ Object.assign(e, patch);
61
+ saveLedger(l);
62
+ }
63
+ catch (err) {
64
+ console.error(`could not update spend ledger: ${err.message}`);
65
+ }
66
+ }
67
+ /**
68
+ * POST to a paid endpoint. First call is always unpaid to read the 402 terms, which must match the
69
+ * pinned payTo and price. Without a valid confirm_token (or with quote_only) it returns a quote and
70
+ * a fresh confirm_token. With one, it pays only if every rail allows it.
71
+ */
72
+ async function paidPost(path, body, quoteOnly, confirmToken) {
73
+ const url = `${BASE_URL}${path}`;
74
+ const init = {
75
+ method: "POST",
76
+ headers: { "content-type": "application/json", "user-agent": `agent-press-wire-mcp/${VERSION}` },
77
+ body: JSON.stringify(body),
78
+ };
79
+ let first;
80
+ try {
81
+ first = await fetch(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
82
+ }
83
+ catch (e) {
84
+ return fail({ error: `network error calling ${url}: ${e.message}` });
85
+ }
86
+ if (first.status !== 402) {
87
+ // Should not happen for a paid route; surface whatever the server said.
88
+ return first.ok ? ok(await readBody(first)) : fail({ http_status: first.status, response: await readBody(first) });
89
+ }
90
+ // Parse x402 payment terms. Use a scheme-less client purely for decoding.
91
+ const decoder = new x402HTTPClient(new x402Client());
92
+ let terms;
93
+ try {
94
+ terms = decoder.getPaymentRequiredResponse((n) => first.headers.get(n), await readBody(first));
95
+ }
96
+ catch (e) {
97
+ return fail({ error: `could not parse x402 payment terms: ${e.message}` });
98
+ }
99
+ const req = pickUsdcOnBase(terms);
100
+ if (!req)
101
+ return fail({ error: "server did not offer USDC on Base; refusing to pay", accepts: terms.accepts });
102
+ const mismatch = checkPinnedTerms(path, req);
103
+ if (mismatch)
104
+ return fail({
105
+ error: `refusing to pay: the server's x402 terms do not match the pinned Agent Press Wire terms. ${mismatch}`,
106
+ charged: false,
107
+ offered: req,
108
+ });
109
+ const priceAtomic = BigInt(req.amount);
110
+ const priceUsd = atomicToUsd(priceAtomic);
111
+ const cap = maxSpendUsd();
112
+ const totalCap = maxTotalSpendUsd();
113
+ const quote = {
114
+ endpoint: `POST ${url}`,
115
+ price_usd: priceUsd,
116
+ price_usdc_atomic: req.amount,
117
+ asset: "USDC",
118
+ network: "Base mainnet (eip155:8453)",
119
+ pay_to: req.payTo,
120
+ description: terms.resource?.description,
121
+ };
122
+ let key;
123
+ try {
124
+ key = walletKey();
125
+ }
126
+ catch (e) {
127
+ return fail({ error: e.message, quote });
128
+ }
129
+ let spentAtomic;
130
+ try {
131
+ spentAtomic = totalSpentAtomic(loadLedger());
132
+ }
133
+ catch (e) {
134
+ return fail({ error: `refusing to pay: cannot read spend ledger ${ledgerPath()}: ${e.message}`, quote });
135
+ }
136
+ const totalReason = spentAtomic + priceAtomic > usdToAtomic(totalCap)
137
+ ? `total spend would be $${atomicToUsd(spentAtomic + priceAtomic)}, over MAX_TOTAL_SPEND_USD=${totalCap} (already spent $${atomicToUsd(spentAtomic)} per ${ledgerPath()})`
138
+ : null;
139
+ const blocker = !key
140
+ ? "X402_PRIVATE_KEY is not set"
141
+ : priceUsd > cap
142
+ ? `price $${priceUsd} exceeds MAX_SPEND_USD=${cap} (set MAX_SPEND_USD >= ${priceUsd} to allow this charge)`
143
+ : totalReason;
144
+ if (quoteOnly || !confirmToken || blocker) {
145
+ const reason = quoteOnly ? "quote_only=true" : blocker ?? "no confirm_token: this was a quote";
146
+ return ok({
147
+ mode: "quote",
148
+ paid: false,
149
+ charged: false,
150
+ reason,
151
+ quote,
152
+ spend: { spent_total_usd: atomicToUsd(spentAtomic), max_total_spend_usd: totalCap, max_spend_usd: cap },
153
+ confirm_token: tokens.issue(path, body, req.amount),
154
+ confirm_token_expires_in_seconds: CONFIRM_TTL_MS / 1000,
155
+ next_step: blocker
156
+ ? `Paying is not possible right now: ${blocker}. Show the user this price; the user must change the MCP server settings to allow it.`
157
+ : "Show the user this exact price and get explicit approval. Then call this tool again with the SAME parameters, quote_only=false and this confirm_token (single use, expires in 10 minutes).",
158
+ });
159
+ }
160
+ const tokenError = tokens.consume(confirmToken, path, body, req.amount);
161
+ if (tokenError)
162
+ return fail({
163
+ error: `refusing to pay: ${tokenError}. Call again with quote_only=true to get a fresh quote and confirm_token, confirm the price with the user, then retry with identical parameters.`,
164
+ charged: false,
165
+ quote,
166
+ });
167
+ // Pay. Second guard: x402 client-side spend control capped at MAX_SPEND_USD.
168
+ const account = privateKeyToAccount(key);
169
+ const client = new x402Client()
170
+ .register(NETWORK, new ExactEvmScheme(account))
171
+ .registerPolicy((_v, reqs) => reqs.filter((r) => checkPinnedTerms(path, r) === null && atomicToUsd(r.amount) <= cap))
172
+ .setSpendControls({ maxAmountPerPayment: `$${cap}` });
173
+ const http = new x402HTTPClient(client);
174
+ // Total cap check, signing and the ledger write happen under one lock so concurrent calls cannot both pass.
175
+ const signed = await withLedgerLock(async () => {
176
+ let ledger;
177
+ try {
178
+ ledger = loadLedger();
179
+ }
180
+ catch (e) {
181
+ return { error: `refusing to pay: cannot read spend ledger ${ledgerPath()}: ${e.message}` };
182
+ }
183
+ const over = checkTotalCap(ledger, priceAtomic, totalCap);
184
+ if (over)
185
+ return { error: `refusing to pay: ${over}` };
186
+ let payload;
187
+ try {
188
+ payload = await http.createPaymentPayload({ ...terms, accepts: [req] });
189
+ }
190
+ catch (e) {
191
+ return { error: `could not create payment: ${e.message}` };
192
+ }
193
+ const auth = payload.payload?.authorization;
194
+ if (!auth || String(auth.to).toLowerCase() !== PAY_TO.toLowerCase() || String(auth.value) !== req.amount)
195
+ return { error: "refusing to send: signed authorization does not match the pinned payTo and price" };
196
+ const entry = {
197
+ ts: new Date().toISOString(),
198
+ endpoint: path,
199
+ amount_atomic: req.amount,
200
+ pay_to: req.payTo,
201
+ nonce: auth.nonce ?? null,
202
+ valid_before: auth.validBefore ?? null,
203
+ status: "signed",
204
+ };
205
+ ledger.entries.push(entry);
206
+ try {
207
+ saveLedger(ledger);
208
+ }
209
+ catch (e) {
210
+ return { error: `refusing to send payment: cannot write spend ledger ${ledgerPath()}: ${e.message}` };
211
+ }
212
+ return { headers: http.encodePaymentSignatureHeader(payload), entry };
213
+ });
214
+ if ("error" in signed)
215
+ return fail({ error: signed.error, charged: false, quote });
216
+ const { headers: paymentHeaders, entry } = signed;
217
+ const payment = { nonce: entry.nonce, valid_before_unix: entry.valid_before, payer: account.address, pay_to: entry.pay_to };
218
+ let res;
219
+ try {
220
+ res = await fetch(url, {
221
+ ...init,
222
+ headers: { ...init.headers, ...paymentHeaders },
223
+ signal: AbortSignal.timeout(TIMEOUT_MS),
224
+ });
225
+ }
226
+ catch (e) {
227
+ updateLedgerEntry(entry.nonce, entry.ts, { status: "unknown_network_error" });
228
+ return fail({
229
+ error: `network error after sending a signed payment authorization: ${e.message}. Settlement status is unknown. Check your wallet for a USDC transfer with this authorization nonce before retrying.`,
230
+ settled: "unknown",
231
+ payment,
232
+ quote,
233
+ });
234
+ }
235
+ let settlement = null;
236
+ try {
237
+ settlement = http.getPaymentSettleResponse((n) => res.headers.get(n));
238
+ }
239
+ catch {
240
+ /* no settlement header */
241
+ }
242
+ const result = await readBody(res);
243
+ if (!res.ok) {
244
+ updateLedgerEntry(entry.nonce, entry.ts, { status: "not_settled_per_server", http_status: res.status });
245
+ return fail({
246
+ http_status: res.status,
247
+ settled: "not settled per server",
248
+ note: `The server reported failure and says it did not settle the payment, but it holds a signed authorization until valid_before. Check your wallet for a USDC transfer with nonce ${entry.nonce} before retrying. This authorization still counts toward MAX_TOTAL_SPEND_USD in ${ledgerPath()}.`,
249
+ payment,
250
+ response: result,
251
+ quote,
252
+ });
253
+ }
254
+ updateLedgerEntry(entry.nonce, entry.ts, { status: "settled", http_status: res.status });
255
+ return ok({ mode: "paid", paid: true, price_usd: priceUsd, payment, settlement, result });
256
+ }
257
+ const server = new McpServer({ name: "agent-press-wire", version: VERSION });
258
+ const PAY_NOTE = "Charges REAL USDC on Base mainnet from the user's own wallet (X402_PRIVATE_KEY) via x402. Two steps: call first WITHOUT confirm_token (or with quote_only=true) to get the price and a confirm_token; show the user the exact price and get explicit approval; then call again with identical parameters and that confirm_token (single use, expires in 10 minutes). " +
259
+ "It only pays if the price is <= MAX_SPEND_USD, the lifetime total stays <= MAX_TOTAL_SPEND_USD, and the server's payment terms match the pinned Agent Press Wire wallet and price; otherwise it returns the quote without paying. The service only settles payment if the job succeeds.";
260
+ const announcement = z
261
+ .string()
262
+ .max(500)
263
+ .optional()
264
+ .describe("What is being announced (launch, new version, feature, funding, milestone). Strongly recommended: without it only news visible on the page is written about, and the call fails uncharged if there is none.");
265
+ const quoteOnly = z
266
+ .boolean()
267
+ .optional()
268
+ .default(false)
269
+ .describe("If true, never pay: just return the x402 price quote and a confirm_token. Use this first to show the user the price.");
270
+ const confirmToken = z
271
+ .string()
272
+ .max(200)
273
+ .optional()
274
+ .describe("confirm_token from a previous quote of this tool with the exact same parameters, passed only after the user approved the quoted price. Without it the call only quotes.");
275
+ server.registerTool("announce_launch_newsroom", {
276
+ title: "Announce a launch (press release on newsroom page, $1)",
277
+ description: `Announce a product launch, release, or update with a press release. PRICE: $1 USDC per call. Use when the user says things like "announce my launch", "write a press release", "publish a press release for my product", "announce our new version". ` +
278
+ "Given a product URL, Agent Press Wire drafts a factual 350-450 word AP-style press release from the page and publishes it on a public, search-indexed newsroom page (IndexNow ping to Bing/Yandex). Returns the headline, newsroom URL, and the release in markdown. About 20 seconds. " +
279
+ "For distribution to real news sites, use distribute_press_release instead. " +
280
+ PAY_NOTE,
281
+ inputSchema: {
282
+ product_url: z.string().url().describe("Public URL of the product, launch page, or repo being announced."),
283
+ announcement,
284
+ company: z.string().max(100).optional().describe("Optional company or project name."),
285
+ contact: z.string().max(200).optional().describe("Optional media contact line (name, email or URL) printed at the end."),
286
+ quote_only: quoteOnly,
287
+ confirm_token: confirmToken,
288
+ },
289
+ annotations: { title: "Announce launch ($1)", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
290
+ }, async ({ product_url, announcement, company, contact, quote_only, confirm_token }) => paidPost("/v1/press-release", { product_url, announcement, company, contact }, quote_only, confirm_token));
291
+ server.registerTool("distribute_press_release", {
292
+ title: "Write and distribute a press release over a newswire ($99 / $299)",
293
+ description: `Write a press release and distribute it over a newswire to get press coverage. PRICE: tier "wire" = $99 USDC (500+ news sites plus 100+ TV and radio network sites); tier "premium" = $299 USDC (everything in wire plus AP News, USA Today, Barchart, StreetInsider). ` +
294
+ `Use when the user says things like "get press coverage", "write and distribute a press release", "send a press release to news sites", "put out a press release on the wire", "PR distribution". ` +
295
+ "Drafts a 450-600 word AP-style release from the product URL; it goes through editorial review and is distributed, usually same day on weekdays. Returns an order_id; poll get_release_status for live links. Not accepted: crypto token sales, gambling, adult, health/cure claims, cannabis. " +
296
+ PAY_NOTE,
297
+ inputSchema: {
298
+ tier: z.enum(["wire", "premium"]).describe('"wire" = $99 (500+ news sites); "premium" = $299 (adds AP News, USA Today, Barchart, StreetInsider).'),
299
+ product_url: z.string().url().describe("Public URL of the product, launch page, or repo being announced."),
300
+ city: z.string().min(1).max(80).describe("City of the announcing company (newswire dateline)."),
301
+ country: z.string().min(1).max(80).describe("Country name, e.g. 'United States'."),
302
+ contact_email: z.string().email().describe("Media contact email printed on the release (required by newswires)."),
303
+ announcement,
304
+ company: z.string().max(100).optional().describe("Optional company or project name."),
305
+ quote_only: quoteOnly,
306
+ confirm_token: confirmToken,
307
+ },
308
+ annotations: { title: "Distribute press release ($99/$299)", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
309
+ }, async ({ tier, product_url, city, country, contact_email, announcement, company, quote_only, confirm_token }) => paidPost(tier === "premium" ? "/v1/wire-release/premium" : "/v1/wire-release", { product_url, city, country, contact_email, announcement, company }, quote_only, confirm_token));
310
+ server.registerTool("get_release_status", {
311
+ title: "Get press release distribution status (free)",
312
+ description: "FREE. Check the status of a newswire press release order from distribute_press_release (order id like w_ab12cd34ef) and get live links to the published coverage once distributed.",
313
+ inputSchema: {
314
+ order_id: z.string().describe("The order_id returned by distribute_press_release, e.g. w_3f9d0a1b2c."),
315
+ },
316
+ annotations: { title: "Release status (free)", readOnlyHint: true, openWorldHint: true },
317
+ }, async ({ order_id }) => {
318
+ const id = order_id.trim();
319
+ if (!/^w_[0-9a-f]+$/.test(id))
320
+ return fail({ error: "order_id must look like w_<hex>, as returned by distribute_press_release" });
321
+ let res;
322
+ try {
323
+ res = await fetch(`${BASE_URL}/v1/wire-release/${id}`, {
324
+ headers: { "user-agent": `agent-press-wire-mcp/${VERSION}` },
325
+ signal: AbortSignal.timeout(30_000),
326
+ });
327
+ }
328
+ catch (e) {
329
+ return fail({ error: `network error: ${e.message}` });
330
+ }
331
+ const body = await readBody(res);
332
+ if (res.status === 404)
333
+ return fail({ error: `no wire order found with id ${id}`, http_status: 404 });
334
+ if (!res.ok)
335
+ return fail({ http_status: res.status, response: body });
336
+ return ok(body);
337
+ });
338
+ await server.connect(new StdioServerTransport());
339
+ console.error(`agent-press-wire MCP ${VERSION} ready (${BASE_URL}, MAX_SPEND_USD=${maxSpendUsd()}, MAX_TOTAL_SPEND_USD=${maxTotalSpendUsd()}, ledger ${ledgerPath()}, wallet ${process.env.X402_PRIVATE_KEY ? "set" : "not set"})`);
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "agent-press-wire-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Agent Press Wire: AI agents announce launches and distribute press releases to 500+ news sites, paid per call in USDC over x402.",
5
+ "mcpName": "io.github.dylancaponi/agent-press-wire",
6
+ "type": "module",
7
+ "bin": { "agent-press-wire-mcp": "dist/index.js" },
8
+ "main": "dist/index.js",
9
+ "files": ["dist", "README.md", "server.json", "LICENSE"],
10
+ "scripts": {
11
+ "build": "tsc && chmod +x dist/index.js",
12
+ "start": "node dist/index.js",
13
+ "test": "npm run build && node --test test/hardening-test.mjs",
14
+ "test:quote": "node test/quote-test.mjs",
15
+ "prepublishOnly": "npm run build"
16
+ },
17
+ "keywords": ["mcp", "model-context-protocol", "press-release", "pr", "launch", "newswire", "x402", "usdc", "base", "ai-agents"],
18
+ "homepage": "https://agent-press-wire.dylan-caponi.workers.dev",
19
+ "author": "Dylan Caponi",
20
+ "license": "MIT",
21
+ "engines": { "node": ">=20" },
22
+ "dependencies": {
23
+ "@modelcontextprotocol/sdk": "1.32.0",
24
+ "@x402/core": "2.28.0",
25
+ "@x402/evm": "2.28.0",
26
+ "@x402/fetch": "2.28.0",
27
+ "viem": "2.57.2",
28
+ "zod": "4.6.5"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "26.6.4",
32
+ "typescript": "7.0.2"
33
+ }
34
+ }
package/server.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.dylancaponi/agent-press-wire",
4
+ "title": "Agent Press Wire",
5
+ "description": "Announce launches and distribute press releases to 500+ news sites. Pay per call in USDC (x402).",
6
+ "websiteUrl": "https://agent-press-wire.dylan-caponi.workers.dev",
7
+ "version": "0.1.0",
8
+ "packages": [
9
+ {
10
+ "registryType": "npm",
11
+ "identifier": "agent-press-wire-mcp",
12
+ "version": "0.1.0",
13
+ "transport": { "type": "stdio" },
14
+ "environmentVariables": [
15
+ {
16
+ "name": "X402_PRIVATE_KEY",
17
+ "description": "Private key of YOUR OWN Base wallet holding USDC, used to pay per call via x402. Omit for quote-only mode.",
18
+ "isRequired": false,
19
+ "isSecret": true,
20
+ "format": "string"
21
+ },
22
+ {
23
+ "name": "MAX_SPEND_USD",
24
+ "description": "Max USD the server may pay for a single call. 0 (default) = quote only, never pays. Prices: $1 newsroom, $99 wire, $299 premium wire.",
25
+ "isRequired": false,
26
+ "isSecret": false,
27
+ "format": "number",
28
+ "default": "0"
29
+ },
30
+ {
31
+ "name": "MAX_TOTAL_SPEND_USD",
32
+ "description": "Lifetime cap in USD across all calls and restarts, tracked in a ledger at ~/.agent-press-wire/spend.json. Defaults to MAX_SPEND_USD (one paid call's worth).",
33
+ "isRequired": false,
34
+ "isSecret": false,
35
+ "format": "number"
36
+ },
37
+ {
38
+ "name": "AGENT_PRESS_WIRE_HOME",
39
+ "description": "Directory for the spend ledger (spend.json). Default ~/.agent-press-wire.",
40
+ "isRequired": false,
41
+ "isSecret": false,
42
+ "format": "string"
43
+ },
44
+ {
45
+ "name": "PRESS_API_URL",
46
+ "description": "Override the API base URL (testing only).",
47
+ "isRequired": false,
48
+ "isSecret": false,
49
+ "format": "string"
50
+ }
51
+ ]
52
+ }
53
+ ]
54
+ }