Skip to content

Commit 2e78725

Browse files
committed
feat: enhance MCP server debugging and ingestion handling
- Added environment variable support for enabling debug logging in MCP server. - Improved logging of MCP server startup context and environment variables for better traceability. - Updated ingestion handling in Next.js and Nuxt.js to validate ACK responses, ensuring correct project and dev server identification. - Refactored terminal output suppression logic based on remote ingest availability and explicit configuration settings.
1 parent 091100c commit 2e78725

6 files changed

Lines changed: 359 additions & 170 deletions

File tree

.cursor/mcp.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
"mcpServers": {
33
"browser-echo-local": {
44
"command": "node",
5-
"args": ["packages/mcp/bin/cli.mjs"]
5+
"args": ["packages/mcp/bin/cli.mjs"],
6+
"env": {
7+
"BROWSER_ECHO_DEBUG": "1"
8+
}
69
}
710
}
811
}

example/vue-vite-app/vite.config.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@ import browserEcho from '@browser-echo/vite'
66
export default defineConfig({
77
plugins: [vue(), browserEcho(
88
{
9-
stackMode: 'condensed',
10-
network: { enabled: true },
11-
mcp: { url: 'http://127.0.0.1:5179', suppressTerminal: true }
9+
stackMode: 'condensed'
1210
},
1311
)],
1412
})

packages/mcp/src/server.ts

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,39 @@ export async function startServer(
4747
server: McpServer,
4848
options: StartOptions,
4949
): Promise<void> {
50+
// Debug context: where and how this MCP server is started
51+
const __debugEnabled = (() => {
52+
try {
53+
const v = String(process.env.BROWSER_ECHO_DEBUG ?? '').trim().toLowerCase();
54+
return v !== '' && v !== '0' && v !== 'false';
55+
} catch { return false; }
56+
})();
57+
58+
if (__debugEnabled) {
59+
try {
60+
const envSnapshot: Record<string, string> = {};
61+
for (const k of Object.keys(process.env)) {
62+
if (k.startsWith('BROWSER_ECHO_') || k === 'NODE_ENV') {
63+
const val = process.env[k];
64+
envSnapshot[k] = typeof val === 'string' ? val : '';
65+
}
66+
}
67+
const transport = options.type;
68+
const host = transport === 'http' ? options.host : (options.host || '127.0.0.1');
69+
const port = transport === 'http'
70+
? (options as any).port
71+
: (() => {
72+
const envIngest = process.env.BROWSER_ECHO_INGEST_PORT;
73+
const preferred = ((options as any).port ?? (envIngest ? (Number(envIngest) | 0) : 5179)) | 0;
74+
return preferred > 0 ? preferred : 5179;
75+
})();
76+
const route = transport === 'http' ? (options as any).logsRoute : ((options as any).logsRoute || '/__client-logs');
77+
// eslint-disable-next-line no-console
78+
console.error(`[MCP debug] pid=${process.pid} cwd=${process.cwd()} transport=${transport} host=${host} port=${port} route=${route}`);
79+
// eslint-disable-next-line no-console
80+
console.error(`[MCP debug] env: ${JSON.stringify(envSnapshot)}`);
81+
} catch {}
82+
}
5083
// Create store
5184
const bufferMax = Number(process.env.BROWSER_ECHO_BUFFER_SIZE ?? 1000) | 0;
5285
const store = new LogStore(bufferMax > 0 ? bufferMax : 1000);
@@ -273,19 +306,10 @@ export async function startHttpServer(
273306
throw err;
274307
}
275308

276-
// For Streamable HTTP, we intentionally do not write project JSON here. The per-project
277-
// source of truth is written by the stdio ingest server only.
278-
279309
// eslint-disable-next-line no-console
280310
console.log(`MCP (Streamable HTTP) listening → http://${opts.host}:${opts.port}${opts.endpoint}`);
281311
// eslint-disable-next-line no-console
282312
console.log(`Log ingest endpoint → http://${opts.host}:${opts.port}${opts.logsRoute}`);
283-
// Expose ingest discovery for other MCP instances in the same process tree
284-
try {
285-
process.env.BROWSER_ECHO_INGEST_BASE = `http://${opts.host}:${opts.port}`;
286-
process.env.BROWSER_ECHO_LOGS_ROUTE = String(opts.logsRoute);
287-
process.env.BROWSER_ECHO_INGEST_OWNER = '1';
288-
} catch {}
289313
}
290314

