@absol-labs/agent 0.1.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/LICENSE +21 -0
- package/README.md +196 -0
- package/dist/discovery/registry.d.ts +68 -0
- package/dist/discovery/registry.d.ts.map +1 -0
- package/dist/discovery/registry.js +148 -0
- package/dist/discovery/registry.js.map +1 -0
- package/dist/frameworks/agentkit.d.ts +158 -0
- package/dist/frameworks/agentkit.d.ts.map +1 -0
- package/dist/frameworks/agentkit.js +339 -0
- package/dist/frameworks/agentkit.js.map +1 -0
- package/dist/frameworks/crewai.d.ts +19 -0
- package/dist/frameworks/crewai.d.ts.map +1 -0
- package/dist/frameworks/crewai.js +73 -0
- package/dist/frameworks/crewai.js.map +1 -0
- package/dist/frameworks/eliza.d.ts +393 -0
- package/dist/frameworks/eliza.d.ts.map +1 -0
- package/dist/frameworks/eliza.js +542 -0
- package/dist/frameworks/eliza.js.map +1 -0
- package/dist/frameworks/langchain.d.ts +31 -0
- package/dist/frameworks/langchain.d.ts.map +1 -0
- package/dist/frameworks/langchain.js +206 -0
- package/dist/frameworks/langchain.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/mandates/env.d.ts +51 -0
- package/dist/mandates/env.d.ts.map +1 -0
- package/dist/mandates/env.js +148 -0
- package/dist/mandates/env.js.map +1 -0
- package/dist/mandates/mandate.d.ts +214 -0
- package/dist/mandates/mandate.d.ts.map +1 -0
- package/dist/mandates/mandate.js +172 -0
- package/dist/mandates/mandate.js.map +1 -0
- package/dist/mcp/http-server.d.ts +2 -0
- package/dist/mcp/http-server.d.ts.map +1 -0
- package/dist/mcp/http-server.js +32 -0
- package/dist/mcp/http-server.js.map +1 -0
- package/dist/mcp/http.d.ts +114 -0
- package/dist/mcp/http.d.ts.map +1 -0
- package/dist/mcp/http.js +428 -0
- package/dist/mcp/http.js.map +1 -0
- package/dist/mcp/server.d.ts +119 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +679 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/stdio.d.ts +2 -0
- package/dist/mcp/stdio.d.ts.map +1 -0
- package/dist/mcp/stdio.js +6 -0
- package/dist/mcp/stdio.js.map +1 -0
- package/dist/sdk/client.d.ts +78 -0
- package/dist/sdk/client.d.ts.map +1 -0
- package/dist/sdk/client.js +99 -0
- package/dist/sdk/client.js.map +1 -0
- package/dist/wallet/provider.d.ts +51 -0
- package/dist/wallet/provider.d.ts.map +1 -0
- package/dist/wallet/provider.js +107 -0
- package/dist/wallet/provider.js.map +1 -0
- package/dist/x402/facilitator.d.ts +315 -0
- package/dist/x402/facilitator.d.ts.map +1 -0
- package/dist/x402/facilitator.js +194 -0
- package/dist/x402/facilitator.js.map +1 -0
- package/dist/zktls/reclaim.d.ts +137 -0
- package/dist/zktls/reclaim.d.ts.map +1 -0
- package/dist/zktls/reclaim.js +245 -0
- package/dist/zktls/reclaim.js.map +1 -0
- package/package.json +86 -0
- package/src/discovery/registry.ts +255 -0
- package/src/frameworks/agentkit.ts +537 -0
- package/src/frameworks/crewai.ts +102 -0
- package/src/frameworks/eliza.ts +776 -0
- package/src/frameworks/langchain.ts +325 -0
- package/src/index.ts +179 -0
- package/src/mandates/env.ts +222 -0
- package/src/mandates/mandate.ts +280 -0
- package/src/mcp/http-server.ts +38 -0
- package/src/mcp/http.ts +620 -0
- package/src/mcp/server.ts +1006 -0
- package/src/mcp/stdio.ts +8 -0
- package/src/sdk/client.ts +222 -0
- package/src/wallet/provider.ts +197 -0
- package/src/x402/facilitator.ts +336 -0
- package/src/zktls/reclaim.ts +424 -0
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
7
|
+
import { http, isAddress, isHex, type Chain } from "viem";
|
|
8
|
+
import { baseSepolia } from "viem/chains";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
discoverServices as defaultDiscoverServices,
|
|
13
|
+
type DiscoverServicesOptions,
|
|
14
|
+
type ServiceListing,
|
|
15
|
+
} from "../discovery/registry.js";
|
|
16
|
+
import { checkMandate, type SignedSpendMandate } from "../mandates/mandate.js";
|
|
17
|
+
import {
|
|
18
|
+
parseServerMandateCapsEnv,
|
|
19
|
+
signServerMandate,
|
|
20
|
+
ServerMandateConfigError,
|
|
21
|
+
} from "../mandates/env.js";
|
|
22
|
+
import {
|
|
23
|
+
VerifiedStreamAgentClient,
|
|
24
|
+
type ReclaimAuthorizedStreamInput,
|
|
25
|
+
type ReclaimVerifiedStreamResult,
|
|
26
|
+
type StreamStatusView,
|
|
27
|
+
type VerifiedStreamAgentOpener,
|
|
28
|
+
} from "../sdk/client.js";
|
|
29
|
+
import {
|
|
30
|
+
createWalletBackedAgentClient,
|
|
31
|
+
parseAgentWalletEnv,
|
|
32
|
+
type AgentWalletInput,
|
|
33
|
+
} from "../wallet/provider.js";
|
|
34
|
+
import {
|
|
35
|
+
createReclaimConsumerProofServiceFromEnv,
|
|
36
|
+
type ConsumerDeliveryProofService,
|
|
37
|
+
type VerifiedConsumedHttpsResponseProof,
|
|
38
|
+
} from "../zktls/reclaim.js";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* MCP server for the agent lane.
|
|
42
|
+
*
|
|
43
|
+
* The server exposes hire/status/reclaim/list tools over the MCP SDK while
|
|
44
|
+
* keeping all fund-moving operations fail-closed behind a signed spend mandate.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
const addressSchema = z
|
|
48
|
+
.string()
|
|
49
|
+
.refine((value) => isAddress(value), "must be an EVM address");
|
|
50
|
+
const bytes32Schema = z
|
|
51
|
+
.string()
|
|
52
|
+
.refine(
|
|
53
|
+
(value) => isHex(value, { strict: true }) && value.length === 66,
|
|
54
|
+
"must be bytes32 hex",
|
|
55
|
+
);
|
|
56
|
+
const decimalStringSchema = z
|
|
57
|
+
.string()
|
|
58
|
+
.regex(/^(0|[1-9]\d*)$/, "must be a base-10 integer string");
|
|
59
|
+
|
|
60
|
+
// The spend mandate is NOT a tool argument. An MCP caller (an LLM) cannot
|
|
61
|
+
// produce an EIP-712 signature, so the mandate is configured once at server
|
|
62
|
+
// construction (see `signedMandate` in VerifiedStreamMcpServerOptions) exactly
|
|
63
|
+
// like the AgentKit action provider. Tool inputs carry only the hire terms.
|
|
64
|
+
const hireVerifiedServiceSchema = {
|
|
65
|
+
operator: addressSchema.describe(
|
|
66
|
+
"Seller payout wallet (EVM address) that receives USDC for oracle-verified delivery.",
|
|
67
|
+
),
|
|
68
|
+
serviceRef: bytes32Schema.describe(
|
|
69
|
+
"bytes32 service identifier the oracle verifies delivery against (from discover_services).",
|
|
70
|
+
),
|
|
71
|
+
budgetUsdc: decimalStringSchema.describe(
|
|
72
|
+
'Total USDC to escrow for this stream, in ATOMIC units (6 decimals): 1 USDC = "1000000".',
|
|
73
|
+
),
|
|
74
|
+
ratePerSecondUsdc: decimalStringSchema.describe(
|
|
75
|
+
"Accrual to the seller per verified second, in USDC ATOMIC units (6 decimals).",
|
|
76
|
+
),
|
|
77
|
+
durationSeconds: z
|
|
78
|
+
.number()
|
|
79
|
+
.int()
|
|
80
|
+
.positive()
|
|
81
|
+
.optional()
|
|
82
|
+
.describe(
|
|
83
|
+
"Optional max stream lifetime in seconds. Defaults to the server mandate's max duration.",
|
|
84
|
+
),
|
|
85
|
+
} as const;
|
|
86
|
+
|
|
87
|
+
const getStreamStatusSchema = {
|
|
88
|
+
streamId: bytes32Schema.describe(
|
|
89
|
+
"Stream id returned by hire_verified_service.",
|
|
90
|
+
),
|
|
91
|
+
} as const;
|
|
92
|
+
|
|
93
|
+
const reclaimStreamSchema = {
|
|
94
|
+
streamId: bytes32Schema.describe("Stream id to reclaim unspent USDC from."),
|
|
95
|
+
closeFirst: z
|
|
96
|
+
.boolean()
|
|
97
|
+
.optional()
|
|
98
|
+
.describe(
|
|
99
|
+
"Close the stream before reclaiming. Use when the stream is still active.",
|
|
100
|
+
),
|
|
101
|
+
} as const;
|
|
102
|
+
|
|
103
|
+
const proveHttpsResponseSchema = {
|
|
104
|
+
streamId: bytes32Schema,
|
|
105
|
+
url: z.string().url(),
|
|
106
|
+
method: z.enum(["GET", "POST", "PUT"]).optional(),
|
|
107
|
+
headers: z.record(z.string()).optional(),
|
|
108
|
+
body: z.string().optional(),
|
|
109
|
+
responseMatches: z
|
|
110
|
+
.array(
|
|
111
|
+
z.object({
|
|
112
|
+
type: z.enum(["regex", "contains"]),
|
|
113
|
+
value: z.string().min(1),
|
|
114
|
+
invert: z.boolean().optional(),
|
|
115
|
+
isOptional: z.boolean().optional(),
|
|
116
|
+
}),
|
|
117
|
+
)
|
|
118
|
+
.min(1),
|
|
119
|
+
responseRedactions: z
|
|
120
|
+
.array(
|
|
121
|
+
z
|
|
122
|
+
.object({
|
|
123
|
+
regex: z.string().optional(),
|
|
124
|
+
jsonPath: z.string().optional(),
|
|
125
|
+
xPath: z.string().optional(),
|
|
126
|
+
hash: z.enum(["oprf", "oprf-raw"]).optional(),
|
|
127
|
+
})
|
|
128
|
+
.refine(
|
|
129
|
+
(value) =>
|
|
130
|
+
value.regex !== undefined ||
|
|
131
|
+
value.jsonPath !== undefined ||
|
|
132
|
+
value.xPath !== undefined,
|
|
133
|
+
"response redaction needs regex, jsonPath, or xPath",
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
.optional(),
|
|
137
|
+
geoLocation: z.string().optional(),
|
|
138
|
+
useTee: z.boolean().optional(),
|
|
139
|
+
zkEngine: z.enum(["snarkjs", "stwo"]).optional(),
|
|
140
|
+
} as const;
|
|
141
|
+
|
|
142
|
+
const metrikAgentEnvSchema = z.object({
|
|
143
|
+
METRIK_AGENT_RPC_URL: z.string().url(),
|
|
144
|
+
METRIK_AGENT_ESCROW: addressSchema,
|
|
145
|
+
METRIK_AGENT_USDC: addressSchema,
|
|
146
|
+
METRIK_AGENT_CHAIN_ID: z.string().optional(),
|
|
147
|
+
METRIK_AGENT_CHAIN_NAME: z.string().optional(),
|
|
148
|
+
METRIK_AGENT_MCP_STATE_FILE: z.string().optional(),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
export interface McpToolSpec {
|
|
152
|
+
name: string;
|
|
153
|
+
description: string;
|
|
154
|
+
/** Whether invoking this tool can move funds (must pass a spend mandate check). */
|
|
155
|
+
movesFunds: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const METRIK_MCP_TOOLS: readonly McpToolSpec[] = [
|
|
159
|
+
{
|
|
160
|
+
name: "discover_services",
|
|
161
|
+
description:
|
|
162
|
+
"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.",
|
|
163
|
+
movesFunds: false,
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: "hire_verified_service",
|
|
167
|
+
description:
|
|
168
|
+
"Open a verified payment stream for a third-party service. Pays only for oracle-verified delivered time. Subject to the agent's spend mandate.",
|
|
169
|
+
movesFunds: true,
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: "check_stream_status",
|
|
173
|
+
description:
|
|
174
|
+
"Read a stream's live status, accrued amount, and claimable/reclaimable balances.",
|
|
175
|
+
movesFunds: false,
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: "reclaim_unspent",
|
|
179
|
+
description:
|
|
180
|
+
"Close a stream and reclaim unspent funds to the buyer after failure or completion.",
|
|
181
|
+
movesFunds: true,
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: "list_streams",
|
|
185
|
+
description: "List the agent's streams and their current state.",
|
|
186
|
+
movesFunds: false,
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: "prove_https_response",
|
|
190
|
+
description:
|
|
191
|
+
"Generate a zkTLS proof of the exact HTTPS response consumed for a tracked stream. This is an L4 delivery signal and must be cross-checked with oracle/L1 evidence.",
|
|
192
|
+
movesFunds: false,
|
|
193
|
+
},
|
|
194
|
+
] as const;
|
|
195
|
+
|
|
196
|
+
/** @deprecated Renamed to METRIK_MCP_TOOLS; retained for back-compat. */
|
|
197
|
+
export const STREAMPROOF_MCP_TOOLS = METRIK_MCP_TOOLS;
|
|
198
|
+
|
|
199
|
+
export interface StoredStreamRecord {
|
|
200
|
+
readonly streamId: `0x${string}`;
|
|
201
|
+
readonly buyer: `0x${string}`;
|
|
202
|
+
readonly operator: `0x${string}`;
|
|
203
|
+
readonly serviceRef: `0x${string}`;
|
|
204
|
+
readonly budgetUsdc: bigint;
|
|
205
|
+
readonly ratePerSecondUsdc: bigint;
|
|
206
|
+
readonly maxDurationSeconds: number;
|
|
207
|
+
readonly openedAtSeconds: number;
|
|
208
|
+
readonly mandateId: `0x${string}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface StreamRegistry {
|
|
212
|
+
put(record: StoredStreamRecord): Promise<void>;
|
|
213
|
+
get(streamId: `0x${string}`): Promise<StoredStreamRecord | null>;
|
|
214
|
+
list(): Promise<readonly StoredStreamRecord[]>;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export class InMemoryStreamRegistry implements StreamRegistry {
|
|
218
|
+
private readonly records = new Map<`0x${string}`, StoredStreamRecord>();
|
|
219
|
+
|
|
220
|
+
async put(record: StoredStreamRecord): Promise<void> {
|
|
221
|
+
this.records.set(record.streamId, record);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async get(streamId: `0x${string}`): Promise<StoredStreamRecord | null> {
|
|
225
|
+
return this.records.get(streamId) ?? null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async list(): Promise<readonly StoredStreamRecord[]> {
|
|
229
|
+
return Array.from(this.records.values());
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export class FileStreamRegistry implements StreamRegistry {
|
|
234
|
+
constructor(private readonly filePath: string) {}
|
|
235
|
+
|
|
236
|
+
async put(record: StoredStreamRecord): Promise<void> {
|
|
237
|
+
const records = await this.readAll();
|
|
238
|
+
records.set(record.streamId, record);
|
|
239
|
+
await this.writeAll(records);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async get(streamId: `0x${string}`): Promise<StoredStreamRecord | null> {
|
|
243
|
+
const records = await this.readAll();
|
|
244
|
+
return records.get(streamId) ?? null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async list(): Promise<readonly StoredStreamRecord[]> {
|
|
248
|
+
return Array.from((await this.readAll()).values());
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private async readAll(): Promise<Map<`0x${string}`, StoredStreamRecord>> {
|
|
252
|
+
const contents = await fs.readFile(this.filePath, "utf8").catch((error) => {
|
|
253
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
254
|
+
return "[]";
|
|
255
|
+
}
|
|
256
|
+
throw error;
|
|
257
|
+
});
|
|
258
|
+
const parsed = z
|
|
259
|
+
.array(
|
|
260
|
+
z.object({
|
|
261
|
+
streamId: bytes32Schema,
|
|
262
|
+
buyer: addressSchema,
|
|
263
|
+
operator: addressSchema,
|
|
264
|
+
serviceRef: bytes32Schema,
|
|
265
|
+
budgetUsdc: decimalStringSchema,
|
|
266
|
+
ratePerSecondUsdc: decimalStringSchema,
|
|
267
|
+
maxDurationSeconds: z.number().int().positive(),
|
|
268
|
+
openedAtSeconds: z.number().int().nonnegative(),
|
|
269
|
+
mandateId: bytes32Schema,
|
|
270
|
+
}),
|
|
271
|
+
)
|
|
272
|
+
.parse(JSON.parse(contents));
|
|
273
|
+
|
|
274
|
+
return new Map(
|
|
275
|
+
parsed.map((record) => [
|
|
276
|
+
record.streamId as `0x${string}`,
|
|
277
|
+
{
|
|
278
|
+
streamId: record.streamId as `0x${string}`,
|
|
279
|
+
buyer: record.buyer as `0x${string}`,
|
|
280
|
+
operator: record.operator as `0x${string}`,
|
|
281
|
+
serviceRef: record.serviceRef as `0x${string}`,
|
|
282
|
+
budgetUsdc: BigInt(record.budgetUsdc),
|
|
283
|
+
ratePerSecondUsdc: BigInt(record.ratePerSecondUsdc),
|
|
284
|
+
maxDurationSeconds: record.maxDurationSeconds,
|
|
285
|
+
openedAtSeconds: record.openedAtSeconds,
|
|
286
|
+
mandateId: record.mandateId as `0x${string}`,
|
|
287
|
+
},
|
|
288
|
+
]),
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private async writeAll(
|
|
293
|
+
records: Map<`0x${string}`, StoredStreamRecord>,
|
|
294
|
+
): Promise<void> {
|
|
295
|
+
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
|
296
|
+
await fs.writeFile(
|
|
297
|
+
this.filePath,
|
|
298
|
+
JSON.stringify(
|
|
299
|
+
Array.from(records.values()).map((record) => ({
|
|
300
|
+
...record,
|
|
301
|
+
budgetUsdc: record.budgetUsdc.toString(),
|
|
302
|
+
ratePerSecondUsdc: record.ratePerSecondUsdc.toString(),
|
|
303
|
+
})),
|
|
304
|
+
null,
|
|
305
|
+
2,
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export interface MandateStateResolver {
|
|
312
|
+
getSpentSoFarUsdc(owner: `0x${string}`): Promise<bigint>;
|
|
313
|
+
getRevokedMandateIds(owner: `0x${string}`): Promise<readonly `0x${string}`[]>;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export interface VerifiedStreamMcpClient extends Pick<
|
|
317
|
+
VerifiedStreamAgentOpener,
|
|
318
|
+
"openVerifiedStream"
|
|
319
|
+
> {
|
|
320
|
+
getStreamStatus(streamId: `0x${string}`): Promise<StreamStatusView>;
|
|
321
|
+
reclaimStream(
|
|
322
|
+
input: ReclaimAuthorizedStreamInput,
|
|
323
|
+
): Promise<ReclaimVerifiedStreamResult>;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export interface VerifiedStreamMcpServerOptions {
|
|
327
|
+
readonly agentClient: VerifiedStreamMcpClient;
|
|
328
|
+
/**
|
|
329
|
+
* The owner-signed spend mandate that bounds every hire/reclaim (per-stream +
|
|
330
|
+
* total budget, rate, duration, allowed operators, expiry). Configured once at
|
|
331
|
+
* construction — the MCP caller (an LLM) never supplies or edits it, because it
|
|
332
|
+
* cannot produce an EIP-712 signature. Mirrors the AgentKit action provider.
|
|
333
|
+
*/
|
|
334
|
+
readonly signedMandate: SignedSpendMandate;
|
|
335
|
+
/**
|
|
336
|
+
* Optional provider returning the CURRENT signed mandate. When set, it
|
|
337
|
+
* supersedes {@link signedMandate} for fund-moving operations and is resolved
|
|
338
|
+
* per call — so a provider that re-signs before expiry keeps a long-running
|
|
339
|
+
* server authorizing hires. The env builder supplies a lazily-re-signing one.
|
|
340
|
+
*/
|
|
341
|
+
readonly mandateProvider?: () =>
|
|
342
|
+
| SignedSpendMandate
|
|
343
|
+
| Promise<SignedSpendMandate>;
|
|
344
|
+
readonly registry?: StreamRegistry;
|
|
345
|
+
readonly deliveryProofService?: ConsumerDeliveryProofService;
|
|
346
|
+
readonly mandateStateResolver?: Partial<MandateStateResolver>;
|
|
347
|
+
readonly now?: () => number;
|
|
348
|
+
/**
|
|
349
|
+
* Discovery driver for the `discover_services` tool. Defaults to the live
|
|
350
|
+
* registry-backed {@link discoverServices}; tests inject a double. Defaults for
|
|
351
|
+
* the registry URL/apikey come from env inside `discoverServices`.
|
|
352
|
+
*/
|
|
353
|
+
readonly discoverServices?: DiscoverServicesFn;
|
|
354
|
+
readonly serverInfo?: {
|
|
355
|
+
readonly name: string;
|
|
356
|
+
readonly version: string;
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export interface VerifiedStreamMcpServerRuntime {
|
|
361
|
+
readonly mcpServer: McpServer;
|
|
362
|
+
readonly registry: StreamRegistry;
|
|
363
|
+
connectStdio(): Promise<void>;
|
|
364
|
+
close(): Promise<void>;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export interface ParsedMetrikAgentEnv {
|
|
368
|
+
readonly rpcUrl: string;
|
|
369
|
+
readonly escrow: `0x${string}`;
|
|
370
|
+
readonly usdc: `0x${string}`;
|
|
371
|
+
readonly chain: Chain;
|
|
372
|
+
readonly stateFile?: string;
|
|
373
|
+
readonly wallet: AgentWalletInput;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Re-sign the server mandate when it is within this many seconds of expiry, so a
|
|
378
|
+
* long-running server never bricks hires on a lapsed construction-time mandate.
|
|
379
|
+
*/
|
|
380
|
+
const MANDATE_REFRESH_WINDOW_SECONDS = 300;
|
|
381
|
+
|
|
382
|
+
const warnedResolverGaps = new Set<string>();
|
|
383
|
+
|
|
384
|
+
/** Emits a stderr warning at most once per process for a given key. */
|
|
385
|
+
function warnOnce(key: string, message: string): void {
|
|
386
|
+
if (warnedResolverGaps.has(key)) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
warnedResolverGaps.add(key);
|
|
390
|
+
// stderr, never stdout: stdout is the MCP stdio transport channel.
|
|
391
|
+
console.error(message);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const DISCOVER_TOOL_SPEC = METRIK_MCP_TOOLS[0]!;
|
|
395
|
+
const HIRE_TOOL_SPEC = METRIK_MCP_TOOLS[1]!;
|
|
396
|
+
const STATUS_TOOL_SPEC = METRIK_MCP_TOOLS[2]!;
|
|
397
|
+
const RECLAIM_TOOL_SPEC = METRIK_MCP_TOOLS[3]!;
|
|
398
|
+
const LIST_TOOL_SPEC = METRIK_MCP_TOOLS[4]!;
|
|
399
|
+
const PROVE_TOOL_SPEC = METRIK_MCP_TOOLS[5]!;
|
|
400
|
+
|
|
401
|
+
const discoverServicesSchema = {
|
|
402
|
+
category: z
|
|
403
|
+
.string()
|
|
404
|
+
.min(1)
|
|
405
|
+
.optional()
|
|
406
|
+
.describe("Optional marketplace category to filter listings by."),
|
|
407
|
+
limit: z
|
|
408
|
+
.number()
|
|
409
|
+
.int()
|
|
410
|
+
.positive()
|
|
411
|
+
.optional()
|
|
412
|
+
.describe("Optional cap on the number of verified listings to return."),
|
|
413
|
+
} as const;
|
|
414
|
+
|
|
415
|
+
/** Discovery driver signature — overridable in tests. */
|
|
416
|
+
export type DiscoverServicesFn = (
|
|
417
|
+
opts?: DiscoverServicesOptions,
|
|
418
|
+
) => Promise<ServiceListing[]>;
|
|
419
|
+
|
|
420
|
+
export function createVerifiedStreamMcpServer(
|
|
421
|
+
options: VerifiedStreamMcpServerOptions,
|
|
422
|
+
): VerifiedStreamMcpServerRuntime {
|
|
423
|
+
const registry = options.registry ?? new InMemoryStreamRegistry();
|
|
424
|
+
|
|
425
|
+
// Fail-closed mandate accounting defaults (security):
|
|
426
|
+
// - Cumulative-cap (`maxTotalUsdc`) is enforced by DEFAULT: spent-so-far is
|
|
427
|
+
// summed from escrowed budget tracked in the stream registry, so the total
|
|
428
|
+
// cap applies with no external wiring. This is conservative (counts full
|
|
429
|
+
// escrowed budget, buyer-favoring) — wire an explicit resolver for exact
|
|
430
|
+
// settled-spend accounting.
|
|
431
|
+
// - Revocation cannot be known without an external store, so it defaults to
|
|
432
|
+
// "none revoked" but emits a loud one-time warning: a revoked mandate is NOT
|
|
433
|
+
// rejected until `getRevokedMandateIds` is wired.
|
|
434
|
+
const registryBackedSpent = async (owner: `0x${string}`): Promise<bigint> => {
|
|
435
|
+
const records = await registry.list();
|
|
436
|
+
return records
|
|
437
|
+
.filter((record) => record.buyer.toLowerCase() === owner.toLowerCase())
|
|
438
|
+
.reduce((sum, record) => sum + record.budgetUsdc, 0n);
|
|
439
|
+
};
|
|
440
|
+
if (options.mandateStateResolver?.getRevokedMandateIds === undefined) {
|
|
441
|
+
warnOnce(
|
|
442
|
+
"mcp-revocation",
|
|
443
|
+
"[metrik] MCP server started with no mandate revocation resolver: revoked mandates will NOT be rejected until getRevokedMandateIds is wired. Cumulative spend is bounded by escrowed budget from the stream registry.",
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
const resolver: MandateStateResolver = {
|
|
447
|
+
getSpentSoFarUsdc:
|
|
448
|
+
options.mandateStateResolver?.getSpentSoFarUsdc ?? registryBackedSpent,
|
|
449
|
+
getRevokedMandateIds:
|
|
450
|
+
options.mandateStateResolver?.getRevokedMandateIds ?? (async () => []),
|
|
451
|
+
};
|
|
452
|
+
// Resolve the CURRENT mandate per fund-moving call. A provider (env builder)
|
|
453
|
+
// re-signs before expiry so a long-running server keeps authorizing hires;
|
|
454
|
+
// without one, the static construction-time mandate is used.
|
|
455
|
+
const resolveMandate =
|
|
456
|
+
options.mandateProvider ?? (() => options.signedMandate);
|
|
457
|
+
|
|
458
|
+
// Per-owner serialization for hires. `getSpentSoFarUsdc` reads the registry
|
|
459
|
+
// BEFORE the new stream's record is written, so concurrent hires from one
|
|
460
|
+
// owner could both observe stale spend and overshoot `maxTotalUsdc`. Chaining
|
|
461
|
+
// hires per owner makes the read→check→open→record sequence atomic per owner.
|
|
462
|
+
const ownerHireChains = new Map<string, Promise<unknown>>();
|
|
463
|
+
const serializeHire = <T>(
|
|
464
|
+
owner: `0x${string}`,
|
|
465
|
+
task: () => Promise<T>,
|
|
466
|
+
): Promise<T> => {
|
|
467
|
+
const key = owner.toLowerCase();
|
|
468
|
+
const prev = ownerHireChains.get(key) ?? Promise.resolve();
|
|
469
|
+
const run = prev.then(task, task);
|
|
470
|
+
ownerHireChains.set(
|
|
471
|
+
key,
|
|
472
|
+
run.then(
|
|
473
|
+
() => undefined,
|
|
474
|
+
() => undefined,
|
|
475
|
+
),
|
|
476
|
+
);
|
|
477
|
+
return run;
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const now = options.now ?? (() => Math.floor(Date.now() / 1000));
|
|
481
|
+
const discover = options.discoverServices ?? defaultDiscoverServices;
|
|
482
|
+
|
|
483
|
+
const mcpServer = new McpServer(
|
|
484
|
+
options.serverInfo ?? {
|
|
485
|
+
name: "metrik-agent-mcp",
|
|
486
|
+
version: "0.1.0",
|
|
487
|
+
},
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
mcpServer.registerTool(
|
|
491
|
+
"discover_services",
|
|
492
|
+
{
|
|
493
|
+
description: DISCOVER_TOOL_SPEC.description,
|
|
494
|
+
inputSchema: discoverServicesSchema,
|
|
495
|
+
annotations: {
|
|
496
|
+
idempotentHint: true,
|
|
497
|
+
openWorldHint: true,
|
|
498
|
+
readOnlyHint: true,
|
|
499
|
+
title: "Discover Verified Services",
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
async ({ category, limit }) => {
|
|
503
|
+
try {
|
|
504
|
+
const listings = await discover({
|
|
505
|
+
...(category === undefined ? {} : { category }),
|
|
506
|
+
...(limit === undefined ? {} : { limit }),
|
|
507
|
+
});
|
|
508
|
+
return successResult(
|
|
509
|
+
`discovered ${listings.length} verified service${listings.length === 1 ? "" : "s"}`,
|
|
510
|
+
{ services: listings.map(serializeServiceListing) },
|
|
511
|
+
);
|
|
512
|
+
} catch (error) {
|
|
513
|
+
return safeToolError(error);
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
mcpServer.registerTool(
|
|
519
|
+
"hire_verified_service",
|
|
520
|
+
{
|
|
521
|
+
description: HIRE_TOOL_SPEC.description,
|
|
522
|
+
inputSchema: hireVerifiedServiceSchema,
|
|
523
|
+
annotations: {
|
|
524
|
+
destructiveHint: true,
|
|
525
|
+
idempotentHint: false,
|
|
526
|
+
openWorldHint: true,
|
|
527
|
+
readOnlyHint: false,
|
|
528
|
+
title: "Hire Verified Service",
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
async (args) => {
|
|
532
|
+
try {
|
|
533
|
+
const signedMandate = await resolveMandate();
|
|
534
|
+
const owner = signedMandate.owner as `0x${string}`;
|
|
535
|
+
const nowSeconds = now();
|
|
536
|
+
const request = {
|
|
537
|
+
operator: args.operator as `0x${string}`,
|
|
538
|
+
budgetUsdc: BigInt(args.budgetUsdc),
|
|
539
|
+
ratePerSecondUsdc: BigInt(args.ratePerSecondUsdc),
|
|
540
|
+
maxDurationSeconds:
|
|
541
|
+
args.durationSeconds ?? signedMandate.mandate.maxDurationSeconds,
|
|
542
|
+
} satisfies Parameters<typeof checkMandate>[1];
|
|
543
|
+
|
|
544
|
+
// Serialize per owner: the spent-so-far read, cap check, open, and
|
|
545
|
+
// registry write must be atomic, or concurrent hires could both pass
|
|
546
|
+
// the cumulative-cap check on stale spend and overshoot maxTotalUsdc.
|
|
547
|
+
return await serializeHire(owner, async () => {
|
|
548
|
+
const spentSoFarUsdc = await resolver.getSpentSoFarUsdc(owner);
|
|
549
|
+
const revokedMandateIds = await resolver.getRevokedMandateIds(owner);
|
|
550
|
+
const decision = await checkMandate(
|
|
551
|
+
signedMandate,
|
|
552
|
+
request,
|
|
553
|
+
spentSoFarUsdc,
|
|
554
|
+
{
|
|
555
|
+
nowSeconds,
|
|
556
|
+
revokedMandateIds,
|
|
557
|
+
},
|
|
558
|
+
);
|
|
559
|
+
if (!decision.allowed) {
|
|
560
|
+
return errorResult(
|
|
561
|
+
`mandate denied: ${decision.reason ?? "unknown"}`,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const result = await options.agentClient.openVerifiedStream({
|
|
566
|
+
operator: request.operator,
|
|
567
|
+
serviceRef: args.serviceRef as `0x${string}`,
|
|
568
|
+
budgetUsdc: request.budgetUsdc,
|
|
569
|
+
ratePerSecondUsdc: request.ratePerSecondUsdc,
|
|
570
|
+
maxDurationSeconds: request.maxDurationSeconds,
|
|
571
|
+
signedMandate,
|
|
572
|
+
spentSoFarUsdc,
|
|
573
|
+
nowSeconds,
|
|
574
|
+
revokedMandateIds,
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
await registry.put({
|
|
578
|
+
streamId: result.streamId,
|
|
579
|
+
buyer: owner,
|
|
580
|
+
operator: request.operator,
|
|
581
|
+
serviceRef: args.serviceRef as `0x${string}`,
|
|
582
|
+
budgetUsdc: request.budgetUsdc,
|
|
583
|
+
ratePerSecondUsdc: request.ratePerSecondUsdc,
|
|
584
|
+
maxDurationSeconds: request.maxDurationSeconds,
|
|
585
|
+
openedAtSeconds: nowSeconds,
|
|
586
|
+
mandateId: signedMandate.mandateId as `0x${string}`,
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
return successResult(
|
|
590
|
+
`opened verified stream ${result.streamId} on chain ${result.handle.chainId}`,
|
|
591
|
+
{
|
|
592
|
+
streamId: result.streamId,
|
|
593
|
+
txHash: result.txHash,
|
|
594
|
+
approveTxHash: result.approveTxHash,
|
|
595
|
+
handle: {
|
|
596
|
+
streamId: result.handle.streamId,
|
|
597
|
+
escrow: result.handle.escrow,
|
|
598
|
+
chainId: result.handle.chainId,
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
);
|
|
602
|
+
});
|
|
603
|
+
} catch (error) {
|
|
604
|
+
return safeToolError(error);
|
|
605
|
+
}
|
|
606
|
+
},
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
mcpServer.registerTool(
|
|
610
|
+
"check_stream_status",
|
|
611
|
+
{
|
|
612
|
+
description: STATUS_TOOL_SPEC.description,
|
|
613
|
+
inputSchema: getStreamStatusSchema,
|
|
614
|
+
annotations: {
|
|
615
|
+
idempotentHint: true,
|
|
616
|
+
openWorldHint: false,
|
|
617
|
+
readOnlyHint: true,
|
|
618
|
+
title: "Get Stream Status",
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
async ({ streamId }) => {
|
|
622
|
+
try {
|
|
623
|
+
const status = await options.agentClient.getStreamStatus(
|
|
624
|
+
streamId as `0x${string}`,
|
|
625
|
+
);
|
|
626
|
+
return successResult(
|
|
627
|
+
`stream ${streamId} is ${status.stream.status}`,
|
|
628
|
+
serializeStreamStatus(status, streamId as `0x${string}`),
|
|
629
|
+
);
|
|
630
|
+
} catch (error) {
|
|
631
|
+
return safeToolError(error);
|
|
632
|
+
}
|
|
633
|
+
},
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
mcpServer.registerTool(
|
|
637
|
+
"reclaim_unspent",
|
|
638
|
+
{
|
|
639
|
+
description: RECLAIM_TOOL_SPEC.description,
|
|
640
|
+
inputSchema: reclaimStreamSchema,
|
|
641
|
+
annotations: {
|
|
642
|
+
destructiveHint: true,
|
|
643
|
+
idempotentHint: false,
|
|
644
|
+
openWorldHint: true,
|
|
645
|
+
readOnlyHint: false,
|
|
646
|
+
title: "Reclaim Stream",
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
async ({ streamId, closeFirst }) => {
|
|
650
|
+
try {
|
|
651
|
+
const record = await registry.get(streamId as `0x${string}`);
|
|
652
|
+
if (record === null) {
|
|
653
|
+
return errorResult(`unknown stream: ${streamId}`);
|
|
654
|
+
}
|
|
655
|
+
const signedMandate = await resolveMandate();
|
|
656
|
+
|
|
657
|
+
// Reclaim/close only RETURN unspent funds to the buyer or stop payment.
|
|
658
|
+
// They are authorized on mandate-owner signature ALONE (see the SDK
|
|
659
|
+
// client's authorizeBuyerRecovery) — never gated by expiry, revocation,
|
|
660
|
+
// or caps, so a lapsed/revoked mandate can never strand buyer funds.
|
|
661
|
+
const result = await options.agentClient.reclaimStream({
|
|
662
|
+
streamId: streamId as `0x${string}`,
|
|
663
|
+
signedMandate,
|
|
664
|
+
spentSoFarUsdc: 0n,
|
|
665
|
+
nowSeconds: now(),
|
|
666
|
+
...(closeFirst === undefined ? {} : { closeFirst }),
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
return successResult(`reclaimed stream ${streamId}`, {
|
|
670
|
+
closeTxHash: result.closeResult?.txHash ?? null,
|
|
671
|
+
reclaimTxHash: result.reclaimResult.txHash,
|
|
672
|
+
});
|
|
673
|
+
} catch (error) {
|
|
674
|
+
return safeToolError(error);
|
|
675
|
+
}
|
|
676
|
+
},
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
mcpServer.registerTool(
|
|
680
|
+
"list_streams",
|
|
681
|
+
{
|
|
682
|
+
description: LIST_TOOL_SPEC.description,
|
|
683
|
+
annotations: {
|
|
684
|
+
idempotentHint: true,
|
|
685
|
+
openWorldHint: false,
|
|
686
|
+
readOnlyHint: true,
|
|
687
|
+
title: "List Streams",
|
|
688
|
+
},
|
|
689
|
+
},
|
|
690
|
+
async () => {
|
|
691
|
+
try {
|
|
692
|
+
const records = await registry.list();
|
|
693
|
+
const entries = await Promise.all(
|
|
694
|
+
records.map(async (record) => {
|
|
695
|
+
const status = await options.agentClient
|
|
696
|
+
.getStreamStatus(record.streamId)
|
|
697
|
+
.then((value) => serializeStreamStatus(value, record.streamId))
|
|
698
|
+
.catch((error) => ({
|
|
699
|
+
streamId: record.streamId,
|
|
700
|
+
statusError: asErrorMessage(error),
|
|
701
|
+
}));
|
|
702
|
+
return {
|
|
703
|
+
streamId: record.streamId,
|
|
704
|
+
buyer: record.buyer,
|
|
705
|
+
operator: record.operator,
|
|
706
|
+
serviceRef: record.serviceRef,
|
|
707
|
+
budgetUsdc: record.budgetUsdc.toString(),
|
|
708
|
+
ratePerSecondUsdc: record.ratePerSecondUsdc.toString(),
|
|
709
|
+
maxDurationSeconds: record.maxDurationSeconds,
|
|
710
|
+
openedAtSeconds: record.openedAtSeconds,
|
|
711
|
+
mandateId: record.mandateId,
|
|
712
|
+
status,
|
|
713
|
+
};
|
|
714
|
+
}),
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
return successResult(
|
|
718
|
+
`tracked ${entries.length} stream${entries.length === 1 ? "" : "s"}`,
|
|
719
|
+
{ streams: entries },
|
|
720
|
+
);
|
|
721
|
+
} catch (error) {
|
|
722
|
+
return safeToolError(error);
|
|
723
|
+
}
|
|
724
|
+
},
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
if (options.deliveryProofService !== undefined) {
|
|
728
|
+
const deliveryProofService = options.deliveryProofService;
|
|
729
|
+
mcpServer.registerTool(
|
|
730
|
+
"prove_https_response",
|
|
731
|
+
{
|
|
732
|
+
description: PROVE_TOOL_SPEC.description,
|
|
733
|
+
inputSchema: proveHttpsResponseSchema,
|
|
734
|
+
annotations: {
|
|
735
|
+
idempotentHint: false,
|
|
736
|
+
openWorldHint: true,
|
|
737
|
+
readOnlyHint: true,
|
|
738
|
+
title: "Prove HTTPS Response",
|
|
739
|
+
},
|
|
740
|
+
},
|
|
741
|
+
async ({
|
|
742
|
+
streamId,
|
|
743
|
+
url,
|
|
744
|
+
method,
|
|
745
|
+
headers,
|
|
746
|
+
body,
|
|
747
|
+
responseMatches,
|
|
748
|
+
responseRedactions,
|
|
749
|
+
geoLocation,
|
|
750
|
+
useTee,
|
|
751
|
+
zkEngine,
|
|
752
|
+
}) => {
|
|
753
|
+
try {
|
|
754
|
+
const record = await registry.get(streamId as `0x${string}`);
|
|
755
|
+
if (record === null) {
|
|
756
|
+
return errorResult(`unknown stream: ${streamId}`);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const result = await deliveryProofService.proveConsumedHttpsResponse({
|
|
760
|
+
url,
|
|
761
|
+
...(method === undefined ? {} : { method }),
|
|
762
|
+
...(headers === undefined ? {} : { headers }),
|
|
763
|
+
...(body === undefined ? {} : { body }),
|
|
764
|
+
responseMatches,
|
|
765
|
+
...(responseRedactions === undefined ? {} : { responseRedactions }),
|
|
766
|
+
...(geoLocation === undefined ? {} : { geoLocation }),
|
|
767
|
+
...(useTee === undefined ? {} : { useTee }),
|
|
768
|
+
...(zkEngine === undefined ? {} : { zkEngine }),
|
|
769
|
+
delivery: {
|
|
770
|
+
owner: record.buyer,
|
|
771
|
+
streamId: record.streamId,
|
|
772
|
+
operator: record.operator,
|
|
773
|
+
serviceRef: record.serviceRef,
|
|
774
|
+
mandateId: record.mandateId,
|
|
775
|
+
},
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
return successResult(
|
|
779
|
+
`generated zkTLS response proof for stream ${streamId}`,
|
|
780
|
+
serializeConsumedResponseProof(record.streamId, result),
|
|
781
|
+
);
|
|
782
|
+
} catch (error) {
|
|
783
|
+
return safeToolError(error);
|
|
784
|
+
}
|
|
785
|
+
},
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
return {
|
|
790
|
+
mcpServer,
|
|
791
|
+
registry,
|
|
792
|
+
connectStdio: async () => {
|
|
793
|
+
await mcpServer.connect(new StdioServerTransport());
|
|
794
|
+
},
|
|
795
|
+
close: async () => {
|
|
796
|
+
await mcpServer.close();
|
|
797
|
+
},
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
export function parseMetrikAgentEnv(
|
|
802
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
803
|
+
): ParsedMetrikAgentEnv {
|
|
804
|
+
const parsed = metrikAgentEnvSchema.parse(env);
|
|
805
|
+
const chainId = Number(parsed.METRIK_AGENT_CHAIN_ID ?? baseSepolia.id);
|
|
806
|
+
if (!Number.isInteger(chainId) || chainId <= 0) {
|
|
807
|
+
throw new Error("METRIK_AGENT_CHAIN_ID must be a positive integer");
|
|
808
|
+
}
|
|
809
|
+
if (chainId !== baseSepolia.id) {
|
|
810
|
+
throw new Error(
|
|
811
|
+
`unsupported METRIK_AGENT_CHAIN_ID ${chainId}; phase 2 is Base Sepolia only`,
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
return {
|
|
816
|
+
rpcUrl: parsed.METRIK_AGENT_RPC_URL,
|
|
817
|
+
escrow: parsed.METRIK_AGENT_ESCROW as `0x${string}`,
|
|
818
|
+
usdc: parsed.METRIK_AGENT_USDC as `0x${string}`,
|
|
819
|
+
chain: {
|
|
820
|
+
...baseSepolia,
|
|
821
|
+
name: parsed.METRIK_AGENT_CHAIN_NAME ?? baseSepolia.name,
|
|
822
|
+
},
|
|
823
|
+
...(parsed.METRIK_AGENT_MCP_STATE_FILE === undefined
|
|
824
|
+
? {}
|
|
825
|
+
: {
|
|
826
|
+
stateFile: parsed.METRIK_AGENT_MCP_STATE_FILE,
|
|
827
|
+
}),
|
|
828
|
+
wallet: parseAgentWalletEnv(env),
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Builds the {@link VerifiedStreamMcpServerOptions} for a single wallet-backed
|
|
834
|
+
* tenant from environment configuration.
|
|
835
|
+
*
|
|
836
|
+
* This is split out from {@link createVerifiedStreamMcpServerFromEnv} so a
|
|
837
|
+
* multi-session transport (see `mcp/http.ts`) can construct a FRESH
|
|
838
|
+
* {@link McpServer} per MCP session while sharing one tenant's wallet-backed
|
|
839
|
+
* agent client and stream registry. A single `McpServer` can only be bound to
|
|
840
|
+
* one transport at a time, so concurrent sessions each need their own server
|
|
841
|
+
* instance built from these shared options.
|
|
842
|
+
*/
|
|
843
|
+
export async function createVerifiedStreamMcpServerOptionsFromEnv(
|
|
844
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
845
|
+
): Promise<VerifiedStreamMcpServerOptions> {
|
|
846
|
+
const parsed = parseMetrikAgentEnv(env);
|
|
847
|
+
const caps = parseServerMandateCapsEnv(env);
|
|
848
|
+
if (caps === null) {
|
|
849
|
+
throw new ServerMandateConfigError(
|
|
850
|
+
"no spend mandate configured: set METRIK_AGENT_MANDATE_MAX_PER_STREAM_USDC, " +
|
|
851
|
+
"METRIK_AGENT_MANDATE_MAX_TOTAL_USDC, METRIK_AGENT_MANDATE_MAX_RATE_PER_SECOND_USDC, " +
|
|
852
|
+
"and METRIK_AGENT_MANDATE_MAX_DURATION_SECONDS. A fund-moving server refuses to run " +
|
|
853
|
+
"without an explicit spend ceiling.",
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
const deliveryProofService = createReclaimConsumerProofServiceFromEnv(env);
|
|
857
|
+
const { agentClient, wallet } = await createWalletBackedAgentClient(
|
|
858
|
+
{
|
|
859
|
+
chain: parsed.chain,
|
|
860
|
+
transport: http(parsed.rpcUrl),
|
|
861
|
+
escrow: parsed.escrow,
|
|
862
|
+
usdc: parsed.usdc,
|
|
863
|
+
settlementTarget: {
|
|
864
|
+
chainId: parsed.chain.id,
|
|
865
|
+
escrow: parsed.escrow,
|
|
866
|
+
},
|
|
867
|
+
},
|
|
868
|
+
parsed.wallet,
|
|
869
|
+
);
|
|
870
|
+
|
|
871
|
+
// Self-sign the mandate from the server's own funding wallet (its address is
|
|
872
|
+
// the mandate owner). The MCP caller never signs — see hire_verified_service.
|
|
873
|
+
// A lazily-re-signing provider keeps a long-running server authorizing hires:
|
|
874
|
+
// when the cached mandate is within the refresh window of its expiry, it is
|
|
875
|
+
// re-signed. (Reclaim/close are signature-only, so they never expire.)
|
|
876
|
+
const chainId = parsed.chain.id;
|
|
877
|
+
const signMandate = () =>
|
|
878
|
+
signServerMandate(wallet.account, caps, { chainId });
|
|
879
|
+
const signedMandate = await signMandate();
|
|
880
|
+
let cachedMandate = signedMandate;
|
|
881
|
+
const mandateProvider = async (): Promise<SignedSpendMandate> => {
|
|
882
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
883
|
+
if (
|
|
884
|
+
nowSeconds >=
|
|
885
|
+
cachedMandate.mandate.expiresAt - MANDATE_REFRESH_WINDOW_SECONDS
|
|
886
|
+
) {
|
|
887
|
+
cachedMandate = await signMandate();
|
|
888
|
+
}
|
|
889
|
+
return cachedMandate;
|
|
890
|
+
};
|
|
891
|
+
|
|
892
|
+
return {
|
|
893
|
+
agentClient,
|
|
894
|
+
signedMandate,
|
|
895
|
+
mandateProvider,
|
|
896
|
+
registry:
|
|
897
|
+
parsed.stateFile === undefined
|
|
898
|
+
? new InMemoryStreamRegistry()
|
|
899
|
+
: new FileStreamRegistry(parsed.stateFile),
|
|
900
|
+
...(deliveryProofService === null ? {} : { deliveryProofService }),
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
export async function createVerifiedStreamMcpServerFromEnv(
|
|
905
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
906
|
+
): Promise<VerifiedStreamMcpServerRuntime> {
|
|
907
|
+
return createVerifiedStreamMcpServer(
|
|
908
|
+
await createVerifiedStreamMcpServerOptionsFromEnv(env),
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
export async function startVerifiedStreamMcpServerStdio(
|
|
913
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
914
|
+
): Promise<void> {
|
|
915
|
+
const server = await createVerifiedStreamMcpServerFromEnv(env);
|
|
916
|
+
await server.connectStdio();
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function serializeServiceListing(
|
|
920
|
+
listing: ServiceListing,
|
|
921
|
+
): Record<string, unknown> {
|
|
922
|
+
return {
|
|
923
|
+
serviceRef: listing.serviceRef,
|
|
924
|
+
operator: listing.operator,
|
|
925
|
+
publicUrl: listing.publicUrl,
|
|
926
|
+
category: listing.category,
|
|
927
|
+
...(listing.targetMetadata === undefined
|
|
928
|
+
? {}
|
|
929
|
+
: { targetMetadata: listing.targetMetadata }),
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function serializeStreamStatus(
|
|
934
|
+
status: StreamStatusView,
|
|
935
|
+
streamId: `0x${string}`,
|
|
936
|
+
): Record<string, unknown> {
|
|
937
|
+
return {
|
|
938
|
+
streamId,
|
|
939
|
+
stream: {
|
|
940
|
+
buyer: status.stream.buyer,
|
|
941
|
+
operator: status.stream.operator,
|
|
942
|
+
serviceRef: status.stream.serviceRef,
|
|
943
|
+
deposit: status.stream.deposit.toString(),
|
|
944
|
+
ratePerSecond: status.stream.ratePerSecond.toString(),
|
|
945
|
+
accrued: status.stream.accrued.toString(),
|
|
946
|
+
claimed: status.stream.claimed.toString(),
|
|
947
|
+
openedAt: status.stream.openedAt,
|
|
948
|
+
expiresAt: status.stream.expiresAt,
|
|
949
|
+
lastVerifiedAt: status.stream.lastVerifiedAt,
|
|
950
|
+
lastSequence: status.stream.lastSequence.toString(),
|
|
951
|
+
consecutiveFailures: status.stream.consecutiveFailures,
|
|
952
|
+
status: status.stream.status,
|
|
953
|
+
},
|
|
954
|
+
claimable: status.claimable.toString(),
|
|
955
|
+
reclaimable: status.reclaimable.toString(),
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function serializeConsumedResponseProof(
|
|
960
|
+
streamId: `0x${string}`,
|
|
961
|
+
proof: VerifiedConsumedHttpsResponseProof,
|
|
962
|
+
): Record<string, unknown> {
|
|
963
|
+
return {
|
|
964
|
+
streamId,
|
|
965
|
+
proof: proof.proof,
|
|
966
|
+
request: {
|
|
967
|
+
...proof.request,
|
|
968
|
+
responseMatches: [...proof.request.responseMatches],
|
|
969
|
+
responseRedactions: [...proof.request.responseRedactions],
|
|
970
|
+
},
|
|
971
|
+
verification: proof.verification,
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function successResult(
|
|
976
|
+
text: string,
|
|
977
|
+
structuredContent?: Record<string, unknown>,
|
|
978
|
+
) {
|
|
979
|
+
return {
|
|
980
|
+
content: [{ type: "text" as const, text }],
|
|
981
|
+
...(structuredContent === undefined ? {} : { structuredContent }),
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function errorResult(text: string) {
|
|
986
|
+
return {
|
|
987
|
+
content: [{ type: "text" as const, text }],
|
|
988
|
+
isError: true,
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function asErrorMessage(error: unknown): string {
|
|
993
|
+
return error instanceof Error ? error.message : String(error);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Logs the full error server-side (stderr) and returns a GENERIC message to the
|
|
998
|
+
* MCP caller. Underlying viem/CDP/SDK error strings can carry internal detail
|
|
999
|
+
* (RPC URLs, addresses, revert traces); tool callers see only that it failed.
|
|
1000
|
+
* Deliberate, caller-actionable messages (mandate denied, unknown stream) are
|
|
1001
|
+
* returned directly via errorResult and are unaffected.
|
|
1002
|
+
*/
|
|
1003
|
+
function safeToolError(error: unknown) {
|
|
1004
|
+
console.error("[metrik] MCP tool error:", asErrorMessage(error));
|
|
1005
|
+
return errorResult("The tool call failed. See server logs for details.");
|
|
1006
|
+
}
|