Skip to content

Commit 67a9148

Browse files
committed
docs: update README files and enhance MCP server integration details
- Updated BROWSER_ECHO_MCP_URL in README files to remove the '/mcp' suffix for clarity. - Improved documentation on environment variables and server route integration for various frameworks (React, Next.js, Nuxt, Vite, Vue). - Enhanced descriptions for log retrieval and clearing functionalities, including project-specific operations. - Streamlined instructions for setting up MCP server connections across different environments.
1 parent d802397 commit 67a9148

14 files changed

Lines changed: 187 additions & 228 deletions

File tree

packages/core/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ For core usage, MCP forwarding depends on your server-side route implementation.
9393

9494
### Environment Variables
9595

96-
- `BROWSER_ECHO_MCP_URL=http://127.0.0.1:5179/mcp` — Set in your server environment
96+
- `BROWSER_ECHO_MCP_URL=http://127.0.0.1:5179` — Set in your server environment
9797
- `BROWSER_ECHO_SUPPRESS_TERMINAL=1` — Control terminal output in your route handler
9898

9999
### Server Route MCP Integration

packages/mcp/README.md

Lines changed: 24 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -288,160 +288,63 @@ Best for local development with AI assistants:
288288
}
289289
```
290290

291-
In stdio mode:
292-
- MCP communication happens over **stdio** (no HTTP MCP endpoint)
293-
- An **HTTP ingest server** runs on a local port (127.0.0.1) for browsers to POST logs
294-
- The actual URL is written to `.browser-echo-mcp.json` in your project root
295-
- Console output (stderr): `MCP (stdio) listening on stdio (ingest HTTP active)`
296-
297-
### HTTP Mode
298-
299-
For web-based AI tools or when you need HTTP MCP access:
300-
301-
```json
302-
// .cursor/mcp.json
303-
{
304-
"mcpServers": {
305-
"browser-echo": {
306-
"command": "node",
307-
"args": ["packages/mcp/bin/cli.mjs", "--http"]
308-
}
309-
}
310-
}
311-
```
312-
313-
In HTTP mode:
314-
- Full **Streamable HTTP** MCP endpoint and HTTP ingest endpoint run on the specified host/port
315-
- Console output: `MCP (Streamable HTTP) listening → http://127.0.0.1:<port>/mcp`
316-
317-
### Custom Configuration
318-
319-
```bash
320-
# Custom ingest port in stdio mode (override ephemeral)
321-
BROWSER_ECHO_INGEST_PORT=8081 node packages/mcp/bin/cli.mjs
322-
323-
# Custom HTTP server
324-
node packages/mcp/bin/cli.mjs --http --host 0.0.0.0 --port 5179
325-
```
291+
- Vite/Next/Nuxt send `X-Browser-Echo-Project-Name` so logs are project-tagged.
292+
- No `.browser-echo-mcp.json` discovery files are used anymore.
326293

327294
---
328295

329-
## How Logs Reach the Server
330-
331-
### Browser → Ingest (Recommended)
332-
333-
Your framework packages automatically send logs to the ingest endpoint:
296+
## Available Tools
334297

335-
```typescript
336-
// Browser automatically POSTs to ingest endpoint
337-
POST http://127.0.0.1:5179/__client-logs
338-
{
339-
"sessionId": "tab-123",
340-
"entries": [
341-
{
342-
"level": "error",
343-
"text": "Failed to fetch user",
344-
"time": 1724200000000,
345-
"source": "api.ts:42",
346-
"stack": "Error: ..."
347-
}
348-
]
349-
}
350-
```
298+
### `get_logs` — Fetch Frontend Browser Logs
351299

352-
### Framework Forwarding
300+
Key params (selection):
301+
- `project?: string` — filter by project name
302+
- `level?: string[]`, `sinceMs?: number`, `contains?: string`
303+
- `session?: string`, `includeStack?: boolean`, `limit?: number`
304+
- `autoBaseline?: boolean` (default true)
305+
- `stackedMode?: boolean` (default false) — disables auto-baseline
353306

354-
Framework packages (Next.js, Nuxt, etc.) can forward logs to the MCP server:
307+
Adaptive output:
308+
- If multiple projects are active and no `project` specified, returns grouped previews per project with counts and a tip to filter.
355309

356-
```bash
357-
# Set this in your app's environment
358-
export BROWSER_ECHO_MCP_URL=http://127.0.0.1:5179/mcp
359-
```
310+
### `clear_logs` — Clear Frontend Browser Logs
360311

361-
When set, framework handlers automatically forward browser logs to the MCP ingest endpoint.
312+
- Supports `project?: string` to clear only that project’s logs.
313+
- `scope: 'soft' | 'hard'` for baselining vs deletion.
362314

363315
---
364316

365317
## Programmatic API
366318

