Skip to main content
Glama
README.md
# ffmcp

Расширения для Firefox и Chrome + MCP-сервер, дающие агентам полный доступ к твоим настоящим
браузерам. Транспорт целиком локальный: **stdio и Unix-сокет, ни одного HTTP-запроса
и ни одного открытого порта**.

## Как устроено

```
                                 ┌─ Unix-сокет ~/.ffmcp/firefox.sock ─▶ ffmcp-host.js ─▶ расширение Firefox
MCP-агент ──stdio JSON-RPC──▶ ffmcp-mcp.js
                                 └─ Unix-сокет ~/.ffmcp/chrome.sock ──▶ ffmcp-host.js ─▶ расширение Chrome
```

У каждого браузера свой native-хост и свой сокет (права `0600`). Хост поднимается самим
браузером при старте расширения и работает как брокер: к нему подключаются MCP-серверы.
Поэтому **несколько агентов одновременно** работают с одним браузером, а внешне не открыт
ни один сетевой порт.

Один MCP-сервер обслуживает оба браузера сразу — какой именно использовать, агент решает
вызовом `browser_use` либо аргументом `browser` у любого инструмента.

Ответы больше ~700 КБ (скриншоты, дампы DOM) автоматически режутся на чанки и собираются
обратно в хосте — ограничение native messaging на размер сообщения обходится прозрачно.

## Установка

```bash
cd ~/Projects/ffmcp
./install.sh
```

Скрипт кладёт манифесты native-хоста в каталоги Firefox и всех найденных браузеров на
Chromium и создаёт лаунчеры с абсолютным путём к `node` (браузер из Finder не видит
`/opt/homebrew/bin`).

Затем ставим расширения — нужно хотя бы одно.

**Firefox:**

1. `about:debugging#/runtime/this-firefox`
2. **Загрузить временное дополнение…** → выбрать `extension/manifest.json`

> Временное дополнение живёт до перезапуска Firefox. Чтобы поставить навсегда, нужен
> Firefox Developer Edition / Nightly / ESR с `xpinstall.signatures.required = false`
> в `about:config` — тогда собери `.xpi` (`npm run build-xpi`) и установи через
> `about:addons` → **Установить дополнение из файла**. В обычном релизном Firefox
> неподписанные расширения навсегда поставить нельзя — это ограничение Mozilla.

**Chrome:**

1. `chrome://extensions` → включить **Режим разработчика**
2. **Загрузить распакованное расширение** → выбрать каталог `extension-chrome`
3. ID должен получиться `mifolcjccjkcdfdoebmkgklimogoemng` — он зашит полем `key`
   в манифесте, и именно его разрешает манифест native-хоста
4. Желательно: в карточке расширения включить **Разрешить пользовательские скрипты**

Последний пункт нужен для `browser_eval` на сайтах со строгим CSP — подробности ниже.
Распакованное расширение переживает перезапуск Chrome, но требует включённого режима
разработчика.

Проверка обоих браузеров сразу:

```bash
./bin/ffmcp.js doctor
./bin/ffmcp.js browsers
```

## Подключение к агентам

Claude Code:

```bash
claude mcp add browser -- node ~/Projects/ffmcp/bin/ffmcp-mcp.js
```

Любой другой MCP-клиент (`mcp.json`, Cursor, Zed):

```json
{
  "mcpServers": {
    "browser": {
      "command": "node",
      "args": ["/абсолютный/путь/к/ffmcp/bin/ffmcp-mcp.js"]
    }
  }
}
```

Если хочется жёстко привязать сервер к одному браузеру — добавь `--browser firefox`
или `--browser chrome` (то же самое делает переменная `FFMCP_BROWSER`). Тогда
`browser_use` не понадобится, а вызовы в другой браузер уходить не будут.

## Выбор браузера

```
browser_list                      # кто доступен и кто выбран сейчас
browser_use {"browser": "chrome"} # закрепить Chrome для всех следующих вызовов
browser_use {}                    # показать текущий выбор
browser_use {"release": true}     # вернуться к автоопределению
browser_tabs {"browser": "firefox"}  # разовый вызов мимо закрепления
```

