bwb-browser 3.1.1 → 3.2.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/AGENTS.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # bwb-browser — Agent Integration Guide
2
2
 
3
3
  > **Author:** Krish Tiwari ([@krshforever](https://github.com/krshforever))
4
- > **Package:** [`bwb-browser`](https://www.npmjs.com/package/bwb-browser) · 76KB source · 25 tools
5
- > **Last updated:** 2026-07-28
4
+ > **Package:** [`bwb-browser`](https://www.npmjs.com/package/bwb-browser) · 76KB source · 26 tools
5
+ > **Last updated:** 2026-08-06
6
6
 
7
7
  ## What is bwb?
8
8
 
9
- **Browser Without Bloat** — a lightweight MCP server that gives any AI agent browser superpowers. 76KB. 25 tools. Zero heavy dependencies.
9
+ **Browser Without Bloat** — a lightweight MCP server that gives any AI agent browser superpowers. 76KB. 26 tools. Zero heavy dependencies.
10
10
 
11
11
  While other MCP browser tools ship a full browser binary (Playwright MCP = ~250MB, Puppeteer MCP = ~400MB), bwb uses **raw Chrome DevTools Protocol (CDP)** — the protocol Chrome speaks natively. It auto-detects the browser already on your system.
12
12
 
@@ -52,7 +52,7 @@ npm install -g bwb-browser
52
52
 
53
53
  # 2. Verify it works
54
54
  bwb --version
55
- # → bwb-browser 3.0.0
55
+ # → bwb-browser 3.2.0
56
56
 
57
57
  # 3. Add to your AI agent's MCP config
58
58
  ```
@@ -64,10 +64,10 @@ bwb --version
64
64
  Give this to any AI agent (Claude, OpenCode, Gemini, etc.) to auto-install and configure bwb:
65
65
 
66
66
  ```
67
- I want you to install and use bwb-browser — a 76KB MCP browser automation server with 25 tools.
67
+ I want you to install and use bwb-browser — a 76KB MCP browser automation server with 26 tools.
68
68
 
69
69
  bwb is Browser Without Bloat. It uses raw Chrome DevTools Protocol (no Playwright, no Puppeteer).
70
- It has 25 tools including:
70
+ It has 26 tools including:
71
71
 
72
72
  - `browser_act` — natural language interaction ("search for X", "click the button", "what's on this page")
73
73
  - `browser_watch` — live capture of console messages, network requests, JS exceptions, page navigations
@@ -126,7 +126,7 @@ When using bwb for web tasks, follow these patterns:
126
126
  ### Basic Browsing
127
127
  - `browser_goto({url: "https://example.com"})` — navigate to a page
128
128
  - `browser_title()` — check page title
129
- - `browser_screenshot()` — take a screenshot (also saves to /storage/emulated/0/Download/bwb-screenshots/ on Android or ~/bwb-screenshots/ on desktop)
129
+ - `browser_screenshot({selector: "#chart"})` — take a screenshot (whole page, viewport, or one element; saves to /storage/emulated/0/Download/bwb-screenshots/ on Android or ~/bwb-screenshots/ on desktop)
130
130
  - `browser_text()` — get page text content
131
131
  - `browser_html()` — get page HTML
132
132
  - `browser_elements({kind: "links"|"buttons"|"inputs"|"headings"})` — find interactive elements
@@ -167,7 +167,7 @@ internally — not just what it looks like.
167
167
  | **`browser_diagnose`** | 🔥 Full page health check — perf, errors, broken images, score |
168
168
  | **`browser_fingerprint`** | 🔥 Realistic browser profile for testing |
169
169
  | `browser_goto` | Navigate to a URL |
170
- | `browser_screenshot` | Take a screenshot (saves to disk + returns base64) |
170
+ | `browser_screenshot` | Take a screenshot — full page, viewport, or a single element via `selector` (saves to disk + returns base64) |
171
171
  | `browser_html` | Get page/selector HTML |
172
172
  | `browser_text` | Get page/selector visible text |
173
173
  | `browser_click` | Click an element (native CDP mouse events) |
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # bwb-browser
2
2
 
3
- **Browser Without Bloat** — 76KB. 25 tools. Zero dependencies. Runs on your phone.
3
+ **Browser Without Bloat** — 76KB. 26 tools. Zero dependencies. Runs on your phone.
4
4
 
5
5
  A lightweight MCP server that gives any AI agent browser superpowers. Written by a guy in India on Termux because the existing tools were 200MB of "why."
6
6
 
@@ -74,6 +74,36 @@ Create tabs, close them, switch between them, save cookies, load them back. Like
74
74
 
75
75
  Normalizes `navigator.webdriver`, plugins, languages, and user-agent for testing environments. Not "stealth mode" — just honest fingerprint normalization so your tests actually match real user conditions.
76
76
 
77
+ ### 7. Element Screenshots (new in 3.2.0)
78
+
79
+ Capture just one element — a login form, a chart, a product card — not the whole page:
80
+
81
+ ```javascript
82
+ browser_screenshot({selector: "#price-chart"})
83
+ browser_screenshot({selector: "h1"}) // The headline, cropped
84
+ browser_screenshot({fullPage: true}) // The whole page
85
+ browser_screenshot({}) // Just the viewport
86
+ ```
87
+
88
+ Every screenshot is saved to disk (Android: `/storage/emulated/0/Download/bwb-screenshots/`, desktop: `~/bwb-screenshots/`) **and** returned to your agent as a base64 image.
89
+
90
+ ---
91
+
92
+ ## What's New in 3.2.0
93
+
94
+ ### New
95
+ - **`browser_screenshot({ selector })`** — element-level capture. Grab just the login form, the chart, the product card — not the whole page.
96
+ - **Screenshot directory auto-detect** — Termux/Android → `/storage/emulated/0/Download/bwb-screenshots/`, desktop → `~/bwb-screenshots/`. Override with `BWB_SCREENSHOTS_DIR`.
97
+
98
+ ### Bug Fixes
99
+ - **`browser_back` rewritten** — native CDP history navigation (the `Page.goBack` call doesn't exist in bundled CDP 1.3; the old `history.back()` JS hack is gone). Verified with real two-step back navigation.
100
+ - **`browser_act` precision fixes** — navigation regex no longer swallows compound instructions ("go to X and read the title" works), "fill X with Y" vs "type Y in X" no longer swap target/text, and `search` can't hijack "fill search with X"
101
+ - **`killOrphanedChrome` safety** — graceful SIGTERM→SIGKILL, now scoped to bwb's own profile so it never kills another agent's browser
102
+ - **`browser_restart` hygiene** — watch listeners can't outlive the dying protocol
103
+ - **`browser_status` accuracy** — uses the real bound port, no more hardcoded 9222 poke
104
+
105
+ *Fixes from the PR #1 code review by @netzro (Hermes Agent) are incorporated and credited in the [changelog](./CHANGELOG.md).*
106
+
77
107
  ---
78
108
 
79
109
  ## Quick Install
@@ -81,7 +111,7 @@ Normalizes `navigator.webdriver`, plugins, languages, and user-agent for testing
81
111
  ```bash
82
112
  npm install -g bwb-browser
83
113
  bwb --version
84
- # → bwb-browser 3.0.0
114
+ # → bwb-browser 3.2.0
85
115
  ```
86
116
 
87
117
  Done. If you have Chrome/Chromium anywhere on your system, bwb finds it. No config files. No environment variables. Just works.
@@ -97,7 +127,7 @@ bwb
97
127
 
98
128
  ---
99
129
 
100
- ## All 25 Tools
130
+ ## All 26 Tools
101
131
 
102
132
  | Tool | Description |
103
133
  |------|-------------|
@@ -106,7 +136,7 @@ bwb
106
136
  | **`browser_diagnose`** | 🔥 Full page health check — perf, errors, broken images, score |
107
137
  | **`browser_fingerprint`** | 🔥 Realistic browser profile for testing |
108
138
  | `browser_goto` | Navigate to a URL |
109
- | `browser_screenshot` | Take a screenshot |
139
+ | `browser_screenshot` | Take a screenshot — whole page, viewport, or a single element via `selector` |
110
140
  | `browser_html` | Get page/selector HTML |
111
141
  | `browser_text` | Get page/selector text |
112
142
  | `browser_title` | Get page title |
package/lib/act.mjs CHANGED
@@ -165,7 +165,10 @@ export async function executeInstruction(protocol, instruction) {
165
165
  const lower = instruction.trim().toLowerCase();
166
166
 
167
167
  // ─── Pattern: "go to URL" / "navigate to URL" / "open URL" ──────────────
168
- const navMatch = lower.match(/^(?:go to|navigate to|open|visit|take me to)\s+(.+)/);
168
+ // Capture only the URL token — stop at natural instruction boundaries so
169
+ // compound instructions ("go to X and tell me the title") don't swallow
170
+ // the whole sentence into the URL.
171
+ const navMatch = lower.match(/^(?:go to|navigate to|open|visit|take me to)\s+([^\s,]+(?:\.[^\s,]+)*)(?=\s|$)/);
169
172
  if (navMatch) {
170
173
  const url = normalizeUrl(navMatch[1]);
171
174
  if (url) {
@@ -187,7 +190,9 @@ export async function executeInstruction(protocol, instruction) {
187
190
  // If navMatch but URL was invalid (normalizeUrl returned null), fall through to search
188
191
 
189
192
  // ─── Pattern: "search for X" / "search X" ───────────────────────────────
190
- const searchMatch = lower.match(/search\s+(?:for\s+)?(.+)/);
193
+ // Anchored to string start: unanchored, "fill search with X" would be
194
+ // hijacked by this pattern before fill could handle it.
195
+ const searchMatch = lower.match(/^search\s+(?:for\s+)?(.+)/);
191
196
  if (searchMatch) {
192
197
  const query = searchMatch[1];
193
198
  let inputInfo = await findInput(Runtime, "search");
@@ -263,7 +268,25 @@ export async function executeInstruction(protocol, instruction) {
263
268
  }
264
269
 
265
270
  // ─── Pattern: "fill X with Y" / "type Y in X" / "enter Y into X" ──────
266
- const fillMatch = lower.match(/(?:fill|type|enter)\s+(.+?)\s+(?:in|into|with)\s+(.+)/);
271
+ // NOTE: "fill X with Y" puts the VALUE in X ("fill email with foo@bar.com"),
272
+ // while "type Y in X" puts the TEXT in X ("type hello in search").
273
+ const fillWithMatch = lower.match(/fill\s+(.+?)\s+with\s+(.+)/);
274
+ if (fillWithMatch) {
275
+ const target = fillWithMatch[1];
276
+ const text = fillWithMatch[2];
277
+ const inputInfo = await findInput(Runtime, target);
278
+ if (inputInfo.found && inputInfo.selector) {
279
+ await Runtime.evaluate({
280
+ expression: `document.querySelector(${JSON.stringify(inputInfo.selector)})?.focus()`,
281
+ });
282
+ await Input.insertText({ text });
283
+ await new Promise(r => setTimeout(r, 200));
284
+ return { action: "fill", target, text, inputTag: inputInfo.tag };
285
+ }
286
+ return { action: "fill_error", target, error: `Could not find input matching "${target}"` };
287
+ }
288
+
289
+ const fillMatch = lower.match(/(?:type|enter)\s+(.+?)\s+(?:in|into)\s+(.+)/);
267
290
  if (fillMatch) {
268
291
  const text = fillMatch[1];
269
292
  const target = fillMatch[2];
package/lib/browser.mjs CHANGED
@@ -41,8 +41,18 @@ export const cfg = {
41
41
  // NOT the user's normal Chrome browser.
42
42
  export function killOrphanedChrome() {
43
43
  try {
44
+ // Only touch Chromes using OUR user-data-dir — never another agent's browser.
45
+ const scopedMatch = cfg.userDataDir
46
+ ? `grep "remote-debugging-port" | grep "${String(cfg.userDataDir).replace(/["\\]/g, "\\$&")}" | grep -v grep`
47
+ : `grep "remote-debugging-port" | grep -v grep`;
48
+ // SIGTERM first for clean shutdown
44
49
  execSync(
45
- `ps aux | grep "remote-debugging-port" | grep -v grep | awk '{print $2}' | xargs -r kill -9 2>/dev/null; true`,
50
+ `ps aux | ${scopedMatch} | awk '{print $2}' | xargs -r kill -15 2>/dev/null; true`,
51
+ { encoding: "utf8", timeout: 5000 }
52
+ );
53
+ // Give them a moment to exit cleanly, then SIGKILL survivors
54
+ execSync(
55
+ `sleep 1 && ps aux | ${scopedMatch} | awk '{print $2}' | xargs -r kill -9 2>/dev/null; true`,
46
56
  { encoding: "utf8", timeout: 5000 }
47
57
  );
48
58
  } catch {}
package/lib/diagnose.mjs CHANGED
@@ -16,9 +16,16 @@ export async function diagnosePage(protocol) {
16
16
  // Collect console errors during diagnostic
17
17
  const consoleErrors = [];
18
18
  let unsubConsole, unsubException;
19
+ let runtimeWasAlreadyEnabled = false;
19
20
 
20
21
  try {
21
- await Runtime.enable();
22
+ // Check if Runtime is already enabled; if not, enable it
23
+ try {
24
+ await Runtime.enable();
25
+ } catch {
26
+ // Already enabled — that's fine, but don't disable it later
27
+ runtimeWasAlreadyEnabled = true;
28
+ }
22
29
  unsubConsole = Runtime.consoleAPICalled((params) => {
23
30
  if (params.type === "error" || params.type === "warning") {
24
31
  consoleErrors.push({
@@ -114,6 +121,9 @@ export async function diagnosePage(protocol) {
114
121
  // Cleanup
115
122
  try { if (unsubConsole) unsubConsole(); } catch {}
116
123
  try { if (unsubException) unsubException(); } catch {}
124
+ if (!runtimeWasAlreadyEnabled) {
125
+ try { await Runtime.disable(); } catch {}
126
+ }
117
127
 
118
128
  return {
119
129
  url: currentUrl,
@@ -49,11 +49,16 @@ export async function applyRealisticProfile(protocol) {
49
49
  configurable: true,
50
50
  });
51
51
 
52
- // Remove automation-specific chrome.runtime
52
+ // Remove automation-specific chrome.runtime (strict-mode safe:
53
+ // delete on a non-configurable prop throws, defineProperty with a
54
+ // getter is legal even in strict mode)
53
55
  if (window.chrome) {
54
- if (window.chrome.runtime) {
55
- delete window.chrome.runtime;
56
- }
56
+ try {
57
+ Object.defineProperty(window.chrome, 'runtime', {
58
+ get: () => undefined,
59
+ configurable: true,
60
+ });
61
+ } catch {}
57
62
  if (!window.chrome.loadTimes) {
58
63
  window.chrome.loadTimes = function() { return {}; };
59
64
  }
package/lib/helpers.mjs CHANGED
@@ -116,8 +116,7 @@ export async function waitForSelector(runtime, selector, opts = {}) {
116
116
  const info = JSON.parse(result?.value || "{}");
117
117
 
118
118
  if (disappear && info.status === "NOT_FOUND") return true;
119
- if (!disappear && info.status === "FOUND" && !info.hidden) return true;
120
- if (!disappear && info.status === "FOUND" && !opts.visible) return true;
119
+ if (!disappear && info.status === "FOUND" && (!opts.visible || !info.hidden)) return true;
121
120
 
122
121
  await new Promise((r) => setTimeout(r, 200));
123
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bwb-browser",
3
- "version": "3.1.1",
3
+ "version": "3.2.0",
4
4
  "description": "Browser Without Bloat — 76KB MCP server that gives AI agents browser superpowers. Natural language interaction, live event watching, persistent sessions, multi-tab. Runs on Termux/Android, desktop, and CI. Zero heavy dependencies.",
5
5
  "bin": {
6
6
  "bwb": "bin/bwb"
package/server.mjs CHANGED
@@ -18,8 +18,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
18
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
19
  import { z } from "zod";
20
20
  import CDP from "chrome-remote-interface";
21
- import { mkdirSync, readFileSync } from "fs";
22
- import { homedir } from "os";
21
+ import { mkdirSync, readFileSync, existsSync } from "fs";
22
+ import { homedir, platform } from "os";
23
23
  import { join, dirname } from "path";
24
24
  import { fileURLToPath } from "url";
25
25
 
@@ -79,7 +79,7 @@ function printHelp() {
79
79
  console.log(`
80
80
  bwb-browser v${BWB_VERSION} — Browser Without Bloat
81
81
 
82
- Browser automation for AI agents. 76KB. 25 tools. Zero heavy dependencies.
82
+ Browser automation for AI agents. 76KB. 26 tools. Zero heavy dependencies.
83
83
  Uses raw CDP — no Playwright, no Puppeteer, no 400MB downloads.
84
84
 
85
85
  Built on Termux/Android. Runs everywhere. Weighs nothing.
@@ -97,7 +97,7 @@ OPTIONS:
97
97
  --version Print version
98
98
  --help Show this help
99
99
 
100
- TOOLS (25):
100
+ TOOLS (26):
101
101
  CORE BROWSING:
102
102
  browser_goto Navigate to a URL
103
103
  browser_screenshot Take a screenshot
@@ -174,7 +174,14 @@ Object.assign(cfg, parseArgs());
174
174
  cfg.port = cfg.port || parseInt(process.env.BWB_CDP_PORT || "0", 10);
175
175
  cfg.headless = cfg.headless !== undefined ? cfg.headless : (process.env.BWB_HEADLESS !== "false");
176
176
  cfg.userDataDir = cfg.userDataDir || process.env.BWB_USER_DATA_DIR || join(homedir(), ".cache", "bwb-browser");
177
- cfg.screenshotsDir = cfg.screenshotsDir || process.env.BWB_SCREENSHOTS_DIR || "/storage/emulated/0/Download/bwb-screenshots";
177
+ cfg.screenshotsDir = cfg.screenshotsDir || process.env.BWB_SCREENSHOTS_DIR || (() => {
178
+ // Auto-detect: Termux/Android path if available, else ~/bwb-screenshots/
179
+ const androidPath = "/storage/emulated/0/Download/bwb-screenshots";
180
+ if (platform() === "android" && existsSync("/storage/emulated/0/Download")) return androidPath;
181
+ if (process.env.HOME?.includes("com.termux")) return androidPath;
182
+ if (process.env.TERMUX_VERSION) return androidPath;
183
+ return join(homedir(), "bwb-screenshots");
184
+ })();
178
185
  cfg.navTimeout = cfg.navTimeout || parseInt(process.env.BWB_NAV_TIMEOUT || "30000", 10);
179
186
 
180
187
  try { mkdirSync(cfg.screenshotsDir, { recursive: true }); } catch {}
@@ -248,16 +255,43 @@ const tools = {
248
255
  },
249
256
 
250
257
  browser_screenshot: {
251
- description: "Take a screenshot of the current page.",
258
+ description: "Take a screenshot of the current page. Pass a CSS selector to capture just that element.",
252
259
  schema: {
253
260
  fullPage: z.boolean().describe("Full page screenshot (default false)").optional(),
254
261
  quality: z.number().describe("JPEG quality 0-100 (default 80)").optional(),
262
+ selector: z.string().describe("CSS selector to capture only that element (optional)").optional(),
255
263
  },
256
- handler: async ({ fullPage = false, quality = 80 }) => {
257
- const { Page } = await getActiveProtocol();
258
- const { data } = await Page.captureScreenshot({ format: "jpeg", quality, captureBeyondViewport: fullPage });
264
+ handler: async ({ fullPage = false, quality = 80, selector }) => {
265
+ const { Page, Runtime } = await getActiveProtocol();
266
+ let clip;
267
+ if (selector) {
268
+ // Element capture: compute bounding rect in page coords, then clip
269
+ const { result } = await Runtime.evaluate({
270
+ expression: `(() => {
271
+ const el = document.querySelector(${JSON.stringify(selector)});
272
+ if (!el) return null;
273
+ const r = el.getBoundingClientRect();
274
+ const sx = window.scrollX || document.documentElement.scrollLeft;
275
+ const sy = window.scrollY || document.documentElement.scrollTop;
276
+ return JSON.stringify({ x: r.x + sx, y: r.y + sy, width: r.width, height: r.height });
277
+ })()`,
278
+ returnByValue: true,
279
+ });
280
+ const rect = result?.value ? JSON.parse(result.value) : null;
281
+ if (!rect || !rect.width || !rect.height) {
282
+ return { content: [{ type: "text", text: JSON.stringify({ error: `Element not found or not visible: ${selector}` }) }] };
283
+ }
284
+ clip = { ...rect, scale: 1 };
285
+ }
286
+ // captureBeyondViewport=false keeps clip math in viewport space; a 0-size clip
287
+ // would be rejected by CDP, so guard against degenerate rects above.
288
+ const { data } = await Page.captureScreenshot({
289
+ format: "jpeg", quality,
290
+ captureBeyondViewport: fullPage || !!clip,
291
+ ...(clip ? { clip } : {}),
292
+ });
259
293
  const savedPath = saveScreenshot(data);
260
- const response = { screenshot: `data:image/jpeg;base64,${data.slice(0, 40)}...` };
294
+ const response = { screenshot: `data:image/jpeg;base64,${data.slice(0, 40)}...`, captured: clip ? selector : (fullPage ? "full page" : "viewport") };
261
295
  if (savedPath) response.savedTo = savedPath;
262
296
  return { content: [
263
297
  { type: "image", data, mimeType: "image/jpeg" },
@@ -317,8 +351,17 @@ const tools = {
317
351
  schema: {},
318
352
  handler: async () => {
319
353
  const { Page, Runtime } = await getActiveProtocol();
320
- await Page.navigate({ url: "javascript:history.back()" });
321
- await new Promise(r => setTimeout(r, 500));
354
+ // Native CDP back: walk history via navigation entries.
355
+ // NOTE: Page.goBack doesn't exist in the bundled CDP 1.3 protocol —
356
+ // getNavigationHistory + navigateToHistoryEntry is the native equivalent.
357
+ // The wrapper returns history FLAT ({currentIndex, entries}), not {result:{...}}.
358
+ const hist = await Page.getNavigationHistory();
359
+ const entries = hist?.entries || [];
360
+ const currentIdx = hist?.currentIndex ?? -1;
361
+ if (currentIdx > 0 && entries[currentIdx - 1]) {
362
+ await Page.navigateToHistoryEntry({ entryId: entries[currentIdx - 1].id });
363
+ }
364
+ await new Promise(r => setTimeout(r, Math.min(cfg.navTimeout, 1000)));
322
365
  const { result } = await Runtime.evaluate({ expression: "document.title" });
323
366
  syncActiveTab(result?.value, undefined);
324
367
  return { content: [{ type: "text", text: JSON.stringify({ title: result?.value || "" }) }] };
@@ -555,18 +598,17 @@ const tools = {
555
598
  status.running = true;
556
599
  status.pid = browser.pid;
557
600
  status.tabs = listTabs();
558
- try {
559
- if (actualCdpPort) {
560
- status.actualPort = actualCdpPort;
561
- const targets = await CDP.List({ port: actualCdpPort });
562
- status.connected = true;
563
- status.targets = targets.map(t => ({ type: t.type, url: t.url, title: t.title }));
564
- } else {
565
- const targets = await CDP.List({ port: cfg.port || 9222 });
601
+ // actualCdpPort is the real bound port; cfg.port may be 0 (random).
602
+ // Never fall back to a hardcoded 9222 — that could be another tool's browser.
603
+ const listPort = actualCdpPort || cfg.port;
604
+ if (listPort) {
605
+ try {
606
+ status.actualPort = actualCdpPort || cfg.port;
607
+ const targets = await CDP.List({ port: listPort });
566
608
  status.connected = true;
567
609
  status.targets = targets.map(t => ({ type: t.type, url: t.url, title: t.title }));
568
- }
569
- } catch { status.connected = false; }
610
+ } catch { status.connected = false; }
611
+ }
570
612
  }
571
613
  return { content: [{ type: "text", text: JSON.stringify(status) }] };
572
614
  },
@@ -577,6 +619,7 @@ const tools = {
577
619
  schema: {},
578
620
  handler: async () => {
579
621
  clearTabs(); // Kill stale tab connections before restart
622
+ cleanupWatch(); // Detach event listeners from the dying protocol before it's gone
580
623
  const result = await restartBrowser();
581
624
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
582
625
  },