@bnbagent/studio-cli 0.0.6-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/DISCLAIMER.md +48 -0
  2. package/LICENSE +201 -0
  3. package/dist/_agentcoreName-DZDWEYD3.js +7 -0
  4. package/dist/_twak-5XQMOFUC.js +25 -0
  5. package/dist/bag.js +19358 -0
  6. package/dist/chunk-7RAKL4AS.js +172 -0
  7. package/dist/chunk-M3ODFCA7.js +1053 -0
  8. package/dist/chunk-U7IDQ3K5.js +14 -0
  9. package/dist/deployCli-N6TPN6XA.js +40 -0
  10. package/package.json +64 -0
  11. package/recipes/agent/code/{{PKG}}/signing.ts.tmpl +287 -0
  12. package/recipes/agent/recipe.toml +35 -0
  13. package/recipes/providers/pieverse-llm/recipe.toml +16 -0
  14. package/recipes/providers/pieverse-llm/skills/funding-pieverse-llm.md +203 -0
  15. package/recipes/runtimes/agentcore/code/{{PKG}}/.dockerignore.tmpl +8 -0
  16. package/recipes/runtimes/agentcore/code/{{PKG}}/Dockerfile.tmpl +50 -0
  17. package/recipes/runtimes/agentcore/code/{{PKG}}/agentCard.ts.tmpl +135 -0
  18. package/recipes/runtimes/agentcore/code/{{PKG}}/dualMain.ts.tmpl +402 -0
  19. package/recipes/runtimes/agentcore/code/{{PKG}}/executor.ts.tmpl +147 -0
  20. package/recipes/runtimes/agentcore/code/{{PKG}}/main.ts.tmpl +344 -0
  21. package/recipes/runtimes/agentcore/code/{{PKG}}/mcpMain.ts.tmpl +677 -0
  22. package/recipes/runtimes/agentcore/code/{{PKG}}/model.ts.tmpl +117 -0
  23. package/recipes/runtimes/agentcore/code/{{PKG}}/sellerCore.ts.tmpl +503 -0
  24. package/recipes/runtimes/agentcore/code/{{PKG}}/tools.ts.tmpl +157 -0
  25. package/recipes/runtimes/agentcore/recipe.toml +97 -0
  26. package/recipes/runtimes/azure-foundry/code/{{PKG}}/.dockerignore.tmpl +8 -0
  27. package/recipes/runtimes/azure-foundry/code/{{PKG}}/Dockerfile.tmpl +47 -0
  28. package/recipes/runtimes/azure-foundry/code/{{PKG}}/agentCard.ts.tmpl +131 -0
  29. package/recipes/runtimes/azure-foundry/code/{{PKG}}/executor.ts.tmpl +504 -0
  30. package/recipes/runtimes/azure-foundry/code/{{PKG}}/foundryMain.ts.tmpl +300 -0
  31. package/recipes/runtimes/azure-foundry/code/{{PKG}}/main.ts.tmpl +196 -0
  32. package/recipes/runtimes/azure-foundry/code/{{PKG}}/mcpMain.ts.tmpl +562 -0
  33. package/recipes/runtimes/azure-foundry/code/{{PKG}}/model.ts.tmpl +117 -0
  34. package/recipes/runtimes/azure-foundry/code/{{PKG}}/tools.ts.tmpl +157 -0
  35. package/recipes/runtimes/azure-foundry/recipe.toml +88 -0
  36. package/recipes/tools-chain/code/{{PKG}}/chainTools.ts.tmpl +166 -0
  37. package/recipes/tools-chain/recipe.toml +11 -0
  38. package/recipes/wallet/recipe.toml +20 -0
  39. package/recipes/x402-buyer/code/{{PKG}}/x402Buyer.ts.tmpl +175 -0
  40. package/recipes/x402-buyer/recipe.toml +15 -0
  41. package/skills/bnbagent-studio.md +107 -0
  42. package/skills/references/bnbagent-studio-adding-to-project.md +241 -0
  43. package/skills/references/bnbagent-studio-buying-from-bazaar.md +169 -0
  44. package/skills/references/bnbagent-studio-buying-via-8183.md +222 -0
  45. package/skills/references/bnbagent-studio-extending-signing.md +227 -0
  46. package/skills/references/bnbagent-studio-operating.md +211 -0
  47. package/skills/references/bnbagent-studio-scaffolding-agent.md +536 -0
  48. package/skills/references/bnbagent-studio-selling-via-8183.md +271 -0
  49. package/skills/references/bnbagent-studio-selling-via-b402.md +194 -0
  50. package/skills/references/bnbagent-studio-use-aws-agentcore.md +208 -0
  51. package/skills/references/bnbagent-studio-use-azure-foundry.md +164 -0
  52. package/skills/references/bnbagent-studio-use-bnb-trial.md +92 -0
  53. package/skills/references/bnbagent-studio-using-altana-wallet.md +68 -0
  54. package/skills/references/bnbagent-studio-using-twak-wallet.md +260 -0
  55. package/skills/references/bnbagent-studio-wiring-llm-tools.md +338 -0
