@absol-labs/agent 0.10.0 → 0.12.0-rc.1

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.
Files changed (331) hide show
  1. package/README.md +106 -30
  2. package/dist/capability/invocation-capability.d.ts +20 -8
  3. package/dist/capability/invocation-capability.d.ts.map +1 -1
  4. package/dist/capability/invocation-capability.js.map +1 -1
  5. package/dist/capability/probe-capability.d.ts +225 -0
  6. package/dist/capability/probe-capability.d.ts.map +1 -0
  7. package/dist/capability/probe-capability.js +248 -0
  8. package/dist/capability/probe-capability.js.map +1 -0
  9. package/dist/cli/args.d.ts +11 -0
  10. package/dist/cli/args.d.ts.map +1 -1
  11. package/dist/cli/args.js +23 -0
  12. package/dist/cli/args.js.map +1 -1
  13. package/dist/cli/commands/claim.d.ts.map +1 -1
  14. package/dist/cli/commands/claim.js +21 -5
  15. package/dist/cli/commands/claim.js.map +1 -1
  16. package/dist/cli/commands/close.d.ts.map +1 -1
  17. package/dist/cli/commands/close.js +51 -7
  18. package/dist/cli/commands/close.js.map +1 -1
  19. package/dist/cli/commands/invoke.d.ts +2 -0
  20. package/dist/cli/commands/invoke.d.ts.map +1 -1
  21. package/dist/cli/commands/invoke.js +3 -2
  22. package/dist/cli/commands/invoke.js.map +1 -1
  23. package/dist/cli/commands/mcp.d.ts.map +1 -1
  24. package/dist/cli/commands/mcp.js +15 -2
  25. package/dist/cli/commands/mcp.js.map +1 -1
  26. package/dist/cli/commands/open.d.ts.map +1 -1
  27. package/dist/cli/commands/open.js +121 -15
  28. package/dist/cli/commands/open.js.map +1 -1
  29. package/dist/cli/commands/pay.d.ts +19 -0
  30. package/dist/cli/commands/pay.d.ts.map +1 -0
  31. package/dist/cli/commands/pay.js +355 -0
  32. package/dist/cli/commands/pay.js.map +1 -0
  33. package/dist/cli/commands/reclaim-unverified.d.ts.map +1 -1
  34. package/dist/cli/commands/reclaim-unverified.js +15 -6
  35. package/dist/cli/commands/reclaim-unverified.js.map +1 -1
  36. package/dist/cli/commands/reclaim.d.ts +23 -2
  37. package/dist/cli/commands/reclaim.d.ts.map +1 -1
  38. package/dist/cli/commands/reclaim.js +200 -21
  39. package/dist/cli/commands/reclaim.js.map +1 -1
  40. package/dist/cli/commands/refund.d.ts +13 -0
  41. package/dist/cli/commands/refund.d.ts.map +1 -0
  42. package/dist/cli/commands/refund.js +224 -0
  43. package/dist/cli/commands/refund.js.map +1 -0
  44. package/dist/cli/commands/status.d.ts.map +1 -1
  45. package/dist/cli/commands/status.js +10 -1
  46. package/dist/cli/commands/status.js.map +1 -1
  47. package/dist/cli/commands/streams.js +9 -13
  48. package/dist/cli/commands/streams.js.map +1 -1
  49. package/dist/cli/commands/watch.d.ts.map +1 -1
  50. package/dist/cli/commands/watch.js +2 -1
  51. package/dist/cli/commands/watch.js.map +1 -1
  52. package/dist/cli/config.d.ts +44 -26
  53. package/dist/cli/config.d.ts.map +1 -1
  54. package/dist/cli/config.js +88 -38
  55. package/dist/cli/config.js.map +1 -1
  56. package/dist/cli/deps.d.ts +86 -7
  57. package/dist/cli/deps.d.ts.map +1 -1
  58. package/dist/cli/deps.js +102 -16
  59. package/dist/cli/deps.js.map +1 -1
  60. package/dist/cli/errors.d.ts.map +1 -1
  61. package/dist/cli/errors.js +21 -1
  62. package/dist/cli/errors.js.map +1 -1
  63. package/dist/cli/escape-hatch.d.ts +6 -4
  64. package/dist/cli/escape-hatch.d.ts.map +1 -1
  65. package/dist/cli/escape-hatch.js +6 -2
  66. package/dist/cli/escape-hatch.js.map +1 -1
  67. package/dist/cli/listings.d.ts +18 -0
  68. package/dist/cli/listings.d.ts.map +1 -1
  69. package/dist/cli/listings.js +22 -12
  70. package/dist/cli/listings.js.map +1 -1
  71. package/dist/cli/preflight.d.ts +20 -14
  72. package/dist/cli/preflight.d.ts.map +1 -1
  73. package/dist/cli/preflight.js +34 -20
  74. package/dist/cli/preflight.js.map +1 -1
  75. package/dist/cli/privy.d.ts +83 -0
  76. package/dist/cli/privy.d.ts.map +1 -0
  77. package/dist/cli/privy.js +232 -0
  78. package/dist/cli/privy.js.map +1 -0
  79. package/dist/cli/reclaimability.d.ts +82 -0
  80. package/dist/cli/reclaimability.d.ts.map +1 -0
  81. package/dist/cli/reclaimability.js +80 -0
  82. package/dist/cli/reclaimability.js.map +1 -0
  83. package/dist/cli/run.d.ts +2 -1
  84. package/dist/cli/run.d.ts.map +1 -1
  85. package/dist/cli/run.js +40 -11
  86. package/dist/cli/run.js.map +1 -1
  87. package/dist/cli/scan-range.d.ts +2 -2
  88. package/dist/cli/scan-range.js +2 -2
  89. package/dist/cli/spend-policy.d.ts +60 -0
  90. package/dist/cli/spend-policy.d.ts.map +1 -0
  91. package/dist/cli/spend-policy.js +65 -0
  92. package/dist/cli/spend-policy.js.map +1 -0
  93. package/dist/cli/status.d.ts +3 -1
  94. package/dist/cli/status.d.ts.map +1 -1
  95. package/dist/cli/status.js +15 -3
  96. package/dist/cli/status.js.map +1 -1
  97. package/dist/cli/wallet.d.ts +109 -40
  98. package/dist/cli/wallet.d.ts.map +1 -1
  99. package/dist/cli/wallet.js +109 -44
  100. package/dist/cli/wallet.js.map +1 -1
  101. package/dist/discovery/rate-band.d.ts +74 -0
  102. package/dist/discovery/rate-band.d.ts.map +1 -0
  103. package/dist/discovery/rate-band.js +114 -0
  104. package/dist/discovery/rate-band.js.map +1 -0
  105. package/dist/escrow-generation.d.ts +81 -0
  106. package/dist/escrow-generation.d.ts.map +1 -0
  107. package/dist/escrow-generation.js +76 -0
  108. package/dist/escrow-generation.js.map +1 -0
  109. package/dist/frameworks/agentkit.d.ts +8 -4
  110. package/dist/frameworks/agentkit.d.ts.map +1 -1
  111. package/dist/frameworks/agentkit.js +27 -10
  112. package/dist/frameworks/agentkit.js.map +1 -1
  113. package/dist/frameworks/eliza.d.ts +3 -3
  114. package/dist/frameworks/eliza.d.ts.map +1 -1
  115. package/dist/frameworks/eliza.js +15 -6
  116. package/dist/frameworks/eliza.js.map +1 -1
  117. package/dist/frameworks/langchain.d.ts.map +1 -1
  118. package/dist/frameworks/langchain.js +8 -0
  119. package/dist/frameworks/langchain.js.map +1 -1
  120. package/dist/gateway/caller-auth-gateway.d.ts +91 -4
  121. package/dist/gateway/caller-auth-gateway.d.ts.map +1 -1
  122. package/dist/gateway/caller-auth-gateway.js +184 -7
  123. package/dist/gateway/caller-auth-gateway.js.map +1 -1
  124. package/dist/gateway/http-server.d.ts +121 -1
  125. package/dist/gateway/http-server.d.ts.map +1 -1
  126. package/dist/gateway/http-server.js +474 -5
  127. package/dist/gateway/http-server.js.map +1 -1
  128. package/dist/gateway/probe-auth.d.ts +112 -0
  129. package/dist/gateway/probe-auth.d.ts.map +1 -0
  130. package/dist/gateway/probe-auth.js +127 -0
  131. package/dist/gateway/probe-auth.js.map +1 -0
  132. package/dist/gateway/signed-rate-bounds.d.ts +51 -0
  133. package/dist/gateway/signed-rate-bounds.d.ts.map +1 -0
  134. package/dist/gateway/signed-rate-bounds.js +94 -0
  135. package/dist/gateway/signed-rate-bounds.js.map +1 -0
  136. package/dist/gateway/x402-opener-mode.d.ts +215 -0
  137. package/dist/gateway/x402-opener-mode.d.ts.map +1 -0
  138. package/dist/gateway/x402-opener-mode.js +639 -0
  139. package/dist/gateway/x402-opener-mode.js.map +1 -0
  140. package/dist/index.d.ts +11 -5
  141. package/dist/index.d.ts.map +1 -1
  142. package/dist/index.js +15 -5
  143. package/dist/index.js.map +1 -1
  144. package/dist/mandates/env.d.ts +3 -2
  145. package/dist/mandates/env.d.ts.map +1 -1
  146. package/dist/mandates/env.js +3 -2
  147. package/dist/mandates/env.js.map +1 -1
  148. package/dist/mcp/server.d.ts +100 -0
  149. package/dist/mcp/server.d.ts.map +1 -1
  150. package/dist/mcp/server.js +719 -39
  151. package/dist/mcp/server.js.map +1 -1
  152. package/dist/mcp/service-invoker.d.ts +97 -0
  153. package/dist/mcp/service-invoker.d.ts.map +1 -0
  154. package/dist/mcp/service-invoker.js +167 -0
  155. package/dist/mcp/service-invoker.js.map +1 -0
  156. package/dist/mcp/x402-tools.d.ts +104 -0
  157. package/dist/mcp/x402-tools.d.ts.map +1 -0
  158. package/dist/mcp/x402-tools.js +434 -0
  159. package/dist/mcp/x402-tools.js.map +1 -0
  160. package/dist/network.d.ts +119 -0
  161. package/dist/network.d.ts.map +1 -0
  162. package/dist/network.js +199 -0
  163. package/dist/network.js.map +1 -0
  164. package/dist/sdk/invoke.d.ts +23 -4
  165. package/dist/sdk/invoke.d.ts.map +1 -1
  166. package/dist/sdk/invoke.js +71 -10
  167. package/dist/sdk/invoke.js.map +1 -1
  168. package/dist/wallet/autonomous-wallet-broker.d.ts +3 -0
  169. package/dist/wallet/autonomous-wallet-broker.d.ts.map +1 -1
  170. package/dist/wallet/autonomous-wallet-broker.js +27 -23
  171. package/dist/wallet/autonomous-wallet-broker.js.map +1 -1
  172. package/dist/wallet/autonomous-wallet-protocol.d.ts +8 -2
  173. package/dist/wallet/autonomous-wallet-protocol.d.ts.map +1 -1
  174. package/dist/wallet/autonomous-wallet-protocol.js +9 -2
  175. package/dist/wallet/autonomous-wallet-protocol.js.map +1 -1
  176. package/dist/wallet/autonomous-wallet.d.ts.map +1 -1
  177. package/dist/wallet/autonomous-wallet.js +3 -2
  178. package/dist/wallet/autonomous-wallet.js.map +1 -1
  179. package/dist/wallet/broker-escrows.d.ts +222 -0
  180. package/dist/wallet/broker-escrows.d.ts.map +1 -0
  181. package/dist/wallet/broker-escrows.js +36 -0
  182. package/dist/wallet/broker-escrows.js.map +1 -0
  183. package/dist/wallet/lifecycle.d.ts +21 -5
  184. package/dist/wallet/lifecycle.d.ts.map +1 -1
  185. package/dist/wallet/lifecycle.js +32 -12
  186. package/dist/wallet/lifecycle.js.map +1 -1
  187. package/dist/wallet/privy-broker-server.d.ts.map +1 -1
  188. package/dist/wallet/privy-broker-server.js +31 -26
  189. package/dist/wallet/privy-broker-server.js.map +1 -1
  190. package/dist/wallet/privy-session-broker.d.ts +14 -5
  191. package/dist/wallet/privy-session-broker.d.ts.map +1 -1
  192. package/dist/wallet/privy-session-broker.js +29 -17
  193. package/dist/wallet/privy-session-broker.js.map +1 -1
  194. package/dist/wallet/privy-session-provider.d.ts +3 -1
  195. package/dist/wallet/privy-session-provider.d.ts.map +1 -1
  196. package/dist/wallet/privy-session-provider.js +5 -3
  197. package/dist/wallet/privy-session-provider.js.map +1 -1
  198. package/dist/wallet/provider.d.ts.map +1 -1
  199. package/dist/wallet/provider.js +15 -8
  200. package/dist/wallet/provider.js.map +1 -1
  201. package/dist/x402/facilitator.d.ts +42 -17
  202. package/dist/x402/facilitator.d.ts.map +1 -1
  203. package/dist/x402/facilitator.js +49 -33
  204. package/dist/x402/facilitator.js.map +1 -1
  205. package/dist/x402-buyer/guarded-fetch.d.ts +51 -0
  206. package/dist/x402-buyer/guarded-fetch.d.ts.map +1 -0
  207. package/dist/x402-buyer/guarded-fetch.js +147 -0
  208. package/dist/x402-buyer/guarded-fetch.js.map +1 -0
  209. package/dist/x402-buyer/index.d.ts +10 -0
  210. package/dist/x402-buyer/index.d.ts.map +1 -0
  211. package/dist/x402-buyer/index.js +10 -0
  212. package/dist/x402-buyer/index.js.map +1 -0
  213. package/dist/x402-buyer/pay.d.ts +135 -0
  214. package/dist/x402-buyer/pay.d.ts.map +1 -0
  215. package/dist/x402-buyer/pay.js +336 -0
  216. package/dist/x402-buyer/pay.js.map +1 -0
  217. package/dist/x402-opener/abi.d.ts +684 -0
  218. package/dist/x402-opener/abi.d.ts.map +1 -0
  219. package/dist/x402-opener/abi.js +880 -0
  220. package/dist/x402-opener/abi.js.map +1 -0
  221. package/dist/x402-opener/facilitator-entry.d.ts +3 -0
  222. package/dist/x402-opener/facilitator-entry.d.ts.map +1 -0
  223. package/dist/x402-opener/facilitator-entry.js +37 -0
  224. package/dist/x402-opener/facilitator-entry.js.map +1 -0
  225. package/dist/x402-opener/facilitator-server.d.ts +180 -0
  226. package/dist/x402-opener/facilitator-server.d.ts.map +1 -0
  227. package/dist/x402-opener/facilitator-server.js +696 -0
  228. package/dist/x402-opener/facilitator-server.js.map +1 -0
  229. package/dist/x402-opener/index.d.ts +14 -0
  230. package/dist/x402-opener/index.d.ts.map +1 -0
  231. package/dist/x402-opener/index.js +14 -0
  232. package/dist/x402-opener/index.js.map +1 -0
  233. package/dist/x402-opener/listing-resolver.d.ts +55 -0
  234. package/dist/x402-opener/listing-resolver.d.ts.map +1 -0
  235. package/dist/x402-opener/listing-resolver.js +125 -0
  236. package/dist/x402-opener/listing-resolver.js.map +1 -0
  237. package/dist/x402-opener/refund-relay.d.ts +240 -0
  238. package/dist/x402-opener/refund-relay.d.ts.map +1 -0
  239. package/dist/x402-opener/refund-relay.js +1240 -0
  240. package/dist/x402-opener/refund-relay.js.map +1 -0
  241. package/dist/x402-opener/relayer-queue.d.ts +14 -0
  242. package/dist/x402-opener/relayer-queue.d.ts.map +1 -0
  243. package/dist/x402-opener/relayer-queue.js +18 -0
  244. package/dist/x402-opener/relayer-queue.js.map +1 -0
  245. package/dist/x402-opener/scheme.d.ts +217 -0
  246. package/dist/x402-opener/scheme.d.ts.map +1 -0
  247. package/dist/x402-opener/scheme.js +749 -0
  248. package/dist/x402-opener/scheme.js.map +1 -0
  249. package/dist/x402-opener/terms.d.ts +105 -0
  250. package/dist/x402-opener/terms.d.ts.map +1 -0
  251. package/dist/x402-opener/terms.js +212 -0
  252. package/dist/x402-opener/terms.js.map +1 -0
  253. package/dist/zktls/reclaim.d.ts +2 -2
  254. package/docs/agent-layer.md +13 -8
  255. package/docs/autonomous-privy-wallet.md +188 -0
  256. package/docs/cli.md +342 -53
  257. package/docs/langchain.md +5 -0
  258. package/docs/mcp-hosted.md +4 -3
  259. package/docs/mcp-reference.md +245 -0
  260. package/docs/probe-capability.md +191 -0
  261. package/docs/quickstart.md +54 -5
  262. package/docs/threat-model.md +24 -3
  263. package/docs/x402.md +681 -0
  264. package/package.json +19 -6
  265. package/src/capability/invocation-capability.ts +17 -1
  266. package/src/capability/probe-capability.ts +355 -0
  267. package/src/cli/args.ts +29 -0
  268. package/src/cli/commands/claim.ts +31 -5
  269. package/src/cli/commands/close.ts +56 -7
  270. package/src/cli/commands/invoke.ts +6 -2
  271. package/src/cli/commands/mcp.ts +18 -2
  272. package/src/cli/commands/open.ts +151 -14
  273. package/src/cli/commands/pay.ts +442 -0
  274. package/src/cli/commands/reclaim-unverified.ts +24 -6
  275. package/src/cli/commands/reclaim.ts +274 -19
  276. package/src/cli/commands/refund.ts +277 -0
  277. package/src/cli/commands/status.ts +17 -1
  278. package/src/cli/commands/streams.ts +11 -15
  279. package/src/cli/commands/watch.ts +7 -1
  280. package/src/cli/config.ts +139 -52
  281. package/src/cli/deps.ts +189 -25
  282. package/src/cli/errors.ts +24 -1
  283. package/src/cli/escape-hatch.ts +19 -5
  284. package/src/cli/listings.ts +43 -10
  285. package/src/cli/preflight.ts +36 -21
  286. package/src/cli/privy.ts +398 -0
  287. package/src/cli/reclaimability.ts +154 -0
  288. package/src/cli/run.ts +54 -11
  289. package/src/cli/scan-range.ts +2 -2
  290. package/src/cli/spend-policy.ts +139 -0
  291. package/src/cli/status.ts +14 -1
  292. package/src/cli/wallet.ts +189 -58
  293. package/src/discovery/rate-band.ts +172 -0
  294. package/src/escrow-generation.ts +135 -0
  295. package/src/frameworks/agentkit.ts +45 -11
  296. package/src/frameworks/eliza.ts +27 -5
  297. package/src/frameworks/langchain.ts +14 -0
  298. package/src/gateway/caller-auth-gateway.ts +336 -13
  299. package/src/gateway/http-server.ts +690 -7
  300. package/src/gateway/probe-auth.ts +191 -0
  301. package/src/gateway/signed-rate-bounds.ts +164 -0
  302. package/src/gateway/x402-opener-mode.ts +974 -0
  303. package/src/index.ts +112 -14
  304. package/src/mandates/env.ts +3 -2
  305. package/src/mcp/server.ts +973 -44
  306. package/src/mcp/service-invoker.ts +285 -0
  307. package/src/mcp/x402-tools.ts +621 -0
  308. package/src/network.ts +302 -0
  309. package/src/sdk/invoke.ts +96 -17
  310. package/src/wallet/autonomous-wallet-broker.ts +46 -20
  311. package/src/wallet/autonomous-wallet-protocol.ts +11 -2
  312. package/src/wallet/autonomous-wallet.ts +4 -2
  313. package/src/wallet/broker-escrows.ts +66 -0
  314. package/src/wallet/lifecycle.ts +53 -18
  315. package/src/wallet/privy-broker-server.ts +35 -25
  316. package/src/wallet/privy-session-broker.ts +58 -25
  317. package/src/wallet/privy-session-provider.ts +7 -3
  318. package/src/wallet/provider.ts +20 -15
  319. package/src/x402/facilitator.ts +85 -46
  320. package/src/x402-buyer/guarded-fetch.ts +213 -0
  321. package/src/x402-buyer/index.ts +9 -0
  322. package/src/x402-buyer/pay.ts +517 -0
  323. package/src/x402-opener/abi.ts +881 -0
  324. package/src/x402-opener/facilitator-entry.ts +45 -0
  325. package/src/x402-opener/facilitator-server.ts +1014 -0
  326. package/src/x402-opener/index.ts +13 -0
  327. package/src/x402-opener/listing-resolver.ts +202 -0
  328. package/src/x402-opener/refund-relay.ts +1743 -0
  329. package/src/x402-opener/relayer-queue.ts +18 -0
  330. package/src/x402-opener/scheme.ts +1133 -0
  331. package/src/x402-opener/terms.ts +300 -0
