feat: MCP Browser - servidor MCP para automação de navegador com Playwright

This commit is contained in:
2026-03-25 10:16:14 -03:00
commit c1d716bb84
24 changed files with 2803 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Dependências
node_modules/
# Screenshots gerados
*.png
# Logs
*.log
# Variáveis de ambiente
.env
.env.local
# OS
.DS_Store
Thumbs.db
desktop.ini
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
+254
View File
@@ -0,0 +1,254 @@
# MCP-Browser
MCP server que permite a qualquer agente de IA controlar um navegador real usando [Playwright](https://playwright.dev/).
![Node](https://img.shields.io/badge/Node.js-18+-339933?logo=node.js&logoColor=white)
![MCP](https://img.shields.io/badge/MCP-stdio-blue)
![Platform](https://img.shields.io/badge/Windows%20|%20Linux%20|%20macOS-lightgrey)
![Tools](https://img.shields.io/badge/17%20tools-green)
## Funcionalidades
- Controle total de navegador via IA
- 17 tools prontas para uso
- Reuso automático de abas (mesmo domínio)
- Preenchimento automático de formulários
- Fluxos agentísticos com retry
- Compatível com qualquer plataforma MCP
## Pré-requisitos
| Dependência | Versão mínima |
|-------------|---------------|
| Node.js | 18+ |
| npm | 9+ |
## Instalação
```bash
# 1. Clonar o repositório
git clone https://git.jf.eng.br/jfeng/MCP-Browser.git
cd MCP-Browser
# 2. Instalar dependências
npm install
# 3. Instalar Chromium do Playwright
npx playwright install chromium
# Linux: instalar dependências do sistema
sudo npx playwright install-deps chromium
```
## Configuração por plataforma
Todas as plataformas usam o mesmo padrão:
```
Comando: node
Argumento: /caminho/absoluto/MCP-Browser/server.js
Transport: stdio
```
### OpenCode
```bash
opencode mcp add
```
- **Nome:** `meu-navegador`
- **Tipo:** `Local`
- **Comando:** `node /caminho/MCP-Browser/server.js`
### Cursor
`~/.cursor/mcp.json` (global) ou `.cursor/mcp.json` (projeto):
```json
{
"mcpServers": {
"browser": {
"command": "node",
"args": ["/caminho/MCP-Browser/server.js"]
}
}
}
```
### Claude Desktop
`%APPDATA%\Claude\claude_desktop_config.json` (Windows)
`~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
```json
{
"mcpServers": {
"browser": {
"command": "node",
"args": ["/caminho/MCP-Browser/server.js"]
}
}
}
```
### Windsurf
`~/.windsurf/mcp.json`:
```json
{
"mcpServers": {
"browser": {
"command": "node",
"args": ["/caminho/MCP-Browser/server.js"]
}
}
}
```
### Trae
Settings → Extensions → MCP → Adicionar server:
- **Name:** `browser`
- **Command:** `node`
- **Args:** `["/caminho/MCP-Browser/server.js"]`
### Antigravity
Configuração de MCP → Novo server:
- **Name:** `browser`
- **Type:** `stdio`
- **Command:** `node /caminho/MCP-Browser/server.js`
### VS Code
`.vscode/mcp.json`:
```json
{
"mcpServers": {
"browser": {
"command": "node",
"args": ["/caminho/MCP-Browser/server.js"]
}
}
}
```
### Qualquer plataforma MCP
Se suporta **MCP stdio transport**, use:
```
Comando: node
Argumento: /caminho/absoluto/MCP-Browser/server.js
```
## Tools disponíveis (17)
| # | Tool | Descrição | Parâmetros |
|---|------|-----------|------------|
| 1 | `open_url` | Abre uma URL | `url` |
| 2 | `click` | Clica por seletor CSS | `selector` |
| 3 | `type` | Digita texto em campo | `selector`, `text` |
| 4 | `screenshot` | Captura screenshot | `fullPage` (opcional) |
| 5 | `get_text` | Extrai texto da página | `selector` (opcional) |
| 6 | `get_links` | Lista links | `selector` (opcional) |
| 7 | `wait_for_selector` | Espera elemento aparecer | `selector`, `timeout` |
| 8 | `extract_elements` | Extrai elementos com atributos | `selector`, `attributes` |
| 9 | `smart_click` | Clica por texto visível | `text` |
| 10 | `smart_type` | Digita por label/placeholder | `label`, `text` |
| 11 | `get_buttons` | Lista botões disponíveis | — |
| 12 | `smart_wait_navigation` | Espera mudança de URL | `timeout` (opcional) |
| 13 | `get_forms` | Lista formulários e campos | — |
| 14 | `fill_form_auto` | Preenche formulário auto | `data` |
| 15 | `agent_flow` | Fluxo automático simples | `goal`, `data` |
| 16 | `agent_flow_v2` | Fluxo com retry/fallback | `goal`, `data`, `retries` |
| 17 | `close_browser` | Fecha o navegador | — |
## Exemplos de uso
### Abrir site e extrair conteúdo
```
open_url("https://example.com")
get_text()
get_links()
screenshot()
```
### Login automático
```
open_url("https://site.com/login")
fill_form_auto({"email": "user@test.com", "password": "1234"})
smart_click("Entrar")
smart_wait_navigation()
```
### Scraping com atributos
```
open_url("https://site.com/produtos")
extract_elements(".produto", ["href", "data-id"])
```
## Estrutura do projeto
```
MCP-Browser/
├── server.js # Servidor MCP (entry point)
├── browser.js # Engine Playwright (singleton)
├── package.json
├── docs.html # Documentação detalhada
└── tools/
├── openUrl.js
├── click.js
├── type.js
├── screenshot.js
├── getText.js
├── getLinks.js
├── getButtons.js
├── getForms.js
├── extractElements.js
├── waitForSelector.js
├── smartClick.js
├── smartType.js
├── fillFormAuto.js
├── smartWaitNavigation.js
├── agentFlow.js
├── agentFlowV2.js
└── closeBrowser.js
```
## Comportamento
- **Reuso de abas:** mesmo domínio reutiliza aba, domínios diferentes abrem nova aba
- **Headed mode:** navegador abre visível. Para headless, edite `browser.js` e mude `headless: true`
- **Singleton:** uma única instância do navegador compartilhada entre todas as tools
## Solução de problemas
| Problema | Solução |
|----------|---------|
| Tools não aparecem | Reiniciar a IDE após configurar MCP |
| `node: not found` | Instalar Node.js 18+ e adicionar ao PATH |
| Chromium não encontrado | `npx playwright install chromium` |
| Erro de permissão (Linux) | `sudo npx playwright install-deps chromium` |
| Caminho com espaços | Usar aspas no caminho |
## Caminhos por SO
| Sistema | Exemplo |
|---------|---------|
| Windows | `C:/Users/seuuser/mcp-browser/server.js` |
| macOS | `/Users/seuuser/mcp-browser/server.js` |
| Linux | `/home/seuuser/mcp-browser/server.js` |
> **Importante:** Nunca usar `\` simples. Sempre `/` ou `\\`.
## Licença
MIT
+121
View File
@@ -0,0 +1,121 @@
const { chromium } = require('playwright');
let browser = null;
let context = null;
let page = null;
async function start() {
if (browser) return;
if (page) return;
browser = await chromium.launch({
headless: false,
args: ['--disable-blink-features=AutomationControlled']
});
context = await browser.newContext({
viewport: { width: 1280, height: 720 },
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
});
page = await context.newPage();
browser.on('disconnected', () => {
browser = null;
context = null;
page = null;
});
}
async function ensurePage() {
if (!browser) {
await start();
return page;
}
if (!page || page.isClosed()) {
page = await context.newPage();
}
return page;
}
async function goto(url) {
const p = await ensurePage();
await p.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 30000
});
await p.waitForTimeout(1500);
}
async function click(selector) {
const p = await ensurePage();
await p.waitForSelector(selector, { state: 'visible', timeout: 10000 });
await p.click(selector);
}
async function type(selector, text) {
const p = await ensurePage();
await p.waitForSelector(selector, { state: 'visible', timeout: 10000 });
await p.fill(selector, text);
}
async function content() {
const p = await ensurePage();
return await p.content();
}
async function title() {
const p = await ensurePage();
return await p.title();
}
async function screenshot(path, fullPage = true) {
const p = await ensurePage();
await p.screenshot({ path, fullPage });
}
async function newTab() {
const p = await context.newPage();
page = p;
return p;
}
async function close() {
if (browser) {
try {
await browser.close();
} catch (e) {
}
browser = null;
context = null;
page = null;
}
}
module.exports = {
start,
goto,
click,
type,
content,
title,
screenshot,
close,
newTab,
ensurePage,
get page() {
return page;
},
get browser() {
return browser;
},
get context() {
return context;
}
};
+444
View File
@@ -0,0 +1,444 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP-Browser — Documentação</title>
<style>
:root {
--bg: #0f172a;
--bg2: #1e293b;
--bg3: #334155;
--accent: #38bdf8;
--green: #22c55e;
--yellow: #eab308;
--red: #ef4444;
--text: #e2e8f0;
--text2: #94a3b8;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
max-width: 960px;
margin: 0 auto;
padding: 40px 20px;
background: var(--bg);
color: var(--text);
line-height: 1.7;
}
h1 { color: var(--accent); font-size: 2em; margin-bottom: 8px; }
h2 { color: var(--accent); font-size: 1.4em; margin-bottom: 12px; border-bottom: 1px solid var(--bg3); padding-bottom: 8px; }
h3 { color: var(--green); font-size: 1.1em; margin: 16px 0 8px; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
pre {
background: var(--bg2);
padding: 16px;
border-radius: 8px;
overflow-x: auto;
margin: 10px 0 16px;
font-size: 0.9em;
border-left: 3px solid var(--accent);
}
code { color: var(--green); font-family: 'Cascadia Code', 'Fira Code', monospace; }
.box {
background: var(--bg2);
padding: 24px;
border-radius: 10px;
margin-bottom: 20px;
}
.warn {
background: #422006;
border-left: 3px solid var(--yellow);
padding: 14px 18px;
border-radius: 6px;
margin: 12px 0;
}
.ok {
background: #052e16;
border-left: 3px solid var(--green);
padding: 14px 18px;
border-radius: 6px;
margin: 12px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
}
th, td {
text-align: left;
padding: 10px 14px;
border-bottom: 1px solid var(--bg3);
}
th { color: var(--accent); font-weight: 600; }
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.8em;
font-weight: 600;
}
.badge-green { background: #166534; color: #86efac; }
.badge-blue { background: #1e3a5f; color: #7dd3fc; }
ol li, ul li { margin-bottom: 6px; }
.platform { margin-bottom: 24px; }
.tab-icon { margin-right: 6px; }
</style>
</head>
<body>
<h1>MCP-Browser</h1>
<p style="color: var(--text2); margin-bottom: 30px;">
MCP server que permite a qualquer agente de IA controlar um navegador real via Playwright.
<span class="badge badge-green">17 tools</span>
<span class="badge badge-blue">Windows / Linux / macOS</span>
</p>
<!-- ============================================ -->
<div class="box">
<h2>Pré-requisitos</h2>
<table>
<tr><th>Dependência</th><th>Versão mínima</th><th>Verificar</th></tr>
<tr><td>Node.js</td><td>18+</td><td><code>node -v</code></td></tr>
<tr><td>npm</td><td>9+</td><td><code>npm -v</code></td></tr>
<tr><td>Playwright</td><td>1.50+</td><td><code>npx playwright --version</code></td></tr>
</table>
</div>
<!-- ============================================ -->
<div class="box">
<h2>Instalação</h2>
<h3>1. Clonar ou copiar o projeto</h3>
<pre><code># Opção A — git clone
git clone https://git.jf.eng.br/jfeng/MCP-Browser.git
cd MCP-Browser
# Opção B — copiar a pasta manualmente para qualquer diretório</code></pre>
<h3>2. Instalar dependências</h3>
<pre><code>npm install</code></pre>
<h3>3. Instalar navegadores do Playwright</h3>
<pre><code>npx playwright install chromium</code></pre>
<div class="warn">
<strong>Linux:</strong> Pode precisar instalar dependências do sistema:<br>
<code>sudo npx playwright install-deps chromium</code>
</div>
<h3>4. Testar se funciona</h3>
<pre><code>node server.js</code></pre>
<p>O servidor inicia e aguarda conexão via stdin/stdout (MCP stdio transport). Não aparece nada no terminal — isso é normal.</p>
</div>
<!-- ============================================ -->
<div class="box">
<h2>Estrutura do projeto</h2>
<pre><code>MCP-Browser/
├── server.js ← servidor MCP (entry point)
├── browser.js ← engine Playwright (singleton)
├── package.json
├── package-lock.json
├── docs.html ← esta documentação
├── node_modules/
└── tools/
├── openUrl.js ← abre URL (reusa aba se mesmo domínio)
├── click.js ← clica por seletor CSS
├── type.js ← digita texto em campo
├── screenshot.js ← captura screenshot
├── getText.js ← extrai texto da página
├── getLinks.js ← lista todos os links
├── getButtons.js ← lista botões disponíveis
├── getForms.js ← lista formulários e campos
├── extractElements.js ← extrai elementos com atributos
├── waitForSelector.js ← espera elemento aparecer
├── smartClick.js ← clica por texto visível
├── smartType.js ← digita por label/placeholder
├── fillFormAuto.js ← preenche formulário automaticamente
├── smartWaitNavigation.js ← espera navegação
├── agentFlow.js ← fluxo automático simples
├── agentFlowV2.js ← fluxo com retry e fallback
└── closeBrowser.js ← fecha o navegador</code></pre>
</div>
<!-- ============================================ -->
<div class="box">
<h2>Configuração por plataforma</h2>
<!-- OPENCODE -->
<div class="platform">
<h3>OpenCode</h3>
<ol>
<li>Abra o terminal e execute:<br>
<code>opencode mcp add</code>
</li>
<li>Preencha os campos:
<ul>
<li><strong>Nome:</strong> <code>meu-navegador</code></li>
<li><strong>Tipo:</strong> <code>Local</code></li>
<li><strong>Comando:</strong> <code>node /caminho/absoluto/MCP-Browser/server.js</code></li>
</ul>
</li>
<li>Reinicie o OpenCode.</li>
<li>Verifique: <code>opencode mcp list</code></li>
</ol>
</div>
<!-- CURSOR -->
<div class="platform">
<h3>Cursor</h3>
<ol>
<li>Abra <strong>Settings → MCP</strong> (ou <code>.cursor/mcp.json</code> no projeto).</li>
<li>Adicione ao arquivo <code>~/.cursor/mcp.json</code> (global) ou <code>.cursor/mcp.json</code> (projeto):</li>
</ol>
<pre><code>{
"mcpServers": {
"browser": {
"command": "node",
"args": ["/caminho/absoluto/MCP-Browser/server.js"]
}
}
}</code></pre>
<ol start="3">
<li>Reinicie o Cursor. As tools aparecerão no chat do Composer.</li>
</ol>
</div>
<!-- CLAUDE DESKTOP -->
<div class="platform">
<h3>Claude Desktop</h3>
<ol>
<li>Abra o arquivo de configuração:
<ul>
<li><strong>Windows:</strong> <code>%APPDATA%\Claude\claude_desktop_config.json</code></li>
<li><strong>macOS:</strong> <code>~/Library/Application Support/Claude/claude_desktop_config.json</code></li>
<li><strong>Linux:</strong> <code>~/.config/Claude/claude_desktop_config.json</code></li>
</ul>
</li>
<li>Adicione:</li>
</ol>
<pre><code>{
"mcpServers": {
"browser": {
"command": "node",
"args": ["/caminho/absoluto/MCP-Browser/server.js"]
}
}
}</code></pre>
<ol start="3">
<li>Reinicie o Claude Desktop.</li>
</ol>
</div>
<!-- WINDSURF -->
<div class="platform">
<h3>Windsurf (Codeium)</h3>
<ol>
<li>Abra <strong>Settings → Cascade → MCP Servers</strong>.</li>
<li>Ou edite <code>~/.windsurf/mcp.json</code>:</li>
</ol>
<pre><code>{
"mcpServers": {
"browser": {
"command": "node",
"args": ["/caminho/absoluto/MCP-Browser/server.js"]
}
}
}</code></pre>
<ol start="3">
<li>Reinicie o Windsurf.</li>
</ol>
</div>
<!-- TRAE -->
<div class="platform">
<h3>Trae</h3>
<ol>
<li>Abra <strong>Settings → Extensions → MCP</strong>.</li>
<li>Adicione server manualmente:
<ul>
<li><strong>Name:</strong> <code>browser</code></li>
<li><strong>Command:</strong> <code>node</code></li>
<li><strong>Args:</strong> <code>["/caminho/absoluto/MCP-Browser/server.js"]</code></li>
</ul>
</li>
<li>Ou edite o arquivo de configuração do Trae equivalente ao <code>mcp.json</code>.</li>
</ol>
</div>
<!-- ANTIGRAVITY -->
<div class="platform">
<h3>Antigravity</h3>
<ol>
<li>Abra a configuração de MCP do Antigravity.</li>
<li>Adicione um novo server com:
<ul>
<li><strong>Name:</strong> <code>browser</code></li>
<li><strong>Type:</strong> <code>stdio</code></li>
<li><strong>Command:</strong> <code>node /caminho/absoluto/MCP-Browser/server.js</code></li>
</ul>
</li>
<li>Salve e reinicie.</li>
</ol>
</div>
<!-- VS CODE -->
<div class="platform">
<h3>VS Code (com extensão MCP)</h3>
<ol>
<li>Instale a extensão <strong>MCP</strong> no VS Code.</li>
<li>Abra <code>.vscode/mcp.json</code> no workspace ou <code>settings.json</code> global.</li>
<li>Adicione:</li>
</ol>
<pre><code>{
"mcpServers": {
"browser": {
"command": "node",
"args": ["/caminho/absoluto/MCP-Browser/server.js"]
}
}
}</code></pre>
</div>
<!-- GENERIC -->
<div class="platform">
<h3>Qualquer outra plataforma MCP</h3>
<p>Se a plataforma suporta <strong>MCP stdio transport</strong>, use:</p>
<pre><code>Comando: node
Argumento: /caminho/absoluto/MCP-Browser/server.js
Transport: stdio</code></pre>
<p>O servidor comunica via <strong>stdin/stdout</strong> — não precisa porta, não precisa daemon.</p>
</div>
</div>
<!-- ============================================ -->
<div class="box">
<h2>Caminhos por sistema operacional</h2>
<table>
<tr><th>Sistema</th><th>Formato do caminho</th><th>Exemplo</th></tr>
<tr><td>Windows</td><td>Use <code>/</code> ou <code>\\</code></td><td><code>C:/Users/seuuser/mcp-browser/server.js</code></td></tr>
<tr><td>macOS</td><td>Unix path</td><td><code>/Users/seuuser/mcp-browser/server.js</code></td></tr>
<tr><td>Linux</td><td>Unix path</td><td><code>/home/seuuser/mcp-browser/server.js</code></td></tr>
</table>
<div class="warn">
<strong>Importante:</strong> Nunca use <code>\</code> simples nos caminhos. Sempre <code>/</code> ou <code>\\</code>.
</div>
</div>
<!-- ============================================ -->
<div class="box">
<h2>Lista de tools (17)</h2>
<table>
<tr><th>#</th><th>Tool</th><th>Descrição</th><th>Parâmetros</th></tr>
<tr><td>1</td><td><code>open_url</code></td><td>Abre uma URL no navegador</td><td><code>url</code> (string, obrigatório)</td></tr>
<tr><td>2</td><td><code>click</code></td><td>Clica em elemento por seletor CSS</td><td><code>selector</code> (string, obrigatório)</td></tr>
<tr><td>3</td><td><code>type</code></td><td>Digita texto em um campo</td><td><code>selector</code>, <code>text</code></td></tr>
<tr><td>4</td><td><code>screenshot</code></td><td>Captura screenshot da página</td><td><code>fullPage</code> (bool, opcional)</td></tr>
<tr><td>5</td><td><code>get_text</code></td><td>Extrai texto da página</td><td><code>selector</code> (opcional, padrão: body)</td></tr>
<tr><td>6</td><td><code>get_links</code></td><td>Lista todos os links</td><td><code>selector</code> (opcional, padrão: a)</td></tr>
<tr><td>7</td><td><code>wait_for_selector</code></td><td>Espera elemento aparecer no DOM</td><td><code>selector</code>, <code>timeout</code></td></tr>
<tr><td>8</td><td><code>extract_elements</code></td><td>Extrai elementos com atributos</td><td><code>selector</code>, <code>attributes</code> (array)</td></tr>
<tr><td>9</td><td><code>smart_click</code></td><td>Clica pelo texto visível</td><td><code>text</code> (string, obrigatório)</td></tr>
<tr><td>10</td><td><code>smart_type</code></td><td>Digita buscando por label/placeholder</td><td><code>label</code>, <code>text</code></td></tr>
<tr><td>11</td><td><code>get_buttons</code></td><td>Lista botões disponíveis</td><td></td></tr>
<tr><td>12</td><td><code>smart_wait_navigation</code></td><td>Espera mudança de URL</td><td><code>timeout</code> (opcional)</td></tr>
<tr><td>13</td><td><code>get_forms</code></td><td>Lista formulários e seus campos</td><td></td></tr>
<tr><td>14</td><td><code>fill_form_auto</code></td><td>Preenche formulário automaticamente</td><td><code>data</code> (objeto, obrigatório)</td></tr>
<tr><td>15</td><td><code>agent_flow</code></td><td>Fluxo automático simples</td><td><code>goal</code>, <code>data</code> (opcional)</td></tr>
<tr><td>16</td><td><code>agent_flow_v2</code></td><td>Fluxo com retry e fallback</td><td><code>goal</code>, <code>data</code>, <code>retries</code></td></tr>
<tr><td>17</td><td><code>close_browser</code></td><td>Fecha o navegador</td><td></td></tr>
</table>
</div>
<!-- ============================================ -->
<div class="box">
<h2>Exemplos de uso</h2>
<h3>Abrir site e extrair conteúdo</h3>
<pre><code>open_url("https://example.com")
get_text()
get_links()
screenshot()</code></pre>
<h3>Login automático</h3>
<pre><code>open_url("https://site.com/login")
fill_form_auto({"email": "user@test.com", "password": "1234"})
smart_click("Entrar")
smart_wait_navigation()
get_text()</code></pre>
<h3>Scraping com atributos</h3>
<pre><code>open_url("https://site.com/produtos")
extract_elements(".produto", ["href", "data-id", "class"])</code></pre>
<h3>Preencher formulário complexo</h3>
<pre><code>open_url("https://site.com/contato")
smart_type("nome", "João Silva")
type("#email", "joao@email.com")
smart_type("mensagem", "Olá, quero mais informações")
smart_click("Enviar")</code></pre>
</div>
<!-- ============================================ -->
<div class="box">
<h2>Comportamento do navegador</h2>
<ul>
<li><strong>Reuso de abas:</strong> Se o navegador já estiver aberto e você abrir uma URL do <strong>mesmo domínio</strong>, a aba atual é reutilizada. Domínios diferentes abrem em <strong>nova aba</strong>.</li>
<li><strong>Singleton:</strong> Apenas uma instância do navegador é mantida. Todas as tools compartilham a mesma página ativa.</li>
<li><strong>Headed mode:</strong> O navegador abre visível (não headless). Para headless, edite <code>browser.js</code> e mude <code>headless: true</code>.</li>
<li><strong>Auto-close:</strong> Use <code>close_browser</code> quando terminar. O navegador não fecha sozinho.</li>
</ul>
</div>
<!-- ============================================ -->
<div class="box">
<h2>Problemas comuns</h2>
<table>
<tr><th>Problema</th><th>Solução</th></tr>
<tr>
<td>Tools não aparecem na plataforma</td>
<td>Reiniciar a IDE/plataforma após configurar o MCP.</td>
</tr>
<tr>
<td><code>node: command not found</code></td>
<td>Instalar Node.js 18+ e garantir que está no PATH.</td>
</tr>
<tr>
<td>Playwright não encontra Chromium</td>
<td><code>npx playwright install chromium</code></td>
</tr>
<tr>
<td>Erro de permissão no Linux</td>
<td><code>sudo npx playwright install-deps chromium</code></td>
</tr>
<tr>
<td>Página não carrega (conteúdo vazio)</td>
<td>O servidor aguarda <code>domcontentloaded</code> + 1.5s. Se a página usa JS pesado, aumente o timeout em <code>browser.js</code>.</td>
</tr>
<tr>
<td>Erro <code>ECONNREFUSED</code></td>
<td>O servidor MCP usa stdio (stdin/stdout), não HTTP. Verifique se o comando está correto.</td>
</tr>
<tr>
<td>Caminho com espaços no Windows</td>
<td>Use aspas: <code>"C:/Users/Meu Usuario/mcp/server.js"</code></td>
</tr>
</table>
</div>
<!-- ============================================ -->
<div class="box">
<h2>Licença</h2>
<p>MIT — uso livre.</p>
</div>
</body>
</html>
+1184
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "opencode-mcp-browser",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"playwright": "^1.58.2"
}
}
+108
View File
@@ -0,0 +1,108 @@
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
const {
ListToolsRequestSchema,
CallToolRequestSchema
} = require('@modelcontextprotocol/sdk/types.js');
// TOOLS
const openUrl = require('./tools/openUrl');
const click = require('./tools/click');
const type = require('./tools/type');
const screenshot = require('./tools/screenshot');
const getText = require('./tools/getText');
const getLinks = require('./tools/getLinks');
const waitForSelector = require('./tools/waitForSelector');
const extractElements = require('./tools/extractElements');
const smartClick = require('./tools/smartClick');
const smartType = require('./tools/smartType');
const getButtons = require('./tools/getButtons');
const smartWaitNavigation = require('./tools/smartWaitNavigation');
const getForms = require('./tools/getForms');
const fillFormAuto = require('./tools/fillFormAuto');
const agentFlow = require('./tools/agentFlow');
const agentFlowV2 = require('./tools/agentFlowV2');
const closeBrowser = require('./tools/closeBrowser');
// SERVER MCP
const server = new Server(
{
name: "browser",
version: "2.0.0"
},
{
capabilities: {
tools: {}
}
}
);
// LISTAR TOOLS
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{ name: "open_url", description: "Abre uma URL", inputSchema: { type: "object", properties: { url: { type: "string" } }, required: ["url"] } },
{ name: "click", description: "Clica por seletor", inputSchema: { type: "object", properties: { selector: { type: "string" } }, required: ["selector"] } },
{ name: "type", description: "Digita texto", inputSchema: { type: "object", properties: { selector: { type: "string" }, text: { type: "string" } }, required: ["selector", "text"] } },
{ name: "screenshot", description: "Screenshot", inputSchema: { type: "object", properties: { fullPage: { type: "boolean" } } } },
{ name: "get_text", description: "Extrai texto", inputSchema: { type: "object", properties: { selector: { type: "string" } } } },
{ name: "get_links", description: "Lista links", inputSchema: { type: "object", properties: { selector: { type: "string" } } } },
{ name: "wait_for_selector", description: "Espera elemento", inputSchema: { type: "object", properties: { selector: { type: "string" }, timeout: { type: "number" } }, required: ["selector"] } },
{ name: "extract_elements", description: "Extrai elementos", inputSchema: { type: "object", properties: { selector: { type: "string" }, attributes: { type: "array", items: { type: "string" } } }, required: ["selector"] } },
{ name: "smart_click", description: "Clica por texto visível", inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } },
{ name: "smart_type", description: "Digita por label/placeholder", inputSchema: { type: "object", properties: { label: { type: "string" }, text: { type: "string" } }, required: ["label", "text"] } },
{ name: "get_buttons", description: "Lista botões disponíveis", inputSchema: { type: "object", properties: {} } },
{ name: "smart_wait_navigation", description: "Espera mudança de página", inputSchema: { type: "object", properties: { timeout: { type: "number" } } } },
{ name: "get_forms", description: "Lista formulários da página", inputSchema: { type: "object", properties: {} } },
{ name: "fill_form_auto", description: "Preenche formulário automaticamente", inputSchema: { type: "object", properties: { data: { type: "object" } }, required: ["data"] } },
{ name: "agent_flow", description: "Fluxo automático simples", inputSchema: { type: "object", properties: { goal: { type: "string" }, data: { type: "object" } }, required: ["goal"] } },
{ name: "agent_flow_v2", description: "Fluxo automático avançado com retry e fallback", inputSchema: { type: "object", properties: { goal: { type: "string" }, data: { type: "object" }, retries: { type: "number" } }, required: ["goal"] } },
{ name: "close_browser", description: "Fecha o navegador", inputSchema: { type: "object", properties: {} } }
]
};
});
// EXECUTAR TOOL
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const tools = {
open_url: openUrl,
click,
type,
screenshot,
get_text: getText,
get_links: getLinks,
wait_for_selector: waitForSelector,
extract_elements: extractElements,
smart_click: smartClick,
smart_type: smartType,
get_buttons: getButtons,
smart_wait_navigation: smartWaitNavigation,
get_forms: getForms,
fill_form_auto: fillFormAuto,
agent_flow: agentFlow,
agent_flow_v2: agentFlowV2,
close_browser: closeBrowser
};
if (!tools[name]) {
throw new Error("Tool não encontrada");
}
const result = await tools[name](args || {});
return {
content: [
{
type: "text",
text: JSON.stringify(result)
}
]
};
});
// START SERVER
const transport = new StdioServerTransport();
server.connect(transport);
+62
View File
@@ -0,0 +1,62 @@
const openUrl = require('./openUrl');
const smartClick = require('./smartClick');
const smartType = require('./smartType');
const fillFormAuto = require('./fillFormAuto');
const smartWaitNavigation = require('./smartWaitNavigation');
const getButtons = require('./getButtons');
const getForms = require('./getForms');
module.exports = async ({ goal, data = {} }) => {
if (!goal) {
throw new Error('Goal é obrigatório');
}
let log = [];
try {
if (goal.includes('http')) {
await openUrl({ url: goal });
log.push("Abriu URL");
}
if (Object.keys(data).length > 0) {
const forms = await getForms();
if (forms.count > 0) {
await fillFormAuto({ data });
log.push("Formulário preenchido automaticamente");
}
}
const buttons = await getButtons();
const keywords = ['login', 'entrar', 'continuar', 'enviar', 'buscar', 'submit', 'ok', 'confirmar'];
for (const btn of buttons.buttons) {
const match = keywords.find(k =>
btn.text.toLowerCase().includes(k)
);
if (match) {
await smartClick({ text: btn.text });
await smartWaitNavigation({});
log.push(`Clicou em: ${btn.text}`);
break;
}
}
return {
success: true,
action: "agent_flow",
goal,
steps: log
};
} catch (error) {
return {
success: false,
error: error.message,
steps: log
};
}
};
+85
View File
@@ -0,0 +1,85 @@
const openUrl = require('./openUrl');
const smartClick = require('./smartClick');
const smartType = require('./smartType');
const fillFormAuto = require('./fillFormAuto');
const smartWaitNavigation = require('./smartWaitNavigation');
const getButtons = require('./getButtons');
const getForms = require('./getForms');
const delay = (ms) => new Promise(res => setTimeout(res, ms));
module.exports = async ({ goal, data = {}, retries = 3 }) => {
if (!goal) {
throw new Error('Goal é obrigatório');
}
let log = [];
let attempt = 0;
const keywords = ['login', 'entrar', 'continuar', 'enviar', 'buscar', 'submit', 'ok', 'confirmar'];
while (attempt < retries) {
try {
log.push(`Tentativa ${attempt + 1}`);
if (goal.includes('http')) {
await openUrl({ url: goal });
log.push("Abriu URL");
await delay(1500);
}
if (Object.keys(data).length > 0) {
const forms = await getForms();
if (forms.count > 0) {
await fillFormAuto({ data });
log.push("Formulário preenchido");
}
}
const buttons = await getButtons();
let clicked = false;
for (const btn of buttons.buttons) {
const match = keywords.find(k =>
btn.text.toLowerCase().includes(k)
);
if (match) {
await smartClick({ text: btn.text });
await smartWaitNavigation({});
log.push(`Clicou em: ${btn.text}`);
clicked = true;
break;
}
}
if (!clicked && buttons.buttons.length > 0) {
await smartClick({ text: buttons.buttons[0].text });
await smartWaitNavigation({});
log.push(`Fallback click: ${buttons.buttons[0].text}`);
}
return {
success: true,
action: "agent_flow_v2",
attempts: attempt + 1,
steps: log
};
} catch (error) {
log.push(`Erro: ${error.message}`);
attempt++;
await delay(1000);
}
}
return {
success: false,
action: "agent_flow_v2",
attempts: attempt,
steps: log
};
};
+19
View File
@@ -0,0 +1,19 @@
const browser = require('../browser');
module.exports = async ({ selector }) => {
if (!selector) {
throw new Error('Selector é obrigatório');
}
await browser.start();
const p = await browser.ensurePage();
await p.waitForSelector(selector, { state: 'visible', timeout: 10000 });
await p.click(selector);
return {
success: true,
action: "click",
selector
};
};
+11
View File
@@ -0,0 +1,11 @@
const browser = require('../browser');
module.exports = async () => {
await browser.close();
return {
success: true,
action: "close_browser",
message: "Navegador fechado com sucesso"
};
};
+36
View File
@@ -0,0 +1,36 @@
const browser = require('../browser');
module.exports = async ({ selector, attributes = [] }) => {
if (!selector) {
throw new Error('Selector é obrigatório');
}
await browser.start();
const p = await browser.ensurePage();
const attrsStr = JSON.stringify(Array.isArray(attributes) ? attributes : []);
const elements = await p.evaluate((sel) => {
const attrs = JSON.parse(sel.__attrs);
const els = Array.from(document.querySelectorAll(sel.__sel));
return els.map(el => {
const data = {
text: (el.innerText || el.textContent || '').trim()
};
for (let i = 0; i < attrs.length; i++) {
data[attrs[i]] = el.getAttribute(attrs[i]);
}
return data;
});
}, { __sel: selector, __attrs: attrsStr });
return {
success: true,
action: "extract_elements",
count: elements.length,
elements
};
};
+62
View File
@@ -0,0 +1,62 @@
const browser = require('../browser');
module.exports = async ({ data }) => {
if (!data) {
throw new Error('Data é obrigatória');
}
await browser.start();
const p = await browser.ensurePage();
const result = await p.evaluate((formData) => {
const inputs = Array.from(document.querySelectorAll('input, textarea, select'));
let filled = [];
inputs.forEach(input => {
const key = Object.keys(formData).find(k =>
(input.name && input.name.toLowerCase().includes(k.toLowerCase())) ||
(input.placeholder && input.placeholder.toLowerCase().includes(k.toLowerCase())) ||
(input.id && input.id.toLowerCase().includes(k.toLowerCase())) ||
(input.getAttribute('aria-label') || '').toLowerCase().includes(k.toLowerCase())
);
if (key) {
const value = formData[key];
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
)?.set;
const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype, 'value'
)?.set;
if (input.tagName === 'INPUT' && nativeInputValueSetter) {
nativeInputValueSetter.call(input, value);
} else if (input.tagName === 'TEXTAREA' && nativeTextAreaValueSetter) {
nativeTextAreaValueSetter.call(input, value);
} else {
input.value = value;
}
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
filled.push({
field: input.name || input.id || input.placeholder || 'unknown',
value: value
});
}
});
return filled;
}, data);
return {
success: true,
action: "fill_form_auto",
filled: result,
count: result.length
};
};
+23
View File
@@ -0,0 +1,23 @@
const browser = require('../browser');
module.exports = async () => {
await browser.start();
const p = await browser.ensurePage();
const buttons = await p.evaluate(() => {
const elements = Array.from(document.querySelectorAll('button, input[type="submit"], input[type="button"], [role="button"], a.btn, a.button'));
return elements.map(el => ({
text: (el.innerText || el.value || el.getAttribute('aria-label') || '').trim(),
type: el.tagName.toLowerCase(),
id: el.id || null
})).filter(b => b.text !== '');
});
return {
success: true,
action: "get_buttons",
count: buttons.length,
buttons
};
};
+32
View File
@@ -0,0 +1,32 @@
const browser = require('../browser');
module.exports = async () => {
await browser.start();
const p = await browser.ensurePage();
const forms = await p.evaluate(() => {
const formElements = Array.from(document.querySelectorAll('form'));
return formElements.map(form => {
const inputs = Array.from(form.querySelectorAll('input, textarea, select'));
return {
action: form.action || null,
method: form.method || 'GET',
fields: inputs.map(input => ({
name: input.name || null,
type: input.type || input.tagName.toLowerCase(),
placeholder: input.placeholder || '',
id: input.id || null
}))
};
});
});
return {
success: true,
action: "get_forms",
count: forms.length,
forms
};
};
+22
View File
@@ -0,0 +1,22 @@
const browser = require('../browser');
module.exports = async ({ selector = 'a' }) => {
await browser.start();
const p = await browser.ensurePage();
const links = await p.evaluate((sel) => {
const elements = Array.from(document.querySelectorAll(sel));
return elements.map(el => ({
text: (el.innerText || el.textContent || '').trim(),
href: el.href || el.getAttribute('href') || ''
})).filter(link => link.href && link.href !== '');
}, selector);
return {
success: true,
action: "get_links",
count: links.length,
links
};
};
+21
View File
@@ -0,0 +1,21 @@
const browser = require('../browser');
module.exports = async ({ selector = 'body' }) => {
await browser.start();
const p = await browser.ensurePage();
const text = await p.evaluate((sel) => {
const el = document.querySelector(sel);
if (!el) return null;
const t = el.innerText || el.textContent || '';
return t.trim() || null;
}, selector);
return {
success: true,
action: "get_text",
selector,
text
};
};
+56
View File
@@ -0,0 +1,56 @@
const browser = require('../browser');
module.exports = async ({ url }) => {
if (!url) {
throw new Error('URL é obrigatória');
}
await browser.start();
const p = await browser.ensurePage();
const currentUrl = p.url();
if (currentUrl && currentUrl !== 'about:blank') {
if (currentUrl.includes(new URL(url).hostname)) {
await p.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 30000
});
await p.waitForTimeout(1500);
return {
success: true,
url,
title: await p.title(),
tab: 'reused'
};
}
const newPage = await browser.newTab();
await newPage.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 30000
});
await newPage.waitForTimeout(1500);
return {
success: true,
url,
title: await newPage.title(),
tab: 'new'
};
}
await p.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 30000
});
await p.waitForTimeout(1500);
return {
success: true,
url,
title: await p.title(),
tab: 'existing'
};
};
+16
View File
@@ -0,0 +1,16 @@
const browser = require('../browser');
module.exports = async ({ fullPage = true }) => {
await browser.start();
const p = await browser.ensurePage();
const path = `screenshot-${Date.now()}.png`;
await p.screenshot({ path, fullPage });
return {
success: true,
action: "screenshot",
file: path
};
};
+54
View File
@@ -0,0 +1,54 @@
const browser = require('../browser');
module.exports = async ({ text }) => {
if (!text) {
throw new Error('Texto é obrigatório');
}
await browser.start();
const p = await browser.ensurePage();
const clicked = await p.evaluate((targetText) => {
const elements = Array.from(document.querySelectorAll(
'a, button, input[type="submit"], input[type="button"], [role="button"], [onclick]'
));
const el = elements.find(e => {
const txt = (e.innerText || e.textContent || e.value || e.getAttribute('aria-label') || '').trim().toLowerCase();
return txt.includes(targetText.toLowerCase());
});
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.click();
if (el.tagName === 'A' && el.href) {
return { clicked: true, href: el.href };
}
return { clicked: true, href: null };
}
return { clicked: false, href: null };
}, text);
if (!clicked.clicked) {
throw new Error(`Elemento não encontrado pelo texto: "${text}"`);
}
if (clicked.href) {
try {
await p.waitForNavigation({ timeout: 5000, waitUntil: 'domcontentloaded' });
} catch (e) {
}
} else {
await p.waitForTimeout(1000);
}
return {
success: true,
action: "smart_click",
text,
navigatedTo: clicked.href || null
};
};
+84
View File
@@ -0,0 +1,84 @@
const browser = require('../browser');
module.exports = async ({ label, text }) => {
if (!label || !text) {
throw new Error('Label e text são obrigatórios');
}
await browser.start();
const p = await browser.ensurePage();
const success = await p.evaluate(({ labelText, inputText }) => {
function setInputValue(input, value) {
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
)?.set;
const nativeTextAreaSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype, 'value'
)?.set;
if (input.tagName === 'INPUT' && nativeSetter) {
nativeSetter.call(input, value);
} else if (input.tagName === 'TEXTAREA' && nativeTextAreaSetter) {
nativeTextAreaSetter.call(input, value);
} else {
input.value = value;
}
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
const labels = Array.from(document.querySelectorAll('label'));
const foundLabel = labels.find(l =>
(l.innerText || l.textContent || '').toLowerCase().includes(labelText.toLowerCase())
);
if (foundLabel) {
const forAttr = foundLabel.getAttribute('for');
if (forAttr) {
const input = document.getElementById(forAttr);
if (input) {
setInputValue(input, inputText);
return true;
}
}
const input = foundLabel.querySelector('input, textarea');
if (input) {
setInputValue(input, inputText);
return true;
}
}
const inputs = Array.from(document.querySelectorAll('input, textarea'));
const foundInput = inputs.find(i =>
(i.placeholder || '').toLowerCase().includes(labelText.toLowerCase()) ||
(i.name || '').toLowerCase().includes(labelText.toLowerCase()) ||
(i.id || '').toLowerCase().includes(labelText.toLowerCase()) ||
(i.getAttribute('aria-label') || '').toLowerCase().includes(labelText.toLowerCase())
);
if (foundInput) {
setInputValue(foundInput, inputText);
return true;
}
return false;
}, { labelText: label, inputText: text });
if (!success) {
throw new Error('Campo não encontrado');
}
return {
success: true,
action: "smart_type",
label,
text
};
};
+26
View File
@@ -0,0 +1,26 @@
const browser = require('../browser');
module.exports = async ({ timeout = 15000 }) => {
await browser.start();
const p = await browser.ensurePage();
const oldUrl = p.url();
try {
await Promise.race([
p.waitForNavigation({ timeout, waitUntil: 'domcontentloaded' }),
p.waitForLoadState('domcontentloaded', { timeout })
]);
} catch (e) {
}
const newUrl = p.url();
return {
success: true,
action: "smart_wait_navigation",
oldUrl,
newUrl,
changed: oldUrl !== newUrl
};
};
+20
View File
@@ -0,0 +1,20 @@
const browser = require('../browser');
module.exports = async ({ selector, text }) => {
if (!selector || !text) {
throw new Error('Selector e text são obrigatórios');
}
await browser.start();
const p = await browser.ensurePage();
await p.waitForSelector(selector, { state: 'visible', timeout: 10000 });
await p.fill(selector, text);
return {
success: true,
action: "type",
selector,
text
};
};
+22
View File
@@ -0,0 +1,22 @@
const browser = require('../browser');
module.exports = async ({ selector, timeout = 15000 }) => {
if (!selector) {
throw new Error('Selector é obrigatório');
}
await browser.start();
const p = await browser.ensurePage();
const el = await p.waitForSelector(selector, {
timeout,
state: 'attached'
});
return {
success: true,
action: "wait_for_selector",
selector,
found: !!el
};
};