@evan_zhong/browser-attach 0.13.1 → 0.14.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.
@@ -1,6 +1,6 @@
1
1
  # 架构与设计边界
2
2
 
3
- 当前实现为 **0.13.1**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
3
+ 当前实现为 **0.14.0**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
4
4
 
5
5
  ## 组件与数据流
6
6
 
@@ -59,7 +59,9 @@ claim 与普通输入都不激活标签或窗口,也不移动系统鼠标;
59
59
 
60
60
  `browser_fetch` 是一次 URL 读取:Hub 按显式 `browser_id` 把请求交给配对扩展;controller 创建后台 `about:blank` 临时标签,在其队列内复用 attach/session 初始化后只导航一次,按 load 或 load + network_idle 等待,在隔离世界读取主文档,再于 finally 关闭。它不接管已有页面,不创建浏览器或窗口。临时标签在扩展内记录 owner,并从标签发现列表排除;即使关闭或初始化失败,也不能被其他连接接管。关闭失败保留临时页记录并报告,release_owner/releaseAll 再次尝试关闭;正常已有标签的 detach 边界保持不变。
61
61
 
62
- 正文按 owner 保留一份最多 1000000 字符的不可变缓存,分页不执行脚本、不导航,临时标签关闭不会删除它;新 fetch、读完、owner 断连或扩展断连清除。读取期间文档或控制权变化会丢弃结果,断连后的迟到结果不能复活缓存。格式、采集上限与分页语义见使用指南。
62
+ 正文按 owner 保留至多 4 份各最多 1000000 字符的不可变采集,cursor 定位采集而非 URL;LRU 淘汰最久未读取的未完成采集,读完、owner 或扩展断连清除。一次读完和失败的请求不淘汰已有缓存。分页不执行脚本、不导航,临时标签关闭不会删除缓存。JSON 在采集上限内整份返回,不按 max_chars 拆分。读取期间文档或控制权变化会丢弃结果,断连后的迟到结果不能复活缓存。格式、采集上限与分页语义见使用指南。
63
+
64
+ HTTP 状态复用 diagnostics 的 Network.responseReceived 观察,按主文档 frame/loader 精确匹配;独立于可淘汰的日志正文保留至多 500 项文档状态元数据,断连/detach 清理。不新增 webRequest 或 host 权限,不重复请求页面。未匹配时 status/ok 返回 null;ok 仅表示 HTTP 2xx。HTML 标记 dom=rendered,保留观察到的 DOM,不声称是服务器原文;空标题省略。
63
65
 
64
66
  ## 观察模型
65
67
 
@@ -1,9 +1,15 @@
1
1
  # 验证报告
2
2
 
3
- 当前版本 **0.13.1**(2026-10-22)。验证使用本地 fixture 和临时 profile,链路为扩展 → 本机 Hub → stdio MCP。
3
+ 当前版本 **0.14.0**(2026-10-22)。验证使用本地 fixture 和临时 profile,链路为扩展 → 本机 Hub → stdio MCP。
4
4
 
5
5
  ## 当前版本
6
6
 
7
+ - `npm test`:165 项通过。新增主文档 frame/loader 状态匹配、未知状态、日志淘汰后状态保留、HTTP 错误正文、渲染后 DOM 标记、空标题省略、JSON 整份返回、四份缓存交叉续读与 LRU 淘汰;读完/失败的请求不淘汰已有缓存,同 URL 独立采集,保留断连及迟到回执不续跑的回归覆盖。
8
+ - Chromium 156 与 Edge 155 headless 完整 stdio MCP 集成:各 230 次调用通过。实际验证重定向后的 HTTP 200、404/500 保留正文并返回 ok=false、200 登录墙不误判成 HTTP 错误、子框架 404 不污染主文档状态、动态 DOM 插入与 dom=rendered、900000 字符 JSON 在 max_chars=400 下整份返回、超限 JSON 报错、四份缓存交叉续读与第 5 份 LRU 淘汰,以及临时页关闭和不激活。
9
+ - `npm run test:package`:仓库外安装运行通过,23 个工具、46 个发布文件,版本为 0.14.0;无新增扩展权限与依赖。
10
+
11
+ ## 0.13.1 验证
12
+
7
13
  - 描述精简:实际 MCP 初始化与 tools/list 的描述文本从 18716 降至 11469 字符,减少 38.7%(不含 schema 结构,不等于 token 数);instructions / 工具描述 / 参数说明分别为 1893 / 3949 / 5627 字符。跨工具规则、工具语义、参数选择规则与文档实现细节各自归位。