Без закрепления браузер выбирается сам: единственный запущенный, иначе последний
удачно использованный, иначе Firefox. Аргумент `browser` у конкретного вызова всегда
сильнее закрепления.

## Инструменты

39 инструментов. Основные:

| Группа | Инструменты |
|---|---|
| Браузер | `browser_list`, `browser_use`, `browser_status` |
| Рабочее окно | `browser_use_window`, `browser_new_window` |
| Вкладки и окна | `browser_tabs`, `browser_open`, `browser_navigate`, `browser_close_tab`, `browser_activate_tab`, `browser_reload`, `browser_back`, `browser_forward`, `browser_windows` |
| Страница | `browser_snapshot`, `browser_read`, `browser_html`, `browser_eval`, `browser_click`, `browser_fill`, `browser_press`, `browser_wait_for`, `browser_screenshot` |
| Отладка | `browser_console`, `browser_network` |
| Профиль | `browser_cookies`, `browser_cookie_set`, `browser_cookie_remove`, `browser_history`, `browser_bookmarks`, `browser_downloads`, `browser_storage`, `browser_containers`, `browser_recent_closed` |
| Всё остальное | `browser_api`, `browser_api_describe` |

Старые имена вида `firefox_tabs` продолжают работать (и заодно задают браузер), но в
списке инструментов их больше нет.

**`browser_api` — это и есть «полный доступ».** Он вызывает любой метод WebExtension API
напрямую, так что агент не ограничен готовым списком:

```json
{ "path": "tabs.query", "args": [{ "audible": true }] }
{ "path": "browsingData.removeCache", "args": [{}] }
{ "path": "proxy.settings.set", "args": [{ "value": { "proxyType": "none" } }] }
```

`browser_api_describe` показывает, что вообще доступно (`{"path": "cookies"}` → список
методов). Наборы API у Firefox и Chrome разные, так что спрашивать стоит у того браузера,
в котором собираешься работать.

### Рабочее окно

По умолчанию операции без явного `tabId` идут в активную вкладку текущего окна — то есть
туда, где сейчас работаешь ты. Чтобы агент не мешал, закрепи за ним окно:

```
browser_new_window {"url": "https://example.com"}   # своё окно, сразу закреплено
browser_use_window {"windowId": 3}                  # закрепить существующее
browser_use_window {}                               # показать текущее закрепление
browser_use_window {"release": true}                # снять
```

После закрепления в это окно уходят все операции без `tabId`, туда же открываются новые
вкладки, а `browser_tabs` показывает только его вкладки (`allWindows: true` — все).
В `browser_windows` закреплённое окно помечено `target: true`, а в попапе расширения видно
строкой «Рабочее окно». Закрепление живёт отдельно в каждом браузере и снимается само,
если окно закрыть. Из CLI — `ffmcp use-window <id>` и `ffmcp new-window [url]`.

<img src="docs/popup.png" alt="popup ffmcp: состояние моста" width="300" />

### Работа со страницей

`browser_snapshot` возвращает интерактивные элементы с короткими `uid` — это дешевле
скриншота и точнее селекторов:

```json
{ "uid": "e12", "tag": "input", "type": "email", "text": "Email address", "rect": {...} }
```

Далее `browser_fill {uid: "e12", value: "..."}` и `browser_click {uid: "e15"}`.
`browser_fill` выставляет значение через нативный сеттер прототипа и шлёт `input`/`change`,
поэтому корректно работает с React, Vue и Svelte.

`browser_eval` выполняет код в контексте страницы — это тело async-функции, доступны
`await` и `return`:

```js
return [...document.querySelectorAll("h2")].map(h => h.innerText)
```

Логи консоли и необработанные исключения собираются автоматически на каждой загруженной
странице (буфер на 2000 записей), сетевые запросы — через `webRequest`. Читать через
`browser_console` и `browser_network`. Вывод `console.*` из самого `browser_eval` тоже
попадает в буфер.

## Чем Chrome отличается от Firefox

Расширение для Chrome — Manifest V3, и часть вещей там устроена иначе. Набор операций
одинаковый, отличается поведение:

