@priors/mcp 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,7 +62,7 @@ Settings → Developer → Edit Config (`claude_desktop_config.json`), then rest
62
62
  "mcpServers": {
63
63
  "priors": {
64
64
  "command": "npx",
65
- "args": ["-y", "@priors/mcp@0.1.4"],
65
+ "args": ["-y", "@priors/mcp@0.1.6"],
66
66
  "env": {
67
67
  "PRIORS_KEY": "0xYOUR_AGENT_WALLET_KEY",
68
68
  "PRIORS_AGENT_ID": "1234"
@@ -84,7 +84,7 @@ A project `.mcp.json` that reads the key from your shell's environment, so the f
84
84
  "mcpServers": {
85
85
  "priors": {
86
86
  "command": "npx",
87
- "args": ["-y", "@priors/mcp@0.1.4"],
87
+ "args": ["-y", "@priors/mcp@0.1.6"],
88
88
  "env": {
89
89
  "PRIORS_KEY": "${PRIORS_KEY}",
90
90
  "PRIORS_AGENT_ID": "${PRIORS_AGENT_ID:-}"
@@ -94,7 +94,7 @@ A project `.mcp.json` that reads the key from your shell's environment, so the f
94
94
  }
95
95
  ```
96
96
 
97
- or, for your user only: `claude mcp add priors --scope user -e PRIORS_KEY="$PRIORS_KEY" -- npx -y @priors/mcp@0.1.4`
97
+ or, for your user only: `claude mcp add priors --scope user -e PRIORS_KEY="$PRIORS_KEY" -- npx -y @priors/mcp@0.1.6`
98
98
  (the key is expanded by your shell from the environment; do not paste it on the command line).
99
99
 
100
100
  ## Try it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@priors/mcp",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "description": "MCP server for Priors on Robinhood Chain: pay x402 URLs in USDG, read balances, credit lines and scores, borrow and repay. Key from PRIORS_KEY only.",
6
6
  "type": "module",
@@ -44,6 +44,7 @@
44
44
  "@modelcontextprotocol/sdk": "^1.30.1",
45
45
  "@priors/x402": "0.1.3",
46
46
  "ethers": "^6.13.4",
47
+ "undici": "6.29.0",
47
48
  "zod": "^4.6.5"
48
49
  }
49
50
  }
package/src/server.mjs CHANGED
@@ -19,6 +19,8 @@ import { readFileSync } from "node:fs";
19
19
  import { randomBytes } from "node:crypto";
20
20
  import { lookup as dnsLookup } from "node:dns/promises";
21
21
  import { isIP } from "node:net";
22
+ import { lookup as dnsLookupCb } from "node:dns";
23
+ import { fetch as undiciFetch, Agent } from "undici";
22
24
  import { ethers } from "ethers";
23
25
  import { z } from "zod";
24
26
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -79,6 +81,27 @@ export function isPrivateAddress(ip) {
79
81
  return (x[0] & 0xfe) === 0xfc || (x[0] === 0xfe && (x[1] & 0xc0) === 0x80) || x[0] === 0xff; // fc00::/7, fe80::/10, ff00::/8
80
82
  }
81
83
 
84
+ /**
85
+ * fetch whose every connection re-checks the address it resolved: a hostname that answered a public address to the
86
+ * pre-check and a private one at connect time (DNS rebinding) is refused there. IP literals skip DNS; the pre-check
87
+ * (checkTarget) refuses the private ones. `resolve` is dns.lookup-shaped (tests swap it).
88
+ */
89
+ export function guardedFetch(resolve = dnsLookupCb) {
90
+ const lookup = (hostname, options, cb) => resolve(hostname, { ...(options || {}), all: true }, (err, addrs) => {
91
+ if (err) return cb(err);
92
+ const list = Array.isArray(addrs) ? addrs : [{ address: addrs, family: isIP(addrs) }];
93
+ const bad = list.find((a) => isPrivateAddress(a.address));
94
+ if (bad || list.length === 0) return cb(Object.assign(new Error(`refused: ${hostname} resolves to a private or local address (${bad?.address ?? "none"})`), { code: "EPRIVATE" }));
95
+ return options?.all ? cb(null, list) : cb(null, list[0].address, list[0].family);
96
+ });
97
+ const dispatcher = new Agent({ connect: { lookup } });
98
+ return async (input, init) => {
99
+ const r = input instanceof Request ? input : new Request(input, init);
100
+ const body = r.body ? await r.arrayBuffer() : undefined;
101
+ return undiciFetch(r.url, { method: r.method, headers: [...r.headers], body, redirect: r.redirect, signal: r.signal, dispatcher });
102
+ };
103
+ }
104
+
82
105
  const usd = (units) => `${X.formatUsdg(units)} USDG`;
83
106
  const when = (t) => new Date(Number(t) * 1000).toISOString().replace(".000Z", "Z");
84
107
  const short = (s, n) => (s.length > n ? `${s.slice(0, n)}… (${s.length - n} more characters cut)` : s);
@@ -125,6 +148,22 @@ export async function createPriorsMcpServer({ env = process.env, fetchImpl = glo
125
148
  // Payments signed and not (yet) counted as paid, by "METHOD url": until validBefore the merchant can still cash
126
149
  // them, so another pay_url for the same purchase resends that payment instead of signing a second one.
127
150
  const outstanding = new Map();
151
+ // One money call at a time (pay_url, borrow, repay): MCP clients may send tool calls concurrently, and each check
152
+ // above (an outstanding payment for this URL, the session totals) must see the previous call's result (Codex review).
153
+ let moneyQueue = Promise.resolve();
154
+ // The time budget counts from the call's arrival, not from its turn: a call queued behind a slow one must still answer
155
+ // before the client gives up (a client timeout hides the "do not retry" answer and invites a retry).
156
+ const serial = (fn) => (args) => {
157
+ const deadline = Date.now() + callBudgetMs;
158
+ const run = moneyQueue.then(() => {
159
+ if (Date.now() >= deadline - Math.min(1000, callBudgetMs / 10)) throw new ToolError("another payment call was still running and this one ran out of time before it could start. Nothing was signed or paid: try again.");
160
+ return fn(args, deadline);
161
+ });
162
+ moneyQueue = run.catch(() => {});
163
+ return run;
164
+ };
165
+ // The real network goes through the rebinding-proof dispatcher; an injected fetchImpl (tests, embedders) is used as given.
166
+ const payFetch = fetchImpl === globalThis.fetch && !allowLocal ? guardedFetch() : fetchImpl;
128
167
 
129
168
  /** https only (http://localhost only with PRIORS_ALLOW_LOCAL=1), and never a host that resolves to a private address. */
130
169
  async function checkTarget(url) {
@@ -136,9 +175,8 @@ export async function createPriorsMcpServer({ env = process.env, fetchImpl = glo
136
175
  }
137
176
  if (u.protocol !== "https:") throw new ToolError("pay_url only pays https URLs.");
138
177
  if (allowLocal) return u;
139
- // SHORTCUT: checked once before the request; a DNS answer that changes between this lookup and the fetch
140
- // (rebinding) is not caught. Ceiling: a hostile DNS server. Upgrade trigger: a report of it, or Node's fetch
141
- // gaining a per-request lookup hook to pin the address checked here.
178
+ // A clear refusal before anything is sent; the connection itself re-checks the address it resolves (guardedFetch),
179
+ // so a DNS answer that turns private in between (rebinding) is refused there too.
142
180
  let addrs;
143
181
  if (isIP(host)) addrs = [{ address: host }];
144
182
  else { try { addrs = await lookup(host); } catch (_) { throw new ToolError(`could not resolve ${host}. Nothing was fetched.`); } }
@@ -242,7 +280,7 @@ export async function createPriorsMcpServer({ env = process.env, fetchImpl = glo
242
280
  max_borrow_usd: z.number().nonnegative().optional().describe("Most to borrow from the Priors line if the wallet is short, in US dollars. Default 0: never borrow."),
243
281
  },
244
282
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
245
- }, async ({ url, method = "GET", body, max_price_usd, max_borrow_usd }) => {
283
+ }, serial(async ({ url, method = "GET", body, max_price_usd, max_borrow_usd }, deadline) => {
246
284
  const signer = needWallet("pay_url");
247
285
  const u = await checkTarget(url);
248
286
  if (method === "GET" && body !== undefined) throw new ToolError("A GET request cannot carry a body: use POST (or another method) with body.");
@@ -263,7 +301,7 @@ export async function createPriorsMcpServer({ env = process.env, fetchImpl = glo
263
301
  let r;
264
302
  if (prior) {
265
303
  // A payment for this purchase is already out and still cashable: send that same one, never a second.
266
- r = await X.createPayer({ signer, fetchImpl, pendingRetries: 2, maxSleepMs: 10_000, timeoutMs: requestTimeoutMs, signal: AbortSignal.timeout(callBudgetMs), ...(sleep ? { sleep } : {}) }).resend(url, prior.paymentHeaders, init);
304
+ r = await X.createPayer({ signer, fetchImpl: payFetch, pendingRetries: 2, maxSleepMs: 10_000, timeoutMs: requestTimeoutMs, signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())), ...(sleep ? { sleep } : {}) }).resend(url, prior.paymentHeaders, init);
267
305
  r = { ...r, requirement: prior.requirement, paid: r.response.ok ? prior.price : 0n, borrowed: 0n, signed: prior, resent: true, ...(r.response.ok ? { settlement: settlementOf(r.response) } : {}) };
268
306
  lines.push("No new payment was signed: the same signed payment was sent again.");
269
307
  } else {
@@ -271,7 +309,7 @@ export async function createPriorsMcpServer({ env = process.env, fetchImpl = glo
271
309
  if (maxBorrow > 0n && session.borrowed + maxBorrow > borrowTotalCap) throw new ToolError(`this could bring what is borrowed in this session to ${usd(session.borrowed + maxBorrow)}, above ${usd(borrowTotalCap)} (PRIORS_MAX_BORROW_TOTAL_USD).`);
272
310
  let agentId;
273
311
  if (maxBorrow > 0n) { agentId = await resolveAgent(); await needController(agentId); }
274
- const payer = X.createPayer({ signer, maxPrice, maxBorrow, fetchImpl, pendingRetries: 2, maxSleepMs: 10_000, timeoutMs: requestTimeoutMs, signal: AbortSignal.timeout(callBudgetMs), ...(sleep ? { sleep } : {}), ...(maxBorrow > 0n ? { pool: addresses.pool, agentId } : {}) });
312
+ const payer = X.createPayer({ signer, maxPrice, maxBorrow, fetchImpl: payFetch, pendingRetries: 2, maxSleepMs: 10_000, timeoutMs: requestTimeoutMs, signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())), ...(sleep ? { sleep } : {}), ...(maxBorrow > 0n ? { pool: addresses.pool, agentId } : {}) });
275
313
  try { r = await payer.pay(url, init); } catch (e) {
276
314
  if (e?.name === "TimeoutError" || e?.name === "AbortError") throw new ToolError(`${method} ${u.href} did not answer in time. Nothing was signed or paid.`);
277
315
  throw e;
@@ -300,7 +338,7 @@ export async function createPriorsMcpServer({ env = process.env, fetchImpl = glo
300
338
  try { ({ text: bodyText, cut } = await X.readCapped(r.response)); } catch (_) { /* no body */ }
301
339
  if (bodyText) lines.push(`Response body${cut ? " (cut at 256 KB)" : ""}:\n${fenced(clean(bodyText, 20_000), "response body")}`);
302
340
  return lines.join("\n");
303
- });
341
+ }));
304
342
 
305
343
  // ---- wallet_balance ------------------------------------------------------------------------------------------
306
344
  tool("wallet_balance", {
@@ -364,7 +402,7 @@ export async function createPriorsMcpServer({ env = process.env, fetchImpl = glo
364
402
  dry_run: z.boolean().optional().describe("true: only quote the fee and due amount, borrow nothing."),
365
403
  },
366
404
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
367
- }, async ({ amount_usd, days, dry_run }) => {
405
+ }, serial(async ({ amount_usd, days, dry_run }) => {
368
406
  needWallet("borrow");
369
407
  const amount = dollars(amount_usd, "amount_usd");
370
408
  if (amount === 0n) throw new ToolError("amount_usd must be above zero");
@@ -378,7 +416,7 @@ export async function createPriorsMcpServer({ env = process.env, fetchImpl = glo
378
416
  const r = await credit.borrow(id, amount, term);
379
417
  session.borrowed += r.principal;
380
418
  return `Borrowed ${usd(r.principal)} for agent #${id}${r.loanId !== null ? ` as loan #${r.loanId}` : ""}: fee ${usd(r.fee)}, so ${usd(r.principal + r.fee)} is due${r.dueAt ? ` by ${when(r.dueAt)}` : ""}. The USDG is in ${wallet.address}. Tx ${r.hash}.`;
381
- });
419
+ }));
382
420
 