8
14
  - 对比精简前后的实际 MCP 输出:23 个工具的名称与输入 schema 在移除 description 后完全一致;类型、枚举、默认值、范围与 required 均未改变。
9
15
  - `npm test`:159 项通过;`npm run test:package`:仓库外安装运行通过,23 个工具、46 个发布文件,版本为 0.13.1。本次只调整描述与版本,未修改浏览器操作实现,未重复运行真实浏览器用例。
package/docs/USAGE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # BrowserAttach 使用指南
2
2
 
3
- 适用于 **0.13.1**:Node.js 22+、Chrome / Edge 125+、23 个 MCP 工具。安装、配对与升级见 [README](../README.md);内部实现与安全模型见 [架构说明](ARCHITECTURE.md)。
3
+ 适用于 **0.14.0**:Node.js 22+、Chrome / Edge 125+、23 个 MCP 工具。安装、配对与升级见 [README](../README.md);内部实现与安全模型见 [架构说明](ARCHITECTURE.md)。
4
4
 
5
5
  工具发现采用三层说明:初始化 `instructions` 提供跨工具规则;工具 description 说明用途、独有行为与结果语义;参数 schema 及其短说明提供类型、默认值和选择规则。本文保留完整原理、边界和示例。客户端集成时应把 MCP 初始化响应中的 instructions 传入模型上下文;仅读取 tools/list 不会包含完整的通用调用规则。
6
6
 
@@ -73,17 +73,19 @@
73
73
 
74
74
  ```js
75
75
  browser_fetch({browser_id, url: "https://example.com/article", wait_until: "network_idle", format: "text", max_chars: 20000})
76
- // → {url, title, contentType, format, body, capture_truncated, truncated, next_cursor}
76
+ // → {url, title?, status, ok, contentType, format, body, capture_truncated, truncated, next_cursor}
77
77
  browser_fetch({browser_id, cursor: next_cursor, max_chars: 20000})
78
78
  ```
79
79
 
80
80
  `browser_fetch` 在选定配置中新建 `active=false` 的临时标签,复用该配置的登录态和导航链路,采集后关闭;不需要对临时页显式 discover/claim。它不操作已有标签,不创建浏览器或窗口,不跟随页面链接。后台标签会短暂出现在标签栏,不能保证浏览器完全不可见。网页脚本、子资源、重定向仍照常执行,打开 URL 也可能产生网站状态变化;“读一页”指工具范围,不代表 HTTP 请求无副作用。
81
81
 
82
82
  - `wait_until=load` 为默认;`network_idle` 在 load 后再等被动请求表中连续 500 ms 无活动请求,复用 `browser_wait` 的网络空闲条件,不保证应用内容全部完成。`timeout_ms` 为 100–30000,默认 15000;调试初始化、导航和读取共享预算,标签创建和关闭受 Hub 超时约束。