| | Firefox | Chrome |
|---|---|---|
| Фон | постоянная фоновая страница | service worker, который браузер усыпляет |
| Произвольный JS | `tabs.executeScript` со строкой | мир страницы + `new Function`, при отказе CSP — мир пользовательских скриптов |
| Скриншот всей страницы | одним вызовом | прокрутка и сшивка кусков (медленнее, до 25 экранов) |
| Скриншот неактивной вкладки | как есть | вкладку приходится на миг активировать |
| Контейнеры | `browser_containers` работает | аналога нет, вызов честно падает |

Что из этого важно на практике:

- **`browser_eval` и CSP.** Код выполняется в мире страницы, а там действует её
  Content-Security-Policy. На сайтах со строгим `script-src` (например GitHub) `eval`
  запрещён. Обход — включить в карточке расширения **«Разрешить пользовательские
  скрипты»**: тогда ffmcp выполнит код в отдельном мире, где CSP страницы не действует.
  Без этого на таких сайтах придёт понятная ошибка с этой же подсказкой.
  Инструменты `browser_click`, `browser_fill`, `browser_snapshot` и остальные структурные
  операции CSP не касается вообще — они внедряются готовыми функциями, а не строками.
- **Буферы логов и сон воркера.** Service worker MV3 живёт от события до события. Расширение
  держит его будильником и переподключается автоматически, но если Chrome всё же усыпит
  воркер, накопленные `browser_console` и `browser_network` обнулятся. Закрепление рабочего
  окна переживает это — оно лежит в `storage.session`.
- **Служебные страницы.** `chrome://*`, Chrome Web Store и PDF расширениям недоступны —
  там любая операция со страницей вернёт ошибку. В Firefox то же самое с `about:*`
  и `addons.mozilla.org`.

## CLI

Тот же мост доступен вручную, без агента:

```bash
./bin/ffmcp.js doctor                      # диагностика обоих браузеров
./bin/ffmcp.js browsers                    # кто сейчас на связи
./bin/ffmcp.js status                      # версия браузера, число вкладок
./bin/ffmcp.js tabs                        # список вкладок
./bin/ffmcp.js open https://example.com
./bin/ffmcp.js snapshot 12                 # элементы вкладки 12
./bin/ffmcp.js eval 'return document.title'
./bin/ffmcp.js api tabs.query '[{"pinned":true}]'
./bin/ffmcp.js call page.text '{"tabId":12}'
```

Браузер выбирается флагом `--browser=chrome` (или `-b chrome`), переменной
`FFMCP_BROWSER`, иначе автоматически:

```bash
./bin/ffmcp.js -b chrome tabs
FFMCP_BROWSER=chrome ./bin/ffmcp.js status
```

## Тесты

```bash
./tests/run.sh
```

Живой браузер не нужен — браузеры и расширения заменяются заглушками. Проверяются: сборка
JSON-RPC и все схемы инструментов, проход вызова MCP → сокет → хост → расширение, выбор
браузера и маршрутизация между двумя хостами, сборка чанкованных ответов, очистка сокета,
отсутствие синхронных циклов при загрузке фона (отдельно для фоновой страницы Firefox
и для service worker Chrome) и, отдельно, устойчивость обоих перехватчиков консоли
к зацикливанию.

Последний тест не декоративный. В первой версии обмен со страницей шёл через
`window.postMessage`, а его видит и сама страница: если её код логирует полученные
сообщения (частый паттерн у виджетов и аналитики), возникала петля
`console.log → postMessage → обработчик страницы → console.log`. Каждая итерация уходила
в фон отдельным `runtime.sendMessage`, забивая главный поток родительского процесса
браузера, и он переставал реагировать на ввод. Сейчас обмен идёт приватным
`CustomEvent`, стоят защита от реентрантности, лимит 100 сообщений в секунду со страницы,
батчинг раз в 250 мс и общий потолок 500 записей в секунду в фоне.

## Безопасность

Это по построению очень мощный доступ: агент видит твои cookies, историю и авторизованные
сессии и может действовать от твоего имени на любом сайте.