@@ -3,14 +3,19 @@ import path from "node:path";
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { http, isAddress, isHex } from "viem";
6
- import { baseSepolia } from "viem/chains";
7
6
  import { z } from "zod";
8
7
  import { discoverServices as defaultDiscoverServices, } from "../discovery/registry.js";
8
+ import { checkListedRateBand } from "../discovery/rate-band.js";
9
9
  import { checkMandate } from "../mandates/mandate.js";
10
10
  import { parseServerMandateCapsEnv, signServerMandate, ServerMandateConfigError, } from "../mandates/env.js";
11
11
  import { isCheckpointStream, } from "../sdk/client.js";
12
12
  import { createWalletBackedAgentClient, parseAgentWalletEnv, } from "../wallet/provider.js";
13
13
  import { createReclaimConsumerProofServiceFromEnv, } from "../zktls/reclaim.js";
14
+ import { CAPABILITY_HEADER_NAME, httpMethodSchema, } from "../capability/invocation-capability.js";
15
+ import { createSdkInvokeStreamReader, InvokeAccessUrlError, InvokeBuyerMismatchError, InvokePathError, InvokeServiceRefMismatchError, InvokeStreamExpiredError, InvokeStreamNotActiveError, } from "../sdk/invoke.js";
16
+ import { createServiceInvoker, RegistryUnavailableError, ServiceListingNotFoundError, } from "./service-invoker.js";
17
+ import { defaultNetwork, readOnlyMetrikClient, registeredDeployment, resolveAgentNetwork, } from "../network.js";
18
+ import { REFUND_STATUS_TOOL_DESCRIPTION, REFUND_TOOL_DESCRIPTION, X402_PAY_TOOL_DESCRIPTION, registerX402Tools, } from "./x402-tools.js";
14
19
  /**
15
20
  * MCP server for the agent lane.
16
21
  *
@@ -42,6 +47,41 @@ const hireVerifiedServiceSchema = {
42
47
  .optional()
43
48
  .describe("Optional max stream lifetime in seconds. Defaults to the server mandate's max duration."),
44
49
  };
50
+ /**
51
+ * Ceiling on a capability's lifetime.
52
+ *
53
+ * A signed capability is a bearer credential for one method+path on one stream:
54
+ * anything holding it can make that call until it expires. The signer's own
55
+ * default is 60s; this bounds what an LLM may ask for so a single tool argument
56
+ * cannot mint a long-lived credential. It is a cap, not the default.
57
+ */
58
+ const MAX_CAPABILITY_TTL_SECONDS = 300;
59
+ const invokeServiceSchema = {
60
+ streamId: bytes32Schema.describe("Stream id of an ACTIVE stream whose buyer is this server's wallet (from hire_verified_service or list_streams)."),
61
+ path: z
62
+ .string()
63
+ .min(1)
64
+ .optional()
65
+ .describe('Origin-relative request path, query string included, e.g. "/price?symbol=BTC". Defaults to "/". The ORIGIN is not a parameter: it is read from the operator-signed listing for this stream\'s serviceRef.'),
66
+ method: httpMethodSchema
67
+ .optional()
68
+ .describe('HTTP method. Defaults to "GET". The capability authorizes exactly this method and this path — nothing else.'),
69
+ headers: z
70
+ .record(z.string())
71
+ .optional()
72
+ .describe("Extra request headers. The capability header is attached by the server and cannot be supplied here."),
73
+ body: z
74
+ .string()
75
+ .optional()
76
+ .describe("Request body, sent verbatim. Defaults to a JSON content-type when a body is present and none is given."),
77
+ ttlSeconds: z
78
+ .number()
79
+ .int()
80
+ .positive()
81
+ .max(MAX_CAPABILITY_TTL_SECONDS)
82
+ .optional()
83
+ .describe(`Capability lifetime in seconds (default 60, max ${MAX_CAPABILITY_TTL_SECONDS}). Shorter is safer; it only has to outlive one request.`),
84
+ };
45
85
  const getStreamStatusSchema = {
46
86
  streamId: bytes32Schema.describe("Stream id returned by hire_verified_service."),
47
87
  };