83
- - 只读当前主文档。`text` 使用 `body.innerText`;`html` 返回执行脚本后的当前 DOM `outerHTML`,并非原始 HTTP 响应;`json` 从浏览器呈现的 JSON 正文解析、校验后序列化,不另发 fetch/XHR,也不返回响应头或 HTTP 状态。`contentType` 来自 `document.contentType`,`url` 为采集时的最终地址。非 HTTP(S) 最终文档报错。
84
- - `body` 始终为字符串,`max_chars` 为 1–100000,默认 20000,按 JavaScript 字符串长度计算。JSON 分页也为字符串片段,应先按顺序拼接再 `JSON.parse`。
85
- - 一次最多保留 1000000 字符。`next_cursor` 非空表示缓存尚有后续片段;`truncated` 表示尚未返回全部内容或采集有损。`capture_truncated=true` 表示文本/HTML 超出采集上限,丢失的尾部不能通过 cursor 取回;JSON 无法完整捕获或解析时直接报错。
86
- - 每个连接、每个浏览器配置最多保留一份缓存;新 fetch 替换旧缓存,读完或断连清除。续读只接受 `browser_id`、`cursor` 和可选 `max_chars`,不再打开页面。缓存不因临时页关闭而失效,也不能跨连接读取。
83
+ - 只读当前主文档。`text` 使用 `body.innerText`;`html` 返回当前 DOM `outerHTML`,同时标记 `dom: "rendered"`,包含页面脚本及浏览器扩展对 DOM 的修改,不能用作服务器原始响应或字节存档;不删除或猜测注入来源。`json` 从浏览器呈现的正文解析、校验后序列化。不另发 fetch/XHR,不返回原始响应体或响应头。`contentType` 来自 `document.contentType`,`url` 为最终采集地址;没有文档标题时省略 `title`。非 HTTP(S) 最终文档报错。
84
+ - `status` 来自已有 CDP 网络观察,按采集主文档的 frame 与 loader 匹配最终响应,排除重定向中间响应、子框架和 XHR/fetch。`ok` 仅表示 HTTP 200–299;未观察到匹配状态时两者均为 `null`,不猜成 200。404/500 仍返回正文及 `ok:false`,不因此设置 MCP `isError`。返回 200 的登录墙、验证码页或软错误仍为 `ok:true`,它不代表内容有效、已授权或业务成功。缓存/service worker 响应使用浏览器所报告的文档响应状态,不保证等于服务器本次实际传输的状态行。
85
+ - `body` 始终为字符串。文本/HTML 按 `max_chars` 分页,范围 1–100000、默认 20000,按 JavaScript 字符串长度计算。JSON 忽略分页预算,在 1000000 字符采集上限内整份返回,可直接 `JSON.parse(body)`;`truncated=false`、`next_cursor=null`,不会返回半截 JSON。JSON 超出采集上限或解析失败时直接报错。
86
+ - 文本/HTML 一次最多采集 1000000 字符。`next_cursor` 非空表示缓存尚有后续片段;`truncated` 表示尚未返回全部内容或采集有损。`capture_truncated=true` 表示超出采集上限,丢失的尾部不能通过 cursor 取回。
87
+ - 每个连接、每个浏览器配置最多保留 4 份尚未读完的不可变采集(每份最多 1000000 字符),可交叉续读;第 5 份需要续读的采集会淘汰最久未读取的一份。一次读完或失败的请求不占缓存槽、不淘汰已有采集。相同 URL 的多次采集各自独立,不按 URL 合并,cursor 自身定位对应采集,无需 fetch_id。读完或断连清除;过期/淘汰的 cursor 明确报错,不重新访问 URL。
88
+ - 续读只接受 `browser_id`、`cursor` 和可选 `max_chars`,只读缓存,不打开页面。缓存不因临时页关闭而失效,也不能跨连接读取。
87
89
  - 超时、解析错误或断连会清理临时页,不自动重新导航。关闭失败会明确报错并保留清理责任,在断连时再次尝试关闭;不会静默移交给其他连接。临时页不出现在 `discover(kind="tabs")` 的结果中。
88
90
 
89
91
  ## 观察:快照与引用
@@ -739,7 +739,8 @@ export function createController(api, notify = () => {}) {
739
739
  expression: `(${capturePage.toString()})(${JSON.stringify(args.format ?? 'text')},${cap})` })));
740
740
  if (claims.get(tab.id) !== claim || documents.get(tab.id) !== version) throw new Error('Document or control changed during fetch; capture discarded');
741
741
  httpUrl(result.url);
742
- return result;
742
+ const status = diagnostics.documentStatus(tab.id, frameTree.frame);
743
+ return { ...result, status, ok: status == null ? null : status >= 200 && status < 300 };
743
744
  } catch (error) { failure = error; throw error; }
