@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
package/src/mcp/server.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
+ import type { MetrikNetwork } from "@absol-labs/sdk";
4
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
7
  import { privateKeyToAccount } from "viem/accounts";
7
8
  import { http, isAddress, isHex, type Chain } from "viem";
8
- import { baseSepolia } from "viem/chains";
9
9
  import { z } from "zod";
10
10
 
11
11
  import {
@@ -13,6 +13,7 @@ import {
13
13
  type DiscoverServicesOptions,
14
14
  type ServiceListing,
15
15
  } from "../discovery/registry.js";
16
+ import { checkListedRateBand } from "../discovery/rate-band.js";
16
17
  import { checkMandate, type SignedSpendMandate } from "../mandates/mandate.js";
17
18
  import {
18
19
  parseServerMandateCapsEnv,
@@ -31,12 +32,47 @@ import {
31
32
  createWalletBackedAgentClient,
32
33
  parseAgentWalletEnv,
33
34
  type AgentWalletInput,
35
+ type ResolvedAgentWallet,
34
36
  } from "../wallet/provider.js";
35
37
  import {
36
38
  createReclaimConsumerProofServiceFromEnv,
37
39
  type ConsumerDeliveryProofService,
38
40
  type VerifiedConsumedHttpsResponseProof,
39
41
  } from "../zktls/reclaim.js";
42
+ import {
43
+ CAPABILITY_HEADER_NAME,
44
+ httpMethodSchema,
45
+ type InvocationCapabilitySigner,
46
+ } from "../capability/invocation-capability.js";
47
+ import {
48
+ createSdkInvokeStreamReader,
49
+ InvokeAccessUrlError,
50
+ InvokeBuyerMismatchError,
51
+ InvokePathError,
52
+ InvokeServiceRefMismatchError,
53
+ InvokeStreamExpiredError,
54
+ InvokeStreamNotActiveError,
55
+ } from "../sdk/invoke.js";
56
+ import {
57
+ createServiceInvoker,
58
+ RegistryUnavailableError,
59
+ ServiceListingNotFoundError,
60
+ type ServiceInvocationResult,
61
+ type ServiceInvoker,
62
+ } from "./service-invoker.js";
63
+ import {
64
+ defaultNetwork,
65
+ readOnlyMetrikClient,
66
+ registeredDeployment,
67
+ resolveAgentNetwork,
68
+ } from "../network.js";
69
+ import {
70
+ REFUND_STATUS_TOOL_DESCRIPTION,
71
+ REFUND_TOOL_DESCRIPTION,
72
+ X402_PAY_TOOL_DESCRIPTION,
73
+ registerX402Tools,
74
+ type McpX402Options,
75
+ } from "./x402-tools.js";
40
76
 
41
77
  /**
42
78
  * MCP server for the agent lane.
@@ -85,6 +121,55 @@ const hireVerifiedServiceSchema = {
85
121
  ),
86
122
  } as const;
87
123
 
124
+ /**
125
+ * Ceiling on a capability's lifetime.
126
+ *
127
+ * A signed capability is a bearer credential for one method+path on one stream:
128
+ * anything holding it can make that call until it expires. The signer's own
129
+ * default is 60s; this bounds what an LLM may ask for so a single tool argument
130
+ * cannot mint a long-lived credential. It is a cap, not the default.
131
+ */
132
+ const MAX_CAPABILITY_TTL_SECONDS = 300;
133
+
134
+ const invokeServiceSchema = {
135
+ streamId: bytes32Schema.describe(
136
+ "Stream id of an ACTIVE stream whose buyer is this server's wallet (from hire_verified_service or list_streams).",
137
+ ),
138
+ path: z
139
+ .string()
140
+ .min(1)
141
+ .optional()
142
+ .describe(
143
+ '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.',
144
+ ),
145
+ method: httpMethodSchema
146
+ .optional()
147
+ .describe(
148
+ 'HTTP method. Defaults to "GET". The capability authorizes exactly this method and this path — nothing else.',
149
+ ),
150
+ headers: z
151
+ .record(z.string())
152
+ .optional()
153
+ .describe(
154
+ "Extra request headers. The capability header is attached by the server and cannot be supplied here.",
155
+ ),
156
+ body: z
157
+ .string()
158
+ .optional()
159
+ .describe(
160
+ "Request body, sent verbatim. Defaults to a JSON content-type when a body is present and none is given.",
161
+ ),
162
+ ttlSeconds: z
163
+ .number()
164
+ .int()
165
+ .positive()
166
+ .max(MAX_CAPABILITY_TTL_SECONDS)
167
+ .optional()
168
+ .describe(
169
+ `Capability lifetime in seconds (default 60, max ${MAX_CAPABILITY_TTL_SECONDS}). Shorter is safer; it only has to outlive one request.`,
170
+ ),
171
+ } as const;
172
+
88
173
  const getStreamStatusSchema = {
89
174
  streamId: bytes32Schema.describe(
90
175
  "Stream id returned by hire_verified_service.",
@@ -147,8 +232,51 @@ const metrikAgentEnvSchema = z.object({
147
232
  METRIK_AGENT_CHAIN_ID: z.string().optional(),
148
233
  METRIK_AGENT_CHAIN_NAME: z.string().optional(),
149
234
  METRIK_AGENT_MCP_STATE_FILE: z.string().optional(),
235
+ METRIK_AGENT_ESCROW_GENERATION: z.string().optional(),
236
+ METRIK_AGENT_REGISTRY_URL: z.string().url().optional(),
237
+ METRIK_AGENT_REGISTRY_APIKEY: z.string().optional(),
238
+ METRIK_AGENT_ALLOW_UNVERIFIED_OPERATOR: z.string().optional(),
239
+ METRIK_AGENT_ALLOW_UNREGISTERED_ESCROW: z.string().optional(),
240
+ METRIK_NETWORK: z.string().optional(),
241
+ METRIK_AGENT_X402: z.string().optional(),
242
+ METRIK_AGENT_X402_ALLOWED_HOSTS: z.string().optional(),
150
243
  });
151
244
 
245
+ /**
246
+ * Opt in to an escrow that is NOT the network's registered `StreamEscrowV2`.
247
+ *
248
+ * Off by default, because a wrong escrow does not fail at read time: the
249
+ * retired V1 escrow's `getStream` returns a tuple that diverges from V2's from
250
+ * index 5 onward, so a V2 decoder pointed at it does not revert — it decodes
251
+ * other fields into correctly-named ones and reports a confident wrong answer
252
+ * about someone's money. This repository's own shipped docs pointed
253
+ * `METRIK_AGENT_ESCROW` at V1 as recently as 0.10.0. Requiring the escrow to be
254
+ * the one the SDK network registry records for the chain refuses V1 (and any
255
+ * other stray address) without naming it in code. Operator-set input only.
256
+ */
257
+ export const ALLOW_UNREGISTERED_ESCROW_ENV =
258
+ "METRIK_AGENT_ALLOW_UNREGISTERED_ESCROW";
259
+
260
+ /**
261
+ * Opt out of the operator/serviceRef cross-check on hire. Operator-set input
262
+ * only — an MCP caller can neither read nor set it.
263
+ */
264
+ export const ALLOW_UNVERIFIED_OPERATOR_ENV =
265
+ "METRIK_AGENT_ALLOW_UNVERIFIED_OPERATOR";
266
+
267
+ /** An unset escrow slot decodes as an all-zero struct rather than reverting. */
268
+ const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as const;
269
+
270
+ /**
271
+ * Escrow generations this server can talk to.
272
+ *
273
+ * Declared, not inferred. The client sets `escrowV2` (which selects the
274
+ * checkpoint decoder for every read) purely from configuration, so the
275
+ * generation has to be a stated fact that can be wrong out loud, rather than an
276
+ * assumption buried in one constructor argument.
277
+ */
278
+ export type EscrowGeneration = "v2" | "v3";
279
+
152
280
  export interface McpToolSpec {
153
281
  name: string;
154
282
  description: string;
@@ -156,42 +284,74 @@ export interface McpToolSpec {
156
284
  movesFunds: boolean;
157
285
  }
158
286
 
287
+ /**
288
+ * The one sentence every stream-opening surface has to carry.
289
+ *
290
+ * A reviewer made ONE call against a stream and was billed for 302 seconds. The
291
+ * escrow behaved exactly as designed — and nothing in the tool surface had told
292
+ * them what they were buying. An LLM picks a tool from its description alone, so
293
+ * the meter is stated in the description of every tool that opens, reads, or
294
+ * ends a stream, not only in the docs it will never read.
295
+ */
296
+ export const METERING_MODEL =
297
+ "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.";
298
+
159
299
  export const METRIK_MCP_TOOLS: readonly McpToolSpec[] = [
160
300
  {
161
301
  name: "discover_services",
162
302
  description:
163
- "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.",
303
+ "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.",
164
304
  movesFunds: false,
165
305
  },
166
306
  {
167
307
  name: "hire_verified_service",
168
- description:
169
- "Open a verified payment stream for a third-party service. Pays only for oracle-verified delivered time. Subject to the agent's spend mandate.",
308
+ 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}`,
170
309
  movesFunds: true,
171
310
  },
172
311
  {
173
- name: "check_stream_status",
312
+ name: "invoke_service",
174
313
  description:
175
- "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.",
314
+ "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.",
315
+ movesFunds: false,
316
+ },
317
+ {
318
+ name: "check_stream_status",
319
+ 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}`,
176
320
  movesFunds: false,
177
321
  },
178
322
  {
179
323
  name: "reclaim_unspent",
180
324
  description:
181
- "Close a stream and reclaim unspent funds to the buyer. V2 reclaim follows checkpoint finalization or escape-window rules.",
325
+ "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.",
182
326
  movesFunds: true,
183
327
  },
184
328
  {
185
329
  name: "list_streams",
186
- description: "List the agent's streams and their current state.",
330
+ description:
331
+ "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.",
187
332
  movesFunds: false,
188
333
  },
189
334
  {
190
335
  name: "prove_https_response",
191
336
  description:
192
- "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.",
337
+ "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.",
193
338
  movesFunds: false,
194
339
  },
340
+ {
341
+ name: "x402_pay",
342
+ description: X402_PAY_TOOL_DESCRIPTION,
343
+ movesFunds: true,
344
+ },
345
+ {
346
+ name: "refund_status",
347
+ description: REFUND_STATUS_TOOL_DESCRIPTION,
348
+ movesFunds: false,
349
+ },
350
+ {
351
+ name: "refund",
352
+ description: REFUND_TOOL_DESCRIPTION,
353
+ movesFunds: true,
354
+ },
195
355
  ] as const;
196
356
 
197
357
  /** @deprecated Renamed to METRIK_MCP_TOOLS; retained for back-compat. */
@@ -207,12 +367,28 @@ export interface StoredStreamRecord {
207
367
  readonly maxDurationSeconds: number;
208
368
  readonly openedAtSeconds: number;
209
369
  readonly mandateId: `0x${string}`;
370
+ /** x402 streams only: the seller's refund relay URL for this stream. */
371
+ readonly refundUrl?: string;
372
+ /** x402 streams only: the X402StreamOpener that bought the stream. */
373
+ readonly opener?: `0x${string}`;
374
+ /**
375
+ * An x402 payment approved and signed whose stream is not (yet) known. It
376
+ * counts against the cumulative cap exactly like a stream; `streamId` is a
377
+ * reservation key, not an escrow stream id.
378
+ */
379
+ readonly x402Reservation?: boolean;
210
380
  }
211
381
 
212
382
  export interface StreamRegistry {
213
383
  put(record: StoredStreamRecord): Promise<void>;
214
384
  get(streamId: `0x${string}`): Promise<StoredStreamRecord | null>;
215
385
  list(): Promise<readonly StoredStreamRecord[]>;
386
+ /**
387
+ * Remove a record. Used ONLY to release an x402 spend reservation that
388
+ * provably never moved money, or one superseded by its real stream. A
389
+ * registry without it keeps reservations (they over-count: buyer-favouring).
390
+ */
391
+ delete?(streamId: `0x${string}`): Promise<void>;
216
392
  }
217
393
 
218
394
  export class InMemoryStreamRegistry implements StreamRegistry {
@@ -229,6 +405,10 @@ export class InMemoryStreamRegistry implements StreamRegistry {
229
405
  async list(): Promise<readonly StoredStreamRecord[]> {
230
406
  return Array.from(this.records.values());
231
407
  }
408
+
409
+ async delete(streamId: `0x${string}`): Promise<void> {
410
+ this.records.delete(streamId);
411
+ }
232
412
  }
233
413
 
234
414
  export class FileStreamRegistry implements StreamRegistry {
@@ -249,6 +429,11 @@ export class FileStreamRegistry implements StreamRegistry {
249
429
  return Array.from((await this.readAll()).values());
250
430
  }
251
431
 
432
+ async delete(streamId: `0x${string}`): Promise<void> {
433
+ const records = await this.readAll();
434
+ if (records.delete(streamId)) await this.writeAll(records);
435
+ }
436
+
252
437
  private async readAll(): Promise<Map<`0x${string}`, StoredStreamRecord>> {
253
438
  const contents = await fs.readFile(this.filePath, "utf8").catch((error) => {
254
439
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
@@ -268,6 +453,9 @@ export class FileStreamRegistry implements StreamRegistry {
268
453
  maxDurationSeconds: z.number().int().positive(),
269
454
  openedAtSeconds: z.number().int().nonnegative(),
270
455
  mandateId: bytes32Schema,
456
+ refundUrl: z.string().url().optional(),
457
+ opener: addressSchema.optional(),
458
+ x402Reservation: z.boolean().optional(),
271
459
  }),
272
460
  )
273
461
  .parse(JSON.parse(contents));
@@ -285,6 +473,13 @@ export class FileStreamRegistry implements StreamRegistry {
285
473
  maxDurationSeconds: record.maxDurationSeconds,
286
474
  openedAtSeconds: record.openedAtSeconds,
287
475
  mandateId: record.mandateId as `0x${string}`,
476
+ ...(record.refundUrl === undefined
477
+ ? {}
478
+ : { refundUrl: record.refundUrl }),
479
+ ...(record.opener === undefined
480
+ ? {}
481
+ : { opener: record.opener as `0x${string}` }),
482
+ ...(record.x402Reservation === true ? { x402Reservation: true } : {}),
288
483
  },
289
484
  ]),
290
485
  );
@@ -343,6 +538,23 @@ export interface VerifiedStreamMcpServerOptions {
343
538
  | SignedSpendMandate
344
539
  | Promise<SignedSpendMandate>;
345
540
  readonly registry?: StreamRegistry;
541
+ /**
542
+ * Escape hatch for the operator/serviceRef cross-check, from
543
+ * {@link ALLOW_UNVERIFIED_OPERATOR_ENV}.
544
+ *
545
+ * OFF by default and deliberately not reachable from a tool argument: the
546
+ * caller being asked to relax a fund-safety check is the LLM the check exists
547
+ * to constrain. Even when on, a positive contradiction (`mismatch`) is still
548
+ * refused — this only downgrades "could not verify" from a refusal to a
549
+ * warning, for an operator who has accepted that risk explicitly.
550
+ */
551
+ readonly allowUnverifiedOperatorPairing?: boolean;
552
+ /**
553
+ * Drives `invoke_service`. Omitted, the tool is not registered at all rather
554
+ * than advertised and broken — the env builder always supplies one, so this
555
+ * is only omitted by callers wiring the server by hand (and by tests).
556
+ */
557
+ readonly serviceInvoker?: ServiceInvoker;
346
558
  readonly deliveryProofService?: ConsumerDeliveryProofService;
347
559
  readonly mandateStateResolver?: Partial<MandateStateResolver>;
348
560
  readonly now?: () => number;
@@ -352,6 +564,12 @@ export interface VerifiedStreamMcpServerOptions {
352
564
  * the registry URL/apikey come from env inside `discoverServices`.
353
565
  */
354
566
  readonly discoverServices?: DiscoverServicesFn;
567
+ /**
568
+ * The buyer side of x402. When set, `x402_pay`, `refund_status` and `refund`
569
+ * are registered (mandate-gated like `hire_verified_service`); unset, the
570
+ * tool surface is exactly as before.
571
+ */
572
+ readonly x402?: McpX402Options;
355
573
  readonly serverInfo?: {
356
574
  readonly name: string;
357
575
  readonly version: string;
@@ -368,8 +586,27 @@ export interface VerifiedStreamMcpServerRuntime {
368
586
  export interface ParsedMetrikAgentEnv {
369
587
  readonly rpcUrl: string;
370
588
  readonly escrow: `0x${string}`;
589
+ /** Which escrow generation {@link escrow} is, as CONFIGURED (never probed). */
590
+ readonly escrowGeneration: EscrowGeneration;
591
+ /**
592
+ * True when `escrow` is NOT a registered deployment and the operator opted
593
+ * in (`METRIK_AGENT_ALLOW_UNREGISTERED_ESCROW=true` + an explicit
594
+ * `METRIK_AGENT_ESCROW_GENERATION`); the SDK then skips its code-hash pin.
595
+ */
596
+ readonly unregisteredEscrow: boolean;
597
+ /** Registry the invocation origin is verified against. Undefined = the public default. */
598
+ readonly registryUrl?: string;
599
+ readonly registryApiKey?: string;
600
+ /** {@link ALLOW_UNVERIFIED_OPERATOR_ENV}; false unless explicitly `true`. */
601
+ readonly allowUnverifiedOperatorPairing: boolean;
371
602
  readonly usdc: `0x${string}`;
372
603
  readonly chain: Chain;
604
+ /** The registered network this server settles on (SDK network registry). */
605
+ readonly network: MetrikNetwork;
606
+ /** `METRIK_AGENT_X402`: register the x402 tools. OPT-IN: only `1`/`true`/`yes`/`on`. */
607
+ readonly x402Enabled: boolean;
608
+ /** `METRIK_AGENT_X402_ALLOWED_HOSTS`, comma-separated; empty = any public host. */
609
+ readonly x402AllowedHosts: readonly string[];
373
610
  readonly stateFile?: string;
374
611
  readonly wallet: AgentWalletInput;
375
612
  }
@@ -392,12 +629,27 @@ function warnOnce(key: string, message: string): void {
392
629
  console.error(message);
393
630
  }
394
631
 
395
- const DISCOVER_TOOL_SPEC = METRIK_MCP_TOOLS[0]!;
396
- const HIRE_TOOL_SPEC = METRIK_MCP_TOOLS[1]!;
397
- const STATUS_TOOL_SPEC = METRIK_MCP_TOOLS[2]!;
398
- const RECLAIM_TOOL_SPEC = METRIK_MCP_TOOLS[3]!;
399
- const LIST_TOOL_SPEC = METRIK_MCP_TOOLS[4]!;
400
- const PROVE_TOOL_SPEC = METRIK_MCP_TOOLS[5]!;
632
+ /**
633
+ * Looks a tool spec up by NAME. This used to index into METRIK_MCP_TOOLS by
634
+ * position, which silently rebinds every description the moment a tool is
635
+ * inserted anywhere but the end — a tool whose advertised description belongs
636
+ * to a different tool is worse than a missing one, because an agent acts on it.
637
+ */
638
+ function toolSpec(name: string): McpToolSpec {
639
+ const spec = METRIK_MCP_TOOLS.find((entry) => entry.name === name);
640
+ if (spec === undefined) {
641
+ throw new Error(`unknown MCP tool spec: ${name}`);
642
+ }
643
+ return spec;
644
+ }
645
+
646
+ const DISCOVER_TOOL_SPEC = toolSpec("discover_services");
647
+ const HIRE_TOOL_SPEC = toolSpec("hire_verified_service");
648
+ const INVOKE_TOOL_SPEC = toolSpec("invoke_service");
649
+ const STATUS_TOOL_SPEC = toolSpec("check_stream_status");
650
+ const RECLAIM_TOOL_SPEC = toolSpec("reclaim_unspent");
651
+ const LIST_TOOL_SPEC = toolSpec("list_streams");
652
+ const PROVE_TOOL_SPEC = toolSpec("prove_https_response");
401
653
 
402
654
  const discoverServicesSchema = {
403
655
  category: z
@@ -480,6 +732,14 @@ export function createVerifiedStreamMcpServer(
480
732
 
481
733
  const now = options.now ?? (() => Math.floor(Date.now() / 1000));
482
734
  const discover = options.discoverServices ?? defaultDiscoverServices;
735
+ const allowUnverifiedOperator =
736
+ options.allowUnverifiedOperatorPairing ?? false;
737
+ if (allowUnverifiedOperator) {
738
+ warnOnce(
739
+ "mcp-unverified-operator",
740
+ `[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.`,
741
+ );
742
+ }
483
743
 
484
744
  const mcpServer = new McpServer(
485
745
  options.serverInfo ?? {
@@ -507,10 +767,17 @@ export function createVerifiedStreamMcpServer(
507
767
  ...(limit === undefined ? {} : { limit }),
508
768
  });
509
769
  return successResult(
510
- `discovered ${listings.length} verified service${listings.length === 1 ? "" : "s"}`,
770
+ `discovered ${listings.length} verified service${listings.length === 1 ? "" : "s"}, priced per verified second`,
511
771
  { services: listings.map(serializeServiceListing) },
512
772
  );
513
773
  } catch (error) {
774
+ if (error instanceof RegistryUnavailableError) {
775
+ // Loud, not silent: discovery never degrades to stale local data, so
776
+ // "the registry is down" must not read as "there are no services".
777
+ return errorResult(
778
+ "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.",
779
+ );
780
+ }
514
781
  return safeToolError(error);
515
782
  }
516
783
  },
@@ -542,6 +809,64 @@ export function createVerifiedStreamMcpServer(
542
809
  args.durationSeconds ?? signedMandate.mandate.maxDurationSeconds,
543
810
  } satisfies Parameters<typeof checkMandate>[1];
544
811
 
812
+ // Does this operator actually own this serviceRef?
813
+ //
814
+ // The two arrive as independent tool arguments, and `operator` is the
815
+ // wallet that gets paid. An LLM that pairs a real serviceRef with an
816
+ // attacker's payout address escrows the buyer's USDC to a stranger, and
817
+ // nothing downstream catches it: the escrow does not know who owns a
818
+ // serviceRef, and the oracle would happily verify the real endpoint
819
+ // while the money accrued elsewhere. Discovery DOES know — it recovers
820
+ // the operator's signature over the record the serviceRef is derived
821
+ // from — so the pairing is checked against it here.
822
+ //
823
+ // A serviceRef that is not listed at all is allowed with a warning
824
+ // (the registry is not the authority on what exists, and refusing would
825
+ // block private or freshly-listed services); a serviceRef that IS
826
+ // listed under a DIFFERENT operator is refused outright.
827
+ const pairing = await checkOperatorPairing(
828
+ discover,
829
+ args.serviceRef as `0x${string}`,
830
+ request.operator,
831
+ );
832
+ if (pairing.verdict !== "verified") {
833
+ // FAIL CLOSED. Anything short of a positive match is a refusal: a
834
+ // registry outage is not evidence that the pairing is sound, and the
835
+ // money moves before any note attached to a success result could be
836
+ // read. The only override is an operator-set environment variable
837
+ // (never a tool argument), and even that never overrides a positive
838
+ // contradiction.
839
+ if (pairing.verdict === "mismatch" || !allowUnverifiedOperator) {
840
+ return errorResult(
841
+ explainPairingRefusal(pairing, args.serviceRef, request.operator),
842
+ );
843
+ }
844
+ }
845
+
846
+ // Is the rate inside the band the operator SIGNED? The escrow accepts
847
+ // any non-zero rate, but the seller's capability gateway answers every
848
+ // request on an out-of-band stream with 403
849
+ // `stream-rate-out-of-bounds`, so such a hire escrows the buyer's USDC
850
+ // into a stream that can never be served. Checked against the verified
851
+ // listing the pairing check just resolved — no second registry read,
852
+ // and no tool argument can widen the band. A descriptor that does not
853
+ // verify is a refusal, never "no band". Only an unverified pairing
854
+ // (which already needed the operator-set override above) has no listing
855
+ // to check, and its success result says so.
856
+ if (pairing.verdict === "verified") {
857
+ const band = await checkListedRateBand(
858
+ async () => [pairing.listing],
859
+ args.serviceRef,
860
+ request.ratePerSecondUsdc,
861
+ "ratePerSecondUsdc",
862
+ );
863
+ if (!band.ok) {
864
+ return errorResult(
865
+ `${band.message} Pick a ratePerSecondUsdc inside the band (discover_services reports it as ratePerSecondBand).`,
866
+ );
867
+ }
868
+ }
869
+
545
870
  // Serialize per owner: the spent-so-far read, cap check, open, and
546
871
  // registry write must be atomic, or concurrent hires could both pass
547
872
  // the cumulative-cap check on stale spend and overshoot maxTotalUsdc.
@@ -587,8 +912,18 @@ export function createVerifiedStreamMcpServer(
587
912
  mandateId: signedMandate.mandateId as `0x${string}`,
588
913
  });
589
914
 
915
+ // The meter starts HERE, not on the first invoke_service call. Say
916
+ // so in the result as well as the tool description: a reviewer who
917
+ // opened a stream, made one call and was billed for 302 seconds read
918
+ // the result, not the docs.
919
+ const maxSpendUsdc =
920
+ request.budgetUsdc <
921
+ request.ratePerSecondUsdc * BigInt(request.maxDurationSeconds)
922
+ ? request.budgetUsdc
923
+ : request.ratePerSecondUsdc * BigInt(request.maxDurationSeconds);
924
+
590
925
  return successResult(
591
- `opened verified stream ${result.streamId} on chain ${result.handle.chainId}`,
926
+ `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.`,
592
927
  {
593
928
  streamId: result.streamId,
594
929
  txHash: result.txHash,
@@ -598,6 +933,15 @@ export function createVerifiedStreamMcpServer(
598
933
  escrow: result.handle.escrow,
599
934
  chainId: result.handle.chainId,
600
935
  },
936
+ operatorPairing: pairing.verdict,
937
+ metering: {
938
+ model: "time-metered" as const,
939
+ ratePerSecondUsdc: request.ratePerSecondUsdc.toString(),
940
+ budgetUsdc: request.budgetUsdc.toString(),
941
+ maxDurationSeconds: request.maxDurationSeconds,
942
+ maxSpendUsdc: maxSpendUsdc.toString(),
943
+ note: METERING_MODEL,
944
+ },
601
945
  },
602
946
  );
603
947
  });
@@ -607,6 +951,77 @@ export function createVerifiedStreamMcpServer(
607
951
  },
608
952
  );
609
953
 
954
+ if (options.serviceInvoker !== undefined) {
955
+ const serviceInvoker = options.serviceInvoker;
956
+ mcpServer.registerTool(
957
+ "invoke_service",
958
+ {
959
+ description: INVOKE_TOOL_SPEC.description,
960
+ inputSchema: invokeServiceSchema,
961
+ annotations: {
962
+ // Not idempotent and not read-only: the call reaches a third party's
963
+ // endpoint, which may do anything. It is NOT destructive in Metrik's
964
+ // own terms — it signs no transaction and moves no USDC.
965
+ destructiveHint: false,
966
+ idempotentHint: false,
967
+ openWorldHint: true,
968
+ readOnlyHint: false,
969
+ title: "Invoke Paid Service",
970
+ },
971
+ },
972
+ async ({ streamId, path, method, headers, body, ttlSeconds }) => {
973
+ const requestPath = path ?? "/";
974
+ // Defence in depth. `resolveInvocationUrl` is the authority — it
975
+ // resolves the path and proves the result did not leave the signed
976
+ // origin — but a tool argument that cannot possibly be a safe path is
977
+ // refused here, with a message the caller can act on, before anything
978
+ // reads the chain. A prefix check ALONE was the bug: the URL parser
979
+ // treats a backslash as a host separator for http(s), so a path
980
+ // beginning `/\\` resolved against a signed origin points somewhere
981
+ // else entirely while passing a `//` test.
982
+ if (!requestPath.startsWith("/") || requestPath.startsWith("//")) {
983
+ return errorResult(
984
+ `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.`,
985
+ );
986
+ }
987
+ if (hasForbiddenPathCharacter(requestPath)) {
988
+ return errorResult(
989
+ `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.`,
990
+ );
991
+ }
992
+ const requestHeaders = normalizeHeaders(headers);
993
+ if (CAPABILITY_HEADER_NAME in requestHeaders) {
994
+ return errorResult(
995
+ `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.`,
996
+ );
997
+ }
998
+ if (
999
+ body !== undefined &&
1000
+ requestHeaders["content-type"] === undefined
1001
+ ) {
1002
+ requestHeaders["content-type"] = "application/json";
1003
+ }
1004
+
1005
+ try {
1006
+ const result = await serviceInvoker.invokeService({
1007
+ streamId: streamId as `0x${string}`,
1008
+ method: method ?? "GET",
1009
+ path: requestPath,
1010
+ headers: requestHeaders,
1011
+ ...(body === undefined ? {} : { body }),
1012
+ ...(ttlSeconds === undefined ? {} : { ttlSeconds }),
1013
+ });
1014
+ return serializeInvocation(result);
1015
+ } catch (error) {
1016
+ const explained = explainInvokeFailure(error, streamId);
1017
+ return explained === null
1018
+ ? safeToolError(error)
1019
+ : errorResult(explained);
1020
+ }
1021
+ },
1022
+ );
1023
+ }
1024
+
610
1025
  mcpServer.registerTool(
611
1026
  "check_stream_status",
612
1027
  {
@@ -624,8 +1039,18 @@ export function createVerifiedStreamMcpServer(
624
1039
  const status = await options.agentClient.getStreamStatus(
625
1040
  streamId as `0x${string}`,
626
1041
  );
1042
+ if (status.stream.buyer === ZERO_ADDRESS) {
1043
+ // An unset slot decodes as an all-zero struct rather than reverting,
1044
+ // which otherwise surfaces as a confident "the stream is closed" for
1045
+ // a stream that never existed. A real stream always has a buyer.
1046
+ return errorResult(
1047
+ `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.`,
1048
+ );
1049
+ }
627
1050
  return successResult(
628
- `stream ${streamId} is ${status.stream.status}`,
1051
+ status.stream.status === "active"
1052
+ ? `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`
1053
+ : `stream ${streamId} is ${status.stream.status}`,
629
1054
  serializeStreamStatus(status, streamId as `0x${string}`),
630
1055
  );
631
1056
  } catch (error) {
@@ -649,10 +1074,14 @@ export function createVerifiedStreamMcpServer(
649
1074
  },
650
1075
  async ({ streamId, closeFirst }) => {
651
1076
  try {
1077
+ // The local registry is a CONVENIENCE, not an authority. It used to be
1078
+ // required here, which meant a restarted server with in-memory state
1079
+ // could no longer return the buyer's own money through this tool — a
1080
+ // local bookkeeping gap standing between a buyer and their funds. The
1081
+ // escrow itself enforces that only the buyer may close and reclaim, so
1082
+ // proceeding without a record is safe and strictly buyer-favouring; an
1083
+ // id that is not this wallet's stream simply reverts on chain.
652
1084
  const record = await registry.get(streamId as `0x${string}`);
653
- if (record === null) {
654
- return errorResult(`unknown stream: ${streamId}`);
655
- }
656
1085
  const signedMandate = await resolveMandate();
657
1086
 
658
1087
  // Reclaim/close only RETURN unspent funds to the buyer or stop payment.
@@ -667,10 +1096,14 @@ export function createVerifiedStreamMcpServer(
667
1096
  ...(closeFirst === undefined ? {} : { closeFirst }),
668
1097
  });
669
1098
 
670
- return successResult(`reclaimed stream ${streamId}`, {
671
- closeTxHash: result.closeResult?.txHash ?? null,
672
- reclaimTxHash: result.reclaimResult.txHash,
673
- });
1099
+ return successResult(
1100
+ `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)" : ""}`,
1101
+ {
1102
+ closeTxHash: result.closeResult?.txHash ?? null,
1103
+ reclaimTxHash: result.reclaimResult.txHash,
1104
+ tracked: record !== null,
1105
+ },
1106
+ );
674
1107
  } catch (error) {
675
1108
  return safeToolError(error);
676
1109
  }
@@ -693,13 +1126,24 @@ export function createVerifiedStreamMcpServer(
693
1126
  const records = await registry.list();
694
1127
  const entries = await Promise.all(
695
1128
  records.map(async (record) => {
696
- const status = await options.agentClient
697
- .getStreamStatus(record.streamId)
698
- .then((value) => serializeStreamStatus(value, record.streamId))
699
- .catch((error) => ({
700
- streamId: record.streamId,
701
- statusError: asErrorMessage(error),
702
- }));
1129
+ // A reservation key is not an escrow stream: report it as what it
1130
+ // is instead of reading a stream that does not exist.
1131
+ const status =
1132
+ record.x402Reservation === true
1133
+ ? {
1134
+ streamId: record.streamId,
1135
+ x402Reservation:
1136
+ "an x402 payment was signed but its stream could not be confirmed; its amount stays counted against the spend mandate",
1137
+ }
1138
+ : await options.agentClient
1139
+ .getStreamStatus(record.streamId)
1140
+ .then((value) =>
1141
+ serializeStreamStatus(value, record.streamId),
1142
+ )
1143
+ .catch((error) => ({
1144
+ streamId: record.streamId,
1145
+ statusError: asErrorMessage(error),
1146
+ }));
703
1147
  return {
704
1148
  streamId: record.streamId,
705
1149
  buyer: record.buyer,
@@ -725,6 +1169,34 @@ export function createVerifiedStreamMcpServer(
725
1169
  },
726
1170
  );
727
1171
 
1172
+ if (options.x402 !== undefined) {
1173
+ registerX402Tools(mcpServer, {
1174
+ x402: options.x402,
1175
+ resolveMandate,
1176
+ getSpentSoFarUsdc: (owner) => resolver.getSpentSoFarUsdc(owner),
1177
+ getRevokedMandateIds: (owner) => resolver.getRevokedMandateIds(owner),
1178
+ // The SAME per-owner chain as hire_verified_service: a hire and an x402
1179
+ // payment from one owner can never both pass the cumulative cap on
1180
+ // stale spend.
1181
+ serialize: serializeHire,
1182
+ recordStream: async (record) => {
1183
+ await registry.put(record);
1184
+ },
1185
+ ...(registry.delete === undefined
1186
+ ? {}
1187
+ : {
1188
+ removeStream: async (streamId: `0x${string}`) => {
1189
+ await registry.delete?.(streamId);
1190
+ },
1191
+ }),
1192
+ findStream: async (streamId) => await registry.get(streamId),
1193
+ now,
1194
+ ok: successResult,
1195
+ fail: errorResult,
1196
+ unexpected: safeToolError,
1197
+ });
1198
+ }
1199
+
728
1200
  if (options.deliveryProofService !== undefined) {
729
1201
  const deliveryProofService = options.deliveryProofService;
730
1202
  mcpServer.registerTool(
@@ -757,6 +1229,46 @@ export function createVerifiedStreamMcpServer(
757
1229
  return errorResult(`unknown stream: ${streamId}`);
758
1230
  }
759
1231
 
1232
+ // Bind the PROOF TARGET to the stream's verified listing.
1233
+ //
1234
+ // Without this the tool would zkTLS-prove any URL an LLM named and
1235
+ // stamp it with this stream's delivery binding (owner, streamId,
1236
+ // operator, serviceRef) — a proof of someone else's endpoint,
1237
+ // labelled as this stream's delivery. The origin has to come from the
1238
+ // operator's signed record for the same reason invoke_service's does.
1239
+ const listings = await discover().catch((error: unknown) => {
1240
+ throw new ProveTargetError(
1241
+ error instanceof RegistryUnavailableError
1242
+ ? "the service registry is unreachable, so this stream's signed service origin could not be verified. No proof was attempted."
1243
+ : `the service registry could not be read: ${asErrorMessage(error)}`,
1244
+ );
1245
+ });
1246
+ const listing = listings.find(
1247
+ (entry) =>
1248
+ entry.serviceRef.toLowerCase() ===
1249
+ record.serviceRef.toLowerCase(),
1250
+ );
1251
+ if (listing === undefined) {
1252
+ return errorResult(
1253
+ `no verified listing matches this stream's serviceRef (${record.serviceRef}), so there is no signed origin to prove against. Run discover_services.`,
1254
+ );
1255
+ }
1256
+ if (listing.access === "gated") {
1257
+ // Not a degradation we can paper over. The Reclaim attestor fetches
1258
+ // the URL itself, from its own network position, and cannot present
1259
+ // this stream's single-use caller-auth capability — so the only
1260
+ // thing it could prove about a gated origin is its own 403.
1261
+ return errorResult(
1262
+ `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.`,
1263
+ );
1264
+ }
1265
+ const signedOrigin = safeOrigin(listing.accessUrl);
1266
+ if (safeOrigin(url) !== signedOrigin) {
1267
+ return errorResult(
1268
+ `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.`,
1269
+ );
1270
+ }
1271
+
760
1272
  const result = await deliveryProofService.proveConsumedHttpsResponse({
761
1273
  url,
762
1274
  ...(method === undefined ? {} : { method }),
@@ -781,6 +1293,9 @@ export function createVerifiedStreamMcpServer(
781
1293
  serializeConsumedResponseProof(record.streamId, result),
782
1294
  );
783
1295
  } catch (error) {
1296
+ if (error instanceof ProveTargetError) {
1297
+ return errorResult(error.message);
1298
+ }
784
1299
  return safeToolError(error);
785
1300
  }
786
1301
  },
@@ -823,28 +1338,100 @@ export function parseMetrikAgentEnv(
823
1338
  // so that array was the ONLY thing an operator ever saw — a correct failure
824
1339
  // rendered as noise. Name the variables instead.
825
1340
  throw new Error(
826
- `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}).`,
1341
+ `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}).`,
827
1342
  );
828
1343
  }
829
1344
  const parsed = result.data;
830
- const chainId = Number(parsed.METRIK_AGENT_CHAIN_ID ?? baseSepolia.id);
831
- if (!Number.isInteger(chainId) || chainId <= 0) {
832
- throw new Error("METRIK_AGENT_CHAIN_ID must be a positive integer");
1345
+ let chainId: number | undefined;
1346
+ if (parsed.METRIK_AGENT_CHAIN_ID !== undefined) {
1347
+ chainId = Number(parsed.METRIK_AGENT_CHAIN_ID);
1348
+ if (!Number.isInteger(chainId) || chainId <= 0) {
1349
+ throw new Error("METRIK_AGENT_CHAIN_ID must be a positive integer");
1350
+ }
833
1351
  }
834
- if (chainId !== baseSepolia.id) {
1352
+ // "Is this a network the registry knows and the build allows": a registered
1353
+ // TESTNET. This server signs with a key from the environment and moves funds.
1354
+ let network: MetrikNetwork;
1355
+ try {
1356
+ network = resolveAgentNetwork(
1357
+ parsed.METRIK_NETWORK !== undefined &&
1358
+ parsed.METRIK_NETWORK.trim().length > 0
1359
+ ? parsed.METRIK_NETWORK
1360
+ : chainId,
1361
+ { testnetOnly: true },
1362
+ );
1363
+ } catch (error) {
835
1364
  throw new Error(
836
- `unsupported METRIK_AGENT_CHAIN_ID ${chainId}; phase 2 is Base Sepolia only`,
1365
+ `unsupported METRIK_NETWORK / METRIK_AGENT_CHAIN_ID: ${error instanceof Error ? error.message : String(error)}`,
837
1366
  );
838
1367
  }
1368
+ if (chainId !== undefined && chainId !== network.chain.id) {
1369
+ throw new Error(
1370
+ `METRIK_AGENT_CHAIN_ID ${chainId} does not match METRIK_NETWORK ${network.id} (chain ${network.chain.id})`,
1371
+ );
1372
+ }
1373
+
1374
+ // Generation: a registered deployment's comes from the registry (a
1375
+ // contradicting env value is refused); an unregistered escrow needs it
1376
+ // DECLARED. Only the checkpoint-settled generations (v2, v3) are served.
1377
+ const declared = parsed.METRIK_AGENT_ESCROW_GENERATION?.trim().toLowerCase();
1378
+ if (declared !== undefined && declared !== "v2" && declared !== "v3") {
1379
+ throw new Error(
1380
+ `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.`,
1381
+ );
1382
+ }
1383
+ const deployment = registeredDeployment(network, parsed.METRIK_AGENT_ESCROW);
1384
+ let generation: EscrowGeneration;
1385
+ if (deployment !== undefined) {
1386
+ generation = deployment.escrow.generation;
1387
+ if (declared !== undefined && declared !== generation) {
1388
+ throw new Error(
1389
+ `METRIK_AGENT_ESCROW_GENERATION "${declared}" contradicts the registry: ${parsed.METRIK_AGENT_ESCROW} is ${network.name}'s ${generation} escrow`,
1390
+ );
1391
+ }
1392
+ } else {
1393
+ if (parsed.METRIK_AGENT_ALLOW_UNREGISTERED_ESCROW !== "true") {
1394
+ throw new Error(
1395
+ `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.`,
1396
+ );
1397
+ }
1398
+ if (declared === undefined) {
1399
+ throw new Error(
1400
+ `${ALLOW_UNREGISTERED_ESCROW_ENV}=true also needs METRIK_AGENT_ESCROW_GENERATION ("v2" | "v3"): an unregistered escrow's generation cannot be read from the registry`,
1401
+ );
1402
+ }
1403
+ generation = declared;
1404
+ }
839
1405
 
840
1406
  return {
841
1407
  rpcUrl: parsed.METRIK_AGENT_RPC_URL,
842
1408
  escrow: parsed.METRIK_AGENT_ESCROW as `0x${string}`,
1409
+ escrowGeneration: generation,
1410
+ unregisteredEscrow: deployment === undefined,
1411
+ // Opt-IN only: any value but the exact string "true" leaves the check on.
1412
+ allowUnverifiedOperatorPairing:
1413
+ parsed.METRIK_AGENT_ALLOW_UNVERIFIED_OPERATOR === "true",
1414
+ ...(parsed.METRIK_AGENT_REGISTRY_URL === undefined
1415
+ ? {}
1416
+ : { registryUrl: parsed.METRIK_AGENT_REGISTRY_URL }),
1417
+ ...(parsed.METRIK_AGENT_REGISTRY_APIKEY === undefined
1418
+ ? {}
1419
+ : { registryApiKey: parsed.METRIK_AGENT_REGISTRY_APIKEY }),
843
1420
  usdc: parsed.METRIK_AGENT_USDC as `0x${string}`,
844
1421
  chain: {
845
- ...baseSepolia,
846
- name: parsed.METRIK_AGENT_CHAIN_NAME ?? baseSepolia.name,
1422
+ ...network.chain,
1423
+ name: parsed.METRIK_AGENT_CHAIN_NAME ?? network.chain.name,
847
1424
  },
1425
+ network,
1426
+ // Opt-IN: a fund-moving tool family whose URL the MODEL picks is never
1427
+ // switched on by default or by an unrecognised value.
1428
+ x402Enabled: ["1", "true", "yes", "on"].includes(
1429
+ (parsed.METRIK_AGENT_X402 ?? "").trim().toLowerCase(),
1430
+ ),
1431
+ x402AllowedHosts: (parsed.METRIK_AGENT_X402_ALLOWED_HOSTS ?? "")
1432
+ .split(",")
1433
+ .map((entry) => entry.trim())
1434
+ .filter((entry) => entry.length > 0),
848
1435
  ...(parsed.METRIK_AGENT_MCP_STATE_FILE === undefined
849
1436
  ? {}
850
1437
  : {
@@ -884,11 +1471,21 @@ export async function createVerifiedStreamMcpServerOptionsFromEnv(
884
1471
  chain: parsed.chain,
885
1472
  transport: http(parsed.rpcUrl),
886
1473
  escrow: parsed.escrow,
887
- // Declare the escrow as V2 as well. Without this the SDK treats the
888
- // client as V1 and routes claimable()/reclaimable() to the legacy ABI,
889
- // which reverts against StreamEscrowV2 — the checkpoint escrow replaced
890
- // those one-argument views with proof-carrying ones.
1474
+ // Select the checkpoint decoder, on the strength of the DECLARED
1475
+ // generation (`parsed.escrowGeneration`, validated in parseMetrikAgentEnv
1476
+ // — which also refuses the retired V1 address outright). Without this the
1477
+ // SDK treats the client as V1 and routes claimable()/reclaimable() to the
1478
+ // legacy ABI, which reverts against StreamEscrowV2: the checkpoint escrow
1479
+ // replaced those one-argument views with proof-carrying ones.
891
1480
  escrowV2: parsed.escrow,
1481
+ // An unregistered escrow was opted into by the operator: the SDK skips
1482
+ // its code-hash pin only with both flags (see parseMetrikAgentEnv).
1483
+ ...(parsed.unregisteredEscrow
1484
+ ? {
1485
+ allowUnregisteredEscrow: true,
1486
+ escrowGeneration: parsed.escrowGeneration,
1487
+ }
1488
+ : {}),
892
1489
  usdc: parsed.usdc,
893
1490
  settlementTarget: {
894
1491
  chainId: parsed.chain.id,
@@ -919,6 +1516,34 @@ export async function createVerifiedStreamMcpServerOptionsFromEnv(
919
1516
  return cachedMandate;
920
1517
  };
921
1518
 
1519
+ // The "use" half. `invoke()` needs a signer that can produce an EIP-712
1520
+ // signature; every wallet this package resolves can, but only a raw key is a
1521
+ // viem LocalAccount by type — hence the structural check rather than a cast.
1522
+ const serviceInvoker = createServiceInvoker({
1523
+ streamReader: createSdkInvokeStreamReader({
1524
+ escrowAddress: parsed.escrow,
1525
+ rpcUrl: parsed.rpcUrl,
1526
+ chainId: parsed.chain.id,
1527
+ ...(parsed.unregisteredEscrow
1528
+ ? { unregisteredEscrowGeneration: parsed.escrowGeneration }
1529
+ : {}),
1530
+ }),
1531
+ buyer: requireCapabilitySigner(wallet.account),
1532
+ domain: { chainId, verifyingContract: parsed.escrow },
1533
+ discoverServices: defaultDiscoverServices,
1534
+ discoverOptions: {
1535
+ ...(parsed.registryUrl === undefined
1536
+ ? {}
1537
+ : { registryUrl: parsed.registryUrl }),
1538
+ ...(parsed.registryApiKey === undefined
1539
+ ? {}
1540
+ : { apiKey: parsed.registryApiKey }),
1541
+ // stderr, never stdout: stdout is the MCP stdio transport channel, and
1542
+ // discovery warns about every listing it DROPS for a bad signature.
1543
+ logger: { warn: (...parts) => console.error(...parts) },
1544
+ },
1545
+ });
1546
+
922
1547
  return {
923
1548
  agentClient,
924
1549
  signedMandate,
@@ -927,10 +1552,62 @@ export async function createVerifiedStreamMcpServerOptionsFromEnv(
927
1552
  parsed.stateFile === undefined
928
1553
  ? new InMemoryStreamRegistry()
929
1554
  : new FileStreamRegistry(parsed.stateFile),
1555
+ allowUnverifiedOperatorPairing: parsed.allowUnverifiedOperatorPairing,
1556
+ serviceInvoker,
1557
+ ...(parsed.x402Enabled
1558
+ ? {
1559
+ x402: {
1560
+ signer: requireCapabilitySigner(
1561
+ wallet.account,
1562
+ ) as unknown as McpX402Options["signer"],
1563
+ network: parsed.network,
1564
+ // The SDK's on-chain quote checks (factory.predict) read through
1565
+ // this server's own RPC.
1566
+ sdkOptions: { transport: http(parsed.rpcUrl) },
1567
+ ...(parsed.x402AllowedHosts.length === 0
1568
+ ? {}
1569
+ : { allowedHosts: parsed.x402AllowedHosts }),
1570
+ refundClient: async (target) => {
1571
+ const { refundClient } = await import("@absol-labs/sdk/x402");
1572
+ return refundClient(target.refundUrl, {
1573
+ streamId: target.streamId,
1574
+ ...(target.opener === undefined
1575
+ ? {}
1576
+ : { opener: target.opener }),
1577
+ ...(target.fetch === undefined ? {} : { fetch: target.fetch }),
1578
+ // Every relay "success" is checked against the receipt.
1579
+ verifyWith: readOnlyMetrikClient({
1580
+ escrowAddress: parsed.escrow,
1581
+ rpcUrl: parsed.rpcUrl,
1582
+ chainId: parsed.chain.id,
1583
+ }),
1584
+ expectedChainId: parsed.chain.id,
1585
+ });
1586
+ },
1587
+ } satisfies McpX402Options,
1588
+ }
1589
+ : {}),
930
1590
  ...(deliveryProofService === null ? {} : { deliveryProofService }),
931
1591
  };
932
1592
  }
933
1593
 
1594
+ /**
1595
+ * Narrows a resolved wallet account to something that can sign a capability.
1596
+ *
1597
+ * Fails closed at STARTUP with a named cause rather than at the first
1598
+ * `invoke_service` call with a `TypeError`.
1599
+ */
1600
+ function requireCapabilitySigner(
1601
+ account: ResolvedAgentWallet["account"],
1602
+ ): InvocationCapabilitySigner {
1603
+ if (typeof account.signTypedData !== "function") {
1604
+ throw new Error(
1605
+ `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).`,
1606
+ );
1607
+ }
1608
+ return account as InvocationCapabilitySigner;
1609
+ }
1610
+
934
1611
  export async function createVerifiedStreamMcpServerFromEnv(
935
1612
  env: NodeJS.ProcessEnv = process.env,
936
1613
  ): Promise<VerifiedStreamMcpServerRuntime> {
@@ -959,6 +1636,42 @@ function serializeServiceListing(
959
1636
  ...(listing.targetMetadata === undefined
960
1637
  ? {}
961
1638
  : { targetMetadata: listing.targetMetadata }),
1639
+ ...signedRateBandField(listing),
1640
+ };
1641
+ }
1642
+
1643
+ /**
1644
+ * The operator-signed rate band, in USDC atomic units per second, so an agent
1645
+ * can pick a `ratePerSecondUsdc` that hire_verified_service will accept. Read
1646
+ * from the record discovery already verified; omitted when none was signed.
1647
+ * hire_verified_service re-verifies it before enforcing it.
1648
+ */
1649
+ function signedRateBandField(listing: ServiceListing): {
1650
+ readonly ratePerSecondBand?: Record<string, string | null>;
1651
+ } {
1652
+ const commercial = (
1653
+ listing.signed as
1654
+ | {
1655
+ readonly descriptor?: {
1656
+ readonly commercial?: {
1657
+ readonly minRatePerSecond?: string;
1658
+ readonly maxRatePerSecond?: string;
1659
+ };
1660
+ };
1661
+ }
1662
+ | undefined
1663
+ )?.descriptor?.commercial;
1664
+ if (
1665
+ commercial?.minRatePerSecond === undefined &&
1666
+ commercial?.maxRatePerSecond === undefined
1667
+ ) {
1668
+ return {};
1669
+ }
1670
+ return {
1671
+ ratePerSecondBand: {
1672
+ minRatePerSecondUsdc: commercial.minRatePerSecond ?? null,
1673
+ maxRatePerSecondUsdc: commercial.maxRatePerSecond ?? null,
1674
+ },
962
1675
  };
963
1676
  }
964
1677
 
@@ -1000,6 +1713,11 @@ function serializeStreamStatus(
1000
1713
  },
1001
1714
  claimable: status.claimable.toString(),
1002
1715
  reclaimable: status.reclaimable.toString(),
1716
+ metering: {
1717
+ model: "time-metered" as const,
1718
+ ratePerSecondUsdc: status.stream.ratePerSecond.toString(),
1719
+ note: METERING_MODEL,
1720
+ },
1003
1721
  };
1004
1722
  }
1005
1723
 
@@ -1019,6 +1737,217 @@ function serializeConsumedResponseProof(
1019
1737
  };
1020
1738
  }
1021
1739
 
1740
+ /**
1741
+ * True when a path carries a character that must never appear in one.
1742
+ *
1743
+ * Mirrors `assertOriginRelativePath` in `../sdk/invoke.ts`. The backslash is the
1744
+ * one that matters: the URL parser reads it as a host separator for http(s), so
1745
+ * it turns an apparently origin-relative path into a different host. C0
1746
+ * controls and DEL go with it — a header- or log-splitting character has no
1747
+ * business in a path either.
1748
+ */
1749
+ function hasForbiddenPathCharacter(value: string): boolean {
1750
+ for (const character of value) {
1751
+ const code = character.codePointAt(0) ?? 0;
1752
+ if (character === "\\" || code < 0x20 || code === 0x7f) {
1753
+ return true;
1754
+ }
1755
+ }
1756
+ return false;
1757
+ }
1758
+
1759
+ /** Lower-cases header names so duplicate/odd-cased keys collapse to one. */
1760
+ type OperatorPairing =
1761
+ | { readonly verdict: "verified"; readonly listing: ServiceListing }
1762
+ | { readonly verdict: "mismatch"; readonly listedOperator: `0x${string}` }
1763
+ | { readonly verdict: "unlisted" }
1764
+ | {
1765
+ readonly verdict: "registry-unavailable" | "registry-error";
1766
+ readonly detail: string;
1767
+ };
1768
+
1769
+ /**
1770
+ * Checks that `operator` — the wallet that gets paid — is the one the
1771
+ * registry's operator-signed record for `serviceRef` names.
1772
+ *
1773
+ * This reports; it does not decide. Every outcome other than `verified` is
1774
+ * treated as a refusal by the hire path (see ALLOW_UNVERIFIED_OPERATOR_ENV).
1775
+ * An earlier version failed OPEN on anything but a positive contradiction,
1776
+ * which meant that during any registry read failure the exact bug this check
1777
+ * exists to stop was fully live: an attacker's `operator` paired with a genuine
1778
+ * `serviceRef`, the oracle verifying the real endpoint, and accrual settling to
1779
+ * the attacker. Settlement does not depend on this HTTP endpoint being
1780
+ * reachable from the MCP host, so a read failure is no evidence of safety.
1781
+ *
1782
+ * A hostile registry cannot forge a match — the operator's signature over the
1783
+ * record the serviceRef is derived from is recovered client-side — but it CAN
1784
+ * suppress a row, which is exactly why `unlisted` must not be a soft verdict.
1785
+ *
1786
+ * An outage is reported distinctly from a malformed/failed read: "retry in a
1787
+ * moment" and "something is wrong with this registry" are different next steps.
1788
+ */
1789
+ async function checkOperatorPairing(
1790
+ discover: DiscoverServicesFn,
1791
+ serviceRef: `0x${string}`,
1792
+ operator: `0x${string}`,
1793
+ ): Promise<OperatorPairing> {
1794
+ let listings: readonly ServiceListing[];
1795
+ try {
1796
+ listings = await discover();
1797
+ } catch (error) {
1798
+ return error instanceof RegistryUnavailableError
1799
+ ? { verdict: "registry-unavailable", detail: asErrorMessage(error) }
1800
+ : { verdict: "registry-error", detail: asErrorMessage(error) };
1801
+ }
1802
+ const listing = listings.find(
1803
+ (entry) => entry.serviceRef.toLowerCase() === serviceRef.toLowerCase(),
1804
+ );
1805
+ if (listing === undefined) {
1806
+ return { verdict: "unlisted" };
1807
+ }
1808
+ if (listing.operator.toLowerCase() !== operator.toLowerCase()) {
1809
+ return { verdict: "mismatch", listedOperator: listing.operator };
1810
+ }
1811
+ return { verdict: "verified", listing };
1812
+ }
1813
+
1814
+ /**
1815
+ * Why a hire was refused, in words the caller can act on.
1816
+ *
1817
+ * Refusing strands nothing: no USDC has moved, and the same call succeeds once
1818
+ * the registry answers again.
1819
+ */
1820
+ function explainPairingRefusal(
1821
+ pairing: Exclude<OperatorPairing, { verdict: "verified" }>,
1822
+ serviceRef: string,
1823
+ operator: string,
1824
+ ): string {
1825
+ switch (pairing.verdict) {
1826
+ case "mismatch":
1827
+ 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.`;
1828
+ case "unlisted":
1829
+ 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.)`;
1830
+ case "registry-unavailable":
1831
+ 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.`;
1832
+ case "registry-error":
1833
+ 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.`;
1834
+ }
1835
+ }
1836
+
1837
+ /** A prove-target precondition that failed with a caller-actionable reason. */
1838
+ class ProveTargetError extends Error {
1839
+ constructor(message: string) {
1840
+ super(message);
1841
+ this.name = "ProveTargetError";
1842
+ }
1843
+ }
1844
+
1845
+ /** Origin of a URL, or the raw value when it will not parse. */
1846
+ function safeOrigin(value: string): string {
1847
+ try {
1848
+ return new URL(value).origin;
1849
+ } catch {
1850
+ return value;
1851
+ }
1852
+ }
1853
+
1854
+ function normalizeHeaders(
1855
+ headers: Record<string, string> | undefined,
1856
+ ): Record<string, string> {
1857
+ const normalized: Record<string, string> = {};
1858
+ for (const [name, value] of Object.entries(headers ?? {})) {
1859
+ normalized[name.toLowerCase()] = value;
1860
+ }
1861
+ return normalized;
1862
+ }
1863
+
1864
+ /**
1865
+ * Renders an invocation as an MCP result.
1866
+ *
1867
+ * A non-2xx answer is returned as `isError` WITH the full structured content:
1868
+ * the call really happened and its body may be the thing the agent needs, but a
1869
+ * buyer who paid and got a 403 should not have to infer that from a status
1870
+ * field. Gateway denials carry their machine-readable reason, because
1871
+ * `stream-expired` and `nonce-replayed` call for opposite responses.
1872
+ */
1873
+ function serializeInvocation(result: ServiceInvocationResult) {
1874
+ const summary = `${result.method} ${result.path} -> ${result.status}`;
1875
+ if (result.ok) {
1876
+ return {
1877
+ content: [{ type: "text" as const, text: summary }],
1878
+ structuredContent: { ...result },
1879
+ };
1880
+ }
1881
+ const because =
1882
+ result.gatewayDenialReason === null
1883
+ ? `the service itself answered ${result.status}; the capability was accepted`
1884
+ : `the seller's caller-auth gateway REFUSED the call: ${result.gatewayDenialReason}${gatewayDenialHint(result.gatewayDenialReason)}`;
1885
+ return {
1886
+ content: [{ type: "text" as const, text: `${summary} — ${because}` }],
1887
+ structuredContent: { ...result },
1888
+ isError: true,
1889
+ };
1890
+ }
1891
+
1892
+ /** One actionable next step per gateway denial reason. */
1893
+ function gatewayDenialHint(reason: string): string {
1894
+ switch (reason) {
1895
+ case "stream-not-funded":
1896
+ return " — the stream's remaining budget is exhausted; open a new one with hire_verified_service.";
1897
+ case "stream-expired":
1898
+ return " — the stream is past its expiry; reclaim_unspent returns the unspent budget.";
1899
+ case "stream-not-active":
1900
+ return " — the stream is closed; open a new one with hire_verified_service.";
1901
+ case "capability-expired":
1902
+ return " — the signed capability outlived its TTL before the gateway saw it; retry (optionally with a larger ttlSeconds).";
1903
+ case "nonce-replayed":
1904
+ return " — each capability is single-use; retry to mint a fresh one.";
1905
+ case "stream-state-stale":
1906
+ return " — the gateway's view of the chain is too stale to authorize; retry shortly.";
1907
+ case "buyer-mismatch":
1908
+ return " — the stream does not belong to this server's wallet.";
1909
+ default:
1910
+ return "";
1911
+ }
1912
+ }
1913
+
1914
+ /**
1915
+ * Turns an invocation failure into something an LLM can act on, or `null` when
1916
+ * it is not a failure this layer understands (those fall through to the generic
1917
+ * handler, which logs the detail server-side and says nothing more).
1918
+ *
1919
+ * The generic "the tool call failed" is correct for an unexpected RPC/viem
1920
+ * error — it can carry internal detail — but it is useless for the three things
1921
+ * that actually go wrong here: wrong wallet, dead stream, unlisted service.
1922
+ */
1923
+ function explainInvokeFailure(error: unknown, streamId: string): string | null {
1924
+ if (error instanceof InvokeBuyerMismatchError) {
1925
+ 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.`;
1926
+ }
1927
+ if (error instanceof InvokeStreamNotActiveError) {
1928
+ return `stream ${streamId} is closed, so it authorizes nothing. Open a new one with hire_verified_service.`;
1929
+ }
1930
+ if (error instanceof InvokeStreamExpiredError) {
1931
+ return `stream ${streamId} has expired, so it authorizes nothing. reclaim_unspent returns the unspent budget; hire_verified_service opens a replacement.`;
1932
+ }
1933
+ if (error instanceof ServiceListingNotFoundError) {
1934
+ 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.`;
1935
+ }
1936
+ if (error instanceof InvokeServiceRefMismatchError) {
1937
+ 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.`;
1938
+ }
1939
+ if (error instanceof InvokeAccessUrlError) {
1940
+ 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.`;
1941
+ }
1942
+ if (error instanceof InvokePathError) {
1943
+ return `invalid request path: ${error.message}`;
1944
+ }
1945
+ if (error instanceof RegistryUnavailableError) {
1946
+ 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.`;
1947
+ }
1948
+ return null;
1949
+ }
1950
+
1022
1951
  function successResult(
1023
1952
  text: string,
1024
1953
  structuredContent?: Record<string, unknown>,