@@ -89,38 +129,96 @@ const metrikAgentEnvSchema = z.object({
89
129
  METRIK_AGENT_CHAIN_ID: z.string().optional(),
90
130
  METRIK_AGENT_CHAIN_NAME: z.string().optional(),
91
131
  METRIK_AGENT_MCP_STATE_FILE: z.string().optional(),
132
+ METRIK_AGENT_ESCROW_GENERATION: z.string().optional(),
133
+ METRIK_AGENT_REGISTRY_URL: z.string().url().optional(),
134
+ METRIK_AGENT_REGISTRY_APIKEY: z.string().optional(),
135
+ METRIK_AGENT_ALLOW_UNVERIFIED_OPERATOR: z.string().optional(),
136
+ METRIK_AGENT_ALLOW_UNREGISTERED_ESCROW: z.string().optional(),
137
+ METRIK_NETWORK: z.string().optional(),
138
+ METRIK_AGENT_X402: z.string().optional(),
139
+ METRIK_AGENT_X402_ALLOWED_HOSTS: z.string().optional(),
92
140
  });
141
+ /**
142
+ * Opt in to an escrow that is NOT the network's registered `StreamEscrowV2`.
143
+ *
144
+ * Off by default, because a wrong escrow does not fail at read time: the
145
+ * retired V1 escrow's `getStream` returns a tuple that diverges from V2's from
146
+ * index 5 onward, so a V2 decoder pointed at it does not revert — it decodes
147
+ * other fields into correctly-named ones and reports a confident wrong answer
148
+ * about someone's money. This repository's own shipped docs pointed
149
+ * `METRIK_AGENT_ESCROW` at V1 as recently as 0.10.0. Requiring the escrow to be
150
+ * the one the SDK network registry records for the chain refuses V1 (and any
151
+ * other stray address) without naming it in code. Operator-set input only.
152
+ */
153
+ export const ALLOW_UNREGISTERED_ESCROW_ENV = "METRIK_AGENT_ALLOW_UNREGISTERED_ESCROW";
154
+ /**
155
+ * Opt out of the operator/serviceRef cross-check on hire. Operator-set input
156
+ * only — an MCP caller can neither read nor set it.
157
+ */
158
+ export const ALLOW_UNVERIFIED_OPERATOR_ENV = "METRIK_AGENT_ALLOW_UNVERIFIED_OPERATOR";
159
+ /** An unset escrow slot decodes as an all-zero struct rather than reverting. */
160
+ const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
161
+ /**
162
+ * The one sentence every stream-opening surface has to carry.
163
+ *
164
+ * A reviewer made ONE call against a stream and was billed for 302 seconds. The
165
+ * escrow behaved exactly as designed — and nothing in the tool surface had told
166
+ * them what they were buying. An LLM picks a tool from its description alone, so
167
+ * the meter is stated in the description of every tool that opens, reads, or
168
+ * ends a stream, not only in the docs it will never read.
169
+ */
170
+ export const METERING_MODEL = "METERING: this is TIME-metered rental of an endpoint that keeps proving it is live, NOT per-call billing. Cost = ratePerSecondUsdc x seconds the oracle VERIFIED, capped at the escrowed budget. One call and a thousand calls cost the same second, and the clock runs while you call nothing. Only closing the stream (reclaim_unspent) stops it; nothing refunds automatically.";
93
171
  export const METRIK_MCP_TOOLS = [
94
172
  {
95
173
  name: "discover_services",
96
- description: "Discover verified third-party services on the Metrik marketplace. Returns operator + serviceRef + endpoint for listings whose operator signature is cryptographically verified, so an agent can go from discovery to hire without pre-known addresses.",
174
+ description: "Discover verified third-party services on the Metrik marketplace. Returns operator + serviceRef + endpoint for listings whose operator signature is cryptographically verified, so an agent can go from discovery to hire without pre-known addresses. Listings are priced per second, not per call.",
97
175
  movesFunds: false,
98
176
  },
99
177
  {
100
178
  name: "hire_verified_service",
101
- description: "Open a verified payment stream for a third-party service. Pays only for oracle-verified delivered time. Subject to the agent's spend mandate.",
179
+ description: `Open a verified payment stream for a third-party service, then call it with invoke_service. Pays only for oracle-verified delivered time. Subject to the agent's spend mandate. ${METERING_MODEL}`,
102
180
  movesFunds: true,
103
181
  },
182
+ {
183
+ name: "invoke_service",
184
+ description: "Call the service a stream is already paying for. Signs a single-use EIP-712 capability as the stream's buyer and calls the origin the operator cryptographically signed (resolved from the verified listing — never a caller-supplied URL). This is how an agent USES what it hired. It moves no funds and adds no cost: billing is by verified elapsed time, so invoking more often is free and invoking never still costs.",
185
+ movesFunds: false,
186
+ },
104
187
  {
105
188
  name: "check_stream_status",
106
- description: "Read a stream's live status, accrued amount, and claimable/reclaimable balances. In V2, failed or unproven intervals do not advance cumulative entitlement; the stream remains active until buyer close or expiry.",
189
+ description: `Read a stream's live status, settled amount, and claimable/reclaimable balances. In V2, failed or unproven intervals do not advance cumulative entitlement; the stream remains active until buyer close or expiry. ${METERING_MODEL}`,
107
190
  movesFunds: false,
108
191
  },
109
192
  {
110
193
  name: "reclaim_unspent",
111
- description: "Close a stream and reclaim unspent funds to the buyer. V2 reclaim follows checkpoint finalization or escape-window rules.",
194
+ description: "Close a stream and reclaim unspent funds to the buyer. Closing is what STOPS the per-second meter — nothing else does, and no refund is ever pushed back on its own. V2 reclaim follows checkpoint finalization or escape-window rules.",
112
195
  movesFunds: true,
113
196
  },
114
197
  {
115
198
  name: "list_streams",
116
- description: "List the agent's streams and their current state.",
199
+ description: "List the streams this server opened and their current state. Every active stream is still accruing cost per verified second whether or not it is being invoked.",
117
200
  movesFunds: false,
118
201
  },
119
202
  {
120
203
  name: "prove_https_response",
121
- description: "Generate a zkTLS proof of the exact HTTPS response consumed for a tracked stream. This is an L2 delivery signal (consumer zkTLS) and must be cross-checked with oracle/L1 evidence.",
204
+ description: "Generate a buyer-side zkTLS proof of an HTTPS response from a stream's verified service origin. PUBLIC listings only: the proof is fetched by a third-party Reclaim attestor, which cannot present this stream's caller-auth capability, so a GATED listing answers the attestor with a 403 and the tool fails closed rather than proving a rejection. This is an L2 delivery signal (consumer zkTLS) and must be cross-checked with oracle/L1 evidence; it is not a substitute for it.",
205
+ movesFunds: false,
206
+ },
207
+ {
208
+ name: "x402_pay",
209
+ description: X402_PAY_TOOL_DESCRIPTION,
210
+ movesFunds: true,
211
+ },
212
+ {
213
+ name: "refund_status",
214
+ description: REFUND_STATUS_TOOL_DESCRIPTION,
122
215
  movesFunds: false,
123
216
  },
217
+ {
218
+ name: "refund",
219
+ description: REFUND_TOOL_DESCRIPTION,
220
+ movesFunds: true,
221
+ },
124
222
  ];
