Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5116a99
feat(core): upgrade to multi-client real-browser MCP v2 with tab targ…
Jul 24, 2026
3d7ef69
feat(background): implement auto-pairing and virtualization recovery
Jul 24, 2026
4bd588f
chore(graph): add graphify output files and graph report
Jul 24, 2026
dd91f6c
chore(config): update browser controller rule and improve message han…
Jul 24, 2026
2fff05b
chore(graph): update graph data with new community and agent config
Jul 24, 2026
7cdf26e
chore(graphify): update .graphifyignore for image extraction support
Jul 24, 2026
01b43ec
chore(graph): update graph data and labels with new community structure
Jul 24, 2026
1f25b4c
refactor(graphify): update community labels and analysis tokens
Jul 24, 2026
b9268ff
refactor(core): rename real-browser-mcp to browser-controller and upd…
Jul 24, 2026
1ec502e
refactor(docs): rename product from Real Browser MCP to Browser Contr…
Jul 24, 2026
bcebfa9
docs(graph): update graphify labels and community structure report
Jul 24, 2026
51fed14
refactor(daemon): improve client management and heartbeat for zombie …
Jul 24, 2026
92aee0f
docs(architecture): add detailed architecture overview and key invari…
Jul 24, 2026
71faff7
fix(tab-lock): improve tab locking and navigation snapshot handling
Jul 25, 2026
26fb902
chore(graph-report): remove outdated graph analysis and labels files
Jul 25, 2026
27a6a08
style(popup): improve popup.html styles and accessibility
Jul 25, 2026
a4ea55c
style(popup): replace fixed spacing with semantic spacing scale
Jul 25, 2026
5e1e7af
docs: clean ofershap refs, fix identity for new repo (compnew2006/bro…
Jul 25, 2026
ce338d1
chore: remove dead lint/format scripts (eslint+prettier never install…
Jul 25, 2026
e65611b
chore(deps): bump the all-actions group with 2 updates
dependabot[bot] Jul 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 49 additions & 41 deletions .cursor/rules/project.mdc
Original file line number Diff line number Diff line change
@@ -1,67 +1,75 @@
---
description: Core project context for real-browser-mcp
description: Core project context for browser-controller
alwaysApply: true
---

# real-browser-mcp
# browser-controller

MCP server (TypeScript) + Chrome extension (plain JS) that talk over WebSocket on localhost:7225.
Multi-client MCP server (TypeScript) + Chrome extension (plain JS). Renamed from `real-browser-mcp` (v2.0.0). npm `browser-controller`, MCP id `io.github.noiemany/browser-controller`.

## Versioning
## Three-piece architecture

Bump minor versions: 1.0.0 -> 1.1.0 -> 1.2.0. Three places must stay in sync:
1. `package.json` "version"
2. `extension/manifest.json` "version"
3. `mcp-server/src/index.ts` SERVER_VERSION
```
Agent ─stdio─ thin MCP client (index.ts) ─IPC─► daemon (daemon.ts, owns :7225) ─WS─► extension (background.js)
```

## Adding a Tool
- The daemon owns the WS server on 7225 AND an HTTP server on the **same port** (`/pair` returns the token, `/status` returns agents). The extension auto-fetches the token via `/pair` (MV3 can't read the filesystem).
- Bind host is **`127.0.0.1`** (NOT 'localhost' — macOS resolves localhost to IPv6 ::1 first and refuses IPv4). Both WS connect and HTTP fetch use 127.0.0.1.
- Thin client auto-spawns the daemon if absent. State in `~/.browser-controller/`.

Four files to touch:
1. `mcp-server/src/tools/<name>.ts` - ToolDefinition with Zod schema
2. `mcp-server/src/tools/index.ts` - add to allTools array
3. `extension/background.js` - add handler in dispatch() map + handler function
4. `tests/tools/` - add test
## The two invariants

The schema type MUST be `z.ZodObject<z.ZodRawShape>`, not `z.ZodType`. The MCP SDK calls `.shape` on it.
1. **`tabId` is mandatory** for every page-interaction tool. `navigate` is the ONE exception (optional, active-tab fallback). `tabs` create/close/focus/list take no target tabId. Enforced via `requireTabId()` in `tools/types.ts`.
2. **background.js never acts on "the active tab"** implicitly — `resolveTab(tabId)` is the entry point. The active-tab fallback survives ONLY inside `handleNavigate`.

## Tool inventory (18 tools)
## Element identification (3 layers + auto-recovery)

Navigation: browser_navigate, browser_tabs
Interaction: browser_click (ref OR selector), browser_click_text (by visible text, CSP-safe), browser_type, browser_press_key, browser_scroll, browser_hover, browser_select
Reading: browser_snapshot (compact mode default), browser_screenshot, browser_text, browser_find
JS execution: browser_evaluate (via chrome.debugger CDP - bypasses CSP but shows debugger banner)
Dialogs: browser_handle_dialog (override alert/confirm/prompt)
Wait: browser_wait
Debug: browser_console (read-only), browser_network
1. `data-mcp-ref="e5"` (fast, breaks on re-render)
2. robust CSS selector via `smart-selector.js` (climbs ≤3 ancestors, ignores React/generated classes+ids)
3. text+role+tag scan (survives total class/id churn) — resolves the **correct** sibling via `nth` ordinal when several elements share text+role (e.g. 3 "Like" buttons)
- `autoReSnapshot`: on ref failure (virtualized feed), snapshot the tab and embed `freshRefs` in the response so the agent retries in one step. Do NOT auto-retry the click (non-idempotent). `browser_scroll` returns `refsMayBeStale: true` as a hint.
- `isNew`: each snapshot tracks `role|name` fingerprints per tab; elements appearing since the last snapshot are tagged `isNew: true` so the agent can focus on what changed (big token saver after an overlay/dropdown opens). State lives in `lastSnapshotFingerprints` Map, cleared on tab close.

## CSP and browser_evaluate
## Concurrency (extension/lib/tab-concurrency.js — pure, unit-tested)

`browser_evaluate` uses chrome.debugger API (CDP Runtime.evaluate). This bypasses CSP but:
- Shows "is being debugged" infobar
- Attaching/detaching the debugger can steal focus and close open popups/dropdowns
- Requires `debugger` permission in manifest.json
- `TabMutexMap`: same-tab serializes, cross-tab parallelizes.
- `TabLockMap`: per-agent tab ownership via `browser_tabs {action:'lock'}`; non-owner calls queue.
- `isIdempotent` (daemon-config.ts): read tools retry on timeout; **click/type/navigate/evaluate NEVER retry**.

For DOM interactions (clicking dropdown options, etc.), prefer `browser_click` with CSS selectors or `browser_click_text` instead. These use `chrome.scripting.executeScript` with compiled functions — no eval, no CSP issues.
## Tool inventory (22 tools)

Previous attempts using `new Function()` in MAIN world failed on GitHub/Google/strict-CSP sites. Sandbox/offscreen approaches can't access page DOM. chrome.debugger is the only universal eval but has the focus-stealing side effect.
Navigation: browser_navigate (tabId optional), browser_tabs (list/create/close/focus/**lock**/**unlock**)
Interaction (tabId required): browser_click, browser_click_text, browser_type, browser_press_key, browser_scroll, browser_hover, browser_select, browser_drag, browser_fill_form, browser_upload_file
Reading (tabId required): browser_snapshot, browser_screenshot, browser_text, browser_find
JS/Dialogs (tabId required): browser_evaluate (MAIN world, no banner), browser_handle_dialog, browser_run_action
Waiting: browser_wait
Debug (tabId required, per-tab buffers capped 200): browser_console, browser_network

## Snapshot modes
## browser_evaluate (changed in v2)

`compact: true` (default) - returns only interactive elements + landmarks, ~70% smaller output. No `tag` field.
`compact: false` - full accessibility tree with all visible elements. Includes `tag` for generic roles.
Now runs via `chrome.scripting` MAIN world (NOT chrome.debugger) — no "is being debugged" infobar, CSP-safe. `run_action`/`upload_file`/`drag` still use CDP (CDP-only operations: `DOM.setFileInputFiles`, `Input.dispatchMouseEvent`).

## Extension is plain JS
## chrome.scripting serialization gotcha

No build step. Reload in chrome://extensions after changes. Chrome kills service workers after 30s idle - the `chrome.alarms` keepalive handles this. Don't remove it.
`chrome.scripting.executeScript` args **cannot be functions** (recent MV3). The smart-selector fallback fns are passed as their `.toString()` source and rebuilt via `eval('(' + src + ')')` inside the page. If a tool suddenly fails with "Value is unserializable" at args index N, that's the cause.

## Element refs are ephemeral
## Adding a tool

`browser_snapshot` assigns `data-mcp-ref` attributes. These refs break after navigation, scroll, or DOM changes. Always re-snapshot before using refs.
1. `mcp-server/src/tools/<name>.ts` — ToolDefinition with `requireTabId()` in the Zod schema
2. `mcp-server/src/tools/index.ts` — add to `allTools`
3. `extension/background.js` — add to `dispatch()` map + handler (use `resolveTab(tabId)` + `safeExec`)
4. If it has side effects, ensure `isIdempotent()` in `daemon-config.ts` does NOT list it.

## Port 7225
## Build/test

Not negotiable without updating both server default (index.ts) AND extension default (background.js + popup.html). Configurable via `WS_PORT` env var on server side, popup input on extension side.
- `npm run build` (tsc), `npm test` (vitest), `npm run typecheck`
- Extension is plain JS (no build) — `node --check` each file after edits
- Daemon is built to `mcp-server/dist/`. Reload the extension at `chrome://extensions` after any extension edit; content scripts cache per-page-load so open tabs need a hard refresh.

## Tests need port isolation
## graphify

Each test gets a unique port (counter starting at 19230). WebSocket servers take time to unbind - tests will flake without this.
This repo is graphify-indexed (457 nodes / 727 edges / 28 communities). Use `graphify query "..."` to trace code paths, `graphify update .` for zero-cost code refresh, `graphify . --backend claude` for image extraction. See memory `mem:graphify-setup` for the Z.ai endpoint config.

## Known benign console noise (already filtered)

`ResizeObserver loop` and `message channel closed` are filtered in `content.js`. Both need extension reload + tab refresh to take effect (content scripts cache per page).
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/scorecard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: ossf/scorecard-action@v2.4.3
- uses: ossf/scorecard-action@v2.4.4
with:
results_file: results.sarif
results_format: sarif
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@ node_modules/
mcp-server/dist/
*.tsbuildinfo
.DS_Store
graphify-out/
# daemon runtime state (task 1.0 / 3.1)
daemon.log
.serena/
3 changes: 3 additions & 0 deletions .graphifyignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Images ARE extracted now via the Z.ai Anthropic endpoint (glm-4.6 vision).
# Keep generated graphify output out of itself.
graphify-out/
146 changes: 146 additions & 0 deletions .zcode/plans/plan-sess_cb96dc0a-88a2-4f04-b749-9806799a675a.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# إصلاح الوكلاء الأشباح (Zombie) + اسم الوكيل + إدارة التبويبات من الـ Popup

أربعة أعراض، ثلاثة أسباب جذرية مثبتة بالكود وسجل الـ daemon الحيّ. كل تغيير مؤكد ضد `daemon.ts`, `bridge.ts`, `popup.js`, `background.js`, `manifest.json`.

---

## السبب الجذري لكل عرض

| العرض | السبب | الدليل |
|---|---|---|
| ١. الاسم "agent" بدلاً من "Cursor" | `deriveAgentName()` ترجع القيمة الافتراضية الحرفية `'agent'` عند فشل اكتشاف البيئة | `index.ts:218` |
| ٢. ١٤ وكيل أشباح | لا نبضة حياة (heartbeat) على مقابس IPC ← تأتي `close` متأخرة (دقائق). سجل الـ daemon يُظهر s8–s21 أُنشئت خلال ثوانٍ (IDE يعيد تشغيل MCP)، وفصلت بعد ٥ دقائق فقط | `daemon.ts:54,167,186`؛ السجل أسطر ٩٨-١٢٩ |
| ٣. أقفال التبويب "none" | **سلوك صحي** — الأقفال تُنشأ فقط بأداة `browser_tabs lock` الصريحة، لا أثناء التنفيذ العادي. المشكلة أنك لا تملك واجهة لقفل التبويب | `background.js:1066` |
| ٤. لا قائمة تبويبات ولا قفل فردي | الـ popup لا يستدعي `chrome.tabs.query` أبداً ولا يعرض الأقفال إلا للقراءة | `popup.js` |

بالإضافة لذلك: سجل الـ daemon يُكتب في `~/.real-browser-mcp/daemon.log` (الاسم القديم) بينما الحالة في `~/.browser-controller/` (`index.ts:229`).

---

## التغييرات (٥ ملفات)

### ١. `mcp-server/src/daemon.ts` — نبضة الحياة + إزالة التكرار + `/kill`

**أ. إزالة التكرار بالاسم + نبضة الحياة (يصلح الأشباح من المصدر):**
- عند تسجيل عميل جديد (`hello`، السطر ١٦٥)، إذا وجد عميل موجود بنفس `agentName`، استبدله (احذف القديم ثم أضف الجديد). هذا يمنع تراكم N سجل بنفس الاسم عندما يعيد IDE تشغيل الـ MCP.
- أضف `setInterval` كل ١٥ ثانية يرسل `kind:'ping'` لكل عميل عبر الـ IPC socket؛ العميلة ترسل `kind:'pong'`. إذا فات ٣ نبضات متتالية (~٤٥ ثانية)، اعتبر السوكيت ميتاً وألغِ الاتصال (`socket.destroy()` → يُطلق `close` → تنظيف موجود).
- مكّن `socket.setKeepAlive(true, 30_000)` لتسريع كشف السوكيت شبه المفتوح على مستوى النواة.

**ب. نقطة نهاية `/kill` (GET):** في `handleHttp` (السطر ٢٤٦) أضف:
```ts
if (path === '/kill') {
const sid = url.searchParams.get('sessionId');
const victim = Array.from(this.clients.values()).find(c => c.sessionId === sid);
if (!victim) return { ok: false, error: 'session not found' };
victim.socket.destroy(); // → fires close → cleanup
return { ok: true, killed: sid };
}
```
ملاحظة: يستخدم GET لأن `bridge.ts:195` يرفض أي طلب غير GET — لا حاجة لتعديل الـ bridge.

**ج. إضافة `kind:'ping'`/`'pong'` لبروتوكول IPC** في `daemon-config.ts`:
```ts
| { kind: 'ping' } | { kind: 'pong' }
```
وأضف فرعاً في `handleNewClient` (السطر ١٧٨) يتجاهل `pong` بهدوء.

### ٢. `mcp-server/src/index.ts` — اسم وكيل أفضل + نبضة pong + سجل صحيح

**أ. اسم وكيل أقوى (`deriveAgentName` السطر ٢١٠):** أضف كشف العملية الأم قبل الـ fallback:
```ts
// كشف اسم العملية الأم (ppid) — أكثر دقة من متغيرات البيئة
try {
const ppid = process.ppid;
const parent = execSync(`ps -p ${ppid} -o comm=`, {encoding:'utf8'}).trim();
if (/cursor/i.test(parent)) return 'Cursor';
if (/claude/i.test(parent)) return 'Claude';
if (/windsurf|code/i.test(parent)) return 'Windsurf';
if (parent) return parent.split('/').pop(); // اسم العملية كبديل أخير
} catch {}
return 'agent'; // أبعد ما يكون
```
يحافظ على ترتيب متغيرات البيئة الموجود (السطور ٢١٢-٢١٧) فوقه.

**ب. نبضة pong (عميل IPC):** في `handleMessage` (السطر ١٢٨) أضف:
```ts
case 'ping':
this.socket?.write(JSON.stringify({ kind: 'pong' }) + '\n');
break;
```

**ج. إصلاح مسار السجل (السطر ٢٢٩):** غيّر من `~/.real-browser-mcp/daemon.log` إلى `path.join(STATE_DIR, 'daemon.log')` (= `~/.browser-controller/daemon.log`). استورد `STATE_DIR` من `daemon-config`.

### ٣. `extension/background.js` — قفل/فتح من الـ popup + قائمة التبويبات

أضف معالجتي رسائل جديدتين في مستمع `chrome.runtime.onMessage` (السطر ١٤٤٢):

```js
if (msg.type === 'lockTab') {
// { tabId, sessionId } — يربط تبويب بوكيل من الـ popup
tabLocks.lock(msg.tabId, msg.sessionId);
broadcastStatus(`Tab ${msg.tabId} pinned to ${msg.sessionId}`);
respond({ success: true });
return false;
}
if (msg.type === 'unlockTab') {
// { tabId } — يفتح تبويباً واحداً (ليس الكل)
const was = tabLocks.owner(msg.tabId);
tabLocks.release(msg.tabId);
broadcastStatus(`Tab ${msg.tabId} unlocked (was ${was || '-'})`);
respond({ success: true, previousSession: was });
return false;
}
```

وسّع `buildStatusPayload` (السطر ٢٤٨) ليشمل `tabLocks` **وقائمة التبويبات المفتوحة** (`tabs` مع `lockedBy`) حتى يعرضها الـ popup دفعة واحدة:
```js
tabs: await getOpenTabs(), // [{id, title, url, active, lockedBy}]
```
حيث `getOpenTabs()` تغلف `chrome.tabs.query({currentWindow:true})` + `tabLocks.owner(t.id)` (نفس منطق `handleTabs` السطور ١٠٣٨-١٠٤٩ — **أعد استخدام النمط، لا تكرره**).

### ٤. `extension/popup/popup.html` + `popup.js` — واجهة حقيقية

**HTML:** أضف قسماً جديداً "Open Tabs" (قائمة التبويبات) **قبل** قسم "Connected Agents". كل صف تبويب يحتوي:
- عنوان/رابط التبويب (مقتطف)
- `<select>` يحتوي: "— Not pinned —" + كل الوكلاء المتصلين (اسم + sessionId)
- زر "Pin" (يستدعي `lockTab`) يظهر فقط عند الاختيار، أو "Locked: {agent}" + زر "✕" للفتح إذا مقفل

أضف زر **"✕" (Disconnect)** لكل صف في قائمة "Connected Agents".

**popup.js:**
- `refreshAgents()`: يعرض كل صف وكيل مع زر ✕ يستدعي `fetch('/kill?sessionId=s8')` ثم يعيد الاستطلاع فوراً.
- `updateUI(state)`: يعرض قائمة `state.tabs` — كل تبويب له القائمة المنسدلة + زر Pin. يختار الـ select الـ sessionId المقفل حالياً (من `t.lockedBy`).
- زر Pin: `chrome.runtime.sendMessage({type:'lockTab', tabId, sessionId})`. زر ✕ على تبويب مقفل: `{type:'unlockTab', tabId}`.
- لأن `getStatus` (السطر ١٢٧) لا يُستطلع دورياً (مرة واحدة عند الفتح)، أضف `setInterval` كل ٢ ثانية لاستدعاء `chrome.runtime.sendMessage({type:'getStatus'})` وتحديث التبويبات/الأقفال — بنفس نمط `refreshAgents`.

### ٥. الاختبارات — `tests/bridge.test.ts` أو ملف جديد

- نبضة الحياة: محاكاة عميل لا يرد بـ pong → يُطرد بعد ٣ نبضات.
- إزالة التكرار: عميلان بنفس `agentName` → الأول يُستبدل (لا تراكم).
- `/kill`: يُدخل جلسة موجودة → الـ socket يُدمر → يختفي من `agents()`.

---

## نطاق الانفجار (blast radius) — من graphify Phase 4

كل تغيير يلمس:
- `daemon.ts` — مستهلكون: `index.ts` (يستدعي `Daemon` عبر `spawn`)، `tests/bridge.test.ts`. لا تغيير في الواجهة العامة (`clientCount()`, `agents()`, `stop()`).
- `daemon-config.ts` — مستهلكون: `daemon.ts`, `index.ts`, `bridge.ts`. إضافة `ping`/`pong` للأنواع فقط — متوافق مع الإصدارات السابقة (الأطراف القديمة تتجاهل الرسائل غير المعروفة).
- `index.ts` — مستهلكون: `package.json` bin. التغيير داخلي فقط.
- `background.js` — مستهلكون: `popup.js` (عبر `chrome.runtime` messaging)، content scripts (لا تتأثر — لا تلمس القفل). الرسائل الجديدة `lockTab`/`unlockTab` إضافية.
- `popup.*` — مستهلكون: لا أحد (طرف الورقة).

لا توجد استيرادات دائرية (تأكد سابقاً عبر graphify). `chrome.tabs` ممنوح بالفعل في `manifest.json:12` — **لا تغيير في المانيفست**.

---

## ترتيب التنفيذ

1. `daemon-config.ts` — أنواع `ping`/`pong` (أساس كل شيء آخر)
2. `daemon.ts` — نبضة الحياة + إزالة التكرار + `/kill`
3. `index.ts` — `pong` + اسم أمين + مسار السجل
4. `background.js` — معالجات `lockTab`/`unlockTab` + `tabs` في الحالة
5. `popup.html` + `popup.js` — الواجهة (تستخدم كل ما سبق)
6. الاختبارات
7. `npm run build` + تشغيل الاختبارات (الهدف: كلها خضراء)
8. `graphify update .` — تحديث الـ graph (Phase 6: >٥ ملفات تغيرت)
Loading