@haven_ai/mcp 0.9.0-alpha.0 → 0.11.0-alpha.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 CHANGED
@@ -105,6 +105,44 @@ Environment variable form:
105
105
  }
106
106
  ```
107
107
 
108
+ ## Per-chain RPC endpoints
109
+
110
+ When an x402 payment hits an idempotency collision, the server can tell a
111
+ finished payment from a stuck one only by reading the agent delegate's balance
112
+ on chain. Without an RPC endpoint it cannot, so every collision is refused as
113
+ `unverifiable`, including the recoverable case where the delegate is still
114
+ funded and the payment could resume.
115
+
116
+ Give the server a read-only RPC endpoint per chain and it can check:
117
+
118
+ ```json
119
+ {
120
+ "mcpServers": {
121
+ "haven": {
122
+ "command": "npx",
123
+ "args": ["@haven_ai/mcp"],
124
+ "env": {
125
+ "HAVEN_CREDENTIALS": "/absolute/path/to/haven-agent.json",
126
+ "HAVEN_RPC_URL_8453": "https://your-rpc.example/v3/key",
127
+ "HAVEN_RPC_URL_84532": "https://your-rpc.example/sepolia/v3/key"
128
+ }
129
+ }
130
+ }
131
+ }
132
+ ```
133
+
134
+ One variable per chain: the suffix is the chain id (`8453` is Base mainnet,
135
+ `84532` is Base Sepolia). An unset variable changes nothing: collisions are
136
+ refused exactly as before. A value whose suffix is not a positive integer is
137
+ ignored, and a malformed or unreachable URL degrades to the same refusal.
138
+ Nothing is written to stdout in any of these cases, because stdout is the
139
+ protocol channel.
140
+
141
+ With an endpoint wired for the payment's chain, a collision where the delegate
142
+ is still funded resumes: the balance says the merchant never pulled the
143
+ payment, so the server mints a fresh authorization. A drained delegate still
144
+ refuses, with a settled basis, because the payment already completed.
145
+
108
146
  ## Tools
109
147
 
110
148
  - `haven_quote_x402`
package/dist/cli.cjs CHANGED
@@ -238,6 +238,18 @@ async function warnIfCredentialFilePermissive(path, log = (message) => process.s
238
238
  );
239
239
  }
240
240
  }
241
+ function chainRpcsFromEnv(env) {
242
+ const chainRpcs = {};
243
+ for (const [name, value] of Object.entries(env)) {
244
+ const match = /^HAVEN_RPC_URL_(\d+)$/.exec(name);
245
+ if (!match) continue;
246
+ const chainId = Number.parseInt(match[1], 10);
247
+ if (!Number.isSafeInteger(chainId) || chainId <= 0) continue;
248
+ const url = value?.trim();
249
+ if (url) chainRpcs[chainId] = url;
250
+ }
251
+ return chainRpcs;
252
+ }
241
253
  var TASK_BUDGET_MIN_SIGNER_VERSION = "0.6.0-alpha.0";
242
254
  var SIGNER_CAPABILITY_KEY = "haven/signer-compatibility";
243
255
  function taskSignerCompatibilityNotice() {
@@ -250,6 +262,20 @@ function taskSignerCompatibilityNotice() {
250
262
  };
251
263
  }
252
264
  var headersSchema = v3.z.record(v3.z.string(), v3.z.string()).optional();
265
+ function selectQuoteOption(accepts, agent) {
266
+ if (agent.executionRail === "delegation") {
267
+ const erc7710 = sdk.selectErc7710PaymentOption(accepts);
268
+ if (erc7710) return erc7710;
269
+ }
270
+ return sdk.selectStandardPaymentOption(accepts);
271
+ }
272
+ function humanDecimalToAtomic(human, decimals) {
273
+ const match = /^([0-9]+)(?:\.([0-9]+))?$/.exec(human);
274
+ if (!match) return null;
275
+ const fraction = match[2] ?? "";
276
+ if (fraction.length > decimals) return null;
277
+ return BigInt(match[1] + fraction.padEnd(decimals, "0"));
278
+ }
253
279
  var toolSchemas = {
254
280
  haven_send: {
255
281
  asset: v3.z.enum(["ETH", "USDC"]),
@@ -281,6 +307,17 @@ var toolSchemas = {
281
307
  headers: headersSchema,
282
308
  body: v3.z.string().optional(),
283
309
  idempotency_key: v3.z.string().optional(),
310
+ /**
311
+ * #3967: the price the agent EXPECTS, in whole tokens ("0.10" = 0.1 USDC),
312
+ * compared against the amount of the option pay will actually select. A
313
+ * mismatch stops the agent — the same stop signals the hosted quote
314
+ * carries. Local parity: a hosted-only input would split the two tools'
315
+ * contracts. Same shape and name as the hosted surface's.
316
+ */
317
+ expected_amount: v3.z.string().regex(
318
+ /^[0-9]+(\.[0-9]+)?$/,
319
+ 'expected_amount must be a plain decimal amount in whole tokens, e.g. "0.10"'
320
+ ).optional(),
284
321
  /** REMOVED (#3411): declared only so the resolver can refuse it by name, never accepted. */
285
322
  idempotencyKey: v3.z.string().optional().describe("REMOVED (#3411): refused \u2014 send idempotency_key")
286
323
  },
@@ -627,8 +664,39 @@ function createToolHandlers(haven) {
627
664
  const pf = preflight("haven_quote_x402", input);
628
665
  if ("success" in pf) return pf;
629
666
  const { args, warnings } = pf;
667
+ const agentPromise = args.expected_amount !== void 0 ? haven.getAgent().then(
668
+ (a) => a,
669
+ () => void 0
670
+ ) : null;
630
671
  try {
631
672
  const data = await haven.quoteX402(args.url, requestInit(args), { idempotencyKey: args.resolvedIdempotencyKey });
673
+ if (args.expected_amount !== void 0) {
674
+ const agent = agentPromise ? await agentPromise : void 0;
675
+ const option = agent ? selectQuoteOption(data.paymentRequired.accepts, agent) : null;
676
+ if (option && data.decimals !== null) {
677
+ const expectedAtomic = humanDecimalToAtomic(args.expected_amount, data.decimals);
678
+ let optionAmount = null;
679
+ try {
680
+ optionAmount = BigInt(sdk.x402AuthorizationAmount(option));
681
+ } catch {
682
+ optionAmount = null;
683
+ }
684
+ if (optionAmount !== null && expectedAtomic !== null) {
685
+ const priceDiffers = optionAmount !== expectedAtomic;
686
+ const payload = {
687
+ ...data,
688
+ price_differs_from_expected: priceDiffers,
689
+ ...priceDiffers ? {
690
+ safe_to_continue: false,
691
+ next_action: sdk.AgentPaymentNextAction.StopAndTellUser,
692
+ next_tool_omitted_reason: "no Haven tool follows: the quoted price does not match the stated expectation \u2014 the user decides before any payment",
693
+ reason: `The merchant's quoted price (${data.amount} ${data.asset}) does not match the expected price (${args.expected_amount}) stated with this quote. Stop and tell the user before any payment; do not re-quote, re-pay, or poll. If the x402 challenge itself is wrong and nothing has been paid via x402 on this invoice, the invoice's own deposit address MAY be an alternative \u2014 but only with the user's explicit go-ahead, never automatically, and never once anything has been paid via x402.`
694
+ } : {}
695
+ };
696
+ return warnings.length > 0 ? { success: true, data: payload, warnings } : { success: true, data: payload };
697
+ }
698
+ }
699
+ }
632
700
  return warnings.length > 0 ? { success: true, data, warnings } : { success: true, data };
