Skip to content

Commit b787ece

Browse files
committed
chore: update MCP configuration to use new ephemeral port for log ingestion, enhance error handling for MCP-Protocol-Version and session ID, and improve discovery file management
1 parent 7123834 commit b787ece

15 files changed

Lines changed: 391 additions & 79 deletions

File tree

.browser-echo-mcp.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"url":"http://127.0.0.1:5179","routeLogs":"/__client-logs","timestamp":1755812597005,"pid":42067}
1+
{"url":"http://127.0.0.1:57918","routeLogs":"/__client-logs","timestamp":1755854608470,"pid":49241}

.cursor/mcp.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"mcpServers": {
33
"browser-echo": {
44
"command": "npx",
5-
"args": ["https://pkg.pr.new/instructa/browser-echo/@browser-echo/mcp@f3039cc"]
5+
"args": ["https://pkg.pr.new/instructa/browser-echo/@browser-echo/mcp@7123834"]
66
}
77
}
88
}

packages/mcp/README.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,9 @@ 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 at `http://127.0.0.1:5179/__client-logs` for browsers to POST logs
292-
- Console output: `MCP (stdio) listening on stdio (ingest HTTP active)`
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
293+
- Console output (stderr): `MCP (stdio) listening on stdio (ingest HTTP active)`
293294

294295
### HTTP Mode
295296

@@ -308,15 +309,14 @@ For web-based AI tools or when you need HTTP MCP access:
308309
```
309310

310311
In HTTP mode:
311-
- Full **Streamable HTTP** MCP endpoint at `http://127.0.0.1:5179/mcp`
312-
- HTTP ingest endpoint at `http://127.0.0.1:5179/__client-logs`
313-
- Console output: `MCP (Streamable HTTP) listening → http://127.0.0.1:5179/mcp`
312+
- Full **Streamable HTTP** MCP endpoint and HTTP ingest endpoint run on the specified host/port
313+
- Console output: `MCP (Streamable HTTP) listening → http://127.0.0.1:<port>/mcp`
314314

315315
### Custom Configuration
316316

