@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,562 @@
1
+ /**
2
+ * Single MCP seller agent entrypoint (the `--protocol MCP` peer to main.ts).
3
+ *
4
+ * The x402 seller is intentionally dormant on Azure Foundry in v1. Use the
5
+ * platform deployment target for the anonymous HTTP-envelope tunnel.
6
+ *
7
+ * This is the VALUABLE agent AND the SOLE key-holder/signer, serving its
8
+ * seller surface over the **Model Context Protocol** instead of A2A. The MCP
9
+ * server is cloud-neutral: a streamable-HTTP MCP server on `0.0.0.0:8000/mcp`
10
+ * (port 8000, `AGENT_PORT` overrides locally) with no cloud-vendor serving
11
+ * dependency. `bag dev` runs `node mcpMain.js` directly. There is no separate
12
+ * forwarding service — the agent IS the seller.
13
+ *
14
+ * (For the azure-foundry runtime, the DEPLOY host speaks the Invocations
15
+ * protocol — `foundryMain.ts`. This MCP entrypoint is for LOCAL run / dev and
16
+ * the eventual cross-cloud transport.)
17
+ *
18
+ * MCP tools (all backed by signing.ts fixed code — NEVER LLM-callable):
19
+ *
20
+ * negotiate → read the FIXED list price → CLAMP to [min,max] → EIP-191 SIGN
21
+ * the offer (no LLM). A message signature; no on-chain tx, no nonce.
22
+ * notify_funded → verify the funded job carries THIS agent's signed quote →
23
+ * produce the deliverable (LLM) → submitResult (SIGN + broadcast
24
+ * on-chain) — all **synchronously within this one tool call**,
25
+ * then return the on-chain result.
26
+ * + the read-only chain tools (wallet / balances / ERC-8004 / ERC-8183 /
27
+ * block / tx / contract-view), so an MCP client can inspect state.
28
+ *
29
+ * ## How delivery works under MCP (synchronous)
30
+ *
31
+ * A2A acks then finishes the work + on-chain `submit` in a background task.
32
+ * An MCP server has no such ack-then-continue hook, so under MCP the
33
+ * long-running pattern is **synchronous**: `notify_funded` does the whole
34
+ * verify → LLM work → submit **inside the single tool invocation**, using
35
+ * MCP progress notifications as a heartbeat to keep the connection alive
36
+ * across the steps. The runtime is
37
+ * therefore **stateful** (per-session `StreamableHTTPServerTransport`,
38
+ * routed by `Mcp-Session-Id`) so progress notifications work. Node's async
39
+ * chain/signing calls never block the event loop, so the platform's
40
+ * liveness probe stays responsive during the call.
41
+ *
42
+ * ## Boundaries (do NOT cross — they are the whole point)
43
+ *
44
+ * - ALL on-chain SIGNING is FIXED code in `signing.ts` — NEVER an
45
+ * MCP/LLM-callable signing tool. There is no raw `sign(...)` tool: only
46
+ * the bounded `negotiate` (sign a quote) and `notify_funded` (submit a
47
+ * verified, funded job) sign, and the LLM only produces the deliverable
48
+ * TEXT inside `notify_funded`.
49
+ * - The price is a FIXED list price from studio.toml (clamped before signing).
50
+ * - The chain tools exposed here are READ-ONLY.
51
+ *
52
+ * You own this file — specialise the work prompt / dispatch, but keep signing
53
+ * bounded to these two ops and keep the read tools read-only.
54
+ */
55
+
56
+ import { randomUUID } from "node:crypto";
57
+ import { pathToFileURL } from "node:url";
58
+ import { loadStudioToml } from "@bnbagent/studio-runtime/config";
59
+ import * as cr from "@bnbagent/studio-runtime/tools";
60
+ import {
61
+ ensureAltanaSessionLoaded,
62
+ ensureKeystoreMaterialized,
63
+ ensureTwakMaterialized,
64
+ } from "@bnbagent/studio-runtime/wallet";
65
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
66
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
67
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
68
+ import { generateText, stepCountIs } from "ai";
69
+ import express from "express";
70
+ import { z } from "zod";
71
+ import * as signing from "./signing.js";
72
+
73
+ const APP_NAME = "agent";
74
+ const log = {
75
+ info: (msg: string) => console.log(`[seller-agent.mcp] ${msg}`),
76
+ error: (msg: string, e?: unknown) =>
77
+ console.error(`[seller-agent.mcp] ERROR ${msg}`, e ?? ""),
78
+ };
79
+
80
+ /**
81
+ * The project-wide default network (`[network].default`) — tool calls that
82
+ * omit `network` fall back to it, never to a hardcoded name.
83
+ */
84
+ function defaultNetwork(): string {
85
+ try {
86
+ const cfg = loadStudioToml();
87
+ return String(
88
+ ((cfg.network ?? {}) as Record<string, unknown>).default ?? "bsc-testnet",
89
+ );
90
+ } catch {
91
+ return "bsc-testnet";
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Deliverable `generator` label: this seller's own name from studio.toml
97
+ * `[project].name` (minus the `-agent` suffix). Best-effort.
98
+ */
99
+ function generatorTag(): string {
100
+ let name = "";
101
+ try {
102
+ const cfg = loadStudioToml();
103
+ name = String(((cfg.project ?? {}) as Record<string, unknown>).name ?? "");
104
+ } catch {
105
+ // a metadata label must never break delivery
106
+ return APP_NAME;
107
+ }
108
+ return name.endsWith("-agent")
109
+ ? name.slice(0, -"-agent".length)
110
+ : name || APP_NAME;
111
+ }
112
+
113
+ // ── LLM work hook (lazy: built on first delivery; negotiate never needs it) ──
114
+ // Deferred construction keeps the negotiate-only path (and a cold start that
115
+ // only quotes) from building the model, and keeps this module importable
116
+ // without the provider env until a deliverable is actually produced.
117
+ type RunLlm = (prompt: string) => Promise<string>;
118
+ let cachedRunLlm: RunLlm | null = null;
119
+
120
+ async function runLlm(prompt: string): Promise<string> {
121
+ if (cachedRunLlm === null) {
122
+ const { buildModel } = await import("./model.js");
123
+ const { LLM_READ_TOOLS } = await import("./tools.js");
124
+ const model = buildModel(); // managed model w/ budget-gated LLM-credit auto-renew
125
+ cachedRunLlm = async (p: string) => {
126
+ const result = await generateText({
127
+ model,
128
+ system:
129
+ "You are a seller agent. You do the actual work once a job is funded. " +
130
+ "Be concrete and concise. Use the read-only chain tools when on-chain " +
131
+ "context helps. If a paid-data tool such as `buy_with_x402` is " +
132
+ "available to you, USE IT to fetch the data a task needs — those " +
133
+ "merchants (e.g. CoinMarketCap) charge via on-chain wallet payment, " +
134
+ "NOT an API key; never reply that you cannot complete the task for " +
135
+ "lack of an API key.",
136
+ prompt: p,
137
+ // READ-ONLY chain tools; signing is never an LLM tool. To add
138
+ // PAID x402 fetch tools (bag x402 trust + x402-buyer recipe):
139
+ // import { X402_BUYER_TOOLS } from "./x402Buyer.js";
140
+ // tools: { ...LLM_READ_TOOLS, ...X402_BUYER_TOOLS },
141
+ tools: LLM_READ_TOOLS,
142
+ stopWhen: stepCountIs(8),
143
+ });
144
+ return result.text.trim();
145
+ };
146
+ }
147
+ return cachedRunLlm(prompt);
148
+ }
149
+
150
+ // ── MCP server ────────────────────────────────────────────────────────────────
151
+
152
+ /** Normalise a job_id (`0x..` / decimal string / number) to int. */
153
+ function parseJobId(raw: unknown): number {
154
+ if (typeof raw === "number" && Number.isInteger(raw)) return raw;
155
+ return Number(BigInt(String(raw).trim()));
156
+ }
157
+
158
+ // Commerce tools are NOT read-only (they sign / move on-chain state via fixed
159
+ // signing.ts code). There is no raw signing tool — only these two bounded ops.
160
+ const COMMERCE_ANNOTATIONS = { readOnlyHint: false, openWorldHint: true };
161
+ const READONLY_ANNOTATIONS = { readOnlyHint: true, openWorldHint: true };
162
+
163
+ /** MCP tool result: the JSON payload as text content (+ the log mirror). */
164
+ function toolResult(payload: Record<string, unknown>) {
165
+ return { content: [{ type: "text" as const, text: JSON.stringify(payload) }] };
166
+ }
167
+
168
+ /** The narrow `extra` surface the progress heartbeat needs. */
169
+ interface ProgressExtra {
170
+ _meta?: { progressToken?: string | number };
171
+ sendNotification: (n: {
172
+ method: "notifications/progress";
173
+ params: {
174
+ progressToken: string | number;
175
+ progress: number;
176
+ total?: number;
177
+ };
178
+ }) => Promise<void>;
179
+ }
180
+
181
+ /** Heartbeat: report step progress when the client sent a progressToken. */
182
+ async function reportProgress(
183
+ extra: ProgressExtra,
184
+ progress: number,
185
+ total: number,
186
+ ): Promise<void> {
187
+ const token = extra._meta?.progressToken;
188
+ if (token === undefined) {
189
+ return;
190
+ }
191
+ await extra.sendNotification({
192
+ method: "notifications/progress",
193
+ params: { progressToken: token, progress, total },
194
+ });
195
+ }
196
+
197
+ /** Build the seller MCP server: 2 commerce tools + the read-only chain tools. */
198
+ export function buildMcpServer(): McpServer {
199
+ const server = new McpServer({ name: "bnbagent-seller", version: "1.0.0" });
200
+
201
+ // ── Commerce tools (signing is FIXED code in signing.ts) ──────────────────
202
+ server.registerTool(
203
+ "negotiate",
204
+ {
205
+ description:
206
+ "Return a wallet-signed ERC-8183 price quote for a task. " +
207
+ "Rule-based: the FIXED list price from studio.toml, CLAMPED to [min,max] " +
208
+ "BEFORE EIP-191 signing — a hostile request can never sign out of bounds. " +
209
+ "No LLM. Anchor the returned envelope on-chain (createJob + fund), then " +
210
+ "call `notify_funded` with the job_id. `terms` MUST include both " +
211
+ '"deliverables" and "quality_standards" (the on-chain evaluator requires ' +
212
+ "them); a request missing either is rejected unsigned.",
213
+ inputSchema: {
214
+ task_description: z.string(),
215
+ terms: z.record(z.unknown()).optional(),
216
+ },
217
+ annotations: COMMERCE_ANNOTATIONS,
218
+ },
219
+ // Error contract (unified with the A2A executor): an unexpected fault
220
+ // here (wallet/signing failure) is left to PROPAGATE — the MCP SDK turns
221
+ // it into a tool result with `isError: true` (MCP's tool-execution-error
222
+ // channel), the peer of the A2A executor throwing
223
+ // `A2AError.internalError` → JSON-RPC -32603. Only CLASSIFIED business
224
+ // outcomes are returned as a normal result. So do NOT wrap this in a
225
+ // try/catch that masks a fault as a successful quote.
226
+ async ({ task_description, terms }) => {
227
+ const request = { task_description, terms: terms ?? {} };
228
+ const clamped = signing.clampPrice(signing.listPrice());
229
+ return toolResult(await signing.signQuote(request, clamped));
230
+ },
231
+ );
232
+
233
+ server.registerTool(
234
+ "notify_funded",
235
+ {
236
+ description:
237
+ "Verify a funded job, produce the deliverable, and submit it on-chain — " +
238
+ 'synchronously. The buyer\'s "I funded job X — deliver it" call. Runs the ' +
239
+ "whole flow inside this one tool invocation (AgentCore permits ~15 min; " +
240
+ "progress notifications keep the connection warm). Returns the on-chain " +
241
+ "result; the buyer can also read it back from the chain (SUBMITTED / " +
242
+ 'get_deliverable_url). Verify-failure status is split: "rejected" is ' +
243
+ "TERMINAL — this agent did not sign it, the terms were tampered, it is " +
244
+ 'underfunded or expired — re-calling will not help. "retry" is TRANSIENT ' +
245
+ "(e.g. a chain read failed); the deal may be fine, so the buyer SHOULD " +
246
+ "re-call.",
247
+ inputSchema: { job_id: z.union([z.number().int(), z.string()]) },
248
+ annotations: COMMERCE_ANNOTATIONS,
249
+ },
250
+ async ({ job_id }, extra) => {
251
+ let jid: number;
252
+ try {
253
+ jid = parseJobId(job_id);
254
+ } catch {
255
+ return toolResult({
256
+ status: "rejected",
257
+ error: `invalid job_id: ${JSON.stringify(job_id)}`,
258
+ });
259
+ }
260
+
261
+ // 1/4 — verify the funded job carries THIS agent's signed quote
262
+ // (eth_calls). Honour the `permanent` flag: a permanent failure is
263
+ // terminal ("rejected"); a transient one (chain read hiccup) is
264
+ // "retry" so the buyer re-calls.
265
+ await reportProgress(extra, 1, 4);
266
+ let verdict: { ok: boolean; reason: string; permanent: boolean };
267
+ try {
268
+ verdict = await signing.verifySignedJob(jid);
269
+ } catch (e) {
270
+ // a failed verify is transient; tell the buyer to retry
271
+ log.error(`verify of job ${jid} failed`, e);
272
+ const name = e instanceof Error ? e.constructor.name : "Error";
273
+ const msg = e instanceof Error ? e.message : String(e);
274
+ return toolResult({
275
+ status: "retry",
276
+ job_id: jid,
277
+ reason: `${name}: ${msg}`,
278
+ });
279
+ }
280
+ if (!verdict.ok) {
281
+ return toolResult({
282
+ status: verdict.permanent ? "rejected" : "retry",
283
+ job_id: jid,
284
+ reason: verdict.reason,
285
+ });
286
+ }
287
+
288
+ // 2/4 — produce the deliverable (THE ONLY LLM CALL; specialise the
289
+ // prompt here)
290
+ await reportProgress(extra, 2, 4);
291
+ const spec = await signing.jobSpec(jid);
292
+ const task =
293
+ spec !== null
294
+ ? JSON.stringify({ task: spec.task, terms: spec.terms })
295
+ : `job ${jid}`;
296
+ const prompt =
297
+ "You accepted and were paid for the following job. Produce the deliverable " +
298
+ `now. Be complete and self-contained.\n\nJOB CONTEXT:\n${task}`;
299
+ // An unexpected fault below (LLM unavailable, RPC/submit hiccup) is
300
+ // left to PROPAGATE — the MCP SDK returns it as an isError tool result
301
+ // (the peer of the A2A executor's internalError/-32603). Only the
302
+ // deterministic, classified outcome SubmitPermanentlyUnsupportedError
303
+ // is a "rejected" business result.
304
+ const work = await runLlm(prompt);
305
+
306
+ // 3/4 — sign + broadcast the on-chain submit (re-verifies FUNDED inside)
307
+ await reportProgress(extra, 3, 4);
308
+ let res: { submitTx: string; deliverableUrl: string | null };
309
+ try {
310
+ res = await signing.submitResult(jid, work, {
311
+ job_id: jid,
312
+ generator: generatorTag(),
313
+ built_with: "https://github.com/bnb-chain/bnbagent-studio",
314
+ });
315
+ } catch (e) {
316
+ if (
317
+ e instanceof Error &&
318
+ e.name === "SubmitPermanentlyUnsupportedError"
319
+ ) {
320
+ // Deterministic for this wallet kind — submit can never succeed.
321
+ return toolResult({
322
+ status: "rejected",
323
+ job_id: jid,
324
+ skip: true,
325
+ reason: e.message,
326
+ });
327
+ }
328
+ throw e;
329
+ }
330
+
331
+ // 4/4 — done
332
+ await reportProgress(extra, 4, 4);
333
+ return toolResult({
334
+ status: "submitted",
335
+ job_id: jid,
336
+ tx_hash: res.submitTx,
337
+ deliverable_url: res.deliverableUrl,
338
+ });
339
+ },
340
+ );
341
+
342
+ // ── Read-only chain tools ──────────────────────────────────────────────────
343
+ const network = z.string().optional().describe("studio network name");
344
+ const roConfig = (description: string, inputSchema: z.ZodRawShape) => ({
345
+ description,
346
+ inputSchema,
347
+ annotations: READONLY_ANNOTATIONS,
348
+ });
349
+
350
+ server.registerTool(
351
+ "wallet_info",
352
+ roConfig("Active wallet summary.", {}),
353
+ async () => toolResult(await cr.walletInfo()),
354
+ );
355
+ server.registerTool(
356
+ "wallet_list",
357
+ roConfig("All local wallet addresses.", {}),
358
+ async () => toolResult(await cr.walletList()),
359
+ );
360
+ server.registerTool(
361
+ "wallet_address",
362
+ roConfig("The active wallet address.", {}),
363
+ async () => toolResult({ address: await cr.walletAddress() }),
364
+ );
365
+ server.registerTool(
366
+ "balance_native",
367
+ roConfig("Native BNB balance (defaults to own wallet).", {
368
+ address: z.string().optional(),
369
+ network,
370
+ }),
371
+ async (a) =>
372
+ toolResult(
373
+ await cr.balanceNative(a.address ?? null, a.network ?? defaultNetwork()),
374
+ ),
375
+ );
376
+ server.registerTool(
377
+ "balance_u",
378
+ roConfig("$U payment-token balance (defaults to own wallet).", {
379
+ address: z.string().optional(),
380
+ network,
381
+ }),
382
+ async (a) =>
383
+ toolResult(
384
+ await cr.balanceU(a.address ?? null, a.network ?? defaultNetwork()),
385
+ ),
386
+ );
387
+ server.registerTool(
388
+ "pieverse_usage",
389
+ roConfig(
390
+ "Pieverse LLM usage/credit summary (SIWE personal_sign; no on-chain effect).",
391
+ { days: z.number().int().optional() },
392
+ ),
393
+ async (a) => toolResult(await cr.pieverseUsage(a.days ?? 7)),
394
+ );
395
+ server.registerTool(
396
+ "agent_info",
397
+ roConfig("ERC-8004 identity record for an agent id.", {
398
+ agent_id: z.number().int(),
399
+ network,
400
+ }),
401
+ async (a) =>
402
+ toolResult(await cr.agentInfo(a.agent_id, a.network ?? defaultNetwork())),
403
+ );
404
+ server.registerTool(
405
+ "agent_by_address",
406
+ roConfig("ERC-8004 registration lookup by wallet address.", {
407
+ address: z.string(),
408
+ network,
409
+ }),
410
+ async (a) =>
411
+ toolResult(
412
+ await cr.agentByAddress(a.address, a.network ?? defaultNetwork()),
413
+ ),
414
+ );
415
+ server.registerTool(
416
+ "job_status",
417
+ roConfig("Read-only ERC-8183 job summary.", {
418
+ job_id: z.number().int(),
419
+ network,
420
+ }),
421
+ async (a) =>
422
+ toolResult(await cr.jobStatus(a.job_id, a.network ?? defaultNetwork())),
423
+ );
424
+ server.registerTool(
425
+ "job_list",
426
+ roConfig("List recent ERC-8183 jobs.", {
427
+ limit: z.number().int().optional(),
428
+ mine: z.boolean().optional(),
429
+ network,
430
+ }),
431
+ async (a) =>
432
+ toolResult(
433
+ await cr.jobList({
434
+ limit: a.limit,
435
+ mine: a.mine,
436
+ network: a.network ?? defaultNetwork(),
437
+ }),
438
+ ),
439
+ );
440
+ server.registerTool(
441
+ "job_count",
442
+ roConfig("Network-wide in-flight ERC-8183 job count.", { network }),
443
+ async (a) => toolResult(await cr.jobCount(a.network ?? defaultNetwork())),
444
+ );
445
+ server.registerTool(
446
+ "tx_status",
447
+ roConfig("Transaction status + receipt summary.", {
448
+ tx_hash: z.string(),
449
+ network,
450
+ }),
451
+ async (a) =>
452
+ toolResult(await cr.txStatus(a.tx_hash, a.network ?? defaultNetwork())),
453
+ );
454
+ server.registerTool(
455
+ "block_info",
456
+ roConfig(
457
+ 'Block header summary ("latest"/"earliest"/"pending", decimal, or 0x hash).',
458
+ { block: z.string().optional(), network },
459
+ ),
460
+ async (a) =>
461
+ toolResult(
462
+ await cr.blockInfo(a.block ?? "latest", a.network ?? defaultNetwork()),
463
+ ),
464
+ );
465
+ server.registerTool(
466
+ "contract_call_view",
467
+ roConfig("Call a read-only (view) contract function by signature.", {
468
+ address: z.string(),
469
+ function_signature: z.string(),
470
+ args: z.array(z.unknown()).optional(),
471
+ output_types: z.array(z.string()).optional(),
472
+ network,
473
+ }),
474
+ async (a) =>
475
+ toolResult(
476
+ await cr.contractCallView(
477
+ a.address,
478
+ a.function_signature,
479
+ (a.args ?? null) as unknown[] | null,
480
+ a.output_types ?? null,
481
+ a.network ?? defaultNetwork(),
482
+ ),
483
+ ),
484
+ );
485
+ server.registerTool(
486
+ "network_info",
487
+ roConfig("Chain id / RPC / token info for a studio network.", { network }),
488
+ async (a) => toolResult(await cr.networkInfo(a.network ?? defaultNetwork())),
489
+ );
490
+
491
+ return server;
492
+ }
493
+
494
+ // ── serving ───────────────────────────────────────────────────────────────────
495
+
496
+ /**
497
+ * Serve the MCP server as **stateful** streamable-HTTP on `/mcp`
498
+ * (0.0.0.0:8000/mcp; `AGENT_PORT` is the local override). Stateful — one
499
+ * transport per `Mcp-Session-Id` — so progress notifications during the
500
+ * multi-step `notify_funded` delivery reach the caller.
501
+ */
502
+ async function main(): Promise<void> {
503
+ // Wallet material is NEVER bundled into the deploy artifact. `bag deploy`
504
+ // injects it through a Foundry CustomKeys connection before process start;
505
+ // these calls materialize it on disk before signing. Each is a no-op for
506
+ // the other wallet kind and locally, where it already lives on disk.
507
+ ensureKeystoreMaterialized();
508
+ ensureTwakMaterialized();
509
+ await ensureAltanaSessionLoaded();
510
+
511
+ const app = express();
512
+ app.use(express.json());
513
+
514
+ const transports: Record<string, StreamableHTTPServerTransport> = {};
515
+
516
+ app.all("/mcp", async (req, res) => {
517
+ const sessionId = req.headers["mcp-session-id"] as string | undefined;
518
+ let transport = sessionId ? transports[sessionId] : undefined;
519
+ if (transport === undefined) {
520
+ if (req.method !== "POST" || !isInitializeRequest(req.body)) {
521
+ res.status(400).json({
522
+ jsonrpc: "2.0",
523
+ error: { code: -32000, message: "Bad Request: no valid session" },
524
+ id: null,
525
+ });
526
+ return;
527
+ }
528
+ // New session: stateful transport keyed by Mcp-Session-Id.
529
+ const t = new StreamableHTTPServerTransport({
530
+ sessionIdGenerator: () => randomUUID(),
531
+ onsessioninitialized: (sid) => {
532
+ transports[sid] = t;
533
+ },
534
+ });
535
+ t.onclose = () => {
536
+ if (t.sessionId !== undefined) {
537
+ delete transports[t.sessionId];
538
+ }
539
+ };
540
+ await buildMcpServer().connect(t);
541
+ transport = t;
542
+ }
543
+ await transport.handleRequest(req, res, req.body);
544
+ });
545
+
546
+ const host = process.env.AGENT_BIND_HOST || "0.0.0.0";
547
+ const port = Number(process.env.AGENT_PORT || "8000");
548
+ app.listen(port, host, () => {
549
+ log.info(`MCP serving on ${host}:${port}/mcp`);
550
+ });
551
+ }
552
+
553
+ // Run only as an entrypoint, never on import (tests import buildMcpServer).
554
+ const isMain =
555
+ process.argv[1] !== undefined &&
556
+ import.meta.url === pathToFileURL(process.argv[1]).href;
557
+ if (isMain) {
558
+ main().catch((e) => {
559
+ log.error("fatal", e);
560
+ process.exit(1);
561
+ });
562
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Model factory — emitted user code shared by every runtime entrypoint.
3
+ *
4
+ * This file is **your project's code**, scaffolded by `bag` and emitted at
5
+ * `{{PKG}}/model.ts` for every project, whatever `[llm].provider` you chose.
6
+ * It is yours to edit, fork, or replace — studio will not silently rewrite
7
+ * it.
8
+ *
9
+ * What it does:
10
+ *
11
+ * - Exposes {@link buildModel}, the factory called by the sibling entrypoint
12
+ * to construct the right AI SDK `LanguageModel` for the project's `[llm]`
13
+ * config. For every provider it resolves a plain model via
14
+ * `@bnbagent/studio-runtime/llm` `resolveModel`; for `pieverse-llm` (with
15
+ * auto-renew on) it additionally wraps it with a credit-ensure middleware.
16
+ * - The middleware awaits a Pieverse credit-ensure hook before every
17
+ * generate/stream call — inert unless the provider is `pieverse-llm`.
18
+ *
19
+ * The credit-refresh / auto-allocate / auto-topup logic itself lives in the
20
+ * library at `@bnbagent/studio-runtime/pieverse` `PieverseCreditEnsurer` —
21
+ * this shell just wires it into the AI SDK's generate-call path (via
22
+ * `wrapLanguageModel`, the AI SDK's sanctioned middleware seam). That keeps
23
+ * the adapter tiny: you can fork *this file* (e.g. to swap the AI SDK for
24
+ * another stack) without forking studio itself.
25
+ *
26
+ * That top-up is the ONLY automatic signing path outside signing.ts — it is
27
+ * budget-gated and is NOT an LLM tool. It rides transparently into the
28
+ * `notify_funded` work step.
29
+ */
30
+
31
+ import { loadStudioToml, type TomlTable } from "@bnbagent/studio-runtime/config";
32
+ import { resolveModel } from "@bnbagent/studio-runtime/llm";
33
+ import {
34
+ BudgetPolicy,
35
+ PieverseCreditEnsurer,
36
+ PieversePolicy,
37
+ } from "@bnbagent/studio-runtime/pieverse";
38
+ import { getWallet } from "@bnbagent/studio-runtime/wallet";
39
+ import {
40
+ type LanguageModel,
41
+ type LanguageModelMiddleware,
42
+ wrapLanguageModel,
43
+ } from "ai";
44
+
45
+ /**
46
+ * Build the AI SDK model object for this project's `[llm]` config.
47
+ *
48
+ * Called by the sibling entrypoint. Reads `studio.toml` via
49
+ * `loadStudioToml`, resolves the provider via
50
+ * `@bnbagent/studio-runtime/llm` `resolveModel`, and (when the provider is
51
+ * `pieverse-llm` and auto-renew is enabled) wraps it with the credit-ensure
52
+ * middleware.
53
+ *
54
+ * For non-Pieverse providers — or when `[llm.auto_renew].enabled = false` —
55
+ * returns the raw inner model unwrapped.
56
+ */
57
+ export function buildModel(): LanguageModel {
58
+ const cfg = loadStudioToml();
59
+ const llmCfg = (cfg.llm ?? {}) as TomlTable;
60
+ const inner = resolveModel(llmCfg);
61
+
62
+ if (String(llmCfg.provider ?? "openrouter") !== "pieverse-llm") {
63
+ return inner;
64
+ }
65
+
66
+ // Pieverse path — wrap with the credit-ensure middleware unless auto-renew
67
+ // opted out.
68
+ const autoRenewCfg = (llmCfg.auto_renew ?? {}) as TomlTable;
69
+ const pieverseCfg = (llmCfg.pieverse ?? {}) as TomlTable;
70
+ const budgetCfg = (cfg.budget ?? {}) as TomlTable;
71
+
72
+ const policy = PieversePolicy.fromToml(autoRenewCfg);
73
+ if (!policy.enabled) {
74
+ return inner;
75
+ }
76
+
77
+ const keyHash = pieverseCfg.key_hash;
78
+ if (!keyHash) {
79
+ throw new Error(
80
+ "[llm.pieverse].key_hash is missing in studio.toml. " +
81
+ "Run `bag llm activate` to create a Pieverse key first. " +
82
+ "(After activate, you may need to restart the agent process " +
83
+ "for changes to take effect.)",
84
+ );
85
+ }
86
+ const networkName = String(pieverseCfg.network ?? "bsc-mainnet");
87
+ const budgetPolicy = BudgetPolicy.fromToml(budgetCfg);
88
+
89
+ const ensurer = new PieverseCreditEnsurer({
90
+ modelId: String(llmCfg.model ?? ""),
91
+ wallet: getWallet(),
92
+ keyHash: String(keyHash),
93
+ networkName,
94
+ policy,
95
+ budgetPolicy,
96
+ });
97
+
98
+ // The AI SDK middleware seam: ensure credits BEFORE each generate/stream
99
+ // call, then delegate untouched through the AI SDK middleware seam.
100
+ const creditEnsure: LanguageModelMiddleware = {
101
+ wrapGenerate: async ({ doGenerate }) => {
102
+ await ensurer.ensureCredits();
103
+ return doGenerate();
104
+ },
105
+ wrapStream: async ({ doStream }) => {
106
+ await ensurer.ensureCredits();
107
+ return doStream();
108
+ },
109
+ };
110
+
111
+ // resolveModel always returns a provider model object (never a bare model
112
+ // id string), so it satisfies wrapLanguageModel's model parameter.
113
+ return wrapLanguageModel({
114
+ model: inner as Parameters<typeof wrapLanguageModel>[0]["model"],
115
+ middleware: creditEnsure,
116
+ });
117
+ }