@absol-labs/agent 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +184 -26
- package/dist/capability/invocation-capability.d.ts +18 -6
- package/dist/capability/invocation-capability.d.ts.map +1 -1
- package/dist/capability/invocation-capability.js.map +1 -1
- package/dist/cli/amounts.d.ts +26 -0
- package/dist/cli/amounts.d.ts.map +1 -0
- package/dist/cli/amounts.js +71 -0
- package/dist/cli/amounts.js.map +1 -0
- package/dist/cli/args.d.ts +32 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/args.js +119 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/attesters.d.ts +51 -0
- package/dist/cli/attesters.d.ts.map +1 -0
- package/dist/cli/attesters.js +58 -0
- package/dist/cli/attesters.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +5 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/commands/claim.d.ts +13 -0
- package/dist/cli/commands/claim.d.ts.map +1 -0
- package/dist/cli/commands/claim.js +102 -0
- package/dist/cli/commands/claim.js.map +1 -0
- package/dist/cli/commands/close.d.ts +3 -0
- package/dist/cli/commands/close.d.ts.map +1 -0
- package/dist/cli/commands/close.js +55 -0
- package/dist/cli/commands/close.js.map +1 -0
- package/dist/cli/commands/discover.d.ts +3 -0
- package/dist/cli/commands/discover.d.ts.map +1 -0
- package/dist/cli/commands/discover.js +183 -0
- package/dist/cli/commands/discover.js.map +1 -0
- package/dist/cli/commands/invoke.d.ts +3 -0
- package/dist/cli/commands/invoke.d.ts.map +1 -0
- package/dist/cli/commands/invoke.js +142 -0
- package/dist/cli/commands/invoke.js.map +1 -0
- package/dist/cli/commands/mcp.d.ts +3 -0
- package/dist/cli/commands/mcp.d.ts.map +1 -0
- package/dist/cli/commands/mcp.js +113 -0
- package/dist/cli/commands/mcp.js.map +1 -0
- package/dist/cli/commands/open.d.ts +3 -0
- package/dist/cli/commands/open.d.ts.map +1 -0
- package/dist/cli/commands/open.js +331 -0
- package/dist/cli/commands/open.js.map +1 -0
- package/dist/cli/commands/reclaim-unverified.d.ts +3 -0
- package/dist/cli/commands/reclaim-unverified.d.ts.map +1 -0
- package/dist/cli/commands/reclaim-unverified.js +172 -0
- package/dist/cli/commands/reclaim-unverified.js.map +1 -0
- package/dist/cli/commands/reclaim.d.ts +33 -0
- package/dist/cli/commands/reclaim.d.ts.map +1 -0
- package/dist/cli/commands/reclaim.js +308 -0
- package/dist/cli/commands/reclaim.js.map +1 -0
- package/dist/cli/commands/status.d.ts +3 -0
- package/dist/cli/commands/status.d.ts.map +1 -0
- package/dist/cli/commands/status.js +43 -0
- package/dist/cli/commands/status.js.map +1 -0
- package/dist/cli/commands/streams.d.ts +3 -0
- package/dist/cli/commands/streams.d.ts.map +1 -0
- package/dist/cli/commands/streams.js +257 -0
- package/dist/cli/commands/streams.js.map +1 -0
- package/dist/cli/commands/watch.d.ts +3 -0
- package/dist/cli/commands/watch.d.ts.map +1 -0
- package/dist/cli/commands/watch.js +55 -0
- package/dist/cli/commands/watch.js.map +1 -0
- package/dist/cli/config.d.ts +61 -0
- package/dist/cli/config.d.ts.map +1 -0
- package/dist/cli/config.js +140 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/context.d.ts +29 -0
- package/dist/cli/context.d.ts.map +1 -0
- package/dist/cli/context.js +2 -0
- package/dist/cli/context.js.map +1 -0
- package/dist/cli/deps.d.ts +191 -0
- package/dist/cli/deps.d.ts.map +1 -0
- package/dist/cli/deps.js +277 -0
- package/dist/cli/deps.js.map +1 -0
- package/dist/cli/descriptor.d.ts +61 -0
- package/dist/cli/descriptor.d.ts.map +1 -0
- package/dist/cli/descriptor.js +78 -0
- package/dist/cli/descriptor.js.map +1 -0
- package/dist/cli/errors.d.ts +48 -0
- package/dist/cli/errors.d.ts.map +1 -0
- package/dist/cli/errors.js +129 -0
- package/dist/cli/errors.js.map +1 -0
- package/dist/cli/escape-hatch.d.ts +50 -0
- package/dist/cli/escape-hatch.d.ts.map +1 -0
- package/dist/cli/escape-hatch.js +45 -0
- package/dist/cli/escape-hatch.js.map +1 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +7 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/listings.d.ts +15 -0
- package/dist/cli/listings.d.ts.map +1 -0
- package/dist/cli/listings.js +56 -0
- package/dist/cli/listings.js.map +1 -0
- package/dist/cli/mandate.d.ts +66 -0
- package/dist/cli/mandate.d.ts.map +1 -0
- package/dist/cli/mandate.js +61 -0
- package/dist/cli/mandate.js.map +1 -0
- package/dist/cli/output.d.ts +33 -0
- package/dist/cli/output.d.ts.map +1 -0
- package/dist/cli/output.js +34 -0
- package/dist/cli/output.js.map +1 -0
- package/dist/cli/preflight.d.ts +38 -0
- package/dist/cli/preflight.d.ts.map +1 -0
- package/dist/cli/preflight.js +72 -0
- package/dist/cli/preflight.js.map +1 -0
- package/dist/cli/privy.d.ts +83 -0
- package/dist/cli/privy.d.ts.map +1 -0
- package/dist/cli/privy.js +230 -0
- package/dist/cli/privy.js.map +1 -0
- package/dist/cli/progress.d.ts +36 -0
- package/dist/cli/progress.d.ts.map +1 -0
- package/dist/cli/progress.js +40 -0
- package/dist/cli/progress.js.map +1 -0
- package/dist/cli/run.d.ts +25 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +173 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/cli/scan-range.d.ts +55 -0
- package/dist/cli/scan-range.d.ts.map +1 -0
- package/dist/cli/scan-range.js +57 -0
- package/dist/cli/scan-range.js.map +1 -0
- package/dist/cli/spend-policy.d.ts +60 -0
- package/dist/cli/spend-policy.d.ts.map +1 -0
- package/dist/cli/spend-policy.js +65 -0
- package/dist/cli/spend-policy.js.map +1 -0
- package/dist/cli/status.d.ts +90 -0
- package/dist/cli/status.d.ts.map +1 -0
- package/dist/cli/status.js +252 -0
- package/dist/cli/status.js.map +1 -0
- package/dist/cli/timeout.d.ts +30 -0
- package/dist/cli/timeout.d.ts.map +1 -0
- package/dist/cli/timeout.js +55 -0
- package/dist/cli/timeout.js.map +1 -0
- package/dist/cli/version.d.ts +12 -0
- package/dist/cli/version.d.ts.map +1 -0
- package/dist/cli/version.js +22 -0
- package/dist/cli/version.js.map +1 -0
- package/dist/cli/wallet.d.ts +164 -0
- package/dist/cli/wallet.d.ts.map +1 -0
- package/dist/cli/wallet.js +234 -0
- package/dist/cli/wallet.js.map +1 -0
- package/dist/frameworks/crewai.d.ts +10 -0
- package/dist/frameworks/crewai.d.ts.map +1 -1
- package/dist/frameworks/crewai.js +12 -2
- package/dist/frameworks/crewai.js.map +1 -1
- package/dist/gateway/caller-auth-gateway.d.ts +38 -3
- package/dist/gateway/caller-auth-gateway.d.ts.map +1 -1
- package/dist/gateway/caller-auth-gateway.js +25 -1
- package/dist/gateway/caller-auth-gateway.js.map +1 -1
- package/dist/gateway/http-server.d.ts +53 -0
- package/dist/gateway/http-server.d.ts.map +1 -1
- package/dist/gateway/http-server.js +189 -4
- package/dist/gateway/http-server.js.map +1 -1
- package/dist/gateway/server-entry.d.ts +1 -0
- package/dist/gateway/server-entry.js +1 -0
- package/dist/gateway/server-entry.js.map +1 -1
- package/dist/gateway/x402-mode.d.ts +123 -0
- package/dist/gateway/x402-mode.d.ts.map +1 -0
- package/dist/gateway/x402-mode.js +212 -0
- package/dist/gateway/x402-mode.js.map +1 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -3
- package/dist/index.js.map +1 -1
- package/dist/mandates/env.d.ts +3 -2
- package/dist/mandates/env.d.ts.map +1 -1
- package/dist/mandates/env.js +3 -2
- package/dist/mandates/env.js.map +1 -1
- package/dist/mcp/http-server.d.ts +1 -0
- package/dist/mcp/http-server.js +1 -0
- package/dist/mcp/http-server.js.map +1 -1
- package/dist/mcp/server.d.ts +49 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +533 -25
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/service-invoker.d.ts +97 -0
- package/dist/mcp/service-invoker.d.ts.map +1 -0
- package/dist/mcp/service-invoker.js +167 -0
- package/dist/mcp/service-invoker.js.map +1 -0
- package/dist/mcp/stdio.d.ts +1 -0
- package/dist/mcp/stdio.js +1 -0
- package/dist/mcp/stdio.js.map +1 -1
- package/dist/sdk/client.d.ts +25 -1
- package/dist/sdk/client.d.ts.map +1 -1
- package/dist/sdk/client.js +20 -1
- package/dist/sdk/client.js.map +1 -1
- package/dist/sdk/invoke.d.ts +19 -4
- package/dist/sdk/invoke.d.ts.map +1 -1
- package/dist/sdk/invoke.js +69 -5
- package/dist/sdk/invoke.js.map +1 -1
- package/dist/x402/delivery-oracle.d.ts +108 -0
- package/dist/x402/delivery-oracle.d.ts.map +1 -0
- package/dist/x402/delivery-oracle.js +174 -0
- package/dist/x402/delivery-oracle.js.map +1 -0
- package/dist/x402/facilitator-server-entry.d.ts +3 -0
- package/dist/x402/facilitator-server-entry.d.ts.map +1 -0
- package/dist/x402/facilitator-server-entry.js +27 -0
- package/dist/x402/facilitator-server-entry.js.map +1 -0
- package/dist/x402/facilitator-server.d.ts +73 -0
- package/dist/x402/facilitator-server.d.ts.map +1 -0
- package/dist/x402/facilitator-server.js +305 -0
- package/dist/x402/facilitator-server.js.map +1 -0
- package/dist/x402/facilitator.d.ts +69 -17
- package/dist/x402/facilitator.d.ts.map +1 -1
- package/dist/x402/facilitator.js +76 -33
- package/dist/x402/facilitator.js.map +1 -1
- package/dist/x402/payment-requirements.d.ts +106 -0
- package/dist/x402/payment-requirements.d.ts.map +1 -0
- package/dist/x402/payment-requirements.js +138 -0
- package/dist/x402/payment-requirements.js.map +1 -0
- package/dist/x402/relayer.d.ts +41 -0
- package/dist/x402/relayer.d.ts.map +1 -0
- package/dist/x402/relayer.js +64 -0
- package/dist/x402/relayer.js.map +1 -0
- package/dist/x402/resource-index.d.ts +75 -0
- package/dist/x402/resource-index.d.ts.map +1 -0
- package/dist/x402/resource-index.js +126 -0
- package/dist/x402/resource-index.js.map +1 -0
- package/dist/x402/verified-delivery-facilitator.d.ts +285 -0
- package/dist/x402/verified-delivery-facilitator.d.ts.map +1 -0
- package/dist/x402/verified-delivery-facilitator.js +423 -0
- package/dist/x402/verified-delivery-facilitator.js.map +1 -0
- package/dist/zktls/t2-delivery-proof.d.ts +6 -6
- package/docs/agent-layer.md +14 -9
- package/docs/cli.md +705 -0
- package/docs/crewai.md +6 -2
- package/docs/eliza.md +6 -1
- package/docs/mcp-hosted.md +7 -5
- package/docs/mcp-reference.md +175 -0
- package/docs/quickstart.md +36 -9
- package/docs/threat-model.md +4 -3
- package/docs/x402.md +648 -0
- package/package.json +33 -10
- package/src/capability/invocation-capability.ts +17 -1
- package/src/cli/amounts.ts +102 -0
- package/src/cli/args.ts +150 -0
- package/src/cli/attesters.ts +86 -0
- package/src/cli/bin.ts +5 -0
- package/src/cli/commands/claim.ts +126 -0
- package/src/cli/commands/close.ts +69 -0
- package/src/cli/commands/discover.ts +228 -0
- package/src/cli/commands/invoke.ts +178 -0
- package/src/cli/commands/mcp.ts +135 -0
- package/src/cli/commands/open.ts +397 -0
- package/src/cli/commands/reclaim-unverified.ts +218 -0
- package/src/cli/commands/reclaim.ts +377 -0
- package/src/cli/commands/status.ts +56 -0
- package/src/cli/commands/streams.ts +344 -0
- package/src/cli/commands/watch.ts +70 -0
- package/src/cli/config.ts +208 -0
- package/src/cli/context.ts +30 -0
- package/src/cli/deps.ts +528 -0
- package/src/cli/descriptor.ts +133 -0
- package/src/cli/errors.ts +162 -0
- package/src/cli/escape-hatch.ts +92 -0
- package/src/cli/index.ts +27 -0
- package/src/cli/listings.ts +77 -0
- package/src/cli/mandate.ts +126 -0
- package/src/cli/output.ts +63 -0
- package/src/cli/preflight.ts +88 -0
- package/src/cli/privy.ts +396 -0
- package/src/cli/progress.ts +75 -0
- package/src/cli/run.ts +209 -0
- package/src/cli/scan-range.ts +76 -0
- package/src/cli/spend-policy.ts +139 -0
- package/src/cli/status.ts +381 -0
- package/src/cli/timeout.ts +64 -0
- package/src/cli/version.ts +21 -0
- package/src/cli/wallet.ts +372 -0
- package/src/frameworks/crewai.ts +13 -2
- package/src/gateway/caller-auth-gateway.ts +77 -6
- package/src/gateway/http-server.ts +280 -6
- package/src/gateway/server-entry.ts +1 -0
- package/src/gateway/x402-mode.ts +350 -0
- package/src/index.ts +40 -5
- package/src/mandates/env.ts +3 -2
- package/src/mcp/http-server.ts +1 -0
- package/src/mcp/server.ts +706 -30
- package/src/mcp/service-invoker.ts +285 -0
- package/src/mcp/stdio.ts +1 -0
- package/src/sdk/client.ts +33 -1
- package/src/sdk/invoke.ts +90 -12
- package/src/x402/delivery-oracle.ts +285 -0
- package/src/x402/facilitator-server-entry.ts +35 -0
- package/src/x402/facilitator-server.ts +445 -0
- package/src/x402/facilitator.ts +125 -46
- package/src/x402/payment-requirements.ts +231 -0
- package/src/x402/relayer.ts +122 -0
- package/src/x402/resource-index.ts +228 -0
- package/src/x402/verified-delivery-facilitator.ts +744 -0
package/src/mcp/server.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
+
import { BASE_SEPOLIA_ESCROW_V2 } 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";
|
|
@@ -31,12 +32,34 @@ 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";
|
|
40
63
|
|
|
41
64
|
/**
|
|
42
65
|
* MCP server for the agent lane.
|
|
@@ -85,6 +108,55 @@ const hireVerifiedServiceSchema = {
|
|
|
85
108
|
),
|
|
86
109
|
} as const;
|
|
87
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Ceiling on a capability's lifetime.
|
|
113
|
+
*
|
|
114
|
+
* A signed capability is a bearer credential for one method+path on one stream:
|
|
115
|
+
* anything holding it can make that call until it expires. The signer's own
|
|
116
|
+
* default is 60s; this bounds what an LLM may ask for so a single tool argument
|
|
117
|
+
* cannot mint a long-lived credential. It is a cap, not the default.
|
|
118
|
+
*/
|
|
119
|
+
const MAX_CAPABILITY_TTL_SECONDS = 300;
|
|
120
|
+
|
|
121
|
+
const invokeServiceSchema = {
|
|
122
|
+
streamId: bytes32Schema.describe(
|
|
123
|
+
"Stream id of an ACTIVE stream whose buyer is this server's wallet (from hire_verified_service or list_streams).",
|
|
124
|
+
),
|
|
125
|
+
path: z
|
|
126
|
+
.string()
|
|
127
|
+
.min(1)
|
|
128
|
+
.optional()
|
|
129
|
+
.describe(
|
|
130
|
+
'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.',
|
|
131
|
+
),
|
|
132
|
+
method: httpMethodSchema
|
|
133
|
+
.optional()
|
|
134
|
+
.describe(
|
|
135
|
+
'HTTP method. Defaults to "GET". The capability authorizes exactly this method and this path — nothing else.',
|
|
136
|
+
),
|
|
137
|
+
headers: z
|
|
138
|
+
.record(z.string())
|
|
139
|
+
.optional()
|
|
140
|
+
.describe(
|
|
141
|
+
"Extra request headers. The capability header is attached by the server and cannot be supplied here.",
|
|
142
|
+
),
|
|
143
|
+
body: z
|
|
144
|
+
.string()
|
|
145
|
+
.optional()
|
|
146
|
+
.describe(
|
|
147
|
+
"Request body, sent verbatim. Defaults to a JSON content-type when a body is present and none is given.",
|
|
148
|
+
),
|
|
149
|
+
ttlSeconds: z
|
|
150
|
+
.number()
|
|
151
|
+
.int()
|
|
152
|
+
.positive()
|
|
153
|
+
.max(MAX_CAPABILITY_TTL_SECONDS)
|
|
154
|
+
.optional()
|
|
155
|
+
.describe(
|
|
156
|
+
`Capability lifetime in seconds (default 60, max ${MAX_CAPABILITY_TTL_SECONDS}). Shorter is safer; it only has to outlive one request.`,
|
|
157
|
+
),
|
|
158
|
+
} as const;
|
|
159
|
+
|
|
88
160
|
const getStreamStatusSchema = {
|
|
89
161
|
streamId: bytes32Schema.describe(
|
|
90
162
|
"Stream id returned by hire_verified_service.",
|
|
@@ -147,8 +219,44 @@ const metrikAgentEnvSchema = z.object({
|
|
|
147
219
|
METRIK_AGENT_CHAIN_ID: z.string().optional(),
|
|
148
220
|
METRIK_AGENT_CHAIN_NAME: z.string().optional(),
|
|
149
221
|
METRIK_AGENT_MCP_STATE_FILE: z.string().optional(),
|
|
222
|
+
METRIK_AGENT_ESCROW_GENERATION: z.string().optional(),
|
|
223
|
+
METRIK_AGENT_REGISTRY_URL: z.string().url().optional(),
|
|
224
|
+
METRIK_AGENT_REGISTRY_APIKEY: z.string().optional(),
|
|
225
|
+
METRIK_AGENT_ALLOW_UNVERIFIED_OPERATOR: z.string().optional(),
|
|
150
226
|
});
|
|
151
227
|
|
|
228
|
+
/**
|
|
229
|
+
* The retired V1 metered escrow.
|
|
230
|
+
*
|
|
231
|
+
* Refused by NAME rather than left to fail at read time, because it does not
|
|
232
|
+
* fail at read time: V1 and V2 `getStream` return different tuples that diverge
|
|
233
|
+
* from index 5 onward, so a V2 decoder pointed at V1 does not revert — it
|
|
234
|
+
* decodes other fields into correctly-named ones and reports a confident wrong
|
|
235
|
+
* answer about someone's money. This repository's own shipped docs pointed
|
|
236
|
+
* `METRIK_AGENT_ESCROW` here as recently as 0.10.0.
|
|
237
|
+
*/
|
|
238
|
+
const RETIRED_V1_ESCROW = "0x21948a5e6ae8d9a3d1050791ab6138657fb54286";
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Opt out of the operator/serviceRef cross-check on hire. Operator-set input
|
|
242
|
+
* only — an MCP caller can neither read nor set it.
|
|
243
|
+
*/
|
|
244
|
+
export const ALLOW_UNVERIFIED_OPERATOR_ENV =
|
|
245
|
+
"METRIK_AGENT_ALLOW_UNVERIFIED_OPERATOR";
|
|
246
|
+
|
|
247
|
+
/** An unset escrow slot decodes as an all-zero struct rather than reverting. */
|
|
248
|
+
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as const;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Escrow generations this server can talk to.
|
|
252
|
+
*
|
|
253
|
+
* Declared, not inferred. The client sets `escrowV2` (which selects the
|
|
254
|
+
* checkpoint decoder for every read) purely from configuration, so the
|
|
255
|
+
* generation has to be a stated fact that can be wrong out loud, rather than an
|
|
256
|
+
* assumption buried in one constructor argument.
|
|
257
|
+
*/
|
|
258
|
+
export type EscrowGeneration = "v2";
|
|
259
|
+
|
|
152
260
|
export interface McpToolSpec {
|
|
153
261
|
name: string;
|
|
154
262
|
description: string;
|
|
@@ -156,40 +264,57 @@ export interface McpToolSpec {
|
|
|
156
264
|
movesFunds: boolean;
|
|
157
265
|
}
|
|
158
266
|
|
|
267
|
+
/**
|
|
268
|
+
* The one sentence every stream-opening surface has to carry.
|
|
269
|
+
*
|
|
270
|
+
* A reviewer made ONE call against a stream and was billed for 302 seconds. The
|
|
271
|
+
* escrow behaved exactly as designed — and nothing in the tool surface had told
|
|
272
|
+
* them what they were buying. An LLM picks a tool from its description alone, so
|
|
273
|
+
* the meter is stated in the description of every tool that opens, reads, or
|
|
274
|
+
* ends a stream, not only in the docs it will never read.
|
|
275
|
+
*/
|
|
276
|
+
export const METERING_MODEL =
|
|
277
|
+
"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.";
|
|
278
|
+
|
|
159
279
|
export const METRIK_MCP_TOOLS: readonly McpToolSpec[] = [
|
|
160
280
|
{
|
|
161
281
|
name: "discover_services",
|
|
162
282
|
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.",
|
|
283
|
+
"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
284
|
movesFunds: false,
|
|
165
285
|
},
|
|
166
286
|
{
|
|
167
287
|
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.",
|
|
288
|
+
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
289
|
movesFunds: true,
|
|
171
290
|
},
|
|
172
291
|
{
|
|
173
|
-
name: "
|
|
292
|
+
name: "invoke_service",
|
|
174
293
|
description:
|
|
175
|
-
"
|
|
294
|
+
"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.",
|
|
295
|
+
movesFunds: false,
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
name: "check_stream_status",
|
|
299
|
+
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
300
|
movesFunds: false,
|
|
177
301
|
},
|
|
178
302
|
{
|
|
179
303
|
name: "reclaim_unspent",
|
|
180
304
|
description:
|
|
181
|
-
"Close a stream and reclaim unspent funds to the buyer. V2 reclaim follows checkpoint finalization or escape-window rules.",
|
|
305
|
+
"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
306
|
movesFunds: true,
|
|
183
307
|
},
|
|
184
308
|
{
|
|
185
309
|
name: "list_streams",
|
|
186
|
-
description:
|
|
310
|
+
description:
|
|
311
|
+
"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
312
|
movesFunds: false,
|
|
188
313
|
},
|
|
189
314
|
{
|
|
190
315
|
name: "prove_https_response",
|
|
191
316
|
description:
|
|
192
|
-
"Generate a zkTLS proof of
|
|
317
|
+
"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
318
|
movesFunds: false,
|
|
194
319
|
},
|
|
195
320
|
] as const;
|
|
@@ -343,6 +468,23 @@ export interface VerifiedStreamMcpServerOptions {
|
|
|
343
468
|
| SignedSpendMandate
|
|
344
469
|
| Promise<SignedSpendMandate>;
|
|
345
470
|
readonly registry?: StreamRegistry;
|
|
471
|
+
/**
|
|
472
|
+
* Escape hatch for the operator/serviceRef cross-check, from
|
|
473
|
+
* {@link ALLOW_UNVERIFIED_OPERATOR_ENV}.
|
|
474
|
+
*
|
|
475
|
+
* OFF by default and deliberately not reachable from a tool argument: the
|
|
476
|
+
* caller being asked to relax a fund-safety check is the LLM the check exists
|
|
477
|
+
* to constrain. Even when on, a positive contradiction (`mismatch`) is still
|
|
478
|
+
* refused — this only downgrades "could not verify" from a refusal to a
|
|
479
|
+
* warning, for an operator who has accepted that risk explicitly.
|
|
480
|
+
*/
|
|
481
|
+
readonly allowUnverifiedOperatorPairing?: boolean;
|
|
482
|
+
/**
|
|
483
|
+
* Drives `invoke_service`. Omitted, the tool is not registered at all rather
|
|
484
|
+
* than advertised and broken — the env builder always supplies one, so this
|
|
485
|
+
* is only omitted by callers wiring the server by hand (and by tests).
|
|
486
|
+
*/
|
|
487
|
+
readonly serviceInvoker?: ServiceInvoker;
|
|
346
488
|
readonly deliveryProofService?: ConsumerDeliveryProofService;
|
|
347
489
|
readonly mandateStateResolver?: Partial<MandateStateResolver>;
|
|
348
490
|
readonly now?: () => number;
|
|
@@ -368,6 +510,13 @@ export interface VerifiedStreamMcpServerRuntime {
|
|
|
368
510
|
export interface ParsedMetrikAgentEnv {
|
|
369
511
|
readonly rpcUrl: string;
|
|
370
512
|
readonly escrow: `0x${string}`;
|
|
513
|
+
/** Which escrow generation {@link escrow} is, as CONFIGURED (never probed). */
|
|
514
|
+
readonly escrowGeneration: EscrowGeneration;
|
|
515
|
+
/** Registry the invocation origin is verified against. Undefined = the public default. */
|
|
516
|
+
readonly registryUrl?: string;
|
|
517
|
+
readonly registryApiKey?: string;
|
|
518
|
+
/** {@link ALLOW_UNVERIFIED_OPERATOR_ENV}; false unless explicitly `true`. */
|
|
519
|
+
readonly allowUnverifiedOperatorPairing: boolean;
|
|
371
520
|
readonly usdc: `0x${string}`;
|
|
372
521
|
readonly chain: Chain;
|
|
373
522
|
readonly stateFile?: string;
|
|
@@ -392,12 +541,27 @@ function warnOnce(key: string, message: string): void {
|
|
|
392
541
|
console.error(message);
|
|
393
542
|
}
|
|
394
543
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
544
|
+
/**
|
|
545
|
+
* Looks a tool spec up by NAME. This used to index into METRIK_MCP_TOOLS by
|
|
546
|
+
* position, which silently rebinds every description the moment a tool is
|
|
547
|
+
* inserted anywhere but the end — a tool whose advertised description belongs
|
|
548
|
+
* to a different tool is worse than a missing one, because an agent acts on it.
|
|
549
|
+
*/
|
|
550
|
+
function toolSpec(name: string): McpToolSpec {
|
|
551
|
+
const spec = METRIK_MCP_TOOLS.find((entry) => entry.name === name);
|
|
552
|
+
if (spec === undefined) {
|
|
553
|
+
throw new Error(`unknown MCP tool spec: ${name}`);
|
|
554
|
+
}
|
|
555
|
+
return spec;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const DISCOVER_TOOL_SPEC = toolSpec("discover_services");
|
|
559
|
+
const HIRE_TOOL_SPEC = toolSpec("hire_verified_service");
|
|
560
|
+
const INVOKE_TOOL_SPEC = toolSpec("invoke_service");
|
|
561
|
+
const STATUS_TOOL_SPEC = toolSpec("check_stream_status");
|
|
562
|
+
const RECLAIM_TOOL_SPEC = toolSpec("reclaim_unspent");
|
|
563
|
+
const LIST_TOOL_SPEC = toolSpec("list_streams");
|
|
564
|
+
const PROVE_TOOL_SPEC = toolSpec("prove_https_response");
|
|
401
565
|
|
|
402
566
|
const discoverServicesSchema = {
|
|
403
567
|
category: z
|
|
@@ -480,6 +644,14 @@ export function createVerifiedStreamMcpServer(
|
|
|
480
644
|
|
|
481
645
|
const now = options.now ?? (() => Math.floor(Date.now() / 1000));
|
|
482
646
|
const discover = options.discoverServices ?? defaultDiscoverServices;
|
|
647
|
+
const allowUnverifiedOperator =
|
|
648
|
+
options.allowUnverifiedOperatorPairing ?? false;
|
|
649
|
+
if (allowUnverifiedOperator) {
|
|
650
|
+
warnOnce(
|
|
651
|
+
"mcp-unverified-operator",
|
|
652
|
+
`[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.`,
|
|
653
|
+
);
|
|
654
|
+
}
|
|
483
655
|
|
|
484
656
|
const mcpServer = new McpServer(
|
|
485
657
|
options.serverInfo ?? {
|
|
@@ -507,10 +679,17 @@ export function createVerifiedStreamMcpServer(
|
|
|
507
679
|
...(limit === undefined ? {} : { limit }),
|
|
508
680
|
});
|
|
509
681
|
return successResult(
|
|
510
|
-
`discovered ${listings.length} verified service${listings.length === 1 ? "" : "s"}`,
|
|
682
|
+
`discovered ${listings.length} verified service${listings.length === 1 ? "" : "s"}, priced per verified second`,
|
|
511
683
|
{ services: listings.map(serializeServiceListing) },
|
|
512
684
|
);
|
|
513
685
|
} catch (error) {
|
|
686
|
+
if (error instanceof RegistryUnavailableError) {
|
|
687
|
+
// Loud, not silent: discovery never degrades to stale local data, so
|
|
688
|
+
// "the registry is down" must not read as "there are no services".
|
|
689
|
+
return errorResult(
|
|
690
|
+
"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.",
|
|
691
|
+
);
|
|
692
|
+
}
|
|
514
693
|
return safeToolError(error);
|
|
515
694
|
}
|
|
516
695
|
},
|
|
@@ -542,6 +721,40 @@ export function createVerifiedStreamMcpServer(
|
|
|
542
721
|
args.durationSeconds ?? signedMandate.mandate.maxDurationSeconds,
|
|
543
722
|
} satisfies Parameters<typeof checkMandate>[1];
|
|
544
723
|
|
|
724
|
+
// Does this operator actually own this serviceRef?
|
|
725
|
+
//
|
|
726
|
+
// The two arrive as independent tool arguments, and `operator` is the
|
|
727
|
+
// wallet that gets paid. An LLM that pairs a real serviceRef with an
|
|
728
|
+
// attacker's payout address escrows the buyer's USDC to a stranger, and
|
|
729
|
+
// nothing downstream catches it: the escrow does not know who owns a
|
|
730
|
+
// serviceRef, and the oracle would happily verify the real endpoint
|
|
731
|
+
// while the money accrued elsewhere. Discovery DOES know — it recovers
|
|
732
|
+
// the operator's signature over the record the serviceRef is derived
|
|
733
|
+
// from — so the pairing is checked against it here.
|
|
734
|
+
//
|
|
735
|
+
// A serviceRef that is not listed at all is allowed with a warning
|
|
736
|
+
// (the registry is not the authority on what exists, and refusing would
|
|
737
|
+
// block private or freshly-listed services); a serviceRef that IS
|
|
738
|
+
// listed under a DIFFERENT operator is refused outright.
|
|
739
|
+
const pairing = await checkOperatorPairing(
|
|
740
|
+
discover,
|
|
741
|
+
args.serviceRef as `0x${string}`,
|
|
742
|
+
request.operator,
|
|
743
|
+
);
|
|
744
|
+
if (pairing.verdict !== "verified") {
|
|
745
|
+
// FAIL CLOSED. Anything short of a positive match is a refusal: a
|
|
746
|
+
// registry outage is not evidence that the pairing is sound, and the
|
|
747
|
+
// money moves before any note attached to a success result could be
|
|
748
|
+
// read. The only override is an operator-set environment variable
|
|
749
|
+
// (never a tool argument), and even that never overrides a positive
|
|
750
|
+
// contradiction.
|
|
751
|
+
if (pairing.verdict === "mismatch" || !allowUnverifiedOperator) {
|
|
752
|
+
return errorResult(
|
|
753
|
+
explainPairingRefusal(pairing, args.serviceRef, request.operator),
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
545
758
|
// Serialize per owner: the spent-so-far read, cap check, open, and
|
|
546
759
|
// registry write must be atomic, or concurrent hires could both pass
|
|
547
760
|
// the cumulative-cap check on stale spend and overshoot maxTotalUsdc.
|
|
@@ -587,8 +800,18 @@ export function createVerifiedStreamMcpServer(
|
|
|
587
800
|
mandateId: signedMandate.mandateId as `0x${string}`,
|
|
588
801
|
});
|
|
589
802
|
|
|
803
|
+
// The meter starts HERE, not on the first invoke_service call. Say
|
|
804
|
+
// so in the result as well as the tool description: a reviewer who
|
|
805
|
+
// opened a stream, made one call and was billed for 302 seconds read
|
|
806
|
+
// the result, not the docs.
|
|
807
|
+
const maxSpendUsdc =
|
|
808
|
+
request.budgetUsdc <
|
|
809
|
+
request.ratePerSecondUsdc * BigInt(request.maxDurationSeconds)
|
|
810
|
+
? request.budgetUsdc
|
|
811
|
+
: request.ratePerSecondUsdc * BigInt(request.maxDurationSeconds);
|
|
812
|
+
|
|
590
813
|
return successResult(
|
|
591
|
-
`opened verified stream ${result.streamId} on chain ${result.handle.chainId}
|
|
814
|
+
`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.`} 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
815
|
{
|
|
593
816
|
streamId: result.streamId,
|
|
594
817
|
txHash: result.txHash,
|
|
@@ -598,6 +821,15 @@ export function createVerifiedStreamMcpServer(
|
|
|
598
821
|
escrow: result.handle.escrow,
|
|
599
822
|
chainId: result.handle.chainId,
|
|
600
823
|
},
|
|
824
|
+
operatorPairing: pairing.verdict,
|
|
825
|
+
metering: {
|
|
826
|
+
model: "time-metered" as const,
|
|
827
|
+
ratePerSecondUsdc: request.ratePerSecondUsdc.toString(),
|
|
828
|
+
budgetUsdc: request.budgetUsdc.toString(),
|
|
829
|
+
maxDurationSeconds: request.maxDurationSeconds,
|
|
830
|
+
maxSpendUsdc: maxSpendUsdc.toString(),
|
|
831
|
+
note: METERING_MODEL,
|
|
832
|
+
},
|
|
601
833
|
},
|
|
602
834
|
);
|
|
603
835
|
});
|
|
@@ -607,6 +839,77 @@ export function createVerifiedStreamMcpServer(
|
|
|
607
839
|
},
|
|
608
840
|
);
|
|
609
841
|
|
|
842
|
+
if (options.serviceInvoker !== undefined) {
|
|
843
|
+
const serviceInvoker = options.serviceInvoker;
|
|
844
|
+
mcpServer.registerTool(
|
|
845
|
+
"invoke_service",
|
|
846
|
+
{
|
|
847
|
+
description: INVOKE_TOOL_SPEC.description,
|
|
848
|
+
inputSchema: invokeServiceSchema,
|
|
849
|
+
annotations: {
|
|
850
|
+
// Not idempotent and not read-only: the call reaches a third party's
|
|
851
|
+
// endpoint, which may do anything. It is NOT destructive in Metrik's
|
|
852
|
+
// own terms — it signs no transaction and moves no USDC.
|
|
853
|
+
destructiveHint: false,
|
|
854
|
+
idempotentHint: false,
|
|
855
|
+
openWorldHint: true,
|
|
856
|
+
readOnlyHint: false,
|
|
857
|
+
title: "Invoke Paid Service",
|
|
858
|
+
},
|
|
859
|
+
},
|
|
860
|
+
async ({ streamId, path, method, headers, body, ttlSeconds }) => {
|
|
861
|
+
const requestPath = path ?? "/";
|
|
862
|
+
// Defence in depth. `resolveInvocationUrl` is the authority — it
|
|
863
|
+
// resolves the path and proves the result did not leave the signed
|
|
864
|
+
// origin — but a tool argument that cannot possibly be a safe path is
|
|
865
|
+
// refused here, with a message the caller can act on, before anything
|
|
866
|
+
// reads the chain. A prefix check ALONE was the bug: the URL parser
|
|
867
|
+
// treats a backslash as a host separator for http(s), so a path
|
|
868
|
+
// beginning `/\\` resolved against a signed origin points somewhere
|
|
869
|
+
// else entirely while passing a `//` test.
|
|
870
|
+
if (!requestPath.startsWith("/") || requestPath.startsWith("//")) {
|
|
871
|
+
return errorResult(
|
|
872
|
+
`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.`,
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
if (hasForbiddenPathCharacter(requestPath)) {
|
|
876
|
+
return errorResult(
|
|
877
|
+
`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.`,
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
const requestHeaders = normalizeHeaders(headers);
|
|
881
|
+
if (CAPABILITY_HEADER_NAME in requestHeaders) {
|
|
882
|
+
return errorResult(
|
|
883
|
+
`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.`,
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
if (
|
|
887
|
+
body !== undefined &&
|
|
888
|
+
requestHeaders["content-type"] === undefined
|
|
889
|
+
) {
|
|
890
|
+
requestHeaders["content-type"] = "application/json";
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
try {
|
|
894
|
+
const result = await serviceInvoker.invokeService({
|
|
895
|
+
streamId: streamId as `0x${string}`,
|
|
896
|
+
method: method ?? "GET",
|
|
897
|
+
path: requestPath,
|
|
898
|
+
headers: requestHeaders,
|
|
899
|
+
...(body === undefined ? {} : { body }),
|
|
900
|
+
...(ttlSeconds === undefined ? {} : { ttlSeconds }),
|
|
901
|
+
});
|
|
902
|
+
return serializeInvocation(result);
|
|
903
|
+
} catch (error) {
|
|
904
|
+
const explained = explainInvokeFailure(error, streamId);
|
|
905
|
+
return explained === null
|
|
906
|
+
? safeToolError(error)
|
|
907
|
+
: errorResult(explained);
|
|
908
|
+
}
|
|
909
|
+
},
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
610
913
|
mcpServer.registerTool(
|
|
611
914
|
"check_stream_status",
|
|
612
915
|
{
|
|
@@ -624,8 +927,18 @@ export function createVerifiedStreamMcpServer(
|
|
|
624
927
|
const status = await options.agentClient.getStreamStatus(
|
|
625
928
|
streamId as `0x${string}`,
|
|
626
929
|
);
|
|
930
|
+
if (status.stream.buyer === ZERO_ADDRESS) {
|
|
931
|
+
// An unset slot decodes as an all-zero struct rather than reverting,
|
|
932
|
+
// which otherwise surfaces as a confident "the stream is closed" for
|
|
933
|
+
// a stream that never existed. A real stream always has a buyer.
|
|
934
|
+
return errorResult(
|
|
935
|
+
`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.`,
|
|
936
|
+
);
|
|
937
|
+
}
|
|
627
938
|
return successResult(
|
|
628
|
-
|
|
939
|
+
status.stream.status === "active"
|
|
940
|
+
? `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`
|
|
941
|
+
: `stream ${streamId} is ${status.stream.status}`,
|
|
629
942
|
serializeStreamStatus(status, streamId as `0x${string}`),
|
|
630
943
|
);
|
|
631
944
|
} catch (error) {
|
|
@@ -649,10 +962,14 @@ export function createVerifiedStreamMcpServer(
|
|
|
649
962
|
},
|
|
650
963
|
async ({ streamId, closeFirst }) => {
|
|
651
964
|
try {
|
|
965
|
+
// The local registry is a CONVENIENCE, not an authority. It used to be
|
|
966
|
+
// required here, which meant a restarted server with in-memory state
|
|
967
|
+
// could no longer return the buyer's own money through this tool — a
|
|
968
|
+
// local bookkeeping gap standing between a buyer and their funds. The
|
|
969
|
+
// escrow itself enforces that only the buyer may close and reclaim, so
|
|
970
|
+
// proceeding without a record is safe and strictly buyer-favouring; an
|
|
971
|
+
// id that is not this wallet's stream simply reverts on chain.
|
|
652
972
|
const record = await registry.get(streamId as `0x${string}`);
|
|
653
|
-
if (record === null) {
|
|
654
|
-
return errorResult(`unknown stream: ${streamId}`);
|
|
655
|
-
}
|
|
656
973
|
const signedMandate = await resolveMandate();
|
|
657
974
|
|
|
658
975
|
// Reclaim/close only RETURN unspent funds to the buyer or stop payment.
|
|
@@ -667,10 +984,14 @@ export function createVerifiedStreamMcpServer(
|
|
|
667
984
|
...(closeFirst === undefined ? {} : { closeFirst }),
|
|
668
985
|
});
|
|
669
986
|
|
|
670
|
-
return successResult(
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
987
|
+
return successResult(
|
|
988
|
+
`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)" : ""}`,
|
|
989
|
+
{
|
|
990
|
+
closeTxHash: result.closeResult?.txHash ?? null,
|
|
991
|
+
reclaimTxHash: result.reclaimResult.txHash,
|
|
992
|
+
tracked: record !== null,
|
|
993
|
+
},
|
|
994
|
+
);
|
|
674
995
|
} catch (error) {
|
|
675
996
|
return safeToolError(error);
|
|
676
997
|
}
|
|
@@ -757,6 +1078,46 @@ export function createVerifiedStreamMcpServer(
|
|
|
757
1078
|
return errorResult(`unknown stream: ${streamId}`);
|
|
758
1079
|
}
|
|
759
1080
|
|
|
1081
|
+
// Bind the PROOF TARGET to the stream's verified listing.
|
|
1082
|
+
//
|
|
1083
|
+
// Without this the tool would zkTLS-prove any URL an LLM named and
|
|
1084
|
+
// stamp it with this stream's delivery binding (owner, streamId,
|
|
1085
|
+
// operator, serviceRef) — a proof of someone else's endpoint,
|
|
1086
|
+
// labelled as this stream's delivery. The origin has to come from the
|
|
1087
|
+
// operator's signed record for the same reason invoke_service's does.
|
|
1088
|
+
const listings = await discover().catch((error: unknown) => {
|
|
1089
|
+
throw new ProveTargetError(
|
|
1090
|
+
error instanceof RegistryUnavailableError
|
|
1091
|
+
? "the service registry is unreachable, so this stream's signed service origin could not be verified. No proof was attempted."
|
|
1092
|
+
: `the service registry could not be read: ${asErrorMessage(error)}`,
|
|
1093
|
+
);
|
|
1094
|
+
});
|
|
1095
|
+
const listing = listings.find(
|
|
1096
|
+
(entry) =>
|
|
1097
|
+
entry.serviceRef.toLowerCase() ===
|
|
1098
|
+
record.serviceRef.toLowerCase(),
|
|
1099
|
+
);
|
|
1100
|
+
if (listing === undefined) {
|
|
1101
|
+
return errorResult(
|
|
1102
|
+
`no verified listing matches this stream's serviceRef (${record.serviceRef}), so there is no signed origin to prove against. Run discover_services.`,
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
if (listing.access === "gated") {
|
|
1106
|
+
// Not a degradation we can paper over. The Reclaim attestor fetches
|
|
1107
|
+
// the URL itself, from its own network position, and cannot present
|
|
1108
|
+
// this stream's single-use caller-auth capability — so the only
|
|
1109
|
+
// thing it could prove about a gated origin is its own 403.
|
|
1110
|
+
return errorResult(
|
|
1111
|
+
`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.`,
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
const signedOrigin = safeOrigin(listing.accessUrl);
|
|
1115
|
+
if (safeOrigin(url) !== signedOrigin) {
|
|
1116
|
+
return errorResult(
|
|
1117
|
+
`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.`,
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
760
1121
|
const result = await deliveryProofService.proveConsumedHttpsResponse({
|
|
761
1122
|
url,
|
|
762
1123
|
...(method === undefined ? {} : { method }),
|
|
@@ -781,6 +1142,9 @@ export function createVerifiedStreamMcpServer(
|
|
|
781
1142
|
serializeConsumedResponseProof(record.streamId, result),
|
|
782
1143
|
);
|
|
783
1144
|
} catch (error) {
|
|
1145
|
+
if (error instanceof ProveTargetError) {
|
|
1146
|
+
return errorResult(error.message);
|
|
1147
|
+
}
|
|
784
1148
|
return safeToolError(error);
|
|
785
1149
|
}
|
|
786
1150
|
},
|
|
@@ -799,10 +1163,34 @@ export function createVerifiedStreamMcpServer(
|
|
|
799
1163
|
};
|
|
800
1164
|
}
|
|
801
1165
|
|
|
1166
|
+
/** One readable line per invalid or missing environment variable. */
|
|
1167
|
+
function describeEnvIssues(error: z.ZodError): string {
|
|
1168
|
+
const seen = new Set<string>();
|
|
1169
|
+
for (const issue of error.issues) {
|
|
1170
|
+
const name = issue.path.join(".");
|
|
1171
|
+
seen.add(
|
|
1172
|
+
issue.code === "invalid_type" && issue.received === "undefined"
|
|
1173
|
+
? `${name} is not set`
|
|
1174
|
+
: `${name} is invalid (${issue.message})`,
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
return [...seen].join("; ");
|
|
1178
|
+
}
|
|
1179
|
+
|
|
802
1180
|
export function parseMetrikAgentEnv(
|
|
803
1181
|
env: NodeJS.ProcessEnv = process.env,
|
|
804
1182
|
): ParsedMetrikAgentEnv {
|
|
805
|
-
const
|
|
1183
|
+
const result = metrikAgentEnvSchema.safeParse(env);
|
|
1184
|
+
if (!result.success) {
|
|
1185
|
+
// `.parse()` threw a ZodError whose message is a JSON array of issue
|
|
1186
|
+
// objects. `metrik-mcp` is started by an MCP host and fails closed on stdio,
|
|
1187
|
+
// so that array was the ONLY thing an operator ever saw — a correct failure
|
|
1188
|
+
// rendered as noise. Name the variables instead.
|
|
1189
|
+
throw new Error(
|
|
1190
|
+
`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}).`,
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
const parsed = result.data;
|
|
806
1194
|
const chainId = Number(parsed.METRIK_AGENT_CHAIN_ID ?? baseSepolia.id);
|
|
807
1195
|
if (!Number.isInteger(chainId) || chainId <= 0) {
|
|
808
1196
|
throw new Error("METRIK_AGENT_CHAIN_ID must be a positive integer");
|
|
@@ -813,9 +1201,36 @@ export function parseMetrikAgentEnv(
|
|
|
813
1201
|
);
|
|
814
1202
|
}
|
|
815
1203
|
|
|
1204
|
+
// Generation is CONFIGURED. `v2` is the only generation the checkpoint escrow
|
|
1205
|
+
// semantics this server implements apply to, so anything else is refused up
|
|
1206
|
+
// front instead of silently mis-decoded (see RETIRED_V1_ESCROW).
|
|
1207
|
+
const generation = (
|
|
1208
|
+
parsed.METRIK_AGENT_ESCROW_GENERATION ?? "v2"
|
|
1209
|
+
).toLowerCase();
|
|
1210
|
+
if (generation !== "v2") {
|
|
1211
|
+
throw new Error(
|
|
1212
|
+
`unsupported METRIK_AGENT_ESCROW_GENERATION "${parsed.METRIK_AGENT_ESCROW_GENERATION}": this server reads checkpoint-settled StreamEscrowV2 only. Leave it unset (or set it to "v2") and point METRIK_AGENT_ESCROW at the V2 escrow.`,
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
if (parsed.METRIK_AGENT_ESCROW.toLowerCase() === RETIRED_V1_ESCROW) {
|
|
1216
|
+
throw new Error(
|
|
1217
|
+
`METRIK_AGENT_ESCROW is the RETIRED V1 metered escrow (${parsed.METRIK_AGENT_ESCROW}). It is no longer settled by the oracle, and its stream tuple is incompatible with the checkpoint decoder this server uses — reads would not revert, they would return confident nonsense. Point it at the live StreamEscrowV2 (${BASE_SEPOLIA_ESCROW_V2}).`,
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
816
1221
|
return {
|
|
817
1222
|
rpcUrl: parsed.METRIK_AGENT_RPC_URL,
|
|
818
1223
|
escrow: parsed.METRIK_AGENT_ESCROW as `0x${string}`,
|
|
1224
|
+
escrowGeneration: "v2",
|
|
1225
|
+
// Opt-IN only: any value but the exact string "true" leaves the check on.
|
|
1226
|
+
allowUnverifiedOperatorPairing:
|
|
1227
|
+
parsed.METRIK_AGENT_ALLOW_UNVERIFIED_OPERATOR === "true",
|
|
1228
|
+
...(parsed.METRIK_AGENT_REGISTRY_URL === undefined
|
|
1229
|
+
? {}
|
|
1230
|
+
: { registryUrl: parsed.METRIK_AGENT_REGISTRY_URL }),
|
|
1231
|
+
...(parsed.METRIK_AGENT_REGISTRY_APIKEY === undefined
|
|
1232
|
+
? {}
|
|
1233
|
+
: { registryApiKey: parsed.METRIK_AGENT_REGISTRY_APIKEY }),
|
|
819
1234
|
usdc: parsed.METRIK_AGENT_USDC as `0x${string}`,
|
|
820
1235
|
chain: {
|
|
821
1236
|
...baseSepolia,
|
|
@@ -860,11 +1275,13 @@ export async function createVerifiedStreamMcpServerOptionsFromEnv(
|
|
|
860
1275
|
chain: parsed.chain,
|
|
861
1276
|
transport: http(parsed.rpcUrl),
|
|
862
1277
|
escrow: parsed.escrow,
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
// which
|
|
866
|
-
//
|
|
867
|
-
|
|
1278
|
+
// Select the checkpoint decoder, on the strength of the DECLARED
|
|
1279
|
+
// generation (`parsed.escrowGeneration`, validated in parseMetrikAgentEnv
|
|
1280
|
+
// — which also refuses the retired V1 address outright). Without this the
|
|
1281
|
+
// SDK treats the client as V1 and routes claimable()/reclaimable() to the
|
|
1282
|
+
// legacy ABI, which reverts against StreamEscrowV2: the checkpoint escrow
|
|
1283
|
+
// replaced those one-argument views with proof-carrying ones.
|
|
1284
|
+
...(parsed.escrowGeneration === "v2" ? { escrowV2: parsed.escrow } : {}),
|
|
868
1285
|
usdc: parsed.usdc,
|
|
869
1286
|
settlementTarget: {
|
|
870
1287
|
chainId: parsed.chain.id,
|
|
@@ -895,6 +1312,30 @@ export async function createVerifiedStreamMcpServerOptionsFromEnv(
|
|
|
895
1312
|
return cachedMandate;
|
|
896
1313
|
};
|
|
897
1314
|
|
|
1315
|
+
// The "use" half. `invoke()` needs a signer that can produce an EIP-712
|
|
1316
|
+
// signature; every wallet this package resolves can, but only a raw key is a
|
|
1317
|
+
// viem LocalAccount by type — hence the structural check rather than a cast.
|
|
1318
|
+
const serviceInvoker = createServiceInvoker({
|
|
1319
|
+
streamReader: createSdkInvokeStreamReader({
|
|
1320
|
+
escrowAddress: parsed.escrow,
|
|
1321
|
+
rpcUrl: parsed.rpcUrl,
|
|
1322
|
+
}),
|
|
1323
|
+
buyer: requireCapabilitySigner(wallet.account),
|
|
1324
|
+
domain: { chainId, verifyingContract: parsed.escrow },
|
|
1325
|
+
discoverServices: defaultDiscoverServices,
|
|
1326
|
+
discoverOptions: {
|
|
1327
|
+
...(parsed.registryUrl === undefined
|
|
1328
|
+
? {}
|
|
1329
|
+
: { registryUrl: parsed.registryUrl }),
|
|
1330
|
+
...(parsed.registryApiKey === undefined
|
|
1331
|
+
? {}
|
|
1332
|
+
: { apiKey: parsed.registryApiKey }),
|
|
1333
|
+
// stderr, never stdout: stdout is the MCP stdio transport channel, and
|
|
1334
|
+
// discovery warns about every listing it DROPS for a bad signature.
|
|
1335
|
+
logger: { warn: (...parts) => console.error(...parts) },
|
|
1336
|
+
},
|
|
1337
|
+
});
|
|
1338
|
+
|
|
898
1339
|
return {
|
|
899
1340
|
agentClient,
|
|
900
1341
|
signedMandate,
|
|
@@ -903,10 +1344,29 @@ export async function createVerifiedStreamMcpServerOptionsFromEnv(
|
|
|
903
1344
|
parsed.stateFile === undefined
|
|
904
1345
|
? new InMemoryStreamRegistry()
|
|
905
1346
|
: new FileStreamRegistry(parsed.stateFile),
|
|
1347
|
+
allowUnverifiedOperatorPairing: parsed.allowUnverifiedOperatorPairing,
|
|
1348
|
+
serviceInvoker,
|
|
906
1349
|
...(deliveryProofService === null ? {} : { deliveryProofService }),
|
|
907
1350
|
};
|
|
908
1351
|
}
|
|
909
1352
|
|
|
1353
|
+
/**
|
|
1354
|
+
* Narrows a resolved wallet account to something that can sign a capability.
|
|
1355
|
+
*
|
|
1356
|
+
* Fails closed at STARTUP with a named cause rather than at the first
|
|
1357
|
+
* `invoke_service` call with a `TypeError`.
|
|
1358
|
+
*/
|
|
1359
|
+
function requireCapabilitySigner(
|
|
1360
|
+
account: ResolvedAgentWallet["account"],
|
|
1361
|
+
): InvocationCapabilitySigner {
|
|
1362
|
+
if (typeof account.signTypedData !== "function") {
|
|
1363
|
+
throw new Error(
|
|
1364
|
+
`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).`,
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
return account as InvocationCapabilitySigner;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
910
1370
|
export async function createVerifiedStreamMcpServerFromEnv(
|
|
911
1371
|
env: NodeJS.ProcessEnv = process.env,
|
|
912
1372
|
): Promise<VerifiedStreamMcpServerRuntime> {
|
|
@@ -976,6 +1436,11 @@ function serializeStreamStatus(
|
|
|
976
1436
|
},
|
|
977
1437
|
claimable: status.claimable.toString(),
|
|
978
1438
|
reclaimable: status.reclaimable.toString(),
|
|
1439
|
+
metering: {
|
|
1440
|
+
model: "time-metered" as const,
|
|
1441
|
+
ratePerSecondUsdc: status.stream.ratePerSecond.toString(),
|
|
1442
|
+
note: METERING_MODEL,
|
|
1443
|
+
},
|
|
979
1444
|
};
|
|
980
1445
|
}
|
|
981
1446
|
|
|
@@ -995,6 +1460,217 @@ function serializeConsumedResponseProof(
|
|
|
995
1460
|
};
|
|
996
1461
|
}
|
|
997
1462
|
|
|
1463
|
+
/**
|
|
1464
|
+
* True when a path carries a character that must never appear in one.
|
|
1465
|
+
*
|
|
1466
|
+
* Mirrors `assertOriginRelativePath` in `../sdk/invoke.ts`. The backslash is the
|
|
1467
|
+
* one that matters: the URL parser reads it as a host separator for http(s), so
|
|
1468
|
+
* it turns an apparently origin-relative path into a different host. C0
|
|
1469
|
+
* controls and DEL go with it — a header- or log-splitting character has no
|
|
1470
|
+
* business in a path either.
|
|
1471
|
+
*/
|
|
1472
|
+
function hasForbiddenPathCharacter(value: string): boolean {
|
|
1473
|
+
for (const character of value) {
|
|
1474
|
+
const code = character.codePointAt(0) ?? 0;
|
|
1475
|
+
if (character === "\\" || code < 0x20 || code === 0x7f) {
|
|
1476
|
+
return true;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
return false;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/** Lower-cases header names so duplicate/odd-cased keys collapse to one. */
|
|
1483
|
+
type OperatorPairing =
|
|
1484
|
+
| { readonly verdict: "verified" }
|
|
1485
|
+
| { readonly verdict: "mismatch"; readonly listedOperator: `0x${string}` }
|
|
1486
|
+
| { readonly verdict: "unlisted" }
|
|
1487
|
+
| {
|
|
1488
|
+
readonly verdict: "registry-unavailable" | "registry-error";
|
|
1489
|
+
readonly detail: string;
|
|
1490
|
+
};
|
|
1491
|
+
|
|
1492
|
+
/**
|
|
1493
|
+
* Checks that `operator` — the wallet that gets paid — is the one the
|
|
1494
|
+
* registry's operator-signed record for `serviceRef` names.
|
|
1495
|
+
*
|
|
1496
|
+
* This reports; it does not decide. Every outcome other than `verified` is
|
|
1497
|
+
* treated as a refusal by the hire path (see ALLOW_UNVERIFIED_OPERATOR_ENV).
|
|
1498
|
+
* An earlier version failed OPEN on anything but a positive contradiction,
|
|
1499
|
+
* which meant that during any registry read failure the exact bug this check
|
|
1500
|
+
* exists to stop was fully live: an attacker's `operator` paired with a genuine
|
|
1501
|
+
* `serviceRef`, the oracle verifying the real endpoint, and accrual settling to
|
|
1502
|
+
* the attacker. Settlement does not depend on this HTTP endpoint being
|
|
1503
|
+
* reachable from the MCP host, so a read failure is no evidence of safety.
|
|
1504
|
+
*
|
|
1505
|
+
* A hostile registry cannot forge a match — the operator's signature over the
|
|
1506
|
+
* record the serviceRef is derived from is recovered client-side — but it CAN
|
|
1507
|
+
* suppress a row, which is exactly why `unlisted` must not be a soft verdict.
|
|
1508
|
+
*
|
|
1509
|
+
* An outage is reported distinctly from a malformed/failed read: "retry in a
|
|
1510
|
+
* moment" and "something is wrong with this registry" are different next steps.
|
|
1511
|
+
*/
|
|
1512
|
+
async function checkOperatorPairing(
|
|
1513
|
+
discover: DiscoverServicesFn,
|
|
1514
|
+
serviceRef: `0x${string}`,
|
|
1515
|
+
operator: `0x${string}`,
|
|
1516
|
+
): Promise<OperatorPairing> {
|
|
1517
|
+
let listings: readonly ServiceListing[];
|
|
1518
|
+
try {
|
|
1519
|
+
listings = await discover();
|
|
1520
|
+
} catch (error) {
|
|
1521
|
+
return error instanceof RegistryUnavailableError
|
|
1522
|
+
? { verdict: "registry-unavailable", detail: asErrorMessage(error) }
|
|
1523
|
+
: { verdict: "registry-error", detail: asErrorMessage(error) };
|
|
1524
|
+
}
|
|
1525
|
+
const listing = listings.find(
|
|
1526
|
+
(entry) => entry.serviceRef.toLowerCase() === serviceRef.toLowerCase(),
|
|
1527
|
+
);
|
|
1528
|
+
if (listing === undefined) {
|
|
1529
|
+
return { verdict: "unlisted" };
|
|
1530
|
+
}
|
|
1531
|
+
if (listing.operator.toLowerCase() !== operator.toLowerCase()) {
|
|
1532
|
+
return { verdict: "mismatch", listedOperator: listing.operator };
|
|
1533
|
+
}
|
|
1534
|
+
return { verdict: "verified" };
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/**
|
|
1538
|
+
* Why a hire was refused, in words the caller can act on.
|
|
1539
|
+
*
|
|
1540
|
+
* Refusing strands nothing: no USDC has moved, and the same call succeeds once
|
|
1541
|
+
* the registry answers again.
|
|
1542
|
+
*/
|
|
1543
|
+
function explainPairingRefusal(
|
|
1544
|
+
pairing: Exclude<OperatorPairing, { verdict: "verified" }>,
|
|
1545
|
+
serviceRef: string,
|
|
1546
|
+
operator: string,
|
|
1547
|
+
): string {
|
|
1548
|
+
switch (pairing.verdict) {
|
|
1549
|
+
case "mismatch":
|
|
1550
|
+
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.`;
|
|
1551
|
+
case "unlisted":
|
|
1552
|
+
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.)`;
|
|
1553
|
+
case "registry-unavailable":
|
|
1554
|
+
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.`;
|
|
1555
|
+
case "registry-error":
|
|
1556
|
+
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.`;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
/** A prove-target precondition that failed with a caller-actionable reason. */
|
|
1561
|
+
class ProveTargetError extends Error {
|
|
1562
|
+
constructor(message: string) {
|
|
1563
|
+
super(message);
|
|
1564
|
+
this.name = "ProveTargetError";
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/** Origin of a URL, or the raw value when it will not parse. */
|
|
1569
|
+
function safeOrigin(value: string): string {
|
|
1570
|
+
try {
|
|
1571
|
+
return new URL(value).origin;
|
|
1572
|
+
} catch {
|
|
1573
|
+
return value;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
function normalizeHeaders(
|
|
1578
|
+
headers: Record<string, string> | undefined,
|
|
1579
|
+
): Record<string, string> {
|
|
1580
|
+
const normalized: Record<string, string> = {};
|
|
1581
|
+
for (const [name, value] of Object.entries(headers ?? {})) {
|
|
1582
|
+
normalized[name.toLowerCase()] = value;
|
|
1583
|
+
}
|
|
1584
|
+
return normalized;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/**
|
|
1588
|
+
* Renders an invocation as an MCP result.
|
|
1589
|
+
*
|
|
1590
|
+
* A non-2xx answer is returned as `isError` WITH the full structured content:
|
|
1591
|
+
* the call really happened and its body may be the thing the agent needs, but a
|
|
1592
|
+
* buyer who paid and got a 403 should not have to infer that from a status
|
|
1593
|
+
* field. Gateway denials carry their machine-readable reason, because
|
|
1594
|
+
* `stream-expired` and `nonce-replayed` call for opposite responses.
|
|
1595
|
+
*/
|
|
1596
|
+
function serializeInvocation(result: ServiceInvocationResult) {
|
|
1597
|
+
const summary = `${result.method} ${result.path} -> ${result.status}`;
|
|
1598
|
+
if (result.ok) {
|
|
1599
|
+
return {
|
|
1600
|
+
content: [{ type: "text" as const, text: summary }],
|
|
1601
|
+
structuredContent: { ...result },
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
const because =
|
|
1605
|
+
result.gatewayDenialReason === null
|
|
1606
|
+
? `the service itself answered ${result.status}; the capability was accepted`
|
|
1607
|
+
: `the seller's caller-auth gateway REFUSED the call: ${result.gatewayDenialReason}${gatewayDenialHint(result.gatewayDenialReason)}`;
|
|
1608
|
+
return {
|
|
1609
|
+
content: [{ type: "text" as const, text: `${summary} — ${because}` }],
|
|
1610
|
+
structuredContent: { ...result },
|
|
1611
|
+
isError: true,
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
/** One actionable next step per gateway denial reason. */
|
|
1616
|
+
function gatewayDenialHint(reason: string): string {
|
|
1617
|
+
switch (reason) {
|
|
1618
|
+
case "stream-not-funded":
|
|
1619
|
+
return " — the stream's remaining budget is exhausted; open a new one with hire_verified_service.";
|
|
1620
|
+
case "stream-expired":
|
|
1621
|
+
return " — the stream is past its expiry; reclaim_unspent returns the unspent budget.";
|
|
1622
|
+
case "stream-not-active":
|
|
1623
|
+
return " — the stream is closed; open a new one with hire_verified_service.";
|
|
1624
|
+
case "capability-expired":
|
|
1625
|
+
return " — the signed capability outlived its TTL before the gateway saw it; retry (optionally with a larger ttlSeconds).";
|
|
1626
|
+
case "nonce-replayed":
|
|
1627
|
+
return " — each capability is single-use; retry to mint a fresh one.";
|
|
1628
|
+
case "stream-state-stale":
|
|
1629
|
+
return " — the gateway's view of the chain is too stale to authorize; retry shortly.";
|
|
1630
|
+
case "buyer-mismatch":
|
|
1631
|
+
return " — the stream does not belong to this server's wallet.";
|
|
1632
|
+
default:
|
|
1633
|
+
return "";
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/**
|
|
1638
|
+
* Turns an invocation failure into something an LLM can act on, or `null` when
|
|
1639
|
+
* it is not a failure this layer understands (those fall through to the generic
|
|
1640
|
+
* handler, which logs the detail server-side and says nothing more).
|
|
1641
|
+
*
|
|
1642
|
+
* The generic "the tool call failed" is correct for an unexpected RPC/viem
|
|
1643
|
+
* error — it can carry internal detail — but it is useless for the three things
|
|
1644
|
+
* that actually go wrong here: wrong wallet, dead stream, unlisted service.
|
|
1645
|
+
*/
|
|
1646
|
+
function explainInvokeFailure(error: unknown, streamId: string): string | null {
|
|
1647
|
+
if (error instanceof InvokeBuyerMismatchError) {
|
|
1648
|
+
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.`;
|
|
1649
|
+
}
|
|
1650
|
+
if (error instanceof InvokeStreamNotActiveError) {
|
|
1651
|
+
return `stream ${streamId} is closed, so it authorizes nothing. Open a new one with hire_verified_service.`;
|
|
1652
|
+
}
|
|
1653
|
+
if (error instanceof InvokeStreamExpiredError) {
|
|
1654
|
+
return `stream ${streamId} has expired, so it authorizes nothing. reclaim_unspent returns the unspent budget; hire_verified_service opens a replacement.`;
|
|
1655
|
+
}
|
|
1656
|
+
if (error instanceof ServiceListingNotFoundError) {
|
|
1657
|
+
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.`;
|
|
1658
|
+
}
|
|
1659
|
+
if (error instanceof InvokeServiceRefMismatchError) {
|
|
1660
|
+
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.`;
|
|
1661
|
+
}
|
|
1662
|
+
if (error instanceof InvokeAccessUrlError) {
|
|
1663
|
+
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.`;
|
|
1664
|
+
}
|
|
1665
|
+
if (error instanceof InvokePathError) {
|
|
1666
|
+
return `invalid request path: ${error.message}`;
|
|
1667
|
+
}
|
|
1668
|
+
if (error instanceof RegistryUnavailableError) {
|
|
1669
|
+
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.`;
|
|
1670
|
+
}
|
|
1671
|
+
return null;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
998
1674
|
function successResult(
|
|
999
1675
|
text: string,
|
|
1000
1676
|
structuredContent?: Record<string, unknown>,
|