317317
```bash
318-
# Custom ingest port in stdio mode
319-
node packages/mcp/bin/cli.mjs --port 8081
318+
# Custom ingest port in stdio mode (override ephemeral)
319+
BROWSER_ECHO_INGEST_PORT=8081 node packages/mcp/bin/cli.mjs
320320

321321
# Custom HTTP server
322322
node packages/mcp/bin/cli.mjs --http --host 0.0.0.0 --port 5179
@@ -397,7 +397,8 @@ publishLogEntry({
397397
## Environment Variables
398398

399399
- `BROWSER_ECHO_BUFFER_SIZE` — Max entries in memory (default: `1000`)
400-
- `BROWSER_ECHO_MCP_URL` — MCP server URL for framework forwarding (e.g., `http://127.0.0.1:5179/mcp`)
400+
- `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)
401402

402403
---
403404

packages/mcp/src/server.ts

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,15 @@ export async function startServer(
6161

6262
// Always bring up an ingest-only HTTP endpoint so clients/frameworks can POST logs
6363
const host = options.host || '127.0.0.1';
64-
const port = (options.port ?? 5179) | 0;
64+
// Use env override if provided, otherwise let OS pick an ephemeral port
65+
const envIngest = process.env.BROWSER_ECHO_INGEST_PORT;
66+
const requested = Number(envIngest || 0) | 0;
67+
const port = requested > 0 ? requested : 0;
6568
const logsRoute = options.logsRoute || '/__client-logs';
6669
await startIngestOnlyServer(store, { host, port, logsRoute });
6770

6871
// eslint-disable-next-line no-console
69-
console.log('MCP (stdio) listening on stdio (ingest HTTP active)');
72+
console.error('MCP (stdio) listening on stdio (ingest HTTP active)');
7073
return;
7174
}
7275

@@ -152,19 +155,36 @@ export async function startHttpServer(
152155

153156
// Normalize body for POST
154157
let bodyBuf: Buffer | undefined;
158+
let isInitialize = false;
155159
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
156160
const raw = await readRawBody(event);
157161
if (raw && typeof raw === 'string') bodyBuf = Buffer.from(raw);
158162
else if (raw && (raw as any) instanceof Uint8Array) bodyBuf = Buffer.from(raw as any);
159163

160-
// Allow tool calls without explicit session id by assigning a default
164+
// Validate MCP-Protocol-Version if provided (allow missing for backwards-compat)
161165
try {
162-
const reqHeaders: Record<string, string | string[] | undefined> = event.node.req.headers as any;
163-
const existingSid = reqHeaders['mcp-session-id'] || reqHeaders['Mcp-Session-Id'];
164-
if (!existingSid) {
165-
(event.node.req.headers as any)['mcp-session-id'] = 'default-session';
166+
const ver = String((event.node.req.headers['mcp-protocol-version'] as any) || '').trim();
167+
if (ver && !['2025-06-18','2025-03-26','2024-11-05'].includes(ver)) {
168+
setResponseStatus(event, 400);
169+
try { event.node.res.setHeader('content-type','application/json'); } catch {}
170+
return JSON.stringify({ jsonrpc: '2.0', error: { code: -32600, message: 'Unsupported MCP-Protocol-Version' }, id: null });
166171
}
167172
} catch {}
173+
174+
// Enforce session id for non-initialize requests
175+
try {
176+
const parsed = bodyBuf ? JSON.parse(bodyBuf.toString('utf-8')) : undefined;
177+
const rpcMethod = parsed && typeof parsed === 'object' ? String(parsed.method || '') : '';
178+
isInitialize = rpcMethod === 'initialize';
179+
} catch {}
180+
if (!isInitialize) {
181+
const sidHeader = (event.node.req.headers['mcp-session-id'] as string | undefined) || '';
182+
if (!sidHeader) {
183+
setResponseStatus(event, 400);
184+
try { event.node.res.setHeader('content-type', 'application/json'); } catch {}
185+
return JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Missing Mcp-Session-Id header' }, id: null });
186+
}
187+
}
168188
}
169189

170190
// Delegate to the SDK transport (writes to the Node response directly)
@@ -177,7 +197,7 @@ export async function startHttpServer(
177197
}
178198
}));
179199

180-
// Attach log ingest routes
200+
// Attach log ingest routes (with optional token validation via header)
181201
app.use(createLogIngestRoutes(store, opts.logsRoute));
182202

183203
// Health
@@ -203,7 +223,7 @@ export async function startHttpServer(
203223
await new Promise<void>((resolve) => nodeServer.listen(opts.port, opts.host, () => resolve()));
204224

205225
// Advertise discovery so providers can auto-detect this server locally
206-
await advertiseDiscovery(opts.host, opts.port, opts.logsRoute);
226+
await advertiseDiscovery(opts.host, opts.port, opts.logsRoute, { projectRoot: process.cwd(), scope: 'http' });
207227

208228
// eslint-disable-next-line no-console
209229
console.log(`MCP (Streamable HTTP) listening → http://${opts.host}:${opts.port}${opts.endpoint}`);
@@ -247,14 +267,18 @@ export async function startIngestOnlyServer(
247267

248268
await new Promise<void>((resolve) => nodeServer.listen(opts.port, opts.host, () => resolve()));
249269

270+
// Determine the actual port assigned (when listening on port 0)
271+
const addr = nodeServer.address() as any;
272+
const actualPort = (addr && typeof addr === 'object' && 'port' in addr) ? (addr.port as number) : opts.port;
273+
250274
// Advertise discovery for tooling that wants to auto-detect the ingest endpoint
251-
await advertiseDiscovery(opts.host, opts.port, opts.logsRoute);
275+
await advertiseDiscovery(opts.host, actualPort, opts.logsRoute, { projectRoot: process.cwd(), scope: 'stdio' });
252276

253277
// eslint-disable-next-line no-console
254-
console.log(`Log ingest endpoint → http://${opts.host}:${opts.port}${opts.logsRoute}`);
278+
console.error(`Log ingest endpoint → http://${opts.host}:${actualPort}${opts.logsRoute}`);
255279
}
256280