125
223
  /** @deprecated Renamed to METRIK_MCP_TOOLS; retained for back-compat. */
126
224
  export const STREAMPROOF_MCP_TOOLS = METRIK_MCP_TOOLS;
@@ -135,6 +233,9 @@ export class InMemoryStreamRegistry {
135
233
  async list() {
136
234
  return Array.from(this.records.values());
137
235
  }
236
+ async delete(streamId) {
237
+ this.records.delete(streamId);
238
+ }
138
239
  }
139
240
  export class FileStreamRegistry {
140
241
  filePath;
@@ -153,6 +254,11 @@ export class FileStreamRegistry {
153
254
  async list() {
154
255
  return Array.from((await this.readAll()).values());
155
256
  }
257
+ async delete(streamId) {
258
+ const records = await this.readAll();
259
+ if (records.delete(streamId))
260
+ await this.writeAll(records);
261
+ }
156
262
  async readAll() {
157
263
  const contents = await fs.readFile(this.filePath, "utf8").catch((error) => {
158
264
  if (error.code === "ENOENT") {
@@ -171,6 +277,9 @@ export class FileStreamRegistry {
171
277
  maxDurationSeconds: z.number().int().positive(),
172
278
  openedAtSeconds: z.number().int().nonnegative(),
173
279
  mandateId: bytes32Schema,
280
+ refundUrl: z.string().url().optional(),
281
+ opener: addressSchema.optional(),
282
+ x402Reservation: z.boolean().optional(),
174
283
  }))
175
284
  .parse(JSON.parse(contents));
176
285
  return new Map(parsed.map((record) => [
@@ -185,6 +294,13 @@ export class FileStreamRegistry {
185
294
  maxDurationSeconds: record.maxDurationSeconds,
186
295
  openedAtSeconds: record.openedAtSeconds,
187
296
  mandateId: record.mandateId,
297
+ ...(record.refundUrl === undefined
298
+ ? {}
299
+ : { refundUrl: record.refundUrl }),
300
+ ...(record.opener === undefined
301
+ ? {}
302
+ : { opener: record.opener }),
303
+ ...(record.x402Reservation === true ? { x402Reservation: true } : {}),
188
304
  },
189
305
  ]));
190
306
  }
@@ -212,12 +328,26 @@ function warnOnce(key, message) {
212
328
  // stderr, never stdout: stdout is the MCP stdio transport channel.
213
329
  console.error(message);
214
330
  }
215
- const DISCOVER_TOOL_SPEC = METRIK_MCP_TOOLS[0];
216
- const HIRE_TOOL_SPEC = METRIK_MCP_TOOLS[1];
217
- const STATUS_TOOL_SPEC = METRIK_MCP_TOOLS[2];
218
- const RECLAIM_TOOL_SPEC = METRIK_MCP_TOOLS[3];
219
- const LIST_TOOL_SPEC = METRIK_MCP_TOOLS[4];
220
- const PROVE_TOOL_SPEC = METRIK_MCP_TOOLS[5];
331
+ /**
332
+ * Looks a tool spec up by NAME. This used to index into METRIK_MCP_TOOLS by
333
+ * position, which silently rebinds every description the moment a tool is
334
+ * inserted anywhere but the end — a tool whose advertised description belongs
335
+ * to a different tool is worse than a missing one, because an agent acts on it.
336
+ */
337
+ function toolSpec(name) {
338
+ const spec = METRIK_MCP_TOOLS.find((entry) => entry.name === name);
339
+ if (spec === undefined) {
340
+ throw new Error(`unknown MCP tool spec: ${name}`);
341
+ }
342
+ return spec;
343
+ }
344
+ const DISCOVER_TOOL_SPEC = toolSpec("discover_services");
345
+ const HIRE_TOOL_SPEC = toolSpec("hire_verified_service");
346
+ const INVOKE_TOOL_SPEC = toolSpec("invoke_service");
347
+ const STATUS_TOOL_SPEC = toolSpec("check_stream_status");
348
+ const RECLAIM_TOOL_SPEC = toolSpec("reclaim_unspent");
349
+ const LIST_TOOL_SPEC = toolSpec("list_streams");
350
+ const PROVE_TOOL_SPEC = toolSpec("prove_https_response");
221
351
  const discoverServicesSchema = {
222
352
  category: z
223
353
  .string()
@@ -273,6 +403,10 @@ export function createVerifiedStreamMcpServer(options) {
273
403
  };
274
404
  const now = options.now ?? (() => Math.floor(Date.now() / 1000));
275
405
  const discover = options.discoverServices ?? defaultDiscoverServices;
406
+ const allowUnverifiedOperator = options.allowUnverifiedOperatorPairing ?? false;
407
+ if (allowUnverifiedOperator) {
408
+ warnOnce("mcp-unverified-operator", `[metrik] ${ALLOW_UNVERIFIED_OPERATOR_ENV} is set: hires whose operator/serviceRef pairing CANNOT be verified will proceed. The operator address is the wallet that gets paid; an unverifiable pairing can escrow to a wallet that does not own the service. A contradicted pairing is still refused.`);
409
+ }
276
410
  const mcpServer = new McpServer(options.serverInfo ?? {
277
411
  name: "metrik-agent-mcp",
278
412
  version: "0.1.0",
@@ -292,9 +426,14 @@ export function createVerifiedStreamMcpServer(options) {
292
426
  ...(category === undefined ? {} : { category }),
293
427
  ...(limit === undefined ? {} : { limit }),
294
428
  });
295
- return successResult(`discovered ${listings.length} verified service${listings.length === 1 ? "" : "s"}`, { services: listings.map(serializeServiceListing) });
429
+ return successResult(`discovered ${listings.length} verified service${listings.length === 1 ? "" : "s"}, priced per verified second`, { services: listings.map(serializeServiceListing) });
296
430
  }
297
431
  catch (error) {
432
+ if (error instanceof RegistryUnavailableError) {
433
+ // Loud, not silent: discovery never degrades to stale local data, so
434
+ // "the registry is down" must not read as "there are no services".
435
+ return errorResult("the service registry is unreachable, so no listing could be verified. This is NOT the same as zero services — do not conclude the marketplace is empty. Retry shortly.");
436
+ }
298
437
  return safeToolError(error);
299
438
  }
300
439
  });
@@ -319,6 +458,49 @@ export function createVerifiedStreamMcpServer(options) {
319
458
  ratePerSecondUsdc: BigInt(args.ratePerSecondUsdc),
320
459
  maxDurationSeconds: args.durationSeconds ?? signedMandate.mandate.maxDurationSeconds,
321
460
  };
461
+ // Does this operator actually own this serviceRef?
462
+ //
463
+ // The two arrive as independent tool arguments, and `operator` is the
464
+ // wallet that gets paid. An LLM that pairs a real serviceRef with an
465
+ // attacker's payout address escrows the buyer's USDC to a stranger, and
466
+ // nothing downstream catches it: the escrow does not know who owns a
467
+ // serviceRef, and the oracle would happily verify the real endpoint
468
+ // while the money accrued elsewhere. Discovery DOES know — it recovers
469
+ // the operator's signature over the record the serviceRef is derived
470
+ // from — so the pairing is checked against it here.
471
+ //
472
+ // A serviceRef that is not listed at all is allowed with a warning
473
+ // (the registry is not the authority on what exists, and refusing would
474
+ // block private or freshly-listed services); a serviceRef that IS
475
+ // listed under a DIFFERENT operator is refused outright.
476
+ const pairing = await checkOperatorPairing(discover, args.serviceRef, request.operator);
477
+ if (pairing.verdict !== "verified") {
478
+ // FAIL CLOSED. Anything short of a positive match is a refusal: a
479
+ // registry outage is not evidence that the pairing is sound, and the
480
+ // money moves before any note attached to a success result could be
481
+ // read. The only override is an operator-set environment variable
482
+ // (never a tool argument), and even that never overrides a positive
483
+ // contradiction.
484
+ if (pairing.verdict === "mismatch" || !allowUnverifiedOperator) {
485
+ return errorResult(explainPairingRefusal(pairing, args.serviceRef, request.operator));
486
+ }
487
+ }
488
+ // Is the rate inside the band the operator SIGNED? The escrow accepts
489
+ // any non-zero rate, but the seller's capability gateway answers every
490
+ // request on an out-of-band stream with 403
491
+ // `stream-rate-out-of-bounds`, so such a hire escrows the buyer's USDC
492
+ // into a stream that can never be served. Checked against the verified
493
+ // listing the pairing check just resolved — no second registry read,
494
+ // and no tool argument can widen the band. A descriptor that does not
495
+ // verify is a refusal, never "no band". Only an unverified pairing
496
+ // (which already needed the operator-set override above) has no listing
497
+ // to check, and its success result says so.
498
+ if (pairing.verdict === "verified") {
499
+ const band = await checkListedRateBand(async () => [pairing.listing], args.serviceRef, request.ratePerSecondUsdc, "ratePerSecondUsdc");
500
+ if (!band.ok) {
501
+ return errorResult(`${band.message} Pick a ratePerSecondUsdc inside the band (discover_services reports it as ratePerSecondBand).`);
502
+ }
503
+ }
322
504
  // Serialize per owner: the spent-so-far read, cap check, open, and
323
505
  // registry write must be atomic, or concurrent hires could both pass
324
506
  // the cumulative-cap check on stale spend and overshoot maxTotalUsdc.
@@ -354,7 +536,15 @@ export function createVerifiedStreamMcpServer(options) {
354
536
  openedAtSeconds: nowSeconds,
355
537
  mandateId: signedMandate.mandateId,
356
538
  });
357
- return successResult(`opened verified stream ${result.streamId} on chain ${result.handle.chainId}`, {
539
+ // The meter starts HERE, not on the first invoke_service call. Say
540
+ // so in the result as well as the tool description: a reviewer who
541
+ // opened a stream, made one call and was billed for 302 seconds read
542
+ // the result, not the docs.
543
+ const maxSpendUsdc = request.budgetUsdc <
544
+ request.ratePerSecondUsdc * BigInt(request.maxDurationSeconds)
545
+ ? request.budgetUsdc
546
+ : request.ratePerSecondUsdc * BigInt(request.maxDurationSeconds);
547
+ return successResult(`opened verified stream ${result.streamId} on chain ${result.handle.chainId}.${pairing.verdict === "verified" ? "" : ` WARNING: the operator/serviceRef pairing was NOT verified (${pairing.verdict}) and this server is configured to escrow anyway via ${ALLOW_UNVERIFIED_OPERATOR_ENV}. The operator is the wallet that gets paid and nothing proved it owns this service, and the operator-signed rate band could not be checked either: if this rate is outside it, the service's gateway refuses every request while the deposit stays escrowed.`} The meter is running from now at ${request.ratePerSecondUsdc} USDC base units per VERIFIED second, whether or not you call the service. Call it with invoke_service; stop the meter with reclaim_unspent.`, {
358
548
  streamId: result.streamId,
359
549
  txHash: result.txHash,
360
550
  approveTxHash: result.approveTxHash,
@@ -363,6 +553,15 @@ export function createVerifiedStreamMcpServer(options) {
363
553
  escrow: result.handle.escrow,
364
554
  chainId: result.handle.chainId,
365
555
  },
556
+ operatorPairing: pairing.verdict,
557
+ metering: {
558
+ model: "time-metered",
559
+ ratePerSecondUsdc: request.ratePerSecondUsdc.toString(),
560
+ budgetUsdc: request.budgetUsdc.toString(),
561
+ maxDurationSeconds: request.maxDurationSeconds,
562
+ maxSpendUsdc: maxSpendUsdc.toString(),
563
+ note: METERING_MODEL,
564
+ },
366
565
  });
367
566
  });