367-
Start the MCP server programmatically in your Node.js application:
368-
369-
```typescript
319+
```ts
370320
import { startMcpServer, publishLogEntry } from '@browser-echo/mcp';
371321

372-
// Start HTTP MCP server in-process
373-
await startMcpServer({
374-
name: 'My App Logs',
375-
version: '1.0.0',
376-
bufferSize: 2000,
377-
host: '127.0.0.1',
378-
port: 5179,
379-
endpoint: '/mcp',
380-
logsRoute: '/__client-logs'
381-
});
322+
await startMcpServer({ host: '127.0.0.1', port: 5179, endpoint: '/mcp', logsRoute: '/__client-logs' });
382323

383-
// Publish log entries programmatically
384324
publishLogEntry({
385325
sessionId: 'user-123',
386326
level: 'error',
387327
text: 'Failed to fetch user data',
388328
time: Date.now(),
389-
source: 'api.ts:42',
390-
stack: 'Error: Failed to fetch...',
391-
tag: '[api]'
329+
tag: '[api]',
330+
project: 'my-app'
392331
});
393332
```
394333

395-
> **Note:** If `BROWSER_ECHO_MCP_URL` is set, `startMcpServer()` becomes a no-op to avoid duplicate servers.
396-
397334
---
398335

399336
## Environment Variables
400337

401-
Configure the MCP server behavior with these environment variables:
402-
403-
- `BROWSER_ECHO_MCP_URL=http://127.0.0.1:5179/mcp` — MCP server URL for framework forwarding (if set, frameworks bypass auto-discovery)
404-
- `BROWSER_ECHO_BUFFER_SIZE=1000` — Max entries kept in memory (default: `1000`)
405-
- `BROWSER_ECHO_INGEST_PORT=5179` — Force a fixed ingest port in stdio mode (default: 5179)
406-
- `BROWSER_ECHO_SUPPRESS_TERMINAL=1` — Force suppress terminal output when MCP is forwarding logs
407-
- `BROWSER_ECHO_SUPPRESS_TERMINAL=0` — Force show terminal output even when MCP is active
408-
409-
---
410-
411-
## Common Workflows
412-
413-
### Debug Hydration Errors
414-
```
415-
1. User: "Clear logs and let me reproduce the hydration error"
416-
→ clear_logs({ scope: 'soft' })
417-
2. User reproduces the issue in browser
418-
3. User: "Check for hydration errors"
419-
→ get_logs({ level: ['error', 'warn'], contains: 'hydration' })
420-
```
421-
422-
### Monitor Specific Browser Tab
423-
```
424-
1. User: "Show me all active sessions"
425-
→ get_logs() // Look for unique session IDs
426-
2. User: "Focus on session starting with 'a1b2'"
427-
→ get_logs({ session: 'a1b2' })
428-
```
429-
430-
### Fresh Error Capture
431-
```
432-
1. clear_logs({ scope: 'soft' }) // Set baseline
433-
2. Run tests or reproduce issue
434-
3. get_logs({ level: ['error', 'warn'] }) // Only new errors
435-
```
338+
- `BROWSER_ECHO_MCP_URL=http://127.0.0.1:5179/mcp` — Frameworks forward logs here
339+
- `BROWSER_ECHO_PROJECT_NAME` — Label logs by project
340+
- `BROWSER_ECHO_BUFFER_SIZE=1000` — Ring buffer size
436341

437342
---
438343

439344
## Security
440345

441-
**Local Development Defaults:**
442-
- CORS headers are permissive (`Access-Control-Allow-Origin: *`)
443-
- Binds to `127.0.0.1` by default for local-only access
444-
- When exposing over network, add authentication/proxy as needed
346+
- Binds to `127.0.0.1` by default
347+
- CORS permissive for local dev; add auth/proxy if exposing
445348

446349
---
447350

packages/mcp/src/schemas/logs.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ export const GetLogsArgs = {
88
limit: z.number().int().min(1).max(5000).optional().describe('Max number of entries to return'),
99
contains: z.string().optional().describe('Substring filter on entry.text'),
1010
sinceMs: z.number().nonnegative().optional().describe('Only entries with time >= sinceMs'),
11-
project: z.string().optional().describe('Project name to filter logs')
11+
project: z.string().optional().describe('Project name to filter logs'),
12+
autoBaseline: z.boolean().optional().default(true).describe('Automatically baseline after retrieval to show only new logs next time'),
13+
stackedMode: z.boolean().optional().default(false).describe('Disable auto-baseline to accumulate logs across calls')
1214
} satisfies z.ZodRawShape;
1315

