caddie-mcp 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/dist/setup.js ADDED
@@ -0,0 +1,429 @@
1
+ #!/usr/bin/env node
2
+ import { createInterface } from "node:readline";
3
+ import { readFileSync, writeFileSync, existsSync, chmodSync, realpathSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { fileURLToPath } from "node:url";
7
+ import { execSync } from "node:child_process";
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const B3OS_SERVER_URL = "https://api.b3os.org";
10
+ const API_KEY_URL = "https://b3os.org/organizations/settings?tab=api-keys";
11
+ /**
12
+ * Read-only / non-destructive b3os-mcp tools that are safe to pre-approve.
13
+ * Write tools (create_workflow, run_action, query_database, etc.) stay
14
+ * behind permission prompts — users must approve each mutation explicitly.
15
+ */
16
+ const SAFE_AUTO_APPROVE_TOOLS = [
17
+ // Identity
18
+ "mcp__b3os-mcp__b3os_whoami",
19
+ // Read-only lists
20
+ "mcp__b3os-mcp__b3os_list_wallets",
21
+ "mcp__b3os-mcp__b3os_list_connectors",
22
+ "mcp__b3os-mcp__b3os_list_telegram_chats",
23
+ "mcp__b3os-mcp__b3os_list_slack_channels",
24
+ "mcp__b3os-mcp__b3os_list_actions",
25
+ "mcp__b3os-mcp__b3os_search_actions",
26
+ "mcp__b3os-mcp__b3os_get_action",
27
+ "mcp__b3os-mcp__b3os_list_triggers",
28
+ "mcp__b3os-mcp__b3os_get_trigger",
29
+ "mcp__b3os-mcp__b3os_list_workflows",
30
+ "mcp__b3os-mcp__b3os_get_workflow",
31
+ "mcp__b3os-mcp__b3os_validate_workflow",
32
+ "mcp__b3os-mcp__b3os_list_runs",
33
+ "mcp__b3os-mcp__b3os_get_run",
34
+ "mcp__b3os-mcp__b3os_list_tables",
35
+ "mcp__b3os-mcp__b3os_get_table_schema",
36
+ // Read-only lookups
37
+ "mcp__b3os-mcp__b3os_query_action",
38
+ "mcp__b3os-mcp__b3os_token_lookup",
39
+ "mcp__b3os-mcp__b3os_price_lookup",
40
+ "mcp__b3os-mcp__b3os_balance_lookup",
41
+ "mcp__b3os-mcp__b3os_defi_lookup",
42
+ "mcp__b3os-mcp__b3os_debug_transaction",
43
+ "mcp__b3os-mcp__b3os_polymarket_lookup",
44
+ // Caddie (analysis-only — doesn't mutate user workflows)
45
+ "mcp__b3os-mcp__b3os_build_workflow",
46
+ "mcp__b3os-mcp__b3os_debug_run",
47
+ ];
48
+ // ── Helpers ──
49
+ function print(msg) {
50
+ process.stdout.write(msg + "\n");
51
+ }
52
+ function prompt(rl, question) {
53
+ return new Promise(resolve => rl.question(question, resolve));
54
+ }
55
+ async function validateApiKey(apiKey) {
56
+ try {
57
+ const response = await fetch(`${B3OS_SERVER_URL}/v1/organizations`, {
58
+ headers: {
59
+ Authorization: `Bearer ${apiKey}`,
60
+ "Content-Type": "application/json",
61
+ },
62
+ signal: AbortSignal.timeout(10_000),
63
+ });
64
+ if (!response.ok)
65
+ return { valid: false };
66
+ const body = (await response.json());
67
+ const orgName = body?.data?.items?.[0]?.name;
68
+ return { valid: true, orgName };
69
+ }
70
+ catch {
71
+ return { valid: false };
72
+ }
73
+ }
74
+ function getPackageDir() {
75
+ // setup.js lives in dist/, package.json is one level up
76
+ return join(__dirname, "..");
77
+ }
78
+ function getServerEntryPath() {
79
+ // setup.js and index.js are always co-located in dist/ after tsc build
80
+ return join(__dirname, "index.js");
81
+ }
82
+ /**
83
+ * Ensure dist/index.js exists. If missing, attempt to build.
84
+ */
85
+ function ensureBuild() {
86
+ const entryPath = getServerEntryPath();
87
+ if (existsSync(entryPath))
88
+ return;
89
+ print(" ⚠ Build not found. Running pnpm build...");
90
+ try {
91
+ execSync("pnpm build", { cwd: getPackageDir(), stdio: "inherit" });
92
+ }
93
+ catch {
94
+ print("\n ✗ Build failed. Run 'pnpm build' manually in packages/b3os-mcp/");
95
+ process.exit(1);
96
+ }
97
+ if (!existsSync(entryPath)) {
98
+ print("\n ✗ Build completed but dist/index.js not found.");
99
+ process.exit(1);
100
+ }
101
+ }
102
+ /**
103
+ * Run `npm link` to create a global `b3os-mcp` command, then verify it resolves.
104
+ * Returns true if the global command is available, false otherwise.
105
+ */
106
+ function linkGlobally() {
107
+ const pkgDir = getPackageDir();
108
+ try {
109
+ execSync("npm link --force", { cwd: pkgDir, stdio: "pipe" });
110
+ }
111
+ catch (err) {
112
+ const stderr = err?.stderr?.toString().trim();
113
+ print(` ⚠ npm link failed: ${stderr || (err instanceof Error ? err.message : String(err))}`);
114
+ return false;
115
+ }
116
+ // Verify the symlink is resolvable
117
+ try {
118
+ execSync("which b3os-mcp", { stdio: "pipe" });
119
+ return true;
120
+ }
121
+ catch {
122
+ print(" ⚠ b3os-mcp command not found after linking");
123
+ return false;
124
+ }
125
+ }
126
+ /**
127
+ * Build the MCP server entry for config files.
128
+ * Uses the global command name if available, falls back to absolute node path.
129
+ *
130
+ * NOTE: We use process.execPath (absolute path to the node binary) instead of
131
+ * "node" because nvm/fnm paths are not available in subprocess environments
132
+ * (e.g. when Claude Code spawns the MCP server process).
133
+ */
134
+ function buildServerEntry(apiKey, useGlobalCommand, opts) {
135
+ const entry = useGlobalCommand
136
+ ? { command: "b3os-mcp" }
137
+ : { command: process.execPath, args: [getServerEntryPath()] };
138
+ if (opts?.type)
139
+ entry.type = opts.type;
140
+ entry.env = { B3OS_API_KEY: apiKey, B3OS_SERVER_URL };
141
+ return entry;
142
+ }
143
+ /**
144
+ * Check if an existing b3os config entry uses the old absolute-path format
145
+ * (command: "node" with args pointing to a file path).
146
+ */
147
+ function isStaleAbsolutePathEntry(entry) {
148
+ if (!entry || typeof entry !== "object")
149
+ return false;
150
+ const e = entry;
151
+ if (e.command !== "node" || !Array.isArray(e.args) || e.args.length === 0)
152
+ return false;
153
+ const firstArg = String(e.args[0]);
154
+ // Only match absolute paths to our index.js, not custom node setups
155
+ return firstArg.startsWith("/") && firstArg.endsWith("/index.js");
156
+ }
157
+ // ── JSON config helpers ──
158
+ function readJsonConfig(filePath) {
159
+ try {
160
+ return JSON.parse(readFileSync(filePath, "utf-8"));
161
+ }
162
+ catch (err) {
163
+ if (err.code !== "ENOENT") {
164
+ print(` ⚠ Could not parse existing config at ${filePath} — creating fresh`);
165
+ }
166
+ return {};
167
+ }
168
+ }
169
+ function writeJsonConfig(path, config) {
170
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
171
+ // Restrict file permissions to owner-only (rw-------) since config contains API keys
172
+ try {
173
+ chmodSync(path, 0o600);
174
+ }
175
+ catch {
176
+ // chmod may fail on Windows — non-fatal
177
+ }
178
+ }
179
+ // ── Permission pre-approval helpers ──
180
+ function getClaudeDir() {
181
+ return join(homedir(), ".claude");
182
+ }
183
+ function getClaudeCodeSettingsPath() {
184
+ return join(getClaudeDir(), "settings.json");
185
+ }
186
+ /**
187
+ * Merge new tool entries into permissions.allow idempotently.
188
+ * Does not touch disk. Mutates the input config in place and returns it.
189
+ */
190
+ export function mergeAllowedTools(config, tools) {
191
+ const perms = (config.permissions && typeof config.permissions === "object" ? config.permissions : {});
192
+ const allow = Array.isArray(perms.allow) ? perms.allow : [];
193
+ const existing = new Set(allow);
194
+ let added = 0;
195
+ for (const tool of tools) {
196
+ if (!existing.has(tool)) {
197
+ allow.push(tool);
198
+ existing.add(tool);
199
+ added++;
200
+ }
201
+ }
202
+ perms.allow = allow;
203
+ config.permissions = perms;
204
+ return { config, added };
205
+ }
206
+ // ── Claude Desktop config ──
207
+ function getClaudeDesktopConfigPath() {
208
+ const platform = process.platform;
209
+ if (platform === "darwin") {
210
+ return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
211
+ }
212
+ else if (platform === "win32") {
213
+ return join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
214
+ }
215
+ // Linux
216
+ return join(homedir(), ".config", "Claude", "claude_desktop_config.json");
217
+ }
218
+ function isClaudeDesktopInstalled() {
219
+ const configPath = getClaudeDesktopConfigPath();
220
+ return existsSync(dirname(configPath));
221
+ }
222
+ function setupClaudeDesktop(apiKey, useGlobalCommand) {
223
+ const configPath = getClaudeDesktopConfigPath();
224
+ const config = readJsonConfig(configPath);
225
+ const raw = config.mcpServers;
226
+ const mcpServers = (typeof raw === "object" && raw && !Array.isArray(raw) ? raw : {});
227
+ const migrated = isStaleAbsolutePathEntry(mcpServers["b3os-mcp"]);
228
+ mcpServers["b3os-mcp"] = buildServerEntry(apiKey, useGlobalCommand);
229
+ config.mcpServers = mcpServers;
230
+ writeJsonConfig(configPath, config);
231
+ return { migrated };
232
+ }
233
+ // ── Claude Code config ──
234
+ /**
235
+ * Get the user-scoped MCP config path (~/.claude/mcp.json).
236
+ * This config applies to ALL projects — ideal for global installs.
237
+ */
238
+ function getUserMcpJsonPath() {
239
+ return join(getClaudeDir(), "mcp.json");
240
+ }
241
+ /**
242
+ * Find the git root directory for the current working directory.
243
+ * Returns null if not in a git repo.
244
+ */
245
+ function findGitRoot() {
246
+ try {
247
+ return execSync("git rev-parse --show-toplevel", { stdio: "pipe" }).toString().trim();
248
+ }
249
+ catch {
250
+ return null;
251
+ }
252
+ }
253
+ /**
254
+ * Get the project-scoped .mcp.json path if inside a git repo.
255
+ * This config applies only to this project.
256
+ */
257
+ function getProjectMcpJsonPath() {
258
+ const root = findGitRoot();
259
+ return root ? join(root, ".mcp.json") : null;
260
+ }
261
+ /**
262
+ * Check if .mcp.json is listed in the repo's .gitignore.
263
+ */
264
+ function isMcpJsonGitignored() {
265
+ try {
266
+ // git check-ignore exits 0 if the path is ignored, 1 if not
267
+ execSync("git check-ignore -q .mcp.json", { stdio: "pipe" });
268
+ return true;
269
+ }
270
+ catch {
271
+ return false;
272
+ }
273
+ }
274
+ /**
275
+ * Write the b3os MCP server entry into an mcp.json config file.
276
+ * Creates the file if it doesn't exist, merges if it does.
277
+ */
278
+ function writeMcpJsonEntry(filePath, apiKey, useGlobalCommand, opts) {
279
+ const config = readJsonConfig(filePath);
280
+ const mcpServers = (typeof config.mcpServers === "object" && config.mcpServers && !Array.isArray(config.mcpServers)
281
+ ? config.mcpServers
282
+ : {});
283
+ const migrated = isStaleAbsolutePathEntry(mcpServers["b3os-mcp"]);
284
+ mcpServers["b3os-mcp"] = buildServerEntry(apiKey, useGlobalCommand, opts);
285
+ config.mcpServers = mcpServers;
286
+ writeJsonConfig(filePath, config);
287
+ return { migrated };
288
+ }
289
+ // ── Main ──
290
+ async function main() {
291
+ print("");
292
+ print(" B3OS MCP Server Setup");
293
+ print(" ─────────────────────");
294
+ print("");
295
+ print(" Connect Claude to the B3OS workflow automation platform.");
296
+ print("");
297
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
298
+ try {
299
+ // Step 1: API Key
300
+ print(" 1. Get your API key from:");
301
+ print(` → ${API_KEY_URL}`);
302
+ print(' (Create a key with "read-write" scope)');
303
+ print("");
304
+ const apiKey = (await prompt(rl, " ? Paste your B3OS API key: ")).trim();
305
+ if (!apiKey) {
306
+ print("\n ✗ No API key provided. Exiting.");
307
+ process.exit(1);
308
+ }
309
+ if (!apiKey.startsWith("b3sk_")) {
310
+ print('\n ✗ Invalid key format. B3OS API keys start with "b3sk_".');
311
+ process.exit(1);
312
+ }
313
+ // Step 2: Validate
314
+ print(" Validating...");
315
+ const { valid, orgName } = await validateApiKey(apiKey);
316
+ if (!valid) {
317
+ print("\n ✗ API key is invalid or expired.");
318
+ print(` Create a new key at: ${API_KEY_URL}`);
319
+ process.exit(1);
320
+ }
321
+ print(` ✓ Key valid — Organization: "${orgName}"`);
322
+ print("");
323
+ // Step 3: Build validation & global link
324
+ ensureBuild();
325
+ print(" Linking b3os-mcp globally...");
326
+ const useGlobalCommand = linkGlobally();
327
+ if (useGlobalCommand) {
328
+ print(" ✓ Global command linked: b3os-mcp");
329
+ }
330
+ else {
331
+ print(" ⚠ Falling back to absolute path (global link unavailable)");
332
+ }
333
+ print("");
334
+ // Step 4: Configure targets
335
+ print(" 4. Configuring Claude clients...");
336
+ // 4a: Claude Code — user-scoped (~/.claude/mcp.json, works in all projects)
337
+ const userMcpPath = getUserMcpJsonPath();
338
+ {
339
+ const { migrated } = writeMcpJsonEntry(userMcpPath, apiKey, useGlobalCommand, { type: "stdio" });
340
+ print(` ✓ Claude Code (global) — ${userMcpPath}`);
341
+ if (migrated)
342
+ print(" ✓ Migrated from absolute path to global command");
343
+ }
344
+ // 4b: Claude Code — project-scoped (.mcp.json in git root, if in a repo and gitignored)
345
+ const projectMcpPath = getProjectMcpJsonPath();
346
+ if (projectMcpPath) {
347
+ if (isMcpJsonGitignored()) {
348
+ const { migrated } = writeMcpJsonEntry(projectMcpPath, apiKey, useGlobalCommand, { type: "stdio" });
349
+ print(` ✓ Claude Code (project) — ${projectMcpPath}`);
350
+ if (migrated)
351
+ print(" ✓ Migrated from absolute path to global command");
352
+ }
353
+ else {
354
+ print(" – Skipped project .mcp.json (not in .gitignore — would leak API key)");
355
+ print(" Add '.mcp.json' to your .gitignore to enable project-scoped config");
356
+ }
357
+ }
358
+ // 4c: Claude Desktop
359
+ let claudeDesktopConfigured = false;
360
+ if (isClaudeDesktopInstalled()) {
361
+ const { migrated } = setupClaudeDesktop(apiKey, useGlobalCommand);
362
+ print(" ✓ Claude Desktop — configured");
363
+ if (migrated)
364
+ print(" ✓ Migrated from absolute path to global command");
365
+ claudeDesktopConfigured = true;
366
+ }
367
+ else {
368
+ print(" – Claude Desktop not detected (skipped)");
369
+ }
370
+ if (!claudeDesktopConfigured) {
371
+ print("");
372
+ print(" ℹ Claude Desktop was not detected. Install it from https://claude.ai/download to use B3OS there too.");
373
+ }
374
+ // Step 5: Offer to pre-approve safe tools (opt-in)
375
+ print("");
376
+ print(" 5. Reduce permission prompts?");
377
+ print(` Pre-approve ${SAFE_AUTO_APPROVE_TOOLS.length} read-only b3os-mcp tools`);
378
+ print(" (list/get/lookup/debug — NO create, update, delete, or run).");
379
+ print("");
380
+ const approve = (await prompt(rl, " ? Pre-approve safe tools? [Y/n]: ")).trim().toLowerCase();
381
+ if (approve === "" || approve === "y" || approve === "yes") {
382
+ const settingsPath = getClaudeCodeSettingsPath();
383
+ const config = readJsonConfig(settingsPath);
384
+ const { config: merged, added } = mergeAllowedTools(config, SAFE_AUTO_APPROVE_TOOLS);
385
+ writeJsonConfig(settingsPath, merged);
386
+ if (added > 0) {
387
+ print(` ✓ Added ${added} tools to ${settingsPath}`);
388
+ }
389
+ else {
390
+ print(` ✓ All ${SAFE_AUTO_APPROVE_TOOLS.length} tools already pre-approved`);
391
+ }
392
+ print(" Write tools (create/update/delete/run) still require approval per call.");
393
+ print(" To revert: remove the mcp__b3os-mcp__* entries from permissions.allow");
394
+ }
395
+ else {
396
+ print(" – Skipped (you can pre-approve tools later by re-running setup)");
397
+ }
398
+ print("");
399
+ print(" ─────────────────────");
400
+ print(" Restart Claude Code and/or Claude Desktop, then try:");
401
+ print("");
402
+ print(' "Build a workflow that monitors ETH price every 5 min"');
403
+ print(' "What actions are available for DeFi?"');
404
+ print(' "Why did tx 0xabc... on Base fail?"');
405
+ print("");
406
+ }
407
+ finally {
408
+ rl.close();
409
+ }
410
+ }
411
+ // Only run main() when this file is the entry point (not when imported by tests).
412
+ // Resolve symlinks so `npm link` (which points argv[1] at a symlink) still matches.
413
+ function isEntryPoint() {
414
+ if (!process.argv[1])
415
+ return false;
416
+ try {
417
+ const modulePath = fileURLToPath(import.meta.url);
418
+ return realpathSync(modulePath) === realpathSync(process.argv[1]);
419
+ }
420
+ catch {
421
+ return false;
422
+ }
423
+ }
424
+ if (isEntryPoint()) {
425
+ main().catch(err => {
426
+ print(`\n ✗ Setup failed: ${err instanceof Error ? err.message : String(err)}`);
427
+ process.exit(1);
428
+ });
429
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * SSE Event types emitted by the Caddie chat endpoint.
3
+ *
4
+ * ## Adding new event types
5
+ * When Caddie adds a new SSE event type:
6
+ * 1. Add the type string to CaddieEventType
7
+ * 2. Add the typed interface to CaddieEvent union
8
+ * 3. consumeCaddieStream() already forwards unknown events — no changes needed there
9
+ */
10
+ export type CaddieEventType = "thinking" | "tool_start" | "tool_result" | "node" | "chunk" | "done" | "error" | "mode_switch" | "sheet_data";
11
+ export interface CaddieDoneData {
12
+ type: "workflow" | "message";
13
+ workflow?: Record<string, unknown>;
14
+ message?: string;
15
+ incompleteFields?: string[];
16
+ validationErrors?: Array<{
17
+ nodeId?: string;
18
+ message: string;
19
+ }>;
20
+ }
21
+ export interface CaddieDoneEvent {
22
+ type: "done";
23
+ data: CaddieDoneData;
24
+ usage?: {
25
+ inputTokens: number;
26
+ outputTokens: number;
27
+ cacheCreationInputTokens?: number;
28
+ cacheReadInputTokens?: number;
29
+ };
30
+ toolUtilization?: Array<{
31
+ toolName: string;
32
+ callCount: number;
33
+ score: number;
34
+ }>;
35
+ }
36
+ export interface CaddieErrorEvent {
37
+ type: "error";
38
+ error: string;
39
+ }
40
+ export type CaddieEvent = {
41
+ type: CaddieEventType;
42
+ [key: string]: any;
43
+ };
44
+ /**
45
+ * Parse raw SSE text into an array of typed events.
46
+ * Each SSE message is separated by a blank line (`\n\n`).
47
+ * Lines starting with `data: ` contain the JSON payload.
48
+ * Lines starting with `:` are comments (heartbeats) and are skipped.
49
+ */
50
+ export declare function parseSseEvents(raw: string): CaddieEvent[];
51
+ /**
52
+ * POST to the Caddie chat endpoint and consume the SSE stream.
53
+ * Returns the final `done` event, or throws on `error` event or timeout.
54
+ *
55
+ * @param apiKey - B3OS API key for Authorization header
56
+ * @param serverUrl - B3OS server base URL
57
+ * @param params - Chat request parameters (message, workflowId, definition, etc.)
58
+ * @param timeoutMs - Max time to wait for the stream to complete (default: 120s)
59
+ */
60
+ export declare function consumeCaddieStream(apiKey: string, serverUrl: string, params: {
61
+ message: string;
62
+ workflowId?: string;
63
+ definition?: Record<string, unknown>;
64
+ agentVersion?: string;
65
+ }, timeoutMs?: number): Promise<CaddieDoneEvent>;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Parse raw SSE text into an array of typed events.
3
+ * Each SSE message is separated by a blank line (`\n\n`).
4
+ * Lines starting with `data: ` contain the JSON payload.
5
+ * Lines starting with `:` are comments (heartbeats) and are skipped.
6
+ */
7
+ export function parseSseEvents(raw) {
8
+ const events = [];
9
+ const blocks = raw.split("\n\n");
10
+ for (const block of blocks) {
11
+ const trimmed = block.trim();
12
+ if (!trimmed)
13
+ continue;
14
+ const dataLines = [];
15
+ for (const line of trimmed.split("\n")) {
16
+ if (line.startsWith("data: ")) {
17
+ dataLines.push(line.slice(6));
18
+ }
19
+ else if (line.startsWith("data:")) {
20
+ dataLines.push(line.slice(5));
21
+ }
22
+ }
23
+ if (dataLines.length === 0)
24
+ continue;
25
+ try {
26
+ const parsed = JSON.parse(dataLines.join(""));
27
+ if (parsed && typeof parsed.type === "string") {
28
+ events.push(parsed);
29
+ }
30
+ }
31
+ catch {
32
+ // Skip malformed JSON
33
+ }
34
+ }
35
+ return events;
36
+ }
37
+ /**
38
+ * POST to the Caddie chat endpoint and consume the SSE stream.
39
+ * Returns the final `done` event, or throws on `error` event or timeout.
40
+ *
41
+ * @param apiKey - B3OS API key for Authorization header
42
+ * @param serverUrl - B3OS server base URL
43
+ * @param params - Chat request parameters (message, workflowId, definition, etc.)
44
+ * @param timeoutMs - Max time to wait for the stream to complete (default: 120s)
45
+ */
46
+ export async function consumeCaddieStream(apiKey, serverUrl, params, timeoutMs = 120_000) {
47
+ const controller = new AbortController();
48
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
49
+ try {
50
+ const response = await fetch(`${serverUrl}/v1/ai/workflow/chat/stream`, {
51
+ method: "POST",
52
+ headers: {
53
+ Authorization: `Bearer ${apiKey}`,
54
+ "Content-Type": "application/json",
55
+ Accept: "text/event-stream",
56
+ },
57
+ body: JSON.stringify({
58
+ message: params.message,
59
+ workflowId: params.workflowId,
60
+ definition: params.definition,
61
+ agentVersion: params.agentVersion ?? "v2",
62
+ }),
63
+ signal: controller.signal,
64
+ });
65
+ if (!response.ok) {
66
+ const body = await response.text();
67
+ // Sanitize: strip HTML error pages (Cloudflare 502s etc.) and truncate
68
+ const clean = /^\s*<(!DOCTYPE|html)/i.test(body)
69
+ ? "(HTML error page — server may be temporarily unavailable)"
70
+ : body.length > 500
71
+ ? body.slice(0, 500) + "…(truncated)"
72
+ : body;
73
+ throw new Error(`Caddie API error ${response.status}: ${clean}`);
74
+ }
75
+ const text = await response.text();
76
+ const events = parseSseEvents(text);
77
+ const errorEvent = events.find((e) => e.type === "error");
78
+ if (errorEvent) {
79
+ throw new Error(`Caddie error: ${errorEvent.error}`);
80
+ }
81
+ const doneEvent = events.find((e) => e.type === "done" && e.data != null && typeof e.data.type === "string");
82
+ if (!doneEvent) {
83
+ // Check if there's a done event without valid data — distinguishes "no done event" from "malformed done event"
84
+ const malformedDone = events.find(e => e.type === "done");
85
+ if (malformedDone) {
86
+ throw new Error(`Caddie returned a done event without valid data: ${JSON.stringify(malformedDone).slice(0, 500)}`);
87
+ }
88
+ throw new Error("Caddie stream ended without a done event");
89
+ }
90
+ return doneEvent;
91
+ }
92
+ catch (err) {
93
+ if (err instanceof Error && err.name === "AbortError") {
94
+ throw new Error(`Caddie request timed out after ${timeoutMs / 1000} seconds`);
95
+ }
96
+ throw err;
97
+ }
98
+ finally {
99
+ clearTimeout(timeout);
100
+ }
101
+ }
@@ -0,0 +1,2 @@
1
+ export declare const ASK_WIDGET_TYPES: readonly ["select", "multiselect", "text", "number", "chain-id", "chain-ids", "network", "networks", "token-address", "token-addresses", "recipient-address", "address", "asset-selector", "price-condition", "price", "contract-address", "token-amount", "textarea", "date-time", "date", "email", "rrule", "color", "boolean", "turnkey-wallet", "telegram-chat", "polymarket-outcome", "slack-channel", "quickbooks-account", "connector-account", "database-table"];
2
+ export type AskWidgetType = (typeof ASK_WIDGET_TYPES)[number];
@@ -0,0 +1,34 @@
1
+ // Code generated by cmd/tools/generate-ask-capabilities; DO NOT EDIT.
2
+ export const ASK_WIDGET_TYPES = [
3
+ "select",
4
+ "multiselect",
5
+ "text",
6
+ "number",
7
+ "chain-id",
8
+ "chain-ids",
9
+ "network",
10
+ "networks",
11
+ "token-address",
12
+ "token-addresses",
13
+ "recipient-address",
14
+ "address",
15
+ "asset-selector",
16
+ "price-condition",
17
+ "price",
18
+ "contract-address",
19
+ "token-amount",
20
+ "textarea",
21
+ "date-time",
22
+ "date",
23
+ "email",
24
+ "rrule",
25
+ "color",
26
+ "boolean",
27
+ "turnkey-wallet",
28
+ "telegram-chat",
29
+ "polymarket-outcome",
30
+ "slack-channel",
31
+ "quickbooks-account",
32
+ "connector-account",
33
+ "database-table",
34
+ ];
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerBlockchainTools(s: McpServer): void;
@@ -0,0 +1,52 @@
1
+ import { z } from "zod";
2
+ import { request, parseJsonParam, truncateResponse } from "../client.js";
3
+ import { ACTION_TYPE_RE } from "./shared.js";
4
+ export function registerBlockchainTools(s) {
5
+ s.registerTool("b3os_query_action", {
6
+ description: `Execute any B3OS action as a read-only query — no CU cost, no state changes.
7
+
8
+ WORKFLOW: b3os_search_actions → b3os_get_action (schema) → b3os_query_action (call)
9
+
10
+ COMMON ACTIONS (no search needed):
11
+ - "coingecko-get-token-data" — token info by address ({network, address})
12
+ - "coingecko-get-token-price" — prices ({coinIds, vsCurrencies: ["usd"]})
13
+ - "sim-dune-get-wallet-balances" — balances ({address, chainIds?})
14
+ - "debug-transaction" — tx trace with decoded calls ({txHash, chainId})
15
+ - "get-transaction-details" — basic tx data ({txHash, chainId})
16
+ - "sim-dune-get-defi-positions" — DeFi positions ({address, chainId?})
17
+ - "polymarket-search-markets" — prediction markets ({query})
18
+ - "polymarket-get-market" — market details ({slug or marketUrl})
19
+ - "evm-read" — smart contract read ({chainId, contractAddress, abi, functionName, args})
20
+
21
+ For write operations (send tokens, place bets), build a workflow instead.`,
22
+ inputSchema: {
23
+ actionType: z.string().describe("Action type (e.g. 'debug-transaction', 'polymarket-search-markets')"),
24
+ payload: z
25
+ .any()
26
+ .describe("Action payload object. Fields depend on the action — use b3os_get_action to see the schema."),
27
+ },
28
+ }, async ({ actionType, payload }) => {
29
+ if (!ACTION_TYPE_RE.test(actionType)) {
30
+ return { content: [{ type: "text", text: `Invalid actionType format: "${actionType}"` }] };
31
+ }
32
+ try {
33
+ const parsedPayload = parseJsonParam(payload ?? {}, "payload");
34
+ const result = await request(`/v1/action-proxy/${actionType}/query`, {
35
+ method: "POST",
36
+ body: { payload: parsedPayload },
37
+ });
38
+ const text = truncateResponse(JSON.stringify(result, null, 2));
39
+ return { content: [{ type: "text", text }] };
40
+ }
41
+ catch (err) {
42
+ return {
43
+ content: [
44
+ {
45
+ type: "text",
46
+ text: `Action query failed (${actionType}): ${err instanceof Error ? err.message : err}`,
47
+ },
48
+ ],
49
+ };
50
+ }
51
+ });
52
+ }