Skip to content

Commit 2afac55

Browse files
committed
feat: enhance log ingestion and discovery features in MCP server
- Added environment variable exposure for ingest discovery in both owner and non-owner instances. - Implemented project-local discovery file creation for frameworks/tools to locate the ingest endpoint. - Updated `clearLogs` and `getLogs` tools to support HTTP proxying for log clearing and retrieval when not the ingest owner. - Improved error handling for log operations to ensure fallback to local store when proxying fails.
1 parent 60cd72e commit 2afac55

3 files changed

Lines changed: 79 additions & 1 deletion

File tree

packages/mcp/src/server.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { createServer as createNodeServer } from 'node:http';
22
import { randomUUID } from 'node:crypto';
33

44
import { createApp, createRouter, defineEventHandler, getQuery, readRawBody, setResponseStatus, toNodeListener } from 'h3';
5+
import { writeFileSync } from 'node:fs';
6+
import { join } from 'node:path';
57
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
68
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
79
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
@@ -278,6 +280,12 @@ export async function startHttpServer(
278280
console.log(`MCP (Streamable HTTP) listening → http://${opts.host}:${opts.port}${opts.endpoint}`);
279281
// eslint-disable-next-line no-console
280282
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 {}
281289
}
282290

283291
/** Start a minimal HTTP server exposing ONLY:
@@ -365,6 +373,12 @@ export async function startIngestOnlyServer(
365373
if (res && res.ok) {
366374
// eslint-disable-next-line no-console
367375
console.error(`Ingest server already running at ${base}${opts.logsRoute}. Reusing existing instance.`);
376+
// Expose discovery for non-owner instances
377+
try {
378+
process.env.BROWSER_ECHO_INGEST_BASE = base;
379+
process.env.BROWSER_ECHO_LOGS_ROUTE = String(opts.logsRoute);
380+
process.env.BROWSER_ECHO_INGEST_OWNER = '0';
381+
} catch {}
368382
return; // Treat as success
369383
}
370384
} catch {}
@@ -376,6 +390,18 @@ export async function startIngestOnlyServer(
376390

377391
// eslint-disable-next-line no-console
378392
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 {}
379405
}
380406

381407
// Removed project JSON discovery in single-server mode
@@ -409,6 +435,13 @@ function createLogIngestRoutes(store: LogStore, logsRoute: `/${string}`) {
409435
const hdrs = event.node.req.headers;
410436
const projectHeader = (hdrs['x-browser-echo-project-name'] || hdrs['x-project-name'] || hdrs['x-project'] || '') as string | string[] | undefined;
411437
const projectName = Array.isArray(projectHeader) ? String(projectHeader[0] || '') : String(projectHeader || '');
438+
// Special command: remote clear request
439+
const isClear = payload.entries.length === 1 && String(payload.entries[0]?.text || '') === '__BROWSER_ECHO_CLEAR__';
440+
if (isClear) {
441+
store.clear({ session: sid ? String(sid).slice(0,8) : undefined, scope: 'hard', project: projectName || undefined });
442+
setResponseStatus(event, 204);
443+
return '';
444+
}
412445
for (const entry of payload.entries as Array<{ level: BrowserLogLevel | string; text: string; time?: number; stack?: string; source?: string; }>) {
413446
const level = normalizeLevel(entry.level);
414447
store.append({

packages/mcp/src/tools/clearLogs.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,21 @@ export function registerClearLogsTool(ctx: McpToolContext) {
1414
const { session, project } = safeArgs as typeof ClearLogsSchema['_output'];
1515
const validSession = validateSessionId(session);
1616

17+
// If this instance is not the ingest owner but a global ingest exists, proxy via HTTP by posting a special clear payload
18+
const ingestBase = (process.env.BROWSER_ECHO_INGEST_BASE || '').replace(/\/$/, '');
19+
const logsRoute = process.env.BROWSER_ECHO_LOGS_ROUTE || '/__client-logs';
20+
const ingestOwner = String(process.env.BROWSER_ECHO_INGEST_OWNER || '1') === '1';
21+
if (!ingestOwner && ingestBase) {
22+
try {
23+
const payload = { sessionId: validSession || 'anon', entries: [{ level: 'debug', text: '__BROWSER_ECHO_CLEAR__', time: Date.now(), source: project ? `project:${project}` : undefined }] };
24+
await fetch(`${ingestBase}${logsRoute}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) });
25+
const msg = 'Browser log buffer clear requested via global ingest.' + (project ? ` for project ${project}.` : validSession ? ` for session ${validSession}.` : '');
26+
return { content: [{ type: 'text' as const, text: msg }] };
27+
} catch {
28+
// fall through to local clear
29+
}
30+
}
31+
1732
store.clear({ session: validSession, scope: 'hard', project });
1833

1934
let message = 'Browser log buffer cleared';

packages/mcp/src/tools/getLogs.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,37 @@ export function registerGetLogsTool(ctx: McpToolContext) {
2626
const validSession = validateSessionId(session);
2727
const validSince = typeof sinceMs === 'number' && sinceMs >= 0 ? sinceMs : undefined;
2828

29-
// Get logs with optional session filter
29+
// If this instance is not the ingest owner but a global ingest exists, proxy via HTTP
30+
const ingestBase = (process.env.BROWSER_ECHO_INGEST_BASE || '').replace(/\/$/, '');
31+
const logsRoute = process.env.BROWSER_ECHO_LOGS_ROUTE || '/__client-logs';
32+
const ingestOwner = String(process.env.BROWSER_ECHO_INGEST_OWNER || '1') === '1';
33+
34+
if (!ingestOwner && ingestBase) {
35+
try {
36+
const url = new URL(`${ingestBase}${logsRoute}`);
37+
if (validSession) url.searchParams.set('session', validSession);
38+
const res = await fetch(url.toString(), { method: 'GET', cache: 'no-store' as any });
39+
const text = await res.text();
40+
// Basic line filtering client-side when proxying (best-effort)
41+
let lines = text.split(/\r?\n/g).filter(Boolean);
42+
if (level?.length) {
43+
const levelSet = new Set(level.map(l => String(l).toUpperCase()));
44+
lines = lines.filter(l => {
45+
const m = l.match(/\]\s+([A-Z]+):\s/);
46+
return m ? levelSet.has(m[1]) : true;
47+
});
48+
}
49+
if (contains) lines = lines.filter(l => l.includes(contains));
50+
// Apply simple limit from the end
51+
if (limit && lines.length > limit) lines = lines.slice(-limit);
52+
const body = lines.join('\n');
53+
return { content: [{ type: 'text' as const, text: body || 'No logs available yet.' }] };
54+
} catch {
55+
// Fall back to local store if proxy fails
56+
}
57+
}
58+
59+
// Get logs with optional session filter from local store
3060
let items = store.snapshot(validSession);
3161
if (validSince) items = items.filter(e => !e.time || e.time >= validSince);
3262
if (level?.length) items = items.filter(e => level.includes(e.level));

0 commit comments

Comments
 (0)