Skip to content

Commit d802397

Browse files
committed
refactor: update MCP server configuration and enhance project logging capabilities
- Renamed "browser-echo" to "browser-echo-local" in .cursor/mcp.json for clarity. - Removed obsolete .browser-echo-mcp.json file to streamline configuration. - Improved transport auto-detection logic in index.ts to prefer Streamable HTTP when running interactively. - Added project metadata handling in log entries and filtering capabilities for logs based on project name. - Simplified MCP server resolution in route handlers for Next.js and Nuxt.js to use environment variables or defaults. - Enhanced log retrieval and clearing tools to support project-specific operations.
1 parent 6ce9221 commit d802397

11 files changed

Lines changed: 172 additions & 176 deletions

File tree

.cursor/mcp.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"mcpServers": {
3-
"browser-echo": {
3+
"browser-echo-local": {
44
"command": "node",
55
"args": ["packages/mcp/bin/cli.mjs"]
66
}

packages/mcp/.browser-echo-mcp.json

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

packages/mcp/src/index.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,12 @@ const cli = defineCommand({
4141
process.env.BROWSER_ECHO_BUFFER_SIZE = String(args.buffer);
4242
}
4343

44-
// Determine transport: stdio by default, or HTTP if --http or network options provided
45-
const useHttp = args.http
46-
|| args.port !== '5179' || args.host !== '127.0.0.1'
47-
|| args.endpoint !== '/mcp' || args.logsRoute !== '/__client-logs';
44+
// Transport auto-detection:
45+
// - Prefer Streamable HTTP when running interactively in a terminal (TTY)
46+
// - Fall back to stdio when not attached to a TTY (e.g., spawned by an editor/agent)
47+
// - Respect --http to force HTTP regardless of TTY
48+
const isInteractiveTty = Boolean((process as any).stdout?.isTTY) && Boolean((process as any).stdin?.isTTY);
49+
const useHttp = Boolean(args.http) || isInteractiveTty;
4850

4951
// Start server with selected transport. Prefer 5179 for ingest if stdio.
5052
await startServer(mcp, useHttp ? {
@@ -116,6 +118,7 @@ export function publishLogEntry(entry: {
116118
source?: string;
117119
stack?: string;
118120
tag?: string;
121+
project?: string;
119122
}): void {
120123
if (!entry.text || !entry.level) {
121124
console.error('[browser-echo] Missing required fields for publishLogEntry');
@@ -129,7 +132,8 @@ export function publishLogEntry(entry: {
129132
time: entry.time ?? Date.now(),
130133
source: entry.source,
131134
stack: entry.stack,
132-
tag: entry.tag || '[browser]'
135+
tag: entry.tag || '[browser]',
136+
project: entry.project
133137
};
134138
if (!activeStore) {
135139
console.error('[browser-echo] No active MCP server to publish log entry');

packages/mcp/src/schemas/logs.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ export const GetLogsArgs = {
77
includeStack: z.boolean().optional().default(false).describe('Include stack traces in text view'),
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'),
10-
sinceMs: z.number().nonnegative().optional().describe('Only entries with time >= sinceMs')
10+
sinceMs: z.number().nonnegative().optional().describe('Only entries with time >= sinceMs'),
11+
project: z.string().optional().describe('Project name to filter logs')
1112
} satisfies z.ZodRawShape;
1213

1314
export const ClearLogsArgs = {
1415
session: z.string().optional().describe('8-char session id prefix to clear only one session'),
15-
scope: z.enum(['soft','hard']).optional().default('hard').describe('soft: set baseline (non-destructive), hard: delete entries')
16+
scope: z.enum(['soft','hard']).optional().default('hard').describe('soft: set baseline (non-destructive), hard: delete entries'),
17+
project: z.string().optional().describe('Project name to clear only that project\'s logs')
1618
} satisfies z.ZodRawShape;
1719

1820
// Full Zod objects for local inference/validation

packages/mcp/src/server.ts

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,6 @@ export async function stopServer(server: McpServer) {
8888
console.error('Error occurred during server stop:', error);
8989
}
9090
finally {
91-
// Best-effort cleanup of project JSON
92-
try { rmSync(joinPath(process.cwd(), '.browser-echo-mcp.json'), { force: true }); } catch {}
9391
process.exit(0);
9492
}
9593
}
@@ -224,7 +222,27 @@ export async function startHttpServer(
224222
});
225223
} catch {}
226224

227-
await new Promise<void>((resolve) => nodeServer.listen(opts.port, opts.host, () => resolve()));
225+
// Attempt to listen on the requested port (fail fast if already in use)
226+
try {
227+
await new Promise<void>((resolve, reject) => {
228+
nodeServer.listen(opts.port, opts.host, () => resolve());
229+
nodeServer.on('error', reject);
230+
});
231+
} catch (err: any) {
232+
const isAddrInUse = err && (err.code === 'EADDRINUSE' || String(err.message || '').includes('EADDRINUSE'));
233+
if (isAddrInUse) {
234+
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`
239+
].join('\n');
240+
console.error(errorMsg);
241+
process.exit(1);
242+
}
243+
throw err;
244+
}
245+
228246
// For Streamable HTTP, we intentionally do not write project JSON here. The per-project
229247
// source of truth is written by the stdio ingest server only.
230248

@@ -299,7 +317,7 @@ export async function startIngestOnlyServer(
299317
});
300318
}
301319

302-
// Prefer requested port (usually 5179); fall back to ephemeral if it's taken
320+
// Prefer requested port (usually 5179); do not fall back (single-server mode)
303321
let nodeServer = createNodeServer(toNodeListener(app));
304322
configureNodeServer(nodeServer);
305323

@@ -308,33 +326,19 @@ export async function startIngestOnlyServer(
308326
actualPort = await listenWithResult(nodeServer, opts.host, opts.port);
309327
} catch (err: any) {
310328
const isAddrInUse = err && (err.code === 'EADDRINUSE' || String(err.message || '').includes('EADDRINUSE'));
311-
if (isAddrInUse && opts.port !== 0) {
312-
try { nodeServer.close?.(); } catch {}
313-
nodeServer = createNodeServer(toNodeListener(app));
314-
configureNodeServer(nodeServer);
315-
actualPort = await listenWithResult(nodeServer, opts.host, 0);
316-
} else {
317-
throw err;
329+
if (isAddrInUse) {
330+
const base = `http://${opts.host}:${opts.port}`;
331+
// eslint-disable-next-line no-console
332+
console.error(`Failed to start ingest-only server: Port in use at ${base}${opts.logsRoute}`);
318333
}
334+
throw err;
319335
}
320336

321-
// Write project-local config for providers
322-
writeProjectJson(opts.host, actualPort, opts.logsRoute);
323-
324337
// eslint-disable-next-line no-console
325338
console.error(`Log ingest endpoint → http://${opts.host}:${actualPort}${opts.logsRoute}`);
326339
}
327340

328-
function writeProjectJson(host: string, port: number, route: `/${string}`) {
329-
try {
330-
const baseUrl = `http://${host}:${port}`;
331-
const payload = JSON.stringify({ url: baseUrl, route, timestamp: Date.now(), pid: typeof process !== 'undefined' ? process.pid : undefined });
332-
const file = joinPath(process.cwd(), '.browser-echo-mcp.json');
333-
const tmp = file + '.tmp';
334-
try { writeFileSync(tmp, payload); renameSync(tmp, file); }
335-
catch { try { writeFileSync(file, payload); } catch {} }
336-
} catch {}
337-
}
341+
// Removed project JSON discovery in single-server mode
338342

339343
/** Create log ingest routes that can be attached to any H3 app */
340344
function createLogIngestRoutes(store: LogStore, logsRoute: `/${string}`) {
@@ -361,6 +365,10 @@ function createLogIngestRoutes(store: LogStore, logsRoute: `/${string}`) {
361365
return 'invalid payload';
362366
}
363367
const sid = String(payload.sessionId ?? 'anon');
368+
// Extract optional project metadata from headers
369+
const hdrs = event.node.req.headers;
370+
const projectHeader = (hdrs['x-browser-echo-project-name'] || hdrs['x-project-name'] || hdrs['x-project'] || '') as string | string[] | undefined;
371+
const projectName = Array.isArray(projectHeader) ? String(projectHeader[0] || '') : String(projectHeader || '');
364372
for (const entry of payload.entries as Array<{ level: BrowserLogLevel | string; text: string; time?: number; stack?: string; source?: string; }>) {
365373
const level = normalizeLevel(entry.level);
366374
store.append({
@@ -370,7 +378,8 @@ function createLogIngestRoutes(store: LogStore, logsRoute: `/${string}`) {
370378
time: entry.time,
371379
source: entry.source,
372380
stack: entry.stack,
373-
tag: '[browser]'
381+
tag: '[browser]',
382+
project: projectName ? projectName : undefined
374383
});
375384
}
376385
setResponseStatus(event, 204);

packages/mcp/src/store.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export interface LogEntry {
88
source?: string;
99
stack?: string;
1010
tag?: string;
11+
/** Optional project name/identifier for multi-project segregation */
12+
project?: string;
1113
}
1214

1315
const DEFAULT_BUFFER = 1000;

packages/mcp/src/tools/clearLogs.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,30 @@ export function registerClearLogsTool(ctx: McpToolContext) {
1111
ClearLogsSchema,
1212
async (args, _extra) => {
1313
const safeArgs = args || {} as any;
14-
const { session, scope = 'hard' } = safeArgs;
14+
const { session, scope = 'hard', project } = safeArgs as typeof ClearLogsSchema['_output'];
1515
const validSession = validateSessionId(session);
16-
store.clear({ session: validSession, scope });
16+
if (project) {
17+
// Project-scoped hard clear by filtering entries not matching the project
18+
// For soft clear, set baseline without deleting
19+
if (scope === 'soft') {
20+
// Use global baseline for project-scoped soft clear by current time; retrieval filters by project
21+
// This keeps implementation simple while honoring freshness for the project
22+
store.clear({ scope: 'soft' });
23+
} else {
24+
// Rebuild entries array excluding the project. Since entries is private, leverage snapshot and re-append
25+
const remaining = store.snapshot().filter(e => (e.project || '') !== project);
26+
// Replace internal state: simulate with hard clear then re-append
27+
store.clear({ scope: 'hard' });
28+
for (const e of remaining) store.append(e);
29+
}
30+
} else {
31+
store.clear({ session: validSession, scope });
32+
}
1733

1834
let message = 'Browser log buffer ';
1935
message += scope === 'soft' ? 'baseline set' : 'cleared';
20-
if (validSession) message += ` for session ${validSession}`;
36+
if (project) message += ` for project ${project}`;
37+
else if (validSession) message += ` for session ${validSession}`;
2138
message += '.';
2239

2340
return { content: [{ type: 'text' as const, text: message }] };

packages/mcp/src/tools/getLogs.ts

Lines changed: 78 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ export function registerGetLogsTool(ctx: McpToolContext) {
1818
includeStack = false,
1919
limit = 1000,
2020
contains,
21-
sinceMs
22-
} = safeArgs;
21+
sinceMs,
22+
project
23+
} = safeArgs as typeof GetLogsSchema['_output'];
2324

2425
const validSession = validateSessionId(session);
2526
const validSince = typeof sinceMs === 'number' && sinceMs >= 0 ? sinceMs : undefined;
@@ -28,23 +29,85 @@ export function registerGetLogsTool(ctx: McpToolContext) {
2829
let items = store.snapshot(validSession);
2930
if (validSince) items = items.filter(e => !e.time || e.time >= validSince);
3031
if (level?.length) items = items.filter(e => level.includes(e.level));
32+
if (project) items = items.filter(e => (e.project || '') === project);
3133
if (contains) items = items.filter(e => (e.text || '').includes(contains));
3234
const final = includeStack ? items : items.map(e => ({ ...e, stack: '' }));
3335

34-
const limited = limit && final.length > limit ? final.slice(-limit) : final;
35-
36-
const text = limited.map(e => {
37-
const sid = (e.sessionId || 'anon').slice(0, 8);
38-
const lvl = (e.level || 'log').toUpperCase();
39-
const tag = e.tag || '[browser]';
40-
let line = `${tag} [${sid}] ${lvl}: ${e.text}`;
41-
if (e.source) line += ` (${e.source})`;
42-
if (includeStack && e.stack?.trim()) {
43-
const indented = e.stack.split(/\r?\n/g).map(l => l ? ` ${l}` : l).join('\n');
44-
return `${line}\n${indented}`;
36+
// Auto-select a single active project by recency when no project is specified
37+
let autoProject: string | undefined;
38+
if (!project) {
39+
const projectsAll = new Set(final.map(e => e.project || '')); projectsAll.delete('');
40+
if (projectsAll.size > 1) {
41+
const latestByProject: Record<string, number> = {};
42+
for (const e of final) {
43+
const p = e.project || '';
44+
if (!p) continue;
45+
const t = e.time || 0;
46+
if (!latestByProject[p] || t > latestByProject[p]) latestByProject[p] = t;
47+
}
48+
const now = Date.now();
49+
const recentWindowMs = 60_000; // 60s window for "active" project
50+
const active = Object.entries(latestByProject)
51+
.filter(([, t]) => t > 0 && (now - t) <= recentWindowMs)
52+
.map(([p]) => p);
53+
if (active.length === 1) autoProject = active[0];
4554
}
46-
return line;
47-
}).join('\n');
55+
}
56+
57+
const baseForOutput = autoProject ? final.filter(e => (e.project || '') === autoProject) : final;
58+
const limited = limit && baseForOutput.length > limit ? baseForOutput.slice(-limit) : baseForOutput;
59+
60+
// Multi-project awareness: if no explicit project and multiple projects exist, show grouped preview
61+
const uniqueProjects = new Set(limited.map(e => e.project || '')); uniqueProjects.delete('');
62+
let text: string;
63+
if (!project && !autoProject && uniqueProjects.size > 1) {
64+
const groups = Array.from(uniqueProjects.values());
65+
const byProject: Record<string, typeof limited> = {};
66+
for (const p of groups) byProject[p] = [];
67+
for (const e of limited) {
68+
const key = e.project || '';
69+
if (key) byProject[key].push(e);
70+
}
71+
// Heuristic: order by most recent entry time desc
72+
const sortedProjects = groups.sort((a, b) => {
73+
const at = (byProject[a].at(-1)?.time || 0);
74+
const bt = (byProject[b].at(-1)?.time || 0);
75+
return bt - at;
76+
});
77+
const previewPerProject = 5;
78+
const sections: string[] = [];
79+
for (const p of sortedProjects) {
80+
const entries = byProject[p];
81+
const preview = entries.slice(-previewPerProject).map(e => {
82+
const sid = (e.sessionId || 'anon').slice(0, 8);
83+
const lvl = (e.level || 'log').toUpperCase();
84+
const tag = e.tag || '[browser]';
85+
let line = `[${p}] ${tag} [${sid}] ${lvl}: ${e.text}`;
86+
if (e.source) line += ` (${e.source})`;
87+
if (includeStack && e.stack?.trim()) {
88+
const indented = e.stack.split(/\r?\n/g).map(l => l ? ` ${l}` : l).join('\n');
89+
return `${line}\n${indented}`;
90+
}
91+
return line;
92+
}).join('\n');
93+
sections.push(`${p}${entries.length} entries (use get_logs with { project: "${p}" } for full list):\n${preview}`);
94+
}
95+
text = sections.join('\n\n');
96+
} else {
97+
text = limited.map(e => {
98+
const sid = (e.sessionId || 'anon').slice(0, 8);
99+
const lvl = (e.level || 'log').toUpperCase();
100+
const projectTag = e.project ? `[${e.project}]` : '';
101+
const tag = e.tag || '[browser]';
102+
let line = `${projectTag ? projectTag + ' ' : ''}${tag} [${sid}] ${lvl}: ${e.text}`;
103+
if (e.source) line += ` (${e.source})`;
104+
if (includeStack && e.stack?.trim()) {
105+
const indented = e.stack.split(/\r?\n/g).map(l => l ? ` ${l}` : l).join('\n');
106+
return `${line}\n${indented}`;
107+
}
108+
return line;
109+
}).join('\n');
110+
}
48111

49112
return {
50113
content: [

packages/next/src/route.ts

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

4-
// Simplified: resolve MCP from project-local JSON once; no fallback
4+
// Simplified: fixed single-server URL or env override
55

66
export type BrowserLogLevel = 'log' | 'info' | 'warn' | 'error' | 'debug';
77
type Entry = { level: BrowserLogLevel | string; text: string; time?: number; stack?: string; source?: string; };
@@ -16,14 +16,17 @@ export async function POST(req: NextRequest) {
1616
catch { return new NextResponse('invalid JSON', { status: 400 }); }
1717
if (!payload || !Array.isArray(payload.entries)) return new NextResponse('invalid payload', { status: 400 });
1818

19-
// Resolve MCP once: project JSON only (no fallback)
20-
const mcp = await __resolveMcpFromProject();
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;
2121

2222
// Forward to MCP server if available (fire-and-forget)
2323
if (mcp.url) {
2424
try {
2525
const route = (mcp.routeLogs as `/${string}`) || '/__client-logs';
2626
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;
2730
fetch(`${mcp.url}${route}`, {
2831
method: 'POST',
2932
headers,
@@ -74,41 +77,3 @@ function color(level: BrowserLogLevel, msg: string) {
7477
}
7578
}
7679
function dim(s: string) { return c.dim + s + c.reset; }
77-
78-
async function __resolveMcpFromProject(): Promise<{ url: string; routeLogs?: `/${string}` }> {
79-
try {
80-
const { readFileSync, existsSync } = await import('node:fs');
81-
const { join, dirname } = await import('node:path');
82-
let dir = process.cwd();
83-
for (let depth = 0; depth < 10; depth++) {
84-
const p = join(dir, '.browser-echo-mcp.json');
85-
if (existsSync(p)) {
86-
const raw = readFileSync(p, 'utf-8');
87-
const data = JSON.parse(raw);
88-
const rawUrl = (data?.url ? String(data.url) : '');
89-
const base = rawUrl.replace(/\/$/, '').replace(/\/mcp$/i, '');
90-
if (!/^(http:\/\/127\.0\.0\.1|http:\/\/localhost)/.test(base)) break;
91-
const routeLogs = (data?.route ? String(data.route) : '/__client-logs') as `/${string}`;
92-
if (base && await __pingHealth(`${base}/health`, 250)) {
93-
return { url: base, routeLogs };
94-
}
95-
}
96-
const up = dirname(dir);
97-
if (up === dir) break;
98-
dir = up;
99-
}
100-
} catch {}
101-
return { url: '' } as any;
102-
}
103-
104-
async function __pingHealth(url: string, timeoutMs: number): Promise<boolean> {
105-
try {
106-
const ctrl = new AbortController();
107-
const t = setTimeout(() => ctrl.abort(), timeoutMs);
108-
const res = await fetch(url, { signal: ctrl.signal, cache: 'no-store' as any });
109-
clearTimeout(t);
110-
return res.ok;
111-
} catch {
112-
return false;
113-
}
114-
}

0 commit comments

Comments
 (0)