257-
async function advertiseDiscovery(host: string, port: number, logsRoute: `/${string}`) {
281+
async function advertiseDiscovery(host: string, port: number, logsRoute: `/${string}`, meta?: { projectRoot?: string; token?: string; scope?: 'http' | 'stdio' }) {
258282
try {
259283
const { writeFileSync } = await import('node:fs');
260284
const { join } = await import('node:path');
@@ -265,13 +289,14 @@ async function advertiseDiscovery(host: string, port: number, logsRoute: `/${str
265289
url: baseUrl,
266290
routeLogs: logsRoute,
267291
timestamp: Date.now(),
268-
pid: typeof process !== 'undefined' ? process.pid : undefined
292+
pid: typeof process !== 'undefined' ? process.pid : undefined,
293+
projectRoot: meta?.projectRoot || process.cwd(),
294+
token: meta?.token || undefined
269295
});
270296

271-
const files = [
272-
join(process.cwd(), '.browser-echo-mcp.json'),
273-
join(tmpdir(), 'browser-echo-mcp.json')
274-
];
297+
const files = meta?.scope === 'http'
298+
? [ join(tmpdir(), 'browser-echo-mcp.json') ]
299+
: [ join(process.cwd(), '.browser-echo-mcp.json'), join(tmpdir(), 'browser-echo-mcp.json') ];
275300

276301
for (const f of files) {
277302
try { writeFileSync(f, payload); } catch {}
@@ -299,6 +324,30 @@ function createLogIngestRoutes(store: LogStore, logsRoute: `/${string}`) {
299324
// Log ingest (POST)
300325
router.post(logsRoute, defineEventHandler(async (event) => {
301326
try {
327+
// Optional token check if discovery provided one (best-effort; disabled by default in dev)
328+
try {
329+
const { readFileSync, existsSync } = await import('node:fs');
330+
const { join } = await import('node:path');
331+
const { tmpdir } = await import('node:os');
332+
const candidates = [join(process.cwd(), '.browser-echo-mcp.json'), join(tmpdir(), 'browser-echo-mcp.json')];
333+
let requiredToken = '';
334+
for (const p of candidates) {
335+
try {
336+
if (!existsSync(p)) continue;
337+
const raw = readFileSync(p, 'utf-8');
338+
const data = JSON.parse(raw);
339+
if (data?.token) { requiredToken = String(data.token); break; }
340+
} catch {}
341+
}
342+
if (requiredToken && process.env.BROWSER_ECHO_REQUIRE_TOKEN === '1') {
343+
const got = String((event.node.req.headers['x-be-token'] as any) || '').trim();
344+
if (!got || got !== requiredToken) {
345+
setResponseStatus(event, 401);
346+
return 'unauthorized';
347+
}
348+
}
349+
} catch {}
350+
302351
const raw = await readRawBody(event);
303352
const payload = typeof raw === 'string' ? JSON.parse(raw) : (raw ? JSON.parse(Buffer.from(raw as any).toString('utf-8')) : undefined);
304353
if (!payload || !Array.isArray(payload.entries)) {

packages/mcp/test/http.smoke.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ describe('@browser-echo/mcp streamable HTTP transport (smoke)', () => {
4646
const body = await diag.text();
4747
expect(body).toContain('http smoke');
4848
});
49+
50+
it('returns 400 on invalid MCP-Protocol-Version header', async () => {
51+
const res = await httpPost('/mcp', { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, { 'MCP-Protocol-Version': 'not-a-version' });
52+
expect(res.status).toBe(400);
53+
});
54+
55+
it('returns 400 on non-initialize POST without Mcp-Session-Id', async () => {
56+
const res = await httpPost('/mcp', { jsonrpc: '2.0', id: 2, method: 'tools/list' }, { 'MCP-Protocol-Version': '2025-06-18' });
57+
expect(res.status).toBe(400);
58+
});
4959
});
5060

5161

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { writeFileSync, existsSync, unlinkSync } from 'node:fs';
3+
import { join } from 'node:path';
4+
import { tmpdir } from 'node:os';
5+
6+
describe('discovery file staleness', () => {
7+
it('treats stale tmp discovery as invalid (over 60s or dead pid)', async () => {
8+
const p = join(tmpdir(), 'browser-echo-mcp.json');
9+
const payload = {
10+
url: 'http://127.0.0.1:59999',
11+
routeLogs: '/__client-logs',
12+
timestamp: Date.now() - 120_000,
13+
pid: 999999
14+
};
15+
try { writeFileSync(p, JSON.stringify(payload)); } catch {}
16+
expect(existsSync(p)).toBe(true);
17+
// The Vite/Next/Nuxt discovery readers ignore stale automatically; we cannot easily assert internal state here.
18+
// This test ensures the file can be created; manual cleanup follows to avoid leaking state.
19+
try { unlinkSync(p); } catch {}
20+
});
21+
});
22+
23+
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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 } 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 transport — multiple editors', () => {
12+
let c1: McpTestClient;
13+
let c2: McpTestClient;
14+
let c1Dir: string;
15+
let c2Dir: string;
16+
17+
beforeAll(async () => {
18+
const cli = join(__dirname, '..', 'bin', 'cli.mjs');
19+
// Simulate two editors with separate working directories so discovery files don't collide
20+
c1Dir = mkdtempSync(join(tmpdir(), 'be-multi-a-'));
21+
c2Dir = mkdtempSync(join(tmpdir(), 'be-multi-b-'));
22+
c1 = new McpTestClient({ cliEntryPoint: cli, env: { BROWSER_ECHO_INGEST_PORT: '0' }, cwd: c1Dir });
23+
c2 = new McpTestClient({ cliEntryPoint: cli, env: { BROWSER_ECHO_INGEST_PORT: '0' }, cwd: c2Dir });
24+
await c1.connect();
25+
await c2.connect();
26+
}, 30_000);
27+
28+
afterAll(async () => {
29+
await c1?.close();
30+
await c2?.close();
31+
});
32+
33+
it('starts two stdio servers with different ingest ports (no conflict)', async () => {
34+
const p1 = join(c1Dir, '.browser-echo-mcp.json');
35+
const p2 = join(c2Dir, '.browser-echo-mcp.json');
36+
// wait briefly
37+
const start = Date.now();
38+
while ((!existsSync(p1) || !existsSync(p2)) && (Date.now() - start) < 2000) {
39+
await new Promise(r => setTimeout(r, 50));
40+
}
41+
const base1 = existsSync(p1) ? String(JSON.parse(readFileSync(p1, 'utf-8'))?.url || '').replace(/\/$/, '') : '';
42+
const base2 = existsSync(p2) ? String(JSON.parse(readFileSync(p2, 'utf-8'))?.url || '').replace(/\/$/, '') : '';
43+
expect(base1).toBeTruthy();
44+
expect(base2).toBeTruthy();
45+
expect(base1).not.toBe(base2);
46+
});
47+
48+
it('logs sent to one ingest do not affect the other session buffer', async () => {
49+
// Read each server's own discovery
50+
const p1 = join(c1Dir, '.browser-echo-mcp.json');
51+
const p2 = join(c2Dir, '.browser-echo-mcp.json');
52+
const waitStart = Date.now();
53+
while ((!existsSync(p1) || !existsSync(p2)) && (Date.now() - waitStart) < 2000) {
54+
await new Promise(r => setTimeout(r, 50));
55+
}
56+
const d1 = existsSync(p1) ? JSON.parse(readFileSync(p1, 'utf-8')) : {};
57+
const d2 = existsSync(p2) ? JSON.parse(readFileSync(p2, 'utf-8')) : {};
58+
const base1 = String(d1?.url || '').replace(/\/$/, '');
59+
const base2 = String(d2?.url || '').replace(/\/$/, '');
60+
const route1 = d1?.routeLogs ? String(d1.routeLogs) : '/__client-logs';
61+
const route2 = d2?.routeLogs ? String(d2.routeLogs) : '/__client-logs';
62+
expect(base1).toBeTruthy();
63+
expect(base2).toBeTruthy();
64+
65+
// Send two distinct sessions to their respective servers
66+
const s1 = 'ed1torA1';
67+
const s2 = 'ed1torB2';
68+
await fetch(`${base1}${route1}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId: s1, entries: [{ level: 'info', text: 'multi A' }] }) });
69+
await fetch(`${base2}${route2}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId: s2, entries: [{ level: 'info', text: 'multi B' }] }) });
70+
71+
// Poll a few times for eventual consistency
72+
let aText = '', bText = '';
73+
for (let i = 0; i < 8; i++) {
74+
const a = await c1.callTool('get_logs', { session: s1, level: ['info','error','warn','log','debug'], includeStack: false, limit: 50 });
75+
const b = await c2.callTool('get_logs', { session: s2, level: ['info','error','warn','log','debug'], includeStack: false, limit: 50 });
76+
aText = String(a?.content?.[0]?.text || '');
77+
bText = String(b?.content?.[0]?.text || '');
78+
if (aText.includes('multi A') && bText.includes('multi B')) break;
79+
await new Promise(r => setTimeout(r, 30));
80+
}
81+
expect(aText).toContain('multi A');
82+
expect(bText).toContain('multi B');
83+
});
84+
});
85+
86+

0 commit comments

Comments
 (0)