1416
export const ClearLogsArgs = {

packages/mcp/src/server.ts

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import { createServer as createNodeServer } from 'node:http';
2-
import { writeFileSync, rmSync, renameSync } from 'node:fs';
3-
import { join as joinPath } from 'node:path';
42
import { randomUUID } from 'node:crypto';
53

64
import { createApp, createRouter, defineEventHandler, getQuery, readRawBody, setResponseStatus, toNodeListener } from 'h3';
@@ -101,6 +99,31 @@ export async function startHttpServer(
10199
store: LogStore,
102100
opts: { host: string; port: number; endpoint: `/${string}`; logsRoute: `/${string}` }
103101
): Promise<void> {
102+
async function tryFetch(url: string, init: any = {}, timeoutMs = 400): Promise<{ ok: boolean; status: number; text?: string }> {
103+
try {
104+
const ctrl = new AbortController();
105+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
106+
const res = await fetch(url, { ...(init || {}), signal: ctrl.signal as any, cache: 'no-store' as any });
107+
clearTimeout(t);
108+
let body: string | undefined;
109+
try { body = await res.text(); } catch {}
110+
return { ok: !!res.ok, status: res.status, text: body };
111+
} catch {
112+
return { ok: false, status: 0 } as any;
113+
}
114+
}
115+
116+
async function isExistingHttpMcp(base: string, endpoint: `/${string}`): Promise<boolean> {
117+
const health = await tryFetch(`${base}/health`);
118+
if (!health.ok) return false;
119+
// Probe MCP endpoint: GET without session should respond 405 with JSON error
120+
const probe = await tryFetch(`${base}${endpoint}`, { method: 'GET' });
121+
if (probe.status === 405 && (probe.text || '').includes('Method not allowed')) return true;
122+
// Some servers may not expose GET; try OPTIONS as a weak signal
123+
const opt = await tryFetch(`${base}${endpoint}`, { method: 'OPTIONS' });
124+
return opt.status === 204 || opt.status === 200;
125+
}
126+
104127
const app = createApp();
105128
const router = createRouter();
106129

@@ -231,11 +254,16 @@ export async function startHttpServer(
231254
} catch (err: any) {
232255
const isAddrInUse = err && (err.code === 'EADDRINUSE' || String(err.message || '').includes('EADDRINUSE'));
233256
if (isAddrInUse) {
257+
const base = `http://${opts.host}:${opts.port}`;
258+
const reuse = await isExistingHttpMcp(base, opts.endpoint);
259+
if (reuse) {
260+
// eslint-disable-next-line no-console
261+
console.error(`Existing MCP server detected at ${base}. Reusing it without starting a new instance.`);
262+
return; // Treat as successful startup (server already available)
263+
}
234264
const errorMsg = [
235-
`Failed to start MCP server: Port ${opts.port} is already in use.`,
236-
`Another instance may be running. Please either:`,
237-
` - Stop the other instance, or`,
238-
` - Use a different port with --port flag`
265+
`Failed to start MCP server: Port ${opts.port} is already in use by a non-MCP service.`,
266+
`Either stop that service or choose a different port with --port.`,
239267
].join('\n');
240268
console.error(errorMsg);
241269
process.exit(1);
@@ -328,6 +356,18 @@ export async function startIngestOnlyServer(
328356
const isAddrInUse = err && (err.code === 'EADDRINUSE' || String(err.message || '').includes('EADDRINUSE'));
329357
if (isAddrInUse) {
330358
const base = `http://${opts.host}:${opts.port}`;
359+
// If an ingest server already responds to /health, reuse it silently
360+
try {
361+
const ctrl = new AbortController();
362+
const t = setTimeout(() => ctrl.abort(), 400);
363+
const res = await fetch(`${base}/health`, { signal: ctrl.signal as any, cache: 'no-store' as any });
364+
clearTimeout(t);
365+
if (res && res.ok) {
366+
// eslint-disable-next-line no-console
367+
console.error(`Ingest server already running at ${base}${opts.logsRoute}. Reusing existing instance.`);
368+
return; // Treat as success
369+
}
370+
} catch {}
331371
// eslint-disable-next-line no-console
332372
console.error(`Failed to start ingest-only server: Port in use at ${base}${opts.logsRoute}`);
333373
}

packages/mcp/src/store.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ export class LogStore {
5151
}
5252
}
5353

54+
/** Set a baseline timestamp without deleting entries. If session omitted, use global baseline. */
55+
baseline(session?: string, when: number = Date.now()) {
56+
const key = session || '__global__';
57+
this.baselineTimestamps.set(key, when);
58+
}
59+
5460
toText(session?: string): string {
5561
return this.snapshot(session).map((e) => {
5662
const sid = (e.sessionId || 'anon').slice(0, 8);

packages/mcp/src/tools/getLogs.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ export function registerGetLogsTool(ctx: McpToolContext) {
1919
limit = 1000,
2020
contains,
2121
sinceMs,
22-
project
22+
project,
23+
autoBaseline = true,
24+
stackedMode = false
2325
} = safeArgs as typeof GetLogsSchema['_output'];
2426

2527
const validSession = validateSessionId(session);
@@ -109,6 +111,15 @@ export function registerGetLogsTool(ctx: McpToolContext) {
109111
}).join('\n');
110112
}
111113

114+
// Auto-baseline unless stackedMode
115+
try {
116+
if (autoBaseline && !stackedMode) {
117+
// baseline per project if selected; else by session or global
118+
const baselineSession = validSession;
119+
store.baseline(baselineSession);
120+
}
121+
} catch {}
122+
112123
return {
113124
content: [
114125
{ type: 'text' as const, text }

packages/next/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,9 +274,9 @@ This package depends on [@browser-echo/core](https://github.com/instructa/browse
274274
## License
275275

276276
MIT
277-
278277
## Links
279278

280279
- [Main Repository](https://github.com/instructa/browser-echo)
281280
- [Documentation](https://github.com/instructa/browser-echo#readme)
282281
- [Core Package](https://github.com/instructa/browser-echo/tree/main/packages/core)
282+

packages/next/src/route.ts

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,35 +10,40 @@ type Payload = { sessionId?: string; entries: Entry[] };
1010
export const runtime = 'nodejs';
1111
export const dynamic = 'force-dynamic';
1212

13+
// Module-scope: suppress only after first confirmed forward (any 2xx)
14+
let __hasForwardedOnce = false;
15+
1316
export async function POST(req: NextRequest) {
1417
let payload: Payload | null = null;
1518
try { payload = (await req.json()) as Payload; }
1619
catch { return new NextResponse('invalid JSON', { status: 400 }); }
1720
if (!payload || !Array.isArray(payload.entries)) return new NextResponse('invalid payload', { status: 400 });
1821

19-
// Fixed resolution (single-server): env or default localhost:5179
20-
const mcp = { url: (process.env.BROWSER_ECHO_MCP_URL || 'http://127.0.0.1:5179').replace(/\/$/, ''), routeLogs: '/__client-logs' } as const;
22+
// Fixed resolution (single-server): env or default localhost:5179 (strip optional /mcp suffix)
23+
const baseUrl = (process.env.BROWSER_ECHO_MCP_URL || 'http://127.0.0.1:5179').replace(/\/$/, '').replace(/\/mcp$/i, '');
24+
const mcp = { url: baseUrl, routeLogs: '/__client-logs' } as const;
25+
// No background probes; only flip after a successful POST
2126

22-
// Forward to MCP server if available (fire-and-forget)
23-
if (mcp.url) {
24-
try {
25-
const route = (mcp.routeLogs as `/${string}`) || '/__client-logs';
26-
const headers: Record<string,string> = { 'content-type': 'application/json' };
27-
// Derive project name from env or package name
28-
const projectName = (process.env.BROWSER_ECHO_PROJECT_NAME || (process.env.npm_package_name || '')).trim();
29-
if (projectName) headers['X-Browser-Echo-Project-Name'] = projectName;
30-
fetch(`${mcp.url}${route}`, {
31-
method: 'POST',
32-
headers,
33-
body: JSON.stringify(payload),
34-
keepalive: true,
35-
cache: 'no-store',
36-
}).catch(() => void 0);
37-
} catch {}
38-
}
27+
// Forward to MCP server (fire-and-forget) and update connection state
28+
try {
29+
const route = (mcp.routeLogs as `/${string}`) || '/__client-logs';
30+
const headers: Record<string,string> = { 'content-type': 'application/json' };
31+
const projectName = (process.env.BROWSER_ECHO_PROJECT_NAME || (process.env.npm_package_name || '')).trim();
32+
if (projectName) headers['X-Browser-Echo-Project-Name'] = projectName;
33+
const ctrl = new AbortController();
34+
const timeout = setTimeout(() => ctrl.abort(), 500);
35+
fetch(`${mcp.url}${route}`, {
36+
method: 'POST',
37+
headers,
38+
body: JSON.stringify(payload),
39+
keepalive: true,
40+
cache: 'no-store',
41+
signal: ctrl.signal as any,
42+
}).then((res) => { try { clearTimeout(timeout); } catch {} if (res && res.ok) __hasForwardedOnce = true; }).catch(() => { try { clearTimeout(timeout); } catch {} });
43+
} catch {}
3944

40-
// Dynamically decide whether to print to terminal
41-
const shouldPrint = !mcp.url;
45+
// Print locally until first confirmed forward; then suppress
46+
const shouldPrint = !__hasForwardedOnce;
4247

4348
const sid = (payload.sessionId ?? 'anon').slice(0, 8);
4449
for (const entry of payload.entries) {

0 commit comments

Comments
 (0)