### Подтверждение подключения

Каждое новое подключение MCP-клиента к сокету пользователь подтверждает вручную. Пока
не подтверждено — все вызовы (кроме health-check `ping` и `__status`) висят, а хост
показывает нотификацию браузера. Клик по ней открывает popup расширения, где для запроса
предлагается выбор области:

<img src="docs/approval-popup.png" alt="popup ffmcp с запросом доступа" width="300" />


- **это подключение** — доступ только для текущего живого соединения; переподключился клиент — спросят снова;
- **на эту сессию** — до перезапуска браузера (запоминается токен клиента в памяти хоста);
- **навсегда** — токен клиента сохраняется в `~/.ffmcp/allowed-<браузер>.json` (0600) и переживает перезапуск.

Одобрение действует в пределах одного браузера: разрешив агенту Firefox, ты не разрешил
ему заодно и Chrome — там он спросит отдельно.

Клиент идентифицируется стабильным токеном из `~/.ffmcp/client-token` (0600); MCP-сервер
и CLI используют один и тот же токен, поэтому одобрение «навсегда» покрывает оба. Если
пользователь не реагирует за `FFMCP_APPROVAL_TIMEOUT_MS` (по умолчанию 120000 мс), доступ
отклоняется (fail-closed). Полностью выключить гейт — `FFMCP_APPROVAL=0` (для headless/CI).

Ограничение платформы: кнопок в нотификациях Firefox нет, поэтому подтверждать нужно
именно в popup (нотификация только сигналит и открывает его).

- Сокеты лежат в `~/.ffmcp` с правами `0600` — доступны только твоему пользователю,
  никакой сети, ни локальной, ни внешней.
- Буферы `browser_console` и `browser_network` пишутся постоянно, со всех вкладок, и в них
  оседает то, что страницы логируют сами. На практике это бывают почта, идентификаторы
  аккаунта, тариф и прочие персональные данные — например, `claude.ai` печатает в консоль
  полный набор трейтов аналитики. Любой подключённый агент прочитает это одним вызовом.
  Чистить буфер: `browser_console {"clear": true}`.
- Манифест хоста ограничивает подключение конкретным расширением: `allowed_extensions`
  с `ffmcp@local` у Firefox, `allowed_origins` с `chrome-extension://mifolcjccjkcdfdoebmkgklimogoemng/`
  у Chrome.
- Чтобы временно всё отключить — выгрузи расширение (`about:debugging` /
  `chrome://extensions`) или закрой браузер: сокет исчезает вместе с хостом.
- Логи хостов: `~/.ffmcp/firefox.log` и `~/.ffmcp/chrome.log`.

## Диагностика

| Симптом | Причина |
|---|---|
| `сокет не найден` | браузер не запущен либо расширение не загружено; `ffmcp browsers` покажет, кто на связи |
| `расширение ffmcp не подключено к хосту` | хост поднялся, но расширение отвалилось — жми «Переподключить» в попапе |
| Значок расширения показывает `off` | native-хост не запускается; смотри `~/.ffmcp/<браузер>.log` и проверь путь к `node` в лаунчере |
| В Chrome ID расширения не `mifolcjccjkcdfdoebmkgklimogoemng` | загружен каталог без поля `key` в манифесте — переустанови из `extension-chrome` |
| `страница запрещает выполнение кода своей CSP` | включи «Разрешить пользовательские скрипты» в карточке расширения Chrome |
| `скрипт не вернул результат` | это внутренняя страница (`about:`, `chrome://`, магазин расширений) — там расширения работать не могут |
| Вызовы висят, значок показывает `!` | ждёт твоего подтверждения — открой popup ffmcp и выбери область доступа |
| `доступ к … не подтверждён (таймаут)` | никто не нажал подтверждение за отведённое время; повтори вызов и подтверди в popup |
| Агент переспрашивает доступ каждый раз | одобрено «на подключение»; выбери «на сессию» или «навсегда» |
| В Chrome пропали логи консоли | service worker уснул и буферы обнулились — логи копятся заново с этого момента |

Maintenance

ActivitySlowing
ResponsivenessNo issues