291315
/** Start a minimal HTTP server exposing ONLY:
@@ -390,18 +414,6 @@ export async function startIngestOnlyServer(
390414

391415
// eslint-disable-next-line no-console
392416
console.error(`Log ingest endpoint → http://${opts.host}:${actualPort}${opts.logsRoute}`);
393-
// Write project-local discovery file for frameworks/tools to find ingest
394-
try {
395-
const discPath = join(process.cwd(), '.browser-echo-mcp.json');
396-
const payload = { url: `http://${opts.host}:${actualPort}`, route: String(opts.logsRoute), timestamp: Date.now() };
397-
writeFileSync(discPath, JSON.stringify(payload));
398-
} catch {}
399-
// Expose discovery for owner instance
400-
try {
401-
process.env.BROWSER_ECHO_INGEST_BASE = `http://${opts.host}:${actualPort}`;
402-
process.env.BROWSER_ECHO_LOGS_ROUTE = String(opts.logsRoute);
403-
process.env.BROWSER_ECHO_INGEST_OWNER = '1';
404-
} catch {}
405417
}
406418

407419
// Removed project JSON discovery in single-server mode
@@ -424,6 +436,15 @@ function createLogIngestRoutes(store: LogStore, logsRoute: `/${string}`) {
424436
// Log ingest (POST)
425437
router.post(logsRoute, defineEventHandler(async (event) => {
426438
try {
439+
const __dbg = String(process.env.BROWSER_ECHO_DEBUG || '').trim().toLowerCase();
440+
const __debug = __dbg !== '' && __dbg !== '0' && __dbg !== 'false';
441+
if (__debug) {
442+
try {
443+
const hdrs = event.node.req.headers || {} as any;
444+
// eslint-disable-next-line no-console
445+
console.error('[MCP ingest debug] headers:', JSON.stringify(hdrs));
446+
} catch {}
447+
}
427448
const raw = await readRawBody(event);
428449
const payload = typeof raw === 'string' ? JSON.parse(raw) : (raw ? JSON.parse(Buffer.from(raw as any).toString('utf-8')) : undefined);
429450
if (!payload || !Array.isArray(payload.entries)) {
@@ -435,10 +456,20 @@ function createLogIngestRoutes(store: LogStore, logsRoute: `/${string}`) {
435456
const hdrs = event.node.req.headers;
436457
const projectHeader = (hdrs['x-browser-echo-project-name'] || hdrs['x-project-name'] || hdrs['x-project'] || '') as string | string[] | undefined;
437458
const projectName = Array.isArray(projectHeader) ? String(projectHeader[0] || '') : String(projectHeader || '');
459+
// Extract dev id for ACK handshake
460+
const devIdHeader = (hdrs['x-browser-echo-dev-id'] || hdrs['x-dev-id'] || '') as string | string[] | undefined;
461+
const devId = Array.isArray(devIdHeader) ? String(devIdHeader[0] || '') : String(devIdHeader || '');
462+
if (__debug) {
463+
try {
464+
// eslint-disable-next-line no-console
465+
console.error(`[MCP ingest debug] session=${sid} project=${projectName} devId=${devId} entries=${payload.entries.length}`);
466+
} catch {}
467+
}
438468
// Special command: remote clear request
439469
const isClear = payload.entries.length === 1 && String(payload.entries[0]?.text || '') === '__BROWSER_ECHO_CLEAR__';
440470
if (isClear) {
441471
store.clear({ session: sid ? String(sid).slice(0,8) : undefined, scope: 'hard', project: projectName || undefined });
472+
try { event.node.res.setHeader('X-Browser-Echo-Ack', `project=${projectName || ''};devId=${devId || ''}`); } catch {}
442473
setResponseStatus(event, 204);
443474
return '';
444475
}
@@ -455,6 +486,10 @@ function createLogIngestRoutes(store: LogStore, logsRoute: `/${string}`) {
455486
project: projectName ? projectName : undefined
456487
});
457488
}
489+
// Mark this instance as the ingest owner so clients can distinguish our server from stray ones
490+
try { event.node.res.setHeader('X-Browser-Echo-Ack', `project=${projectName || ''};devId=${devId || ''};owner=1`); } catch {}
491+
// Mark this instance as the ingest owner so clients can distinguish our server from stray ones
492+
try { event.node.res.setHeader('X-Browser-Echo-Ack', `project=${projectName || ''};devId=${devId || ''};owner=1`); } catch {}
458493
setResponseStatus(event, 204);
459494
return '';
460495
} catch {

packages/next/src/route.ts

Lines changed: 60 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,7 @@ import type { NextRequest } from 'next/server';
22
import { NextResponse } from 'next/server';
33

44
const MCP_BASE = (process.env.BROWSER_ECHO_MCP_URL || 'http://127.0.0.1:5179').replace(/\/$/, '').replace(/\/mcp$/i, '');
5-
let __probeStarted = false;
6-
async function __probeHealthOnce(): Promise<boolean> {
7-
try {
8-
const ctrl = new AbortController();
9-
const t = setTimeout(() => ctrl.abort(), 400);
10-
const res = await fetch(`${MCP_BASE}/health`, { signal: ctrl.signal as any, cache: 'no-store' as any });
11-
clearTimeout(t);
12-
return !!res && res.ok;
13-
} catch { return false; }
14-
}
15-
function __startHealthProbe() {
16-
if (__probeStarted) return;
17-
__probeStarted = true;
18-
setInterval(async () => {
19-
if (__hasForwardedOnce) return;
20-
const ok = await __probeHealthOnce();
21-
if (ok) __hasForwardedOnce = true;
22-
}, 1500);
23-
}
24-
__startHealthProbe();
25-
26-
// Simplified: fixed single-server URL or env override
5+
// Fixed single-server URL or env override; suppression relies on ACK, not health probes
276

287
export type BrowserLogLevel = 'log' | 'info' | 'warn' | 'error' | 'debug';
298
type Entry = { level: BrowserLogLevel | string; text: string; time?: number; stack?: string; source?: string; };
@@ -32,8 +11,22 @@ type Payload = { sessionId?: string; entries: Entry[] };
3211
export const runtime = 'nodejs';
3312
export const dynamic = 'force-dynamic';
3413

35-
// Module-scope: suppress only after first confirmed forward (any 2xx)
36-
let __hasForwardedOnce = false;
14+
// Module-scope: track MCP availability via ACK; unsuppress on failures
15+
let __isRemoteAvailable = false;
16+
let __nextDevInstanceId: string | null = null;
17+
function getOrCreateDevId(): string {
18+
if (__nextDevInstanceId) return __nextDevInstanceId;
19+
try {
20+
// @ts-ignore: webcrypto may exist in Node 20+
21+
const wc = (globalThis as any).crypto?.getRandomValues ? (globalThis as any).crypto : require('node:crypto').webcrypto;
22+
const a = new Uint8Array(8);
23+
wc.getRandomValues(a);
24+
__nextDevInstanceId = Array.from(a).map((b) => b.toString(16).padStart(2,'0')).join('');
25+
} catch {
26+
__nextDevInstanceId = String(Math.random()).slice(2, 10);
27+
}
28+
return __nextDevInstanceId;
29+
}
3730

3831
export async function POST(req: NextRequest) {
3932
let payload: Payload | null = null;
@@ -44,26 +37,65 @@ export async function POST(req: NextRequest) {
4437
const baseUrl = MCP_BASE;
4538
const mcp = { url: baseUrl, routeLogs: '/__client-logs' } as const;
4639

47-
// Forward to MCP server (fire-and-forget) and update connection state
40+
// Forward to MCP server (fire-and-forget) and update availability based on ACK
4841
try {
4942
const route = (mcp.routeLogs as `/${string}`) || '/__client-logs';
5043
const headers: Record<string,string> = { 'content-type': 'application/json' };
5144
const projectName = (process.env.BROWSER_ECHO_PROJECT_NAME || (process.env.npm_package_name || '')).trim();
5245
if (projectName) headers['X-Browser-Echo-Project-Name'] = projectName;
46+
const devId = getOrCreateDevId();
47+
headers['X-Browser-Echo-Dev-Id'] = devId;
48+
5349
const ctrl = new AbortController();
5450
const timeout = setTimeout(() => ctrl.abort(), 500);
51+
5552
fetch(`${mcp.url}${route}`, {
5653
method: 'POST',
5754
headers,
5855
body: JSON.stringify(payload),
5956
keepalive: true,
6057
cache: 'no-store',
6158
signal: ctrl.signal as any,
62-
}).then((res) => { try { clearTimeout(timeout); } catch {} if (res && res.ok) __hasForwardedOnce = true; }).catch(() => { try { clearTimeout(timeout); } catch {} });
59+
}).then((res) => {
60+
try { clearTimeout(timeout); } catch {}
61+
const ok = !!res && res.ok;
62+
let ackOk = false;
63+
try {
64+
const ack = (res as any).headers?.get?.('X-Browser-Echo-Ack') || '';
65+
if (ack) {
66+
const mProject = /project=([^;]*)/i.exec(ack);
67+
const mDev = /devId=([^;]*)/i.exec(ack);
68+
const mOwner = /owner=([^;]*)/i.exec(ack);
69+
const aProject = mProject ? decodeURIComponent(mProject[1]) : '';
70+
const aDev = mDev ? decodeURIComponent(mDev[1]) : '';
71+
const isOwner = (mOwner ? String(mOwner[1]) : '') === '1';
72+
ackOk = (!projectName || aProject === projectName) && aDev === devId && isOwner;
73+
}
74+
} catch {}
75+
if (ok && ackOk) {
76+
__isRemoteAvailable = true;
77+
} else {
78+
__isRemoteAvailable = false;
79+
}
80+
}).catch(() => {
81+
try { clearTimeout(timeout); } catch {}
82+
__isRemoteAvailable = false;
83+
});
6384
} catch {}
6485

65-
// Print locally until first confirmed forward; then suppress
66-
const shouldPrint = !__hasForwardedOnce;
86+
// Terminal printing policy for Next route:
87+
// - Default: ALWAYS print locally
88+
// - If BROWSER_ECHO_SUPPRESS_TERMINAL=1|true, suppress when remote ingest is confirmed available
89+
// - If BROWSER_ECHO_SUPPRESS_TERMINAL=0|false, always print
90+
const envSup = String(process.env.BROWSER_ECHO_SUPPRESS_TERMINAL ?? '').trim().toLowerCase();
91+
const envForceSuppress = envSup === '1' || envSup === 'true';
92+
const envForcePrint = envSup === '0' || envSup === 'false';
93+
let shouldPrint = true;
94+
if (envForcePrint) {
95+
shouldPrint = true;
96+
} else if (envForceSuppress) {
97+
shouldPrint = !__isRemoteAvailable;
98+
}
6799

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

0 commit comments

Comments
 (0)