Skip to content

Commit ccf5f9d

Browse files
committed
chore: remove .browser-echo-mcp.json file, update README files for MCP discovery behavior, and enhance server handling for dynamic MCP resolution
1 parent 94f332d commit ccf5f9d

14 files changed

Lines changed: 602 additions & 292 deletions

File tree

.browser-echo-mcp.json

Lines changed: 0 additions & 1 deletion
This file was deleted.

packages/mcp/README.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,8 @@ Best for local development with AI assistants:
288288

289289
In stdio mode:
290290
- MCP communication happens over **stdio** (no HTTP MCP endpoint)
291-
- An **HTTP ingest server** runs on an ephemeral port (127.0.0.1) for browsers to POST logs
292-
- The actual URL is written to `.browser-echo-mcp.json` in your project root and OS tmpdir
291+
- An **HTTP ingest server** runs on a local port (127.0.0.1) for browsers to POST logs
292+
- The actual URL is written to `.browser-echo-mcp.json` in your project root
293293
- Console output (stderr): `MCP (stdio) listening on stdio (ingest HTTP active)`
294294

295295
### HTTP Mode
@@ -398,10 +398,8 @@ publishLogEntry({
398398

399399
- `BROWSER_ECHO_BUFFER_SIZE` — Max entries in memory (default: `1000`)
400400
- `BROWSER_ECHO_MCP_URL` — MCP server URL for framework forwarding (if set, frameworks bypass discovery)
401-
- `BROWSER_ECHO_INGEST_PORT` — Force a fixed ingest port in stdio mode (default: ephemeral)
402-
- `BROWSER_ECHO_ALLOW_TMP_DISCOVERY=1` — Opt-in to writing tmp discovery file; a token is generated and enforced via `x-be-token`
403-
- `BROWSER_ECHO_PROJECT_ROOT=/abs/path` — Explicit project root to embed in discovery metadata for correct scoping
404-
- `BROWSER_ECHO_ALLOW_PORT_SCAN=1` — Opt-in for the Vite plugin to use port scanning when discovery is unavailable
401+
- `BROWSER_ECHO_INGEST_PORT` — Force a fixed ingest port in stdio mode (default: 5179 preferred)
402+
<!-- Tmp discovery and multi-port scanning removed from defaults -->
405403

406404
---
407405

packages/mcp/src/server.ts

Lines changed: 6 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -324,52 +324,21 @@ export async function startIngestOnlyServer(
324324
console.error(`Log ingest endpoint → http://${opts.host}:${actualPort}${opts.logsRoute}`);
325325
}
326326

327-
async function advertiseDiscovery(host: string, port: number, logsRoute: `/${string}`, meta?: { projectRoot?: string; token?: string; scope?: 'http' | 'stdio'; aggregator?: boolean }) {
327+
async function advertiseDiscovery(host: string, port: number, logsRoute: `/${string}`, _meta?: { projectRoot?: string; token?: string; scope?: 'http' | 'stdio'; aggregator?: boolean }) {
328328
try {
329-
const { writeFileSync, chmodSync } = await import('node:fs');
329+
const { writeFileSync } = await import('node:fs');
330330
const { join } = await import('node:path');
331-
const { tmpdir } = await import('node:os');
332331

333332
const baseUrl = `http://${host}:${port}`;
334-
const allowTmp = process.env.BROWSER_ECHO_ALLOW_TMP_DISCOVERY === '1';
335-
const token = allowTmp ? (process.env.BROWSER_ECHO_DISCOVERY_TOKEN || randomUUID()) : undefined;
336-
if (allowTmp) {
337-
try { process.env.BROWSER_ECHO_DISCOVERY_TOKEN = token as string; } catch {}
338-
try { process.env.BROWSER_ECHO_REQUIRE_TOKEN = process.env.BROWSER_ECHO_REQUIRE_TOKEN || '1'; } catch {}
339-
}
340-
const payloadLocal = JSON.stringify({
341-
url: baseUrl,
342-
routeLogs: logsRoute,
343-
timestamp: Date.now(),
344-
pid: typeof process !== 'undefined' ? process.pid : undefined,
345-
projectRoot: meta?.projectRoot || process.cwd()
346-
});
347-
const payloadTmp = JSON.stringify({
333+
const payload = JSON.stringify({
348334
url: baseUrl,
349335
routeLogs: logsRoute,
350336
timestamp: Date.now(),
351-
pid: typeof process !== 'undefined' ? process.pid : undefined,
352-
projectRoot: meta?.projectRoot || process.cwd(),
353-
token
337+
pid: typeof process !== 'undefined' ? process.pid : undefined
354338
});
355339

356-
let files: string[] = [];
357-
if (meta?.scope === 'http') {
358-
// HTTP transport: write project-local; mirror to tmp only when explicitly enabled
359-
files = [ join(process.cwd(), '.browser-echo-mcp.json') ];
360-
if (allowTmp) files.push(join(tmpdir(), 'browser-echo-mcp.json'));
361-
} else {
362-
// STDIO transport writes only to project-local by default; optionally mirror to tmp when explicitly enabled
363-
files = [ join(process.cwd(), '.browser-echo-mcp.json') ];
364-
if (allowTmp) files.push(join(tmpdir(), 'browser-echo-mcp.json'));
365-
}
366-
367-
for (const f of files) {
368-
const isTmp = f === join(tmpdir(), 'browser-echo-mcp.json');
369-
try { writeFileSync(f, isTmp ? payloadTmp : payloadLocal); } catch {}
370-
// Restrict permissions when writing token-bearing tmp file
371-
if (isTmp) { try { chmodSync(f, 0o600); } catch {} }
372-
}
340+
const file = join(process.cwd(), '.browser-echo-mcp.json');
341+
try { writeFileSync(file, payload); } catch {}
373342
} catch {
374343
// best-effort only
375344
}
@@ -393,30 +362,6 @@ function createLogIngestRoutes(store: LogStore, logsRoute: `/${string}`) {
393362
// Log ingest (POST)
394363
router.post(logsRoute, defineEventHandler(async (event) => {
395364
try {
396-
// Optional token check if discovery provided one (best-effort; disabled by default in dev)
397-
try {
398-
const { readFileSync, existsSync } = await import('node:fs');
399-
const { join } = await import('node:path');
400-
const { tmpdir } = await import('node:os');
401-
const candidates = [join(process.cwd(), '.browser-echo-mcp.json'), join(tmpdir(), 'browser-echo-mcp.json')];
402-
let requiredToken = '';
403-
for (const p of candidates) {
404-
try {
405-
if (!existsSync(p)) continue;
406-
const raw = readFileSync(p, 'utf-8');
407-
const data = JSON.parse(raw);
408-
if (data?.token) { requiredToken = String(data.token); break; }
409-
} catch {}
410-
}
411-
if (requiredToken && process.env.BROWSER_ECHO_REQUIRE_TOKEN === '1') {
412-
const got = String((event.node.req.headers['x-be-token'] as any) || '').trim();
413-
if (!got || got !== requiredToken) {
414-
setResponseStatus(event, 401);
415-
return 'unauthorized';
416-
}
417-
}
418-
} catch {}
419-
420365
const raw = await readRawBody(event);
421366
const payload = typeof raw === 'string' ? JSON.parse(raw) : (raw ? JSON.parse(Buffer.from(raw as any).toString('utf-8')) : undefined);
422367
if (!payload || !Array.isArray(payload.entries)) {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2+
import { fileURLToPath } from 'node:url';
3+
import { dirname, join } from 'node:path';
4+
import { existsSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
5+
import { tmpdir } from 'node:os';
6+
import { McpTestClient } from './utils/mcpTestClient';
7+
8+
const __filename = fileURLToPath(import.meta.url);
9+
const __dirname = dirname(__filename);
10+
11+
describe('@browser-echo/mcp stdio prefers 5179 and writes discovery to cwd', () => {
12+
let client: McpTestClient;
13+
let workDir: string;
14+
15+
beforeAll(async () => {
16+
const cli = join(__dirname, '..', 'bin', 'cli.mjs');
17+
workDir = mkdtempSync(join(tmpdir(), 'be-mcp-port-'));
18+
client = new McpTestClient({ cliEntryPoint: cli, env: { BROWSER_ECHO_INGEST_PORT: '5179' }, cwd: workDir });
19+
await client.connect();
20+
}, 30_000);
21+
22+
afterAll(async () => {
23+
await client?.close();
24+
try { rmSync(workDir, { recursive: true, force: true }); } catch {}
25+
});
26+
27+
it('writes .browser-echo-mcp.json only to project root (cwd) with preferred port (5179 if free or fallback ephemeral)', async () => {
28+
// allow up to 2s for discovery file to appear
29+
const disc = join(workDir, '.browser-echo-mcp.json');
30+
const start = Date.now();
31+
while (!existsSync(disc) && (Date.now() - start) < 2000) {
32+
await new Promise(r => setTimeout(r, 50));
33+
}
34+
expect(existsSync(disc)).toBe(true);
35+
const raw = readFileSync(disc, 'utf-8');
36+
const data = JSON.parse(raw);
37+
expect(String(data.url)).toMatch(/^http:\/\/127\.0\.0\.1:(\d{2,5})$/);
38+
// Should not write legacy tmp discovery
39+
const tmpDisc = join(tmpdir(), 'browser-echo-mcp.json');
40+
expect(existsSync(tmpDisc)).toBe(false);
41+
});
42+
});
43+
44+

packages/next/README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,9 +195,14 @@ Next.js automatically discovers and forwards logs to MCP servers. No configurati
195195

196196
The Next.js route handler automatically detects MCP servers and forwards logs when available. When MCP is detected, terminal output is suppressed by default.
197197

198+
Discovery order:
199+
1. `BROWSER_ECHO_MCP_URL` (normalized, trailing `/mcp` is stripped)
200+
2. Dev probe: `http://127.0.0.1:5179` then `http://localhost:5179`
201+
3. Project-local discovery file: `.browser-echo-mcp.json` (walks up parent directories)
202+
198203
### Environment Variables
199204

200-
- `BROWSER_ECHO_MCP_URL=http://127.0.0.1:5179/mcp` — Set MCP server URL
205+
- `BROWSER_ECHO_MCP_URL=http://127.0.0.1:5179/mcp` — Set MCP server URL (base URL is derived automatically)
201206
- `BROWSER_ECHO_SUPPRESS_TERMINAL=1` — Force suppress terminal output
202207
- `BROWSER_ECHO_SUPPRESS_TERMINAL=0` — Force show terminal output even when MCP is active
203208

packages/next/src/route.ts

Lines changed: 50 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import type { NextRequest } from 'next/server';
22
import { NextResponse } from 'next/server';
33

4-
const MCP_URL = (process.env.BROWSER_ECHO_MCP_URL || '').replace(/\/$/, '');
4+
const MCP_URL = (process.env.BROWSER_ECHO_MCP_URL || '').replace(/\/$/, '').replace(/\/mcp$/i, '');
55
const MCP_LOGS_ROUTE = process.env.BROWSER_ECHO_MCP_LOGS_ROUTE || '/__client-logs';
6-
const SUPPRESS_TERMINAL = MCP_URL && process.env.BROWSER_ECHO_SUPPRESS_TERMINAL !== '0';
76

87
export type BrowserLogLevel = 'log' | 'info' | 'warn' | 'error' | 'debug';
98
type Entry = { level: BrowserLogLevel | string; text: string; time?: number; stack?: string; source?: string; };
@@ -18,15 +17,31 @@ export async function POST(req: NextRequest) {
1817
catch { return new NextResponse('invalid JSON', { status: 400 }); }
1918
if (!payload || !Array.isArray(payload.entries)) return new NextResponse('invalid payload', { status: 400 });
2019

21-
// Resolve MCP URL: env var has priority, otherwise discover in development
22-
const mcp = MCP_URL ? { url: MCP_URL, token: '' } : (process.env.NODE_ENV === 'development' ? await __resolveMcpUrl() : { url: '', token: '' });
20+
// Resolve MCP URL: env (health-checked) → port 5179 (dev) → local discovery file (dev)
21+
let mcp = { url: '', token: '', routeLogs: '' as `/${string}` | '' } as { url: string; token?: string; routeLogs?: `/${string}` };
22+
if (MCP_URL) {
23+
if (await __pingHealth(`${MCP_URL}/health`, 300)) {
24+
mcp = { url: MCP_URL };
25+
}
26+
}
27+
if (!mcp.url && process.env.NODE_ENV === 'development') {
28+
for (const base of ['http://127.0.0.1:5179', 'http://localhost:5179']) {
29+
if (await __pingHealth(`${base}/health`, 300)) { mcp = { url: base }; break; }
30+
}
31+
}
32+
if (!mcp.url && process.env.NODE_ENV === 'development') {
33+
mcp = await __resolveMcpUrl();
34+
}
2335

2436
// Forward to MCP server if available (fire-and-forget)
2537
if (mcp.url) {
2638
try {
27-
fetch(`${mcp.url}${MCP_LOGS_ROUTE}`, {
39+
const route = (MCP_LOGS_ROUTE as `/${string}`) || (mcp.routeLogs as `/${string}`) || '/__client-logs';
40+
const headers: Record<string,string> = { 'content-type': 'application/json' };
41+
if (mcp.token) headers['x-be-token'] = mcp.token;
42+
fetch(`${mcp.url}${route}`, {
2843
method: 'POST',
29-
headers: { 'content-type': 'application/json' },
44+
headers,
3045
body: JSON.stringify(payload),
3146
keepalive: true,
3247
cache: 'no-store',
@@ -35,7 +50,10 @@ export async function POST(req: NextRequest) {
3550
}
3651

3752
// Dynamically decide whether to print to terminal
38-
const shouldPrint = !(mcp.url && process.env.BROWSER_ECHO_SUPPRESS_TERMINAL !== '0');
53+
const envVal = process.env.BROWSER_ECHO_SUPPRESS_TERMINAL;
54+
const forceSuppress = envVal === '1';
55+
const forcePrint = envVal === '0';
56+
const shouldPrint = forcePrint ? true : (forceSuppress ? false : !mcp.url);
3957

4058
const sid = (payload.sessionId ?? 'anon').slice(0, 8);
4159
for (const entry of payload.entries) {
@@ -75,72 +93,50 @@ function color(level: BrowserLogLevel, msg: string) {
7593
}
7694
function dim(s: string) { return c.dim + s + c.reset; }
7795

78-
let __mcpDiscoveryCache: { url: string; token?: string; ts: number } | null = null;
96+
let __mcpDiscoveryCache: { url: string; token?: string; routeLogs?: `/${string}`; ts: number } | null = null;
7997

80-
async function __resolveMcpUrl(): Promise<{ url: string; token?: string }> {
81-
// 1) Env var already handled by caller; only discover in dev here.
98+
async function __resolveMcpUrl(): Promise<{ url: string; token?: string; routeLogs?: `/${string}` }> {
8299
const now = Date.now();
83-
const CACHE_TTL_MS = 30_000;
100+
const CACHE_TTL_MS = 10_000;
84101

85-
// Use fresh cache if present
86102
if (__mcpDiscoveryCache && (now - __mcpDiscoveryCache.ts) < CACHE_TTL_MS) {
87-
return { url: __mcpDiscoveryCache.url, token: __mcpDiscoveryCache.token };
103+
return { url: __mcpDiscoveryCache.url, token: __mcpDiscoveryCache.token, routeLogs: __mcpDiscoveryCache.routeLogs };
88104
}
89105

90-
// 2) Discovery file (project root or OS tmp)
91106
const fromFile = await __readDiscoveryFromFile();
92107
if (fromFile.url) {
93-
// health check to ensure it's alive
94108
if (await __pingHealth(`${fromFile.url}/health`, 300)) {
95-
__mcpDiscoveryCache = { url: fromFile.url, token: fromFile.token, ts: now };
109+
__mcpDiscoveryCache = { url: fromFile.url, token: fromFile.token, routeLogs: fromFile.routeLogs, ts: now };
96110
return fromFile;
97111
}
98-
// purge stale tmp discovery
99-
try {
100-
const { unlinkSync, existsSync } = await import('node:fs');
101-
const { join } = await import('node:path');
102-
const { tmpdir } = await import('node:os');
103-
const stale = join(tmpdir(), 'browser-echo-mcp.json');
104-
if (existsSync(stale)) unlinkSync(stale);
105-
} catch {}
106-
}
107-
108-
// 3) Port scan common local ports
109-
const ports = [5179, 5178, 3001, 4000, 5173];
110-
for (const port of ports) {
111-
const bases = [`http://127.0.0.1:${port}`, `http://localhost:${port}`];
112-
for (const base of bases) {
113-
if (await __pingHealth(`${base}/health`, 400)) {
114-
__mcpDiscoveryCache = { url: base, ts: now };
115-
return { url: base };
116-
}
117-
}
118112
}
119113

120114
__mcpDiscoveryCache = { url: '', ts: now };
121115
return { url: '' };
122116
}
123117

124-
async function __readDiscoveryFromFile(): Promise<{ url: string; token?: string }> {
118+
async function __readDiscoveryFromFile(): Promise<{ url: string; token?: string; routeLogs?: `/${string}` }> {
125119
try {
126120
const { readFileSync, existsSync } = await import('node:fs');
127-
const { join } = await import('node:path');
128-
const { tmpdir } = await import('node:os');
129-
const candidates = [
130-
join(process.cwd(), '.browser-echo-mcp.json'),
131-
join(tmpdir(), 'browser-echo-mcp.json')
132-
];
133-
for (const p of candidates) {
134-
try {
135-
if (!existsSync(p)) continue;
136-
const raw = readFileSync(p, 'utf-8');
137-
const data = JSON.parse(raw);
138-
const url = (data?.url ? String(data.url) : '').replace(/\/$/, '');
139-
const ts = typeof data?.timestamp === 'number' ? data.timestamp : 0;
140-
const token = data?.token ? String(data.token) : undefined;
141-
// Treat as fresh if updated within the last 60s
142-
if (url && (Date.now() - ts) < 60_000) return { url, token };
143-
} catch {}
121+
const { join, dirname } = await import('node:path');
122+
let dir = process.cwd();
123+
const root = dirname('/');
124+
while (true) {
125+
const p = join(dir, '.browser-echo-mcp.json');
126+
if (existsSync(p)) {
127+
try {
128+
const raw = readFileSync(p, 'utf-8');
129+
const data = JSON.parse(raw);
130+
const url = (data?.url ? String(data.url) : '').replace(/\/$/, '');
131+
const ts = typeof data?.timestamp === 'number' ? data.timestamp : 0;
132+
const token = data?.token ? String(data.token) : undefined;
133+
const routeLogs = data?.routeLogs ? String(data.routeLogs) as `/${string}` : undefined;
134+
if (url && (Date.now() - ts) < 60_000) return { url, token, routeLogs };
135+
} catch {}
136+
}
137+
const parent = dirname(dir);
138+
if (parent === dir || parent === root) break;
139+
dir = parent;
144140
}
145141
} catch {}
146142
return { url: '' };

0 commit comments

Comments
 (0)