@@ -0,0 +1,677 @@
1
+ /**
2
+ * Single MCP seller agent entrypoint (the `--protocol MCP` peer to main.ts).
3
+ *
4
+ * This is the VALUABLE agent AND the SOLE key-holder/signer, serving its
5
+ * seller surface over the **Model Context Protocol** instead of A2A. AWS
6
+ * Bedrock AgentCore hosts MCP natively: it expects a streamable-HTTP MCP
7
+ * server on `0.0.0.0:8000/mcp` and wraps the protocol (session isolation,
8
+ * inbound OAuth/Cognito auth, scale-to-zero) exactly as it does for A2A.
9
+ * There is no separate forwarding service — the agent IS the seller.
10
+ *
11
+ * MCP tools (all backed by signing.ts fixed code — NEVER LLM-callable):
12
+ *
13
+ * negotiate → read the FIXED list price → CLAMP to [min,max] → EIP-191 SIGN
14
+ * the offer (no LLM). A message signature; no on-chain tx, no nonce.
15
+ * notify_funded → verify the funded job carries THIS agent's signed quote →
16
+ * produce the deliverable (LLM) → submitResult (SIGN + broadcast
17
+ * on-chain) — all **synchronously within this one tool call**,
18
+ * then return the on-chain result.
19
+ * + the read-only chain tools (wallet / balances / ERC-8004 / ERC-8183 /
20
+ * block / tx / contract-view), so an MCP client can inspect state.
21
+ *
22
+ * ## How delivery works under MCP (synchronous, ≤ ~15 min)
23
+ *
24
+ * A2A acks then finishes the work + on-chain `submit` in a background task
25
+ * kept alive by reporting `HEALTHY_BUSY` to AgentCore's `/ping`. An MCP
26
+ * server has no such hook (its only liveness is the platform's `/mcp/`
27
+ * probe). So under MCP the documented long-running pattern is
28
+ * **synchronous**: `notify_funded` does the whole verify → LLM work → submit
29
+ * **inside the single tool invocation** (AgentCore allows a synchronous
30
+ * request to run up to ~15 minutes), using MCP progress notifications as a
31
+ * heartbeat to keep the connection alive across the steps. The runtime is
32
+ * therefore **stateful** (per-session `StreamableHTTPServerTransport`,
33
+ * routed by `Mcp-Session-Id`) so progress notifications work. Node's async
34
+ * chain/signing calls never block the event loop, so the platform's
35
+ * liveness probe stays responsive during the call.
36
+ *
37
+ * ## Boundaries (do NOT cross — they are the whole point)
38
+ *
39
+ * - ALL on-chain SIGNING is FIXED code in `signing.ts` — NEVER an
40
+ * MCP/LLM-callable signing tool. There is no raw `sign(...)` tool: only
41
+ * the bounded `negotiate` (sign a quote) and `notify_funded` (submit a
42
+ * verified, funded job) sign, and the LLM only produces the deliverable
43
+ * TEXT inside `notify_funded`.
44
+ * - The price is a FIXED list price from studio.toml (clamped before signing).
45
+ * - The chain tools exposed here are READ-ONLY.
46
+ *
47
+ * You own this file — specialise the work prompt / dispatch, but keep signing
48
+ * bounded to these two ops and keep the read tools read-only.
49
+ * In dual-face mode this file is imported as a library by dualMain.ts; main()
50
+ * runs only when mcpMain itself is the selected entrypoint.
51
+ */
52
+
53
+ import { createHash, randomUUID } from "node:crypto";
54
+ import { pathToFileURL } from "node:url";
55
+ import {
56
+ GetSecretValueCommand,
57
+ SecretsManagerClient,
58
+ } from "@aws-sdk/client-secrets-manager";
59
+ import {
60
+ loadStudioToml,
61
+ type TomlTable,
62
+ } from "@bnbagent/studio-runtime/config";
63
+ import * as cr from "@bnbagent/studio-runtime/tools";
64
+ import {
65
+ ensureAltanaSessionLoaded,
66
+ ensureKeystoreMaterialized,
67
+ ensureTwakMaterialized,
68
+ getWallet,
69
+ } from "@bnbagent/studio-runtime/wallet";
70
+ import {
71
+ createEnvelopeMiddleware,
72
+ X402_SELL_PATH,
73
+ type X402HttpRequest,
74
+ X402Seller,
75
+ } from "@bnbagent/studio-runtime/x402";
76
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
77
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
78
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
79
+ import { generateText, stepCountIs } from "ai";
80
+ import express from "express";
81
+ import { z } from "zod";
82
+ import * as signing from "./signing.js";
83
+
84
+ const APP_NAME = "agent";
85
+ const log = {
86
+ info: (msg: string) => console.log(`[seller-agent.mcp] ${msg}`),
87
+ error: (msg: string, e?: unknown) =>
88
+ console.error(`[seller-agent.mcp] ERROR ${msg}`, e ?? ""),
89
+ };
90
+
91
+ // ── Runtime secrets ───────────────────────────────────────────────────────────
92
+ // Keep plaintext secrets OUT of agentcore.json. When BNBAGENT_RUNTIME_SECRET_ID
93
+ // is set (deployed runtime), pull a JSON {ENV_NAME: value} blob from AWS
94
+ // Secrets Manager into the process env BEFORE anything reads it. No-op
95
+ // locally. In a deployed runtime the managed secret bundle is authoritative
96
+ // and replaces any stale spec-level value left by an earlier revision.
97
+ async function loadRuntimeSecrets(): Promise<void> {
98
+ const secretId = process.env.BNBAGENT_RUNTIME_SECRET_ID;
99
+ if (!secretId) {
100
+ return;
101
+ }
102
+ const resp = await new SecretsManagerClient({}).send(
103
+ new GetSecretValueCommand({ SecretId: secretId }),
104
+ );
105
+ const bundle = JSON.parse(resp.SecretString ?? "{}") as Record<
106
+ string,
107
+ unknown
108
+ >;
109
+ for (const [key, value] of Object.entries(bundle)) {
110
+ process.env[key] = String(value);
111
+ }
112
+ const pieverseKey = process.env.PIEVERSE_LLM_API_KEY;
113
+ if (pieverseKey) {
114
+ const fingerprint = createHash("sha256")
115
+ .update(pieverseKey, "utf-8")
116
+ .digest("hex")
117
+ .slice(0, 12);
118
+ log.info(
119
+ `runtime secret PIEVERSE_LLM_API_KEY source=secretsmanager sha256=${fingerprint}…`,
120
+ );
121
+ }
122
+ }
123
+
124
+ /**
125
+ * The project-wide default network (`[network].default`) — tool calls that
126
+ * omit `network` fall back to it, never to a hardcoded name.
127
+ */
128
+ function defaultNetwork(): string {
129
+ try {
130
+ const cfg = loadStudioToml();
131
+ return String(
132
+ ((cfg.network ?? {}) as Record<string, unknown>).default ?? "bsc-testnet",
133
+ );
134
+ } catch {
135
+ return "bsc-testnet";
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Deliverable `generator` label: this seller's own name from studio.toml
141
+ * `[project].name` (minus the `-agent` suffix). Best-effort.
142
+ */
143
+ function generatorTag(): string {
144
+ let name = "";
145
+ try {
146
+ const cfg = loadStudioToml();
147
+ name = String(((cfg.project ?? {}) as Record<string, unknown>).name ?? "");
148
+ } catch {
149
+ // a metadata label must never break delivery
150
+ return APP_NAME;
151
+ }
152
+ return name.endsWith("-agent")
153
+ ? name.slice(0, -"-agent".length)
154
+ : name || APP_NAME;
155
+ }
156
+
157
+ function hasErc8183Rail(cfg: TomlTable): boolean {
158
+ const payments = asTable(cfg.payments);
159
+ return asTable(payments?.erc8183) !== null;
160
+ }
161
+
162
+ function asTable(value: unknown): TomlTable | null {
163
+ return value !== null && typeof value === "object" && !Array.isArray(value)
164
+ ? (value as TomlTable)
165
+ : null;
166
+ }
167
+
168
+ function flatHeaders(
169
+ headers: Record<string, string | string[] | undefined>,
170
+ ): Record<string, string> {
171
+ const out: Record<string, string> = {};
172
+ for (const [name, value] of Object.entries(headers)) {
173
+ if (typeof value === "string") out[name] = value;
174
+ else if (value !== undefined) out[name] = value[0] ?? "";
175
+ }
176
+ return out;
177
+ }
178
+
179
+ function flatQuery(query: Record<string, unknown>): Record<string, string> {
180
+ const out: Record<string, string> = {};
181
+ for (const [name, value] of Object.entries(query)) {
182
+ if (typeof value === "string") out[name] = value;
183
+ }
184
+ return out;
185
+ }
186
+
187
+ // ── LLM work hook (lazy: built on first delivery; negotiate never needs it) ──
188
+ // Deferred construction keeps the negotiate-only path (and a cold start that
189
+ // only quotes) from building the model, and keeps this module importable
190
+ // without the provider env until a deliverable is actually produced.
191
+ type RunLlm = (prompt: string) => Promise<string>;
192
+ let cachedRunLlm: RunLlm | null = null;
193
+
194
+ async function runLlm(prompt: string): Promise<string> {
195
+ if (cachedRunLlm === null) {
196
+ const { buildModel } = await import("./model.js");
197
+ const { LLM_READ_TOOLS } = await import("./tools.js");
198
+ const model = buildModel(); // managed model w/ budget-gated LLM-credit auto-renew
199
+ cachedRunLlm = async (p: string) => {
200
+ const result = await generateText({
201
+ model,
202
+ system:
203
+ "You are a seller agent. You do the actual work once a job is funded. " +
204
+ "Be concrete and concise. Use the read-only chain tools when on-chain " +
205
+ "context helps. If a paid-data tool such as `buy_with_x402` is " +
206
+ "available to you, USE IT to fetch the data a task needs — those " +
207
+ "merchants (e.g. CoinMarketCap) charge via on-chain wallet payment, " +
208
+ "NOT an API key; never reply that you cannot complete the task for " +
209
+ "lack of an API key.",
210
+ prompt: p,
211
+ // READ-ONLY chain tools; signing is never an LLM tool. To add
212
+ // PAID x402 fetch tools (bag x402 trust + x402-buyer recipe):
213
+ // import { X402_BUYER_TOOLS } from "./x402Buyer.js";
214
+ // tools: { ...LLM_READ_TOOLS, ...X402_BUYER_TOOLS },
215
+ tools: LLM_READ_TOOLS,
216
+ stopWhen: stepCountIs(8),
217
+ });
218
+ return result.text.trim();
219
+ };
220
+ }
221
+ return cachedRunLlm(prompt);
222
+ }
223
+
224
+ // ── MCP server ────────────────────────────────────────────────────────────────
225
+
226
+ /** Normalise a job_id (`0x..` / decimal string / number) to int. */
227
+ function parseJobId(raw: unknown): number {
228
+ if (typeof raw === "number" && Number.isInteger(raw)) return raw;
229
+ return Number(BigInt(String(raw).trim()));
230
+ }
231
+
232
+ // Commerce tools are NOT read-only (they sign / move on-chain state via fixed
233
+ // signing.ts code). There is no raw signing tool — only these two bounded ops.
234
+ const COMMERCE_ANNOTATIONS = { readOnlyHint: false, openWorldHint: true };
235
+ const READONLY_ANNOTATIONS = { readOnlyHint: true, openWorldHint: true };
236
+
237
+ /** MCP tool result: the JSON payload as text content (+ the log mirror). */
238
+ function toolResult(payload: Record<string, unknown>) {
239
+ return { content: [{ type: "text" as const, text: JSON.stringify(payload) }] };
240
+ }
241
+
242
+ /** The narrow `extra` surface the progress heartbeat needs. */
243
+ interface ProgressExtra {
244
+ _meta?: { progressToken?: string | number };
245
+ sendNotification: (n: {
246
+ method: "notifications/progress";
247
+ params: {
248
+ progressToken: string | number;
249
+ progress: number;
250
+ total?: number;
251
+ };
252
+ }) => Promise<void>;
253
+ }
254
+
255
+ /** Heartbeat: report step progress when the client sent a progressToken. */
256
+ async function reportProgress(
257
+ extra: ProgressExtra,
258
+ progress: number,
259
+ total: number,
260
+ ): Promise<void> {
261
+ const token = extra._meta?.progressToken;
262
+ if (token === undefined) {
263
+ return;
264
+ }
265
+ await extra.sendNotification({
266
+ method: "notifications/progress",
267
+ params: { progressToken: token, progress, total },
268
+ });
269
+ }
270
+
271
+ /** Build the seller MCP server, gating commerce tools on the ERC-8183 rail. */
272
+ export function buildMcpServer(
273
+ opts: { commerceSkills?: boolean } = {},
274
+ ): McpServer {
275
+ const server = new McpServer({ name: "bnbagent-seller", version: "1.0.0" });
276
+
277
+ // ── Commerce tools (signing is FIXED code in signing.ts) ──────────────────
278
+ if (opts.commerceSkills !== false) {
279
+ server.registerTool(
280
+ "negotiate",
281
+ {
282
+ description:
283
+ "Return a wallet-signed ERC-8183 price quote for a task. " +
284
+ "Rule-based: the FIXED list price from studio.toml, CLAMPED to [min,max] " +
285
+ "BEFORE EIP-191 signing — a hostile request can never sign out of bounds. " +
286
+ "No LLM. Anchor the returned envelope on-chain (createJob + fund), then " +
287
+ "call `notify_funded` with the job_id. `terms` MUST include both " +
288
+ '"deliverables" and "quality_standards" (the on-chain evaluator requires ' +
289
+ "them); a request missing either is rejected unsigned.",
290
+ inputSchema: {
291
+ task_description: z.string(),
292
+ terms: z.record(z.unknown()).optional(),
293
+ },
294
+ annotations: COMMERCE_ANNOTATIONS,
295
+ },
296
+ // Error contract (unified with the A2A executor): an unexpected fault
297
+ // here (wallet/signing failure) is left to PROPAGATE — the MCP SDK turns
298
+ // it into a tool result with `isError: true` (MCP's tool-execution-error
299
+ // channel), the peer of the A2A executor throwing
300
+ // `A2AError.internalError` → JSON-RPC -32603. Only CLASSIFIED business
301
+ // outcomes are returned as a normal result. So do NOT wrap this in a
302
+ // try/catch that masks a fault as a successful quote.
303
+ async ({ task_description, terms }) => {
304
+ const request = { task_description, terms: terms ?? {} };
305
+ const clamped = signing.clampPrice(signing.listPrice());
306
+ return toolResult(await signing.signQuote(request, clamped));
307
+ },
308
+ );
309
+
310
+ server.registerTool(
311
+ "notify_funded",
312
+ {
313
+ description:
314
+ "Verify a funded job, produce the deliverable, and submit it on-chain — " +
315
+ 'synchronously. The buyer\'s "I funded job X — deliver it" call. Runs the ' +
316
+ "whole flow inside this one tool invocation (AgentCore permits ~15 min; " +
317
+ "progress notifications keep the connection warm). Returns the on-chain " +
318
+ "result; the buyer can also read it back from the chain (SUBMITTED / " +
319
+ 'get_deliverable_url). Verify-failure status is split: "rejected" is ' +
320
+ "TERMINAL — this agent did not sign it, the terms were tampered, it is " +
321
+ 'underfunded or expired — re-calling will not help. "retry" is TRANSIENT ' +
322
+ "(e.g. a chain read failed); the deal may be fine, so the buyer SHOULD " +
323
+ "re-call.",
324
+ inputSchema: { job_id: z.union([z.number().int(), z.string()]) },
325
+ annotations: COMMERCE_ANNOTATIONS,
326
+ },
327
+ async ({ job_id }, extra) => {
328
+ let jid: number;
329
+ try {
330
+ jid = parseJobId(job_id);
331
+ } catch {
332
+ return toolResult({
333
+ status: "rejected",
334
+ error: `invalid job_id: ${JSON.stringify(job_id)}`,
335
+ });
336
+ }
337
+
338
+ // 1/4 — verify the funded job carries THIS agent's signed quote
339
+ // (eth_calls). Honour the `permanent` flag: a permanent failure is
340
+ // terminal ("rejected"); a transient one (chain read hiccup) is
341
+ // "retry" so the buyer re-calls.
342
+ await reportProgress(extra, 1, 4);
343
+ let verdict: { ok: boolean; reason: string; permanent: boolean };
344
+ try {
345
+ verdict = await signing.verifySignedJob(jid);
346
+ } catch (e) {
347
+ // a failed verify is transient; tell the buyer to retry
348
+ log.error(`verify of job ${jid} failed`, e);
349
+ const name = e instanceof Error ? e.constructor.name : "Error";
350
+ const msg = e instanceof Error ? e.message : String(e);
351
+ return toolResult({
352
+ status: "retry",
353
+ job_id: jid,
354
+ reason: `${name}: ${msg}`,
355
+ });
356
+ }
357
+ if (!verdict.ok) {
358
+ return toolResult({
359
+ status: verdict.permanent ? "rejected" : "retry",
360
+ job_id: jid,
361
+ reason: verdict.reason,
362
+ });
363
+ }
364
+
365
+ // 2/4 — produce the deliverable (THE ONLY LLM CALL; specialise the
366
+ // prompt here)
367
+ await reportProgress(extra, 2, 4);
368
+ const spec = await signing.jobSpec(jid);
369
+ const task =
370
+ spec !== null
371
+ ? JSON.stringify({ task: spec.task, terms: spec.terms })
372
+ : `job ${jid}`;
373
+ const prompt =
374
+ "You accepted and were paid for the following job. Produce the deliverable " +
375
+ `now. Be complete and self-contained.\n\nJOB CONTEXT:\n${task}`;
376
+ // An unexpected fault below (LLM unavailable, RPC/submit hiccup) is
377
+ // left to PROPAGATE — the MCP SDK returns it as an isError tool result
378
+ // (the peer of the A2A executor's internalError/-32603). Only the
379
+ // deterministic, classified outcome SubmitPermanentlyUnsupportedError
380
+ // is a "rejected" business result.
381
+ const work = await runLlm(prompt);
382
+
383
+ // 3/4 — sign + broadcast the on-chain submit (re-verifies FUNDED inside)
384
+ await reportProgress(extra, 3, 4);
385
+ let res: { submitTx: string; deliverableUrl: string | null };
386
+ try {
387
+ res = await signing.submitResult(jid, work, {
388
+ job_id: jid,
389
+ generator: generatorTag(),
390
+ built_with: "https://github.com/bnb-chain/bnbagent-studio",
391
+ });
392
+ } catch (e) {
393
+ if (
394
+ e instanceof Error &&
395
+ e.name === "SubmitPermanentlyUnsupportedError"
396
+ ) {
397
+ // Deterministic for this wallet kind — submit can never succeed.
398
+ return toolResult({
399
+ status: "rejected",
400
+ job_id: jid,
401
+ skip: true,
402
+ reason: e.message,
403
+ });
404
+ }
405
+ throw e;
406
+ }
407
+
408
+ // 4/4 — done
409
+ await reportProgress(extra, 4, 4);
410
+ return toolResult({
411
+ status: "submitted",
412
+ job_id: jid,
413
+ tx_hash: res.submitTx,
414
+ deliverable_url: res.deliverableUrl,
415
+ });
416
+ },
417
+ );
418
+ }
419
+
420
+ // ── Read-only chain tools ──────────────────────────────────────────────────
421
+ const network = z.string().optional().describe("studio network name");
422
+ const roConfig = (description: string, inputSchema: z.ZodRawShape) => ({
423
+ description,
424
+ inputSchema,
425
+ annotations: READONLY_ANNOTATIONS,
426
+ });
427
+
428
+ server.registerTool(
429
+ "wallet_info",
430
+ roConfig("Active wallet summary.", {}),
431
+ async () => toolResult(await cr.walletInfo()),
432
+ );
433
+ server.registerTool(
434
+ "wallet_list",
435
+ roConfig("All local wallet addresses.", {}),
436
+ async () => toolResult(await cr.walletList()),
437
+ );
438
+ server.registerTool(
439
+ "wallet_address",
440
+ roConfig("The active wallet address.", {}),
441
+ async () => toolResult({ address: await cr.walletAddress() }),
442
+ );
443
+ server.registerTool(
444
+ "balance_native",
445
+ roConfig("Native BNB balance (defaults to own wallet).", {
446
+ address: z.string().optional(),
447
+ network,
448
+ }),
449
+ async (a) =>
450
+ toolResult(
451
+ await cr.balanceNative(a.address ?? null, a.network ?? defaultNetwork()),
452
+ ),
453
+ );
454
+ server.registerTool(
455
+ "balance_u",
456
+ roConfig("$U payment-token balance (defaults to own wallet).", {
457
+ address: z.string().optional(),
458
+ network,
459
+ }),
460
+ async (a) =>
461
+ toolResult(
462
+ await cr.balanceU(a.address ?? null, a.network ?? defaultNetwork()),
463
+ ),
464
+ );
465
+ server.registerTool(
466
+ "pieverse_usage",
467
+ roConfig(
468
+ "Pieverse LLM usage/credit summary (SIWE personal_sign; no on-chain effect).",
469
+ { days: z.number().int().optional() },
470
+ ),
471
+ async (a) => toolResult(await cr.pieverseUsage(a.days ?? 7)),
472
+ );
473
+ server.registerTool(
474
+ "agent_info",
475
+ roConfig("ERC-8004 identity record for an agent id.", {
476
+ agent_id: z.number().int(),
477
+ network,
478
+ }),
479
+ async (a) =>
480
+ toolResult(await cr.agentInfo(a.agent_id, a.network ?? defaultNetwork())),
481
+ );
482
+ server.registerTool(
483
+ "agent_by_address",
484
+ roConfig("ERC-8004 registration lookup by wallet address.", {
485
+ address: z.string(),
486
+ network,
487
+ }),
488
+ async (a) =>
489
+ toolResult(
490
+ await cr.agentByAddress(a.address, a.network ?? defaultNetwork()),
491
+ ),
492
+ );
493
+ server.registerTool(
494
+ "job_status",
495
+ roConfig("Read-only ERC-8183 job summary.", {
496
+ job_id: z.number().int(),
497
+ network,
498
+ }),
499
+ async (a) =>
500
+ toolResult(await cr.jobStatus(a.job_id, a.network ?? defaultNetwork())),
501
+ );
502
+ server.registerTool(
503
+ "job_list",
504
+ roConfig("List recent ERC-8183 jobs.", {
505
+ limit: z.number().int().optional(),
506
+ mine: z.boolean().optional(),
507
+ network,
508
+ }),
509
+ async (a) =>
510
+ toolResult(
511
+ await cr.jobList({
512
+ limit: a.limit,
513
+ mine: a.mine,
514
+ network: a.network ?? defaultNetwork(),
515
+ }),
516
+ ),
517
+ );
518
+ server.registerTool(
519
+ "job_count",
520
+ roConfig("Network-wide in-flight ERC-8183 job count.", { network }),
521
+ async (a) => toolResult(await cr.jobCount(a.network ?? defaultNetwork())),
522
+ );
523
+ server.registerTool(
524
+ "tx_status",
525
+ roConfig("Transaction status + receipt summary.", {
526
+ tx_hash: z.string(),
527
+ network,
528
+ }),
529
+ async (a) =>
530
+ toolResult(await cr.txStatus(a.tx_hash, a.network ?? defaultNetwork())),
531
+ );
532
+ server.registerTool(
533
+ "block_info",
534
+ roConfig(
535
+ 'Block header summary ("latest"/"earliest"/"pending", decimal, or 0x hash).',
536
+ { block: z.string().optional(), network },
537
+ ),
538
+ async (a) =>
539
+ toolResult(
540
+ await cr.blockInfo(a.block ?? "latest", a.network ?? defaultNetwork()),
541
+ ),
542
+ );
543
+ server.registerTool(
544
+ "contract_call_view",
545
+ roConfig("Call a read-only (view) contract function by signature.", {
546
+ address: z.string(),
547
+ function_signature: z.string(),
548
+ args: z.array(z.unknown()).optional(),
549
+ output_types: z.array(z.string()).optional(),
550
+ network,
551
+ }),
552
+ async (a) =>
553
+ toolResult(
554
+ await cr.contractCallView(
555
+ a.address,
556
+ a.function_signature,
557
+ (a.args ?? null) as unknown[] | null,
558
+ a.output_types ?? null,
559
+ a.network ?? defaultNetwork(),
560
+ ),
561
+ ),
562
+ );
563
+ server.registerTool(
564
+ "network_info",
565
+ roConfig("Chain id / RPC / token info for a studio network.", { network }),
566
+ async (a) => toolResult(await cr.networkInfo(a.network ?? defaultNetwork())),
567
+ );
568
+
569
+ return server;
570
+ }
571
+
572
+ // ── serving ───────────────────────────────────────────────────────────────────
573
+
574
+ /**
575
+ * Serve the MCP server as **stateful** streamable-HTTP on `/mcp` (the
576
+ * AgentCore MCP contract: 0.0.0.0:8000/mcp; `AGENT_PORT` is the local
577
+ * override). Stateful — one transport per `Mcp-Session-Id` — so progress
578
+ * notifications during the multi-step `notify_funded` delivery reach the
579
+ * caller; AgentCore routes the session to one microVM via the same header.
580
+ */
581
+ async function main(): Promise<void> {
582
+ await loadRuntimeSecrets();
583
+
584
+ // Wallet material is NEVER bundled into the deploy artifact. `bag deploy`
585
+ // injects it via Secrets Manager and these calls (run once at cold start,
586
+ // before any signing) materialize it on disk. Each is a no-op for the
587
+ // other wallet kind and locally, where the wallet already lives on disk.
588
+ ensureKeystoreMaterialized();
589
+ ensureTwakMaterialized();
590
+ await ensureAltanaSessionLoaded();
591
+
592
+ const cfg = loadStudioToml();
593
+ const rails = { erc8183: hasErc8183Rail(cfg) };
594
+ const host = process.env.AGENT_BIND_HOST || "0.0.0.0";
595
+ const port = Number(process.env.AGENT_PORT || "8000");
596
+ const seller = await X402Seller.create({
597
+ cfg,
598
+ runWork: ({ prompt }) => runLlm(prompt),
599
+ walletAddress: getWallet().address,
600
+ resourceUrl: `${
601
+ process.env.AGENTCORE_RUNTIME_URL ?? `http://localhost:${port}`
602
+ }${X402_SELL_PATH}`,
603
+ });
604
+
605
+ const app = express();
606
+
607
+ if (seller.state !== "disabled") {
608
+ app.all(
609
+ X402_SELL_PATH,
610
+ express.text({ type: "*/*", limit: "1mb" }),
611
+ async (req, res) => {
612
+ const request: X402HttpRequest = {
613
+ method: req.method,
614
+ path: req.path,
615
+ query: flatQuery(req.query),
616
+ headers: flatHeaders(req.headers),
617
+ body:
618
+ typeof req.body === "string"
619
+ ? req.body
620
+ : JSON.stringify(req.body ?? ""),
621
+ };
622
+ const out = await seller.handle(request);
623
+ res.status(out.status).set(out.headers).send(out.body);
624
+ },
625
+ );
626
+ }
627
+
628
+ app.use(express.json({ limit: "8mb" }));
629
+ app.use(createEnvelopeMiddleware({ port }));
630
+
631
+ const transports: Record<string, StreamableHTTPServerTransport> = {};
632
+
633
+ app.all("/mcp", async (req, res) => {
634
+ const sessionId = req.headers["mcp-session-id"] as string | undefined;
635
+ let transport = sessionId ? transports[sessionId] : undefined;
636
+ if (transport === undefined) {
637
+ if (req.method !== "POST" || !isInitializeRequest(req.body)) {
638
+ res.status(400).json({
639
+ jsonrpc: "2.0",
640
+ error: { code: -32000, message: "Bad Request: no valid session" },
641
+ id: null,
642
+ });
643
+ return;
644
+ }
645
+ // New session: stateful transport keyed by Mcp-Session-Id.
646
+ const t = new StreamableHTTPServerTransport({
647
+ sessionIdGenerator: () => randomUUID(),
648
+ onsessioninitialized: (sid) => {
649
+ transports[sid] = t;
650
+ },
651
+ });
652
+ t.onclose = () => {
653
+ if (t.sessionId !== undefined) {
654
+ delete transports[t.sessionId];
655
+ }
656
+ };
657
+ await buildMcpServer({ commerceSkills: rails.erc8183 }).connect(t);
658
+ transport = t;
659
+ }
660
+ await transport.handleRequest(req, res, req.body);
661
+ });
662
+
663
+ app.listen(port, host, () => {
664
+ log.info(`MCP serving on ${host}:${port}/mcp`);
665
+ });
666
+ }
667
+
668
+ // Run only as an entrypoint, never on import (tests import buildMcpServer).
669
+ const isMain =
670
+ process.argv[1] !== undefined &&
671
+ import.meta.url === pathToFileURL(process.argv[1]).href;
672
+ if (isMain) {
673
+ main().catch((e) => {
674
+ log.error("fatal", e);
675
+ process.exit(1);
676
+ });
677
+ }