744
745
  finally {
745
746
  try { await closeTemporary(tab.id); }
@@ -52,7 +52,7 @@ function location(frame = {}) {
52
52
 
53
53
  export function createDiagnostics(cdp) {
54
54
  const tabs = new Map();
55
- function start(tabId) { tabs.set(tabId, { started: Date.now(), console: buffer(), network: buffer(), requests: new Map(), networkActivity: Date.now(), requestsOverflow: false, contexts: new Map(), capture: { enabled: false, max_body_bytes: 65536 }, captureVersion: 0, detailed: new Set(), pending: new Set() }); }
55
+ function start(tabId) { tabs.set(tabId, { started: Date.now(), console: buffer(), network: buffer(), requests: new Map(), documentResponses: new Map(), networkActivity: Date.now(), requestsOverflow: false, contexts: new Map(), capture: { enabled: false, max_body_bytes: 65536 }, captureVersion: 0, detailed: new Set(), pending: new Set() }); }
56
56
  function event(source, method, params) {
57
57
  const state = tabs.get(source.tabId);
58
58
  if (!state) return;
@@ -88,6 +88,7 @@ export function createDiagnostics(cdp) {
88
88
  let request = state.requests.get(key);
89
89
  const add = (event, extra = {}) => state.network.add({ ...origin, event, request_id: id, timestamp, ...request, ...extra });
90
90
  if (method === 'Network.requestWillBeSent') {
91
+ if (params.type === 'Document') state.documentResponses.delete(JSON.stringify([source.sessionId ?? null, params.frameId]));
91
92
  if (params.redirectResponse) add('redirect', { ...(state.capture.enabled ? { headers: redactHeaders(params.redirectResponse.headers) } : {}), status: params.redirectResponse.status,
92
93
  url: clip(params.redirectResponse.url), redirect_to: clip(params.request.url) });
93
94
  request = { url: clip(params.request.url), method: clip(params.request.method, 50), resource_type: clip(params.type, 50),
@@ -104,6 +105,12 @@ export function createDiagnostics(cdp) {
104
105
  if (request && Number.isFinite(params.dataLength) && params.dataLength > 0) request.decodedBytes += params.dataLength;
105
106
  } else if (method === 'Network.responseReceived') {
106
107
  const response = params.response;
108
+ if (params.type === 'Document') {
109
+ const documentKey = JSON.stringify([source.sessionId ?? null, params.frameId]);
110
+ state.documentResponses.delete(documentKey);
111
+ state.documentResponses.set(documentKey, { loaderId: params.loaderId, status: response.status });
112
+ if (state.documentResponses.size > MAX_ENTRIES) state.documentResponses.delete(state.documentResponses.keys().next().value);
113
+ }
107
114
  add('response', { ...(state.detailed.has(key) ? { headers: redactHeaders(response.headers) } : {}), url: clip(response.url), status: response.status, status_text: clip(response.statusText, 200),
108
115
  mime_type: clip(response.mimeType, 200), protocol: clip(response.protocol, 50),
109
116
  from_cache: !!(response.fromDiskCache || response.fromPrefetchCache), from_service_worker: !!response.fromServiceWorker });
@@ -167,7 +174,10 @@ export function createDiagnostics(cdp) {
167
174
  if (args.kind === 'network' && state) await Promise.allSettled([...state.pending]);
168
175
  return read(tabId, args);
169
176
  }
170
- return { start, event, read, readDetails, networkOptions, networkStatus(tabId) {
177
+ return { start, event, read, readDetails, networkOptions, documentStatus(tabId, frame) {
178
+ const response = tabs.get(tabId)?.documentResponses.get(JSON.stringify([null, frame.id]));
179
+ return frame.loaderId && response?.loaderId === frame.loaderId && Number.isInteger(response.status) && response.status >= 100 && response.status <= 599 ? response.status : null;
180
+ }, networkStatus(tabId) {
171
181
  const state = tabs.get(tabId);
172
182
  if (!state) throw new Error('Tab control ended while waiting for network idle');
173
183
  return { inflight: state.requests.size, idle_ms: Date.now() - state.networkActivity, tracking_overflow: state.requestsOverflow };
@@ -175,7 +185,7 @@ export function createDiagnostics(cdp) {
175
185
  const state = tabs.get(tabId);
176
186
  if (!state) return;
177
187
  for (const key of state.detailed) if (JSON.parse(key)[0] === sessionId) state.detailed.delete(key);
178
- for (const map of [state.contexts, state.requests]) for (const key of map.keys()) if (JSON.parse(key)[0] === sessionId) map.delete(key);
188
+ for (const map of [state.contexts, state.requests, state.documentResponses]) for (const key of map.keys()) if (JSON.parse(key)[0] === sessionId) map.delete(key);
179
189
  }, forget: tabId => tabs.delete(tabId) };
180
190
  }
181
191
 
@@ -1,60 +1,77 @@
1
- const CAPTURE_CHARS = 1000000;
2
-
3
- export function fetchOptions(args) {
4
- if (!Number.isInteger(args.max_chars ?? 20000) || (args.max_chars ?? 20000) < 1 || (args.max_chars ?? 20000) > 100000) throw new Error('max_chars must be 1..100000');
5
- if (args.cursor != null) {
6
- if (typeof args.cursor !== 'string' || !args.cursor || ['url', 'format', 'wait_until', 'timeout_ms'].some(key => args[key] != null)) throw new Error('Cursor continuation accepts only browser_id, cursor and max_chars');
7
- return;
8
- }
9
- const url = new URL(args.url);
10
- if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('fetch requires an HTTP(S) URL without credentials');
11
- if (!['text', 'html', 'json'].includes(args.format ?? 'text')) throw new Error('format must be text, html or json');
12
- if (!['load', 'network_idle'].includes(args.wait_until ?? 'load')) throw new Error('wait_until must be load or network_idle');
13
- if (!Number.isInteger(args.timeout_ms ?? 15000) || (args.timeout_ms ?? 15000) < 100 || (args.timeout_ms ?? 15000) > 30000) throw new Error('timeout_ms must be 100..30000');
14
- }
15
-
16
- // Executed in an isolated world. Read the rendered main document, never a second request.
17
- export function capturePage(format, cap) {
18
- if (!/^https?:$/.test(location.protocol)) throw new Error('Final document is outside HTTP(S) scope');
19
- let body = format === 'html' ? document.documentElement.outerHTML : format === 'json'
20
- ? (document.querySelector('pre') || document.body)?.textContent || '' : document.body?.innerText || '';
21
- if (format === 'json') {
22
- if (body.length > cap) throw new Error('JSON document exceeds the 1000000 character capture limit');
23
- body = JSON.stringify(JSON.parse(body));
24
- if (body.length > cap) throw new Error('JSON document exceeds the 1000000 character capture limit');
25
- }
26
- return { url: location.href, title: document.title, contentType: document.contentType, format,
27
- body: body.slice(0, cap), capture_truncated: body.length > cap };
28
- }
29
-
30
- // One bounded immutable capture per owner; closing the temporary tab does not expire it.
31
- export function createFetches(read) {
32
- const results = new Map();
33
- async function fetch(owner, args) {
34
- fetchOptions(args);
35
- let state;
36
- if (args.cursor != null) {
37
- state = results.get(owner);
38
- if (!state?.result || state.cursor !== args.cursor) throw new Error('Fetch cursor is stale or belongs to another connection; a new fetch replaces the previous capture');
39
- } else {
40
- state = { offset: 0 };
41
- results.set(owner, state);
42
- try {
43
- const result = await read(owner, args, CAPTURE_CHARS, () => {
44
- if (results.get(owner) !== state) throw new Error('Fetch connection ended; capture discarded');
45
- });
46
- if (results.get(owner) !== state) throw new Error('Fetch connection ended; capture discarded');
47
- state.result = result;
48
- } catch (error) { if (results.get(owner) === state) results.delete(owner); throw error; }
49
- }
50
- const { body, ...metadata } = state.result;
51
- const end = Math.min(body.length, state.offset + (args.max_chars ?? 20000));
52
- const chunk = body.slice(state.offset, end);
53
- state.offset = end;
54
- state.cursor = end < body.length ? crypto.randomUUID() : null;
55
- const result = { ...metadata, body: chunk, truncated: end < body.length || metadata.capture_truncated, next_cursor: state.cursor };
56
- if (!state.cursor) results.delete(owner);
57
- return result;
58
- }
59
- return { fetch, forget: owner => results.delete(owner), clear: () => results.clear() };
60
- }
1
+ const CAPTURE_CHARS = 1000000, MAX_CAPTURES = 4;
2
+
3
+ export function fetchOptions(args) {
4
+ if (!Number.isInteger(args.max_chars ?? 20000) || (args.max_chars ?? 20000) < 1 || (args.max_chars ?? 20000) > 100000) throw new Error('max_chars must be 1..100000');
5
+ if (args.cursor != null) {
6
+ if (typeof args.cursor !== 'string' || !args.cursor || ['url', 'format', 'wait_until', 'timeout_ms'].some(key => args[key] != null)) throw new Error('Cursor continuation accepts only browser_id, cursor and max_chars');
7
+ return;
8
+ }
9
+ const url = new URL(args.url);
10
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('fetch requires an HTTP(S) URL without credentials');
11
+ if (!['text', 'html', 'json'].includes(args.format ?? 'text')) throw new Error('format must be text, html or json');
12
+ if (!['load', 'network_idle'].includes(args.wait_until ?? 'load')) throw new Error('wait_until must be load or network_idle');
13
+ if (!Number.isInteger(args.timeout_ms ?? 15000) || (args.timeout_ms ?? 15000) < 100 || (args.timeout_ms ?? 15000) > 30000) throw new Error('timeout_ms must be 100..30000');
14
+ }
15
+
16
+ // Executed in an isolated world. Read the rendered main document, never a second request.
17
+ export function capturePage(format, cap) {
18
+ if (!/^https?:$/.test(location.protocol)) throw new Error('Final document is outside HTTP(S) scope');
19
+ let body = format === 'html' ? document.documentElement.outerHTML : format === 'json'
20
+ ? (document.querySelector('pre') || document.body)?.textContent || '' : document.body?.innerText || '';
21
+ if (format === 'json') {
22
+ if (body.length > cap) throw new Error('JSON document exceeds the 1000000 character capture limit');
23
+ body = JSON.stringify(JSON.parse(body));
24
+ if (body.length > cap) throw new Error('JSON document exceeds the 1000000 character capture limit');
25
+ }
26
+ return { url: location.href, ...(document.title ? { title: document.title } : {}), contentType: document.contentType, format,
27
+ ...(format === 'html' ? { dom: 'rendered' } : {}),
28
+ body: body.slice(0, cap), capture_truncated: body.length > cap };
29
+ }
30
+
31
+ // Cursors identify immutable captures, not URLs whose content may have changed.
32
+ export function createFetches(read) {
33
+ const results = new Map();
34
+ async function fetch(owner, args) {
35
+ fetchOptions(args);
36
+ let captures = results.get(owner), state;
37
+ if (args.cursor != null) {
38
+ state = captures && [...captures.values()].find(item => item.result && item.cursor === args.cursor);
39
+ if (!state) throw new Error('Fetch cursor is stale, evicted or belongs to another connection');
40
+ captures.delete(state.id); captures.set(state.id, state);
41
+ } else {
42
+ if (!captures) { captures = new Map(); results.set(owner, captures); }
43
+ state = { id: crypto.randomUUID(), offset: 0, format: args.format ?? 'text' };
44
+ captures.set(state.id, state);
45
+ const checkConnection = () => {
46
+ if (results.get(owner) !== captures || captures.get(state.id) !== state) throw new Error('Fetch connection ended or capture evicted; capture discarded');
47
+ };
48
+ try {
49
+ const result = await read(owner, args, CAPTURE_CHARS, checkConnection);
50
+ checkConnection();
51
+ state.result = result;
52
+ } catch (error) {
53
+ captures.delete(state.id);
54
+ if (!captures.size && results.get(owner) === captures) results.delete(owner);
55
+ throw error;
56
+ }
57
+ }
58
+ const { body, ...metadata } = state.result;
59
+ const end = state.format === 'json' ? body.length : Math.min(body.length, state.offset + (args.max_chars ?? 20000));
60
+ const chunk = body.slice(state.offset, end);
61
+ state.offset = end;
62
+ state.cursor = end < body.length ? crypto.randomUUID() : null;
63
+ const result = { ...metadata, body: chunk, truncated: end < body.length || metadata.capture_truncated, next_cursor: state.cursor };
64
+ if (!state.cursor) {
65
+ captures.delete(state.id);
66
+ if (!captures.size) results.delete(owner);
67
+ } else {
68
+ captures.delete(state.id); captures.set(state.id, state);
69
+ while (captures.size > MAX_CAPTURES) captures.delete(captures.keys().next().value);
70
+ }
71
+ return result;
72
+ }
73
+ return { fetch, forget(owner) { results.get(owner)?.clear(); results.delete(owner); }, clear() {
74
+ for (const captures of results.values()) captures.clear();
75
+ results.clear();
76
+ } };
77
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "BrowserAttach",
4
- "version": "0.13.1",
4
+ "version": "0.14.0",
5
5
  "description": "Connect existing Chrome and Edge tabs to a locally paired MCP client.",
6
6
  "minimum_chrome_version": "125",
7
7
  "permissions": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evan_zhong/browser-attach",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "MCP for existing signed-in Chrome and Edge tabs through a locally paired extension",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.mjs CHANGED
@@ -15,7 +15,7 @@ export const DATA_DIR = configuredHome
15
15
  : process.platform === 'win32' ? join(homedir(), '.browser-attach')
16
16
  : join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'browser-attach');
17
17
  export const DEFAULT_CONFIG = join(DATA_DIR, 'config.json');
18
- export const VERSION = '0.13.1';
18
+ export const VERSION = '0.14.0';
19
19
 
20
20
  export async function loadConfig(path = DEFAULT_CONFIG) {
21
21
  let config;
package/src/mcp.mjs CHANGED
@@ -67,12 +67,12 @@ export function createMcp(client) {
67
67
  tool('open_tab', 'Open an HTTP(S) tab in the selected profile. Returns tab_id; discover tabs after loading before claiming it.', {
68
68
  browser_id: z.string(), url: z.string(), active: z.boolean().default(false),
69
69
  });
70
- tool('fetch', 'Read a URL\'s main document in a temporary background tab using the profile\'s sign-in, then close it. body is a string; concatenate JSON pages before parsing. next_cursor continues cached content without revisiting the URL; a new fetch replaces this connection/profile\'s cache.', {
70
+ tool('fetch', 'Read a URL\'s main document in a temporary background tab using the profile\'s sign-in, then close it. status/ok describe HTTP only (unknown=null), not content validity. body is a string; JSON returns whole. next_cursor reads immutable cache without revisiting the URL; up to 4 captures per connection/profile, evicted by LRU.', {
71
71
  browser_id: z.string().min(1), url: z.string().optional().describe('HTTP(S), no URL credentials; required unless cursor is supplied.'),
72
72
  cursor: z.string().optional().describe('Continuation accepts only browser_id, cursor and max_chars.'),
73
73
  wait_until: z.enum(['load', 'network_idle']).optional().describe('Default load; network_idle adds 500ms with no observed requests after load.'),
74
- format: z.enum(['text', 'html', 'json']).optional().describe('Default text (rendered); html=current DOM; json=validated JSON.'),
75
- max_chars: integerRange('max_chars', 1, 100000).default(20000).describe('Per-page body budget; capture cap 1M chars. capture_truncated means loss; oversized JSON errors.'),
74
+ format: z.enum(['text', 'html', 'json']).optional().describe('Default text (rendered); html=current DOM with dom=rendered; json=complete validated JSON.'),
75
+ max_chars: integerRange('max_chars', 1, 100000).default(20000).describe('Text/HTML page budget; JSON ignores this and returns whole, up to 1M chars. capture_truncated means text/HTML loss; oversized JSON errors.'),
76
76
  timeout_ms: integerRange('timeout_ms', 100, 30000).optional().describe('Default 15000; preparation/navigation/read budget.'),
77
77
  }, null, args => { fetchOptions(args); return args; });
78
78
  tool('claim_tab', 'Exclusively claim a tab_id from this connection\'s latest tab discovery. Optional expected_url/title must match exactly. Returns the current identity.', {