368
567
  }
@@ -370,6 +569,64 @@ export function createVerifiedStreamMcpServer(options) {
370
569
  return safeToolError(error);
371
570
  }
372
571
  });
572
+ if (options.serviceInvoker !== undefined) {
573
+ const serviceInvoker = options.serviceInvoker;
574
+ mcpServer.registerTool("invoke_service", {
575
+ description: INVOKE_TOOL_SPEC.description,
576
+ inputSchema: invokeServiceSchema,
577
+ annotations: {
578
+ // Not idempotent and not read-only: the call reaches a third party's
579
+ // endpoint, which may do anything. It is NOT destructive in Metrik's
580
+ // own terms — it signs no transaction and moves no USDC.
581
+ destructiveHint: false,
582
+ idempotentHint: false,
583
+ openWorldHint: true,
584
+ readOnlyHint: false,
585
+ title: "Invoke Paid Service",
586
+ },
587
+ }, async ({ streamId, path, method, headers, body, ttlSeconds }) => {
588
+ const requestPath = path ?? "/";
589
+ // Defence in depth. `resolveInvocationUrl` is the authority — it
590
+ // resolves the path and proves the result did not leave the signed
591
+ // origin — but a tool argument that cannot possibly be a safe path is
592
+ // refused here, with a message the caller can act on, before anything
593
+ // reads the chain. A prefix check ALONE was the bug: the URL parser
594
+ // treats a backslash as a host separator for http(s), so a path
595
+ // beginning `/\\` resolved against a signed origin points somewhere
596
+ // else entirely while passing a `//` test.
597
+ if (!requestPath.startsWith("/") || requestPath.startsWith("//")) {
598
+ return errorResult(`path must be origin-relative and start with a single slash (got ${JSON.stringify(requestPath)}). The host is not yours to choose: it comes from the operator-signed listing.`);
599
+ }
600
+ if (hasForbiddenPathCharacter(requestPath)) {
601
+ return errorResult(`path must not contain a backslash or a control character (got ${JSON.stringify(requestPath)}). A backslash is a host separator to the URL parser, so it would redirect this call off the operator-signed origin.`);
602
+ }
603
+ const requestHeaders = normalizeHeaders(headers);
604
+ if (CAPABILITY_HEADER_NAME in requestHeaders) {
605
+ return errorResult(`do not set the ${CAPABILITY_HEADER_NAME} header: this server signs the capability itself, as the stream's buyer. Supplying one would be silently overwritten.`);
606
+ }
607
+ if (body !== undefined &&
608
+ requestHeaders["content-type"] === undefined) {
609
+ requestHeaders["content-type"] = "application/json";
610
+ }
611
+ try {
612
+ const result = await serviceInvoker.invokeService({
613
+ streamId: streamId,
614
+ method: method ?? "GET",
615
+ path: requestPath,
616
+ headers: requestHeaders,
617
+ ...(body === undefined ? {} : { body }),
618
+ ...(ttlSeconds === undefined ? {} : { ttlSeconds }),
619
+ });
620
+ return serializeInvocation(result);
621
+ }
622
+ catch (error) {
623
+ const explained = explainInvokeFailure(error, streamId);
624
+ return explained === null
625
+ ? safeToolError(error)
626
+ : errorResult(explained);
627
+ }
628
+ });
629
+ }
373
630
  mcpServer.registerTool("check_stream_status", {
374
631
  description: STATUS_TOOL_SPEC.description,
375
632
  inputSchema: getStreamStatusSchema,
@@ -382,7 +639,15 @@ export function createVerifiedStreamMcpServer(options) {
382
639
  }, async ({ streamId }) => {
383
640
  try {
384
641
  const status = await options.agentClient.getStreamStatus(streamId);
385
- return successResult(`stream ${streamId} is ${status.stream.status}`, serializeStreamStatus(status, streamId));
642
+ if (status.stream.buyer === ZERO_ADDRESS) {
643
+ // An unset slot decodes as an all-zero struct rather than reverting,
644
+ // which otherwise surfaces as a confident "the stream is closed" for
645
+ // a stream that never existed. A real stream always has a buyer.
646
+ return errorResult(`no stream with id ${streamId} exists on the configured escrow. Check the id, and check that this server is pointed at the escrow the stream was opened on.`);
647
+ }
648
+ return successResult(status.stream.status === "active"
649
+ ? `stream ${streamId} is active and still metering at ${status.stream.ratePerSecond} USDC base units per verified second; reclaim_unspent closes it and stops the meter`
650
+ : `stream ${streamId} is ${status.stream.status}`, serializeStreamStatus(status, streamId));
386
651
  }
387
652
  catch (error) {
388
653
  return safeToolError(error);
@@ -400,10 +665,14 @@ export function createVerifiedStreamMcpServer(options) {
400
665
  },
401
666
  }, async ({ streamId, closeFirst }) => {
402
667
  try {
668
+ // The local registry is a CONVENIENCE, not an authority. It used to be
669
+ // required here, which meant a restarted server with in-memory state
670
+ // could no longer return the buyer's own money through this tool — a
671
+ // local bookkeeping gap standing between a buyer and their funds. The
672
+ // escrow itself enforces that only the buyer may close and reclaim, so
673
+ // proceeding without a record is safe and strictly buyer-favouring; an
674
+ // id that is not this wallet's stream simply reverts on chain.
403
675
  const record = await registry.get(streamId);
404
- if (record === null) {
405
- return errorResult(`unknown stream: ${streamId}`);
406
- }
407
676
  const signedMandate = await resolveMandate();
408
677
  // Reclaim/close only RETURN unspent funds to the buyer or stop payment.
409
678
  // They are authorized on mandate-owner signature ALONE (see the SDK
@@ -416,9 +685,10 @@ export function createVerifiedStreamMcpServer(options) {
416
685
  nowSeconds: now(),
417
686
  ...(closeFirst === undefined ? {} : { closeFirst }),
418
687
  });
419
- return successResult(`reclaimed stream ${streamId}`, {
688
+ return successResult(`reclaimed stream ${streamId}; the per-second meter on it has stopped${record === null ? " (this server had no local record of it — the escrow authorized the recovery on the buyer wallet alone)" : ""}`, {
420
689
  closeTxHash: result.closeResult?.txHash ?? null,
421
690
  reclaimTxHash: result.reclaimResult.txHash,
691
+ tracked: record !== null,
422
692
  });
423
693
  }
424
694
  catch (error) {
@@ -437,13 +707,20 @@ export function createVerifiedStreamMcpServer(options) {
437
707
  try {
438
708
  const records = await registry.list();
439
709
  const entries = await Promise.all(records.map(async (record) => {
440
- const status = await options.agentClient
441
- .getStreamStatus(record.streamId)
442
- .then((value) => serializeStreamStatus(value, record.streamId))
443
- .catch((error) => ({
444
- streamId: record.streamId,
445
- statusError: asErrorMessage(error),
446
- }));
710
+ // A reservation key is not an escrow stream: report it as what it
711
+ // is instead of reading a stream that does not exist.
712
+ const status = record.x402Reservation === true
713
+ ? {
714
+ streamId: record.streamId,
715
+ x402Reservation: "an x402 payment was signed but its stream could not be confirmed; its amount stays counted against the spend mandate",
716
+ }
717
+ : await options.agentClient
718
+ .getStreamStatus(record.streamId)
719
+ .then((value) => serializeStreamStatus(value, record.streamId))
720
+ .catch((error) => ({
721
+ streamId: record.streamId,
722
+ statusError: asErrorMessage(error),
723
+ }));
447
724
  return {
448
725
  streamId: record.streamId,
449
726
  buyer: record.buyer,
@@ -463,6 +740,33 @@ export function createVerifiedStreamMcpServer(options) {
463
740
  return safeToolError(error);
464
741
  }
465
742
  });
743
+ if (options.x402 !== undefined) {
744
+ registerX402Tools(mcpServer, {
745
+ x402: options.x402,
746
+ resolveMandate,
747
+ getSpentSoFarUsdc: (owner) => resolver.getSpentSoFarUsdc(owner),
748
+ getRevokedMandateIds: (owner) => resolver.getRevokedMandateIds(owner),
749
+ // The SAME per-owner chain as hire_verified_service: a hire and an x402
750
+ // payment from one owner can never both pass the cumulative cap on
751
+ // stale spend.
752
+ serialize: serializeHire,
753
+ recordStream: async (record) => {
754
+ await registry.put(record);
755
+ },
756
+ ...(registry.delete === undefined
757
+ ? {}
758
+ : {
759
+ removeStream: async (streamId) => {
760
+ await registry.delete?.(streamId);
761
+ },
762
+ }),
763
+ findStream: async (streamId) => await registry.get(streamId),
764
+ now,
765
+ ok: successResult,
766
+ fail: errorResult,
767
+ unexpected: safeToolError,
768
+ });
769
+ }
466
770
  if (options.deliveryProofService !== undefined) {
467
771
  const deliveryProofService = options.deliveryProofService;
468
772
  mcpServer.registerTool("prove_https_response", {
@@ -480,6 +784,34 @@ export function createVerifiedStreamMcpServer(options) {
480
784
  if (record === null) {
481
785
  return errorResult(`unknown stream: ${streamId}`);
482
786
  }
787
+ // Bind the PROOF TARGET to the stream's verified listing.
788
+ //
789
+ // Without this the tool would zkTLS-prove any URL an LLM named and
790
+ // stamp it with this stream's delivery binding (owner, streamId,
791
+ // operator, serviceRef) — a proof of someone else's endpoint,
792
+ // labelled as this stream's delivery. The origin has to come from the
793
+ // operator's signed record for the same reason invoke_service's does.
794
+ const listings = await discover().catch((error) => {
795
+ throw new ProveTargetError(error instanceof RegistryUnavailableError
796
+ ? "the service registry is unreachable, so this stream's signed service origin could not be verified. No proof was attempted."
797
+ : `the service registry could not be read: ${asErrorMessage(error)}`);
798
+ });
799
+ const listing = listings.find((entry) => entry.serviceRef.toLowerCase() ===
800
+ record.serviceRef.toLowerCase());
801
+ if (listing === undefined) {
802
+ return errorResult(`no verified listing matches this stream's serviceRef (${record.serviceRef}), so there is no signed origin to prove against. Run discover_services.`);
803
+ }
804
+ if (listing.access === "gated") {
805
+ // Not a degradation we can paper over. The Reclaim attestor fetches
806
+ // the URL itself, from its own network position, and cannot present
807
+ // this stream's single-use caller-auth capability — so the only
808
+ // thing it could prove about a gated origin is its own 403.
809
+ return errorResult(`this stream's service is GATED behind caller-auth (${safeOrigin(listing.accessUrl)}), and a consumer zkTLS proof cannot authenticate to it: the Reclaim attestor fetches the URL itself and cannot carry this stream's single-use capability, so it would only ever prove a 403. Use invoke_service to call it — that delivery is covered by the oracle's own L1/L4 verification. prove_https_response works for PUBLIC listings only.`);
810
+ }
811
+ const signedOrigin = safeOrigin(listing.accessUrl);
812
+ if (safeOrigin(url) !== signedOrigin) {
813
+ return errorResult(`refusing to prove ${safeOrigin(url)} for this stream: its operator signed ${signedOrigin}. A proof of an origin the stream did not pay for is not evidence of its delivery.`);
814
+ }
483
815
  const result = await deliveryProofService.proveConsumedHttpsResponse({
484
816
  url,
485
817
  ...(method === undefined ? {} : { method }),
@@ -501,6 +833,9 @@ export function createVerifiedStreamMcpServer(options) {
501
833
  return successResult(`generated zkTLS response proof for stream ${streamId}`, serializeConsumedResponseProof(record.streamId, result));
502
834
  }
503
835
  catch (error) {
836
+ if (error instanceof ProveTargetError) {
837
+ return errorResult(error.message);
838
+ }
504
839
  return safeToolError(error);
505
840
  }
506
841
  });
@@ -534,24 +869,81 @@ export function parseMetrikAgentEnv(env = process.env) {
534
869
  // objects. `metrik-mcp` is started by an MCP host and fails closed on stdio,
535
870
  // so that array was the ONLY thing an operator ever saw — a correct failure
536
871
  // rendered as noise. Name the variables instead.
537
- throw new Error(`metrik-mcp is not configured: ${describeEnvIssues(result.error)}. Set METRIK_AGENT_RPC_URL (an http(s) URL), METRIK_AGENT_ESCROW and METRIK_AGENT_USDC (EVM addresses), plus a wallet (METRIK_AGENT_PRIVATE_KEY, or the CDP/Privy variables). METRIK_AGENT_CHAIN_ID defaults to Base Sepolia (${baseSepolia.id}).`);
872
+ throw new Error(`metrik-mcp is not configured: ${describeEnvIssues(result.error)}. Set METRIK_AGENT_RPC_URL (an http(s) URL), METRIK_AGENT_ESCROW and METRIK_AGENT_USDC (EVM addresses), plus a wallet (METRIK_AGENT_PRIVATE_KEY, or the CDP variables — a Privy wallet is a CLI-only source and is not resolved here). METRIK_NETWORK (or METRIK_AGENT_CHAIN_ID) defaults to the registry's default network (${defaultNetwork().name}).`);
538
873
  }
539
874
  const parsed = result.data;
540
- const chainId = Number(parsed.METRIK_AGENT_CHAIN_ID ?? baseSepolia.id);
541
- if (!Number.isInteger(chainId) || chainId <= 0) {
542
- throw new Error("METRIK_AGENT_CHAIN_ID must be a positive integer");
875
+ let chainId;
876
+ if (parsed.METRIK_AGENT_CHAIN_ID !== undefined) {
877
+ chainId = Number(parsed.METRIK_AGENT_CHAIN_ID);
878
+ if (!Number.isInteger(chainId) || chainId <= 0) {
879
+ throw new Error("METRIK_AGENT_CHAIN_ID must be a positive integer");
880
+ }
881
+ }
882
+ // "Is this a network the registry knows and the build allows": a registered
883
+ // TESTNET. This server signs with a key from the environment and moves funds.
884
+ let network;
885
+ try {
886
+ network = resolveAgentNetwork(parsed.METRIK_NETWORK !== undefined &&
887
+ parsed.METRIK_NETWORK.trim().length > 0
888
+ ? parsed.METRIK_NETWORK
889
+ : chainId, { testnetOnly: true });
890
+ }
891
+ catch (error) {
892
+ throw new Error(`unsupported METRIK_NETWORK / METRIK_AGENT_CHAIN_ID: ${error instanceof Error ? error.message : String(error)}`);
893
+ }
894
+ if (chainId !== undefined && chainId !== network.chain.id) {
895
+ throw new Error(`METRIK_AGENT_CHAIN_ID ${chainId} does not match METRIK_NETWORK ${network.id} (chain ${network.chain.id})`);
896
+ }
897
+ // Generation: a registered deployment's comes from the registry (a
898
+ // contradicting env value is refused); an unregistered escrow needs it
899
+ // DECLARED. Only the checkpoint-settled generations (v2, v3) are served.
900
+ const declared = parsed.METRIK_AGENT_ESCROW_GENERATION?.trim().toLowerCase();
901
+ if (declared !== undefined && declared !== "v2" && declared !== "v3") {
902
+ throw new Error(`unsupported METRIK_AGENT_ESCROW_GENERATION "${parsed.METRIK_AGENT_ESCROW_GENERATION}": this server reads the checkpoint-settled StreamEscrowV2/V3 only. Leave it unset for a registered escrow.`);
543
903
  }
544
- if (chainId !== baseSepolia.id) {
545
- throw new Error(`unsupported METRIK_AGENT_CHAIN_ID ${chainId}; phase 2 is Base Sepolia only`);
904
+ const deployment = registeredDeployment(network, parsed.METRIK_AGENT_ESCROW);
905
+ let generation;
906
+ if (deployment !== undefined) {
907
+ generation = deployment.escrow.generation;
908
+ if (declared !== undefined && declared !== generation) {
909
+ throw new Error(`METRIK_AGENT_ESCROW_GENERATION "${declared}" contradicts the registry: ${parsed.METRIK_AGENT_ESCROW} is ${network.name}'s ${generation} escrow`);
910
+ }
911
+ }
912
+ else {
913
+ if (parsed.METRIK_AGENT_ALLOW_UNREGISTERED_ESCROW !== "true") {
914
+ throw new Error(`METRIK_AGENT_ESCROW (${parsed.METRIK_AGENT_ESCROW}) is not a registered escrow of ${network.name} (current ${network.escrow.address}${network.legacy.length === 0 ? "" : `; legacy ${network.legacy.map((d) => d.escrow.address).join(", ")}`}). A wrong escrow does not fail at read time — the retired V1 escrow's stream tuple decodes into confident nonsense with the checkpoint decoder this server uses. Point it at a registered escrow, or set ${ALLOW_UNREGISTERED_ESCROW_ENV}=true and METRIK_AGENT_ESCROW_GENERATION for a deliberate private deployment.`);
915
+ }
916
+ if (declared === undefined) {
917
+ throw new Error(`${ALLOW_UNREGISTERED_ESCROW_ENV}=true also needs METRIK_AGENT_ESCROW_GENERATION ("v2" | "v3"): an unregistered escrow's generation cannot be read from the registry`);
918
+ }
919
+ generation = declared;
546
920
  }
547
921
  return {
548
922
  rpcUrl: parsed.METRIK_AGENT_RPC_URL,
549
923
  escrow: parsed.METRIK_AGENT_ESCROW,
924
+ escrowGeneration: generation,
925
+ unregisteredEscrow: deployment === undefined,
926
+ // Opt-IN only: any value but the exact string "true" leaves the check on.
927
+ allowUnverifiedOperatorPairing: parsed.METRIK_AGENT_ALLOW_UNVERIFIED_OPERATOR === "true",
928
+ ...(parsed.METRIK_AGENT_REGISTRY_URL === undefined
929
+ ? {}
930
+ : { registryUrl: parsed.METRIK_AGENT_REGISTRY_URL }),
931
+ ...(parsed.METRIK_AGENT_REGISTRY_APIKEY === undefined
932
+ ? {}
933
+ : { registryApiKey: parsed.METRIK_AGENT_REGISTRY_APIKEY }),
550
934
  usdc: parsed.METRIK_AGENT_USDC,
551
935
  chain: {
552
- ...baseSepolia,
553
- name: parsed.METRIK_AGENT_CHAIN_NAME ?? baseSepolia.name,
936
+ ...network.chain,
937
+ name: parsed.METRIK_AGENT_CHAIN_NAME ?? network.chain.name,
554
938
  },
939
+ network,
940
+ // Opt-IN: a fund-moving tool family whose URL the MODEL picks is never
941
+ // switched on by default or by an unrecognised value.
942
+ x402Enabled: ["1", "true", "yes", "on"].includes((parsed.METRIK_AGENT_X402 ?? "").trim().toLowerCase()),
943
+ x402AllowedHosts: (parsed.METRIK_AGENT_X402_ALLOWED_HOSTS ?? "")
944
+ .split(",")
945
+ .map((entry) => entry.trim())
946
+ .filter((entry) => entry.length > 0),
555
947
  ...(parsed.METRIK_AGENT_MCP_STATE_FILE === undefined
556
948
  ? {}
557
949
  : {
@@ -585,11 +977,21 @@ export async function createVerifiedStreamMcpServerOptionsFromEnv(env = process.
585
977
  chain: parsed.chain,
586
978
  transport: http(parsed.rpcUrl),
587
979
  escrow: parsed.escrow,
588
- // Declare the escrow as V2 as well. Without this the SDK treats the
589
- // client as V1 and routes claimable()/reclaimable() to the legacy ABI,
590
- // which reverts against StreamEscrowV2 — the checkpoint escrow replaced
591
- // those one-argument views with proof-carrying ones.
980
+ // Select the checkpoint decoder, on the strength of the DECLARED
981
+ // generation (`parsed.escrowGeneration`, validated in parseMetrikAgentEnv
982
+ // — which also refuses the retired V1 address outright). Without this the
983
+ // SDK treats the client as V1 and routes claimable()/reclaimable() to the
984
+ // legacy ABI, which reverts against StreamEscrowV2: the checkpoint escrow
985
+ // replaced those one-argument views with proof-carrying ones.
592
986
  escrowV2: parsed.escrow,
987
+ // An unregistered escrow was opted into by the operator: the SDK skips
988
+ // its code-hash pin only with both flags (see parseMetrikAgentEnv).
989
+ ...(parsed.unregisteredEscrow
990
+ ? {
991
+ allowUnregisteredEscrow: true,
992
+ escrowGeneration: parsed.escrowGeneration,
993
+ }
994
+ : {}),
593
995
  usdc: parsed.usdc,
594
996
  settlementTarget: {
595
997
  chainId: parsed.chain.id,
@@ -613,6 +1015,33 @@ export async function createVerifiedStreamMcpServerOptionsFromEnv(env = process.
613
1015
  }
614
1016
  return cachedMandate;
615
1017
  };
1018
+ // The "use" half. `invoke()` needs a signer that can produce an EIP-712
1019
+ // signature; every wallet this package resolves can, but only a raw key is a
1020
+ // viem LocalAccount by type — hence the structural check rather than a cast.
1021
+ const serviceInvoker = createServiceInvoker({
1022
+ streamReader: createSdkInvokeStreamReader({
1023
+ escrowAddress: parsed.escrow,
1024
+ rpcUrl: parsed.rpcUrl,
1025
+ chainId: parsed.chain.id,
1026
+ ...(parsed.unregisteredEscrow
1027
+ ? { unregisteredEscrowGeneration: parsed.escrowGeneration }
1028
+ : {}),
1029
+ }),
1030
+ buyer: requireCapabilitySigner(wallet.account),
1031
+ domain: { chainId, verifyingContract: parsed.escrow },
1032
+ discoverServices: defaultDiscoverServices,
1033
+ discoverOptions: {
1034
+ ...(parsed.registryUrl === undefined
1035
+ ? {}
1036
+ : { registryUrl: parsed.registryUrl }),
1037
+ ...(parsed.registryApiKey === undefined
1038
+ ? {}
1039
+ : { apiKey: parsed.registryApiKey }),
1040
+ // stderr, never stdout: stdout is the MCP stdio transport channel, and
1041
+ // discovery warns about every listing it DROPS for a bad signature.
1042
+ logger: { warn: (...parts) => console.error(...parts) },
1043
+ },
1044
+ });
616
1045
  return {
617
1046
  agentClient,
618
1047
  signedMandate,
@@ -620,9 +1049,54 @@ export async function createVerifiedStreamMcpServerOptionsFromEnv(env = process.
620
1049
  registry: parsed.stateFile === undefined
621
1050
  ? new InMemoryStreamRegistry()
622
1051
  : new FileStreamRegistry(parsed.stateFile),
1052
+ allowUnverifiedOperatorPairing: parsed.allowUnverifiedOperatorPairing,
1053
+ serviceInvoker,
1054
+ ...(parsed.x402Enabled
1055
+ ? {
1056
+ x402: {
1057
+ signer: requireCapabilitySigner(wallet.account),
1058
+ network: parsed.network,
1059
+ // The SDK's on-chain quote checks (factory.predict) read through
1060
+ // this server's own RPC.
1061
+ sdkOptions: { transport: http(parsed.rpcUrl) },
1062
+ ...(parsed.x402AllowedHosts.length === 0
1063
+ ? {}
1064
+ : { allowedHosts: parsed.x402AllowedHosts }),
1065
+ refundClient: async (target) => {
1066
+ const { refundClient } = await import("@absol-labs/sdk/x402");
1067
+ return refundClient(target.refundUrl, {
1068
+ streamId: target.streamId,
1069
+ ...(target.opener === undefined
1070
+ ? {}
1071
+ : { opener: target.opener }),
1072
+ ...(target.fetch === undefined ? {} : { fetch: target.fetch }),
1073
+ // Every relay "success" is checked against the receipt.
1074
+ verifyWith: readOnlyMetrikClient({
1075
+ escrowAddress: parsed.escrow,
1076
+ rpcUrl: parsed.rpcUrl,
1077
+ chainId: parsed.chain.id,
1078
+ }),
1079
+ expectedChainId: parsed.chain.id,
1080
+ });
1081
+ },
1082
+ },
1083
+ }
1084
+ : {}),
623
1085
  ...(deliveryProofService === null ? {} : { deliveryProofService }),
624
1086
  };
625
1087
  }
1088
+ /**
1089
+ * Narrows a resolved wallet account to something that can sign a capability.
1090
+ *
1091
+ * Fails closed at STARTUP with a named cause rather than at the first
1092
+ * `invoke_service` call with a `TypeError`.
1093
+ */
1094
+ function requireCapabilitySigner(account) {
1095
+ if (typeof account.signTypedData !== "function") {
1096
+ throw new Error(`the configured wallet (${account.address}) cannot sign EIP-712 typed data, so it cannot authorize a service invocation. Use a wallet whose account exposes signTypedData (a raw key, a CDP server account, or a Privy embedded wallet).`);
1097
+ }
1098
+ return account;
1099
+ }
626
1100
  export async function createVerifiedStreamMcpServerFromEnv(env = process.env) {
627
1101
  return createVerifiedStreamMcpServer(await createVerifiedStreamMcpServerOptionsFromEnv(env));
628
1102
  }
@@ -641,6 +1115,26 @@ function serializeServiceListing(listing) {
641
1115
  ...(listing.targetMetadata === undefined
642
1116
  ? {}
643
1117
  : { targetMetadata: listing.targetMetadata }),
1118
+ ...signedRateBandField(listing),
1119
+ };
1120
+ }
1121
+ /**
1122
+ * The operator-signed rate band, in USDC atomic units per second, so an agent
1123
+ * can pick a `ratePerSecondUsdc` that hire_verified_service will accept. Read
1124
+ * from the record discovery already verified; omitted when none was signed.
1125
+ * hire_verified_service re-verifies it before enforcing it.
1126
+ */
1127
+ function signedRateBandField(listing) {
1128
+ const commercial = listing.signed?.descriptor?.commercial;
1129
+ if (commercial?.minRatePerSecond === undefined &&
1130
+ commercial?.maxRatePerSecond === undefined) {
1131
+ return {};
1132
+ }
1133
+ return {
1134
+ ratePerSecondBand: {
1135
+ minRatePerSecondUsdc: commercial.minRatePerSecond ?? null,
1136
+ maxRatePerSecondUsdc: commercial.maxRatePerSecond ?? null,
1137
+ },
644
1138
  };
645
1139
  }
646
1140
  function serializeStreamStatus(status, streamId) {
@@ -678,6 +1172,11 @@ function serializeStreamStatus(status, streamId) {
678
1172
  },
679
1173
  claimable: status.claimable.toString(),
680
1174
  reclaimable: status.reclaimable.toString(),
1175
+ metering: {
1176
+ model: "time-metered",
1177
+ ratePerSecondUsdc: status.stream.ratePerSecond.toString(),
1178
+ note: METERING_MODEL,
1179
+ },
681
1180
  };
682
1181
  }
683
1182
  function serializeConsumedResponseProof(streamId, proof) {
@@ -692,6 +1191,187 @@ function serializeConsumedResponseProof(streamId, proof) {
692
1191
  verification: proof.verification,
693
1192
  };
694
1193
  }
1194
+ /**
1195
+ * True when a path carries a character that must never appear in one.
1196
+ *
1197
+ * Mirrors `assertOriginRelativePath` in `../sdk/invoke.ts`. The backslash is the
1198
+ * one that matters: the URL parser reads it as a host separator for http(s), so
1199
+ * it turns an apparently origin-relative path into a different host. C0
1200
+ * controls and DEL go with it — a header- or log-splitting character has no
1201
+ * business in a path either.
1202
+ */
1203
+ function hasForbiddenPathCharacter(value) {
1204
+ for (const character of value) {
1205
+ const code = character.codePointAt(0) ?? 0;
1206
+ if (character === "\\" || code < 0x20 || code === 0x7f) {
1207
+ return true;
1208
+ }
1209
+ }
1210
+ return false;
1211
+ }
1212
+ /**
1213
+ * Checks that `operator` — the wallet that gets paid — is the one the
1214
+ * registry's operator-signed record for `serviceRef` names.
1215
+ *
1216
+ * This reports; it does not decide. Every outcome other than `verified` is
1217
+ * treated as a refusal by the hire path (see ALLOW_UNVERIFIED_OPERATOR_ENV).
1218
+ * An earlier version failed OPEN on anything but a positive contradiction,
1219
+ * which meant that during any registry read failure the exact bug this check
1220
+ * exists to stop was fully live: an attacker's `operator` paired with a genuine
1221
+ * `serviceRef`, the oracle verifying the real endpoint, and accrual settling to
1222
+ * the attacker. Settlement does not depend on this HTTP endpoint being
1223
+ * reachable from the MCP host, so a read failure is no evidence of safety.
1224
+ *
1225
+ * A hostile registry cannot forge a match — the operator's signature over the
1226
+ * record the serviceRef is derived from is recovered client-side — but it CAN
1227
+ * suppress a row, which is exactly why `unlisted` must not be a soft verdict.
1228
+ *
1229
+ * An outage is reported distinctly from a malformed/failed read: "retry in a
1230
+ * moment" and "something is wrong with this registry" are different next steps.
1231
+ */
1232
+ async function checkOperatorPairing(discover, serviceRef, operator) {
1233
+ let listings;
1234
+ try {
1235
+ listings = await discover();
1236
+ }
1237
+ catch (error) {
1238
+ return error instanceof RegistryUnavailableError
1239
+ ? { verdict: "registry-unavailable", detail: asErrorMessage(error) }
1240
+ : { verdict: "registry-error", detail: asErrorMessage(error) };
1241
+ }
1242
+ const listing = listings.find((entry) => entry.serviceRef.toLowerCase() === serviceRef.toLowerCase());
1243
+ if (listing === undefined) {
1244
+ return { verdict: "unlisted" };
1245
+ }
1246
+ if (listing.operator.toLowerCase() !== operator.toLowerCase()) {
1247
+ return { verdict: "mismatch", listedOperator: listing.operator };
1248
+ }
1249
+ return { verdict: "verified", listing };
1250
+ }
1251
+ /**
1252
+ * Why a hire was refused, in words the caller can act on.
1253
+ *
1254
+ * Refusing strands nothing: no USDC has moved, and the same call succeeds once
1255
+ * the registry answers again.
1256
+ */
1257
+ function explainPairingRefusal(pairing, serviceRef, operator) {
1258
+ switch (pairing.verdict) {
1259
+ case "mismatch":
1260
+ return `refusing to hire: serviceRef ${serviceRef} is listed under operator ${pairing.listedOperator}, not ${operator}. The operator address is the wallet that gets paid — escrowing to a different one would pay a stranger for someone else's service. Take the operator/serviceRef pair from discover_services.`;
1261
+ case "unlisted":
1262
+ return `refusing to hire: serviceRef ${serviceRef} is not in the verified registry, so there is no operator-signed record proving that ${operator} is the wallet this service pays. Run discover_services and hire a listed service. (A row that fails signature verification is dropped, so a revoked or malformed listing looks the same as an absent one.)`;
1263
+ case "registry-unavailable":
1264
+ return `refusing to hire: the service registry is unreachable (${pairing.detail}), so this hire's operator/serviceRef pairing cannot be verified and the escrow could be paid to the wrong wallet. Nothing was spent — retry when the registry answers.`;
1265
+ case "registry-error":
1266
+ return `refusing to hire: the service registry could not be read (${pairing.detail}), so this hire's operator/serviceRef pairing cannot be verified and the escrow could be paid to the wrong wallet. Nothing was spent.`;
1267
+ }
1268
+ }
1269
+ /** A prove-target precondition that failed with a caller-actionable reason. */
1270
+ class ProveTargetError extends Error {
1271
+ constructor(message) {
1272
+ super(message);
1273
+ this.name = "ProveTargetError";
1274
+ }
1275
+ }
1276
+ /** Origin of a URL, or the raw value when it will not parse. */
1277
+ function safeOrigin(value) {
1278
+ try {
1279
+ return new URL(value).origin;
1280
+ }
1281
+ catch {
1282
+ return value;
1283
+ }
1284
+ }
1285
+ function normalizeHeaders(headers) {
1286
+ const normalized = {};
1287
+ for (const [name, value] of Object.entries(headers ?? {})) {
1288
+ normalized[name.toLowerCase()] = value;
1289
+ }
1290
+ return normalized;
1291
+ }
1292
+ /**
1293
+ * Renders an invocation as an MCP result.
1294
+ *
1295
+ * A non-2xx answer is returned as `isError` WITH the full structured content:
1296
+ * the call really happened and its body may be the thing the agent needs, but a
1297
+ * buyer who paid and got a 403 should not have to infer that from a status
1298
+ * field. Gateway denials carry their machine-readable reason, because
1299
+ * `stream-expired` and `nonce-replayed` call for opposite responses.
1300
+ */
1301
+ function serializeInvocation(result) {
1302
+ const summary = `${result.method} ${result.path} -> ${result.status}`;
1303
+ if (result.ok) {
1304
+ return {
1305
+ content: [{ type: "text", text: summary }],
1306
+ structuredContent: { ...result },
1307
+ };
1308
+ }
1309
+ const because = result.gatewayDenialReason === null
1310
+ ? `the service itself answered ${result.status}; the capability was accepted`
1311
+ : `the seller's caller-auth gateway REFUSED the call: ${result.gatewayDenialReason}${gatewayDenialHint(result.gatewayDenialReason)}`;
1312
+ return {
1313
+ content: [{ type: "text", text: `${summary} — ${because}` }],
1314
+ structuredContent: { ...result },
1315
+ isError: true,
1316
+ };
1317
+ }
1318
+ /** One actionable next step per gateway denial reason. */
1319
+ function gatewayDenialHint(reason) {
1320
+ switch (reason) {
1321
+ case "stream-not-funded":
1322
+ return " — the stream's remaining budget is exhausted; open a new one with hire_verified_service.";
1323
+ case "stream-expired":
1324
+ return " — the stream is past its expiry; reclaim_unspent returns the unspent budget.";
1325
+ case "stream-not-active":
1326
+ return " — the stream is closed; open a new one with hire_verified_service.";
1327
+ case "capability-expired":
1328
+ return " — the signed capability outlived its TTL before the gateway saw it; retry (optionally with a larger ttlSeconds).";
1329
+ case "nonce-replayed":
1330
+ return " — each capability is single-use; retry to mint a fresh one.";
1331
+ case "stream-state-stale":
1332
+ return " — the gateway's view of the chain is too stale to authorize; retry shortly.";
1333
+ case "buyer-mismatch":
1334
+ return " — the stream does not belong to this server's wallet.";
1335
+ default:
1336
+ return "";
1337
+ }
1338
+ }
1339
+ /**
1340
+ * Turns an invocation failure into something an LLM can act on, or `null` when
1341
+ * it is not a failure this layer understands (those fall through to the generic
1342
+ * handler, which logs the detail server-side and says nothing more).
1343
+ *
1344
+ * The generic "the tool call failed" is correct for an unexpected RPC/viem
1345
+ * error — it can carry internal detail — but it is useless for the three things
1346
+ * that actually go wrong here: wrong wallet, dead stream, unlisted service.
1347
+ */
1348
+ function explainInvokeFailure(error, streamId) {
1349
+ if (error instanceof InvokeBuyerMismatchError) {
1350
+ return `stream ${streamId} does not belong to this server's wallet, so this server cannot sign a capability for it. Only the stream's buyer can invoke it — use a stream from hire_verified_service or list_streams.`;
1351
+ }
1352
+ if (error instanceof InvokeStreamNotActiveError) {
1353
+ return `stream ${streamId} is closed, so it authorizes nothing. Open a new one with hire_verified_service.`;
1354
+ }
1355
+ if (error instanceof InvokeStreamExpiredError) {
1356
+ return `stream ${streamId} has expired, so it authorizes nothing. reclaim_unspent returns the unspent budget; hire_verified_service opens a replacement.`;
1357
+ }
1358
+ if (error instanceof ServiceListingNotFoundError) {
1359
+ return `no verified listing matches this stream's serviceRef (${error.serviceRef}). Run discover_services: a listing whose operator signature fails verification is dropped, so an unlisted or revoked service cannot be invoked.`;
1360
+ }
1361
+ if (error instanceof InvokeServiceRefMismatchError) {
1362
+ return `the registry's listing does not match this stream's serviceRef. The call was refused rather than sent to an origin this stream did not pay for.`;
1363
+ }
1364
+ if (error instanceof InvokeAccessUrlError) {
1365
+ return `this stream's listing carries no usable signed invocation origin (${error.message}). This server refuses to call an origin the operator did not sign.`;
1366
+ }
1367
+ if (error instanceof InvokePathError) {
1368
+ return `invalid request path: ${error.message}`;
1369
+ }
1370
+ if (error instanceof RegistryUnavailableError) {
1371
+ return `the service registry is unreachable, so the operator-signed origin for this stream could not be verified. The call was not attempted — an unverified origin is never guessed. Retry when the registry is back.`;
1372
+ }
1373
+ return null;
1374
+ }
695
1375
  function successResult(text, structuredContent) {
696
1376
  return {
697
1377
  content: [{ type: "text", text }],