383
421
  // ---- repay ---------------------------------------------------------------------------------------------------
384
422
  tool("repay", {
@@ -389,7 +427,7 @@ export async function createPriorsMcpServer({ env = process.env, fetchImpl = glo
389
427
  all: z.boolean().optional().describe("true: repay every open loan of the agent, earliest due first."),
390
428
  },
391
429
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
392
- }, async ({ loan_id, all }) => {
430
+ }, serial(async ({ loan_id, all }) => {
393
431
  needWallet("repay");
394
432
  if ((loan_id === undefined) === (all !== true)) throw new ToolError("Give exactly one of loan_id or all: true.");
395
433
  // Only the loans of an agent this wallet controls: the pool lets anyone repay any loan, and this wallet's USDG is
@@ -413,7 +451,7 @@ export async function createPriorsMcpServer({ env = process.env, fetchImpl = glo
413
451
  const out = [done.length ? `Repaid for agent #${id}:\n- ${done.join("\n- ")}` : `Nothing was repaid for agent #${id}.`];
414
452
  if (left.length) out.push(`Still open: ${left.map((l) => `#${l.loanId} (${usd(l.due)} due ${when(l.dueAt)})`).join(", ")}.${stop ? ` Stopped because: ${stop}` : ""}`);
415
453
  return out.join("\n");
416
- });
454
+ }));
417
455
 
418
456
  // ---- find_services -------------------------------------------------------------------------------------------
419
457
  tool("find_services", {