633
701
  } catch (err) {
634
702
  return normalizeError(err);
@@ -727,6 +795,10 @@ function createToolHandlers(haven) {
727
795
  price_atomic: entry.priceAtomic,
728
796
  asset: entry.asset,
729
797
  network: entry.network,
798
+ // #3839: same hint the hosted surface emits — a catalog hint, the
799
+ // live quote stays authoritative; "unknown" when nothing is recorded.
800
+ asset_transfer_methods: entry.assetTransferMethods ?? null,
801
+ funding_leg_expected: sdk.catalogFundingLegExpected(entry.assetTransferMethods),
730
802
  status: entry.status,
731
803
  verified_at: entry.verifiedAt,
732
804
  source: entry.source,
@@ -992,17 +1064,26 @@ function parseMaybeJson(text) {
992
1064
  return text;
993
1065
  }
994
1066
  }
1067
+ var MERCHANT_OUT_OF_GAS_REASON = "settlement_wallet_out_of_gas";
1068
+ function merchantOutOfGasMessage(settlementsRemaining, failFloor) {
1069
+ const remaining = typeof settlementsRemaining === "number" ? settlementsRemaining : null;
1070
+ const floor = typeof failFloor === "number" ? failFloor : null;
1071
+ const figures = remaining !== null && floor !== null ? ` It has gas for ${remaining} more settlement${remaining === 1 ? "" : "s"} and refuses new payments below ${floor} (settlements_remaining: ${remaining}, fail_floor: ${floor}).` : remaining !== null ? ` settlements_remaining: ${remaining}.` : "";
1072
+ return `The merchant refused this call: its settlement wallet is out of gas (reason_code: ${MERCHANT_OUT_OF_GAS_REASON}).` + figures + " No payment was created. The merchant's operator must top up its settlement wallet; until then every retry is refused again. Tell the user, and re-quote once the merchant has been topped up.";
1073
+ }
995
1074
  var MerchantNotReadyError = class extends Error {
1075
+ constructor(message, nextToolOmittedReason = "the merchant needs to recover first; re-quote after the retry_after_s in the message") {
1076
+ super(message);
1077
+ this.nextToolOmittedReason = nextToolOmittedReason;
1078
+ this.name = "MerchantNotReadyError";
1079
+ }
1080
+ nextToolOmittedReason;
996
1081
  code = sdk.AgentPaymentFailureCode.MerchantNotReady;
997
1082
  statusCode = 503;
998
1083
  nextAction = sdk.AgentPaymentNextAction.StopAndTellUser;
999
1084
  // Genuinely retryable — unlike a rejection, nothing about THIS call was
1000
1085
  // wrong; the merchant's own wallet needs to recover first.
1001
1086
  retryWithNewQuote = true;
1002
- constructor(message) {
1003
- super(message);
1004
- this.name = "MerchantNotReadyError";
1005
- }
1006
1087
  };
1007
1088
  async function merchantNotReadyErrorFor(response) {
1008
1089
  if (response.status !== 503) return null;
@@ -1015,7 +1096,14 @@ async function merchantNotReadyErrorFor(response) {
1015
1096
  if (!body || typeof body !== "object" || body.error !== "merchant_not_ready") {
1016
1097
  return null;
1017
1098
  }
1018
- const { reason_code, settlements_remaining, retry_after_s } = body;
1099
+ const { reason_code, settlements_remaining, fail_floor, retry_after_s } = body;
1100
+ if (reason_code === MERCHANT_OUT_OF_GAS_REASON) {
1101
+ return new MerchantNotReadyError(
1102
+ merchantOutOfGasMessage(settlements_remaining, fail_floor),
1103
+ // Same reason the hosted runtime returns (support/mcp-context.ts).
1104
+ "the merchant's operator must top up its settlement wallet first; re-quote after that"
1105
+ );
1106
+ }
1019
1107
  return new MerchantNotReadyError(
1020
1108
  "The merchant refused this call: it cannot settle a payment right now" + (typeof reason_code === "string" ? ` (reason_code: ${reason_code})` : "") + (typeof settlements_remaining === "number" ? `, settlements_remaining: ${settlements_remaining}` : "") + ". No payment was created." + (typeof retry_after_s === "number" ? ` Retry after approximately ${retry_after_s}s.` : " This is often transient; retry later.")
1021
1109
  );
@@ -1036,8 +1124,10 @@ function normalizeError(err) {
1036
1124
  next_action: err.nextAction,
1037
1125
  retry_with_new_quote: err.retryWithNewQuote,
1038
1126
  // #3103: the local runtime's one decision site — no tool can act until
1039
- // the merchant recovers; the message carries retry_after_s.
1040
- next_tool_omitted_reason: "the merchant needs to recover first; re-quote after the retry_after_s in the message"
1127
+ // the merchant recovers. The reason rides the error (#3834): the
1128
+ // generic one points at the message's retry_after_s, the out-of-gas
1129
+ // one at the operator top-up.
1130
+ next_tool_omitted_reason: err.nextToolOmittedReason
1041
1131
  };
1042
1132
  }
1043
1133
  if (err instanceof sdk.HavenPaymentStateError) {
@@ -1062,6 +1152,24 @@ function normalizeError(err) {
1062
1152
  message: err.message
1063
1153
  };
1064
1154
  }
1155
+ if (err instanceof sdk.MerchantEgressRefusedError && !err.beforeRequest && err.paymentId) {
1156
+ const noFundingLeg = err.settlementScheme === "erc7710";
1157
+ const nextAction = noFundingLeg ? sdk.AgentPaymentNextAction.CheckStatusLater : sdk.AgentPaymentNextAction.SweepStrandedFunds;
1158
+ return {
1159
+ success: false,
1160
+ code: err.code,
1161
+ message: err.message,
1162
+ statusCode: err.statusCode,
1163
+ paymentId: err.paymentId,
1164
+ status: "merchant_unresponsive_after_funding",
1165
+ phase: noFundingLeg ? "not_delivered" : "funded_but_unsettled",
1166
+ nextAction,
1167
+ next_action: nextAction,
1168
+ next_tool: "haven_get_payment_status",
1169
+ next_arguments: { payment_id: err.paymentId },
1170
+ suggested_tool: "haven_get_payment_status"
1171
+ };
1172
+ }
1065
1173
  if (err instanceof sdk.HavenApiError) {
1066
1174
  const body = err.body;
1067
1175
  return {
@@ -1304,10 +1412,14 @@ async function resolveHavenClient(options = {}) {
1304
1412
  signerPath: options.signerPath
1305
1413
  } : void 0;
1306
1414
  const credentials = options.credentials ?? await loadCredentials(credentialSource);
1415
+ const chainRpcs = chainRpcsFromEnv(process.env);
1307
1416
  const client = new sdk.HavenClient({
1308
1417
  apiKey: credentials.apiKey,
1309
1418
  delegateKey: credentials.delegateKey,
1310
1419
  baseUrl: credentials.apiUrl,
1420
+ // Only added when non-empty so the unset-variable construction is byte-for-byte
1421
+ // today's: the SDK defaults `chainRpcs` to `{}` either way.
1422
+ ...Object.keys(chainRpcs).length > 0 ? { chainRpcs } : {},
1311
1423
  // #3303: name this package, not the SDK inside it, so the backend's
1312
1424
  // update hint and refusal speak about what the user actually installed.
1313
1425
  clientIdentity: sdk.havenClientIdentity(MCP_NAME, MCP_VERSION)
@@ -1315,7 +1427,7 @@ async function resolveHavenClient(options = {}) {
1315
1427
  return { client, credentials };
1316
1428
  }
1317
1429
  var MCP_NAME = "@haven_ai/mcp";
1318
- var MCP_VERSION = "0.9.0-alpha.0";
1430
+ var MCP_VERSION = "0.11.0-alpha.0";
1319
1431
  var MCP_INSTRUCTIONS = [
1320
1432
  "Haven local MCP server: signs in-process with the delegate key it holds on",
1321
1433
  "this machine \u2014 the key never leaves this process. Call haven_get_agent",