Skip to content

Commit d9d42c3

Browse files
authored
fix: support Content-Length MCP frames
Support standard MCP Content-Length frames while retaining JSONL compatibility and Evolver Proxy routing.
1 parent 689736e commit d9d42c3

2 files changed

Lines changed: 107 additions & 11 deletions

File tree

plugins/evolver/.mcp.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
{
22
"mcpServers": {
33
"evolver-proxy": {
4-
"command": "/opt/homebrew/bin/node",
5-
"args": ["./mcp/evolver-proxy.mjs"],
4+
"command": "/opt/homebrew/opt/node@22/bin/node",
5+
"args": [
6+
"./mcp/evolver-proxy.mjs"
7+
],
68
"cwd": ".",
79
"env": {
810
"EVOMAP_PROXY_PORT": "19820",
911
"EVOMAP_MCP_PROXY_TIMEOUT_MS": "45000",
1012
"EVOMAP_MCP_PROXY_HEALTH_TIMEOUT_MS": "2000",
1113
"NO_PROXY": "127.0.0.1,localhost",
1214
"no_proxy": "127.0.0.1,localhost",
13-
"A2A_HUB_URL": "https://evomap.ai"
15+
"A2A_HUB_URL": "https://evomap.ai",
16+
"EVOMAP_MCP_IDLE_EXIT_MS": "0"
1417
}
1518
}
1619
}

plugins/evolver/mcp/evolver-proxy.mjs

Lines changed: 101 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
* Exposes the EvoMap local Proxy mailbox — genes, capsules, status — as MCP
66
* tools so Codex can search/reuse/publish evolution assets natively.
77
*
8-
* Transport: newline-delimited JSON-RPC 2.0 over stdin/stdout (MCP stdio).
8+
* Transport: MCP Content-Length frames, with newline-delimited JSON-RPC kept
9+
* for older hosts. Replies use the same framing as the incoming request.
910
* All diagnostics go to stderr; stdout carries protocol traffic ONLY.
1011
*
1112
* The Proxy is a separate local process started by the @evomap/evolver CLI.
@@ -17,21 +18,28 @@ import { spawn } from 'node:child_process';
1718
import { connect } from 'node:net';
1819
import { homedir } from 'node:os';
1920
import { join } from 'node:path';
20-
import { createInterface } from 'node:readline';
2121

2222
const SERVER = { name: 'evolver-proxy', version: '0.1.0' };
2323
const DEFAULT_PROTOCOL = '2025-06-18';
2424
const PROXY_FETCH_TIMEOUT_MS = Number(process.env.EVOMAP_MCP_PROXY_TIMEOUT_MS) || 45_000;
2525
const PROXY_HEALTH_TIMEOUT_MS = Number(process.env.EVOMAP_MCP_PROXY_HEALTH_TIMEOUT_MS) || 2_000;
2626
const PROXY_AUTOSTART = String(process.env.EVOMAP_MCP_PROXY_AUTOSTART || '1') !== '0';
2727
const PROXY_START_TIMEOUT_MS = Number(process.env.EVOMAP_MCP_PROXY_START_TIMEOUT_MS) || 15_000;
28-
const MCP_IDLE_EXIT_MS = Number(process.env.EVOMAP_MCP_IDLE_EXIT_MS) || 5 * 60_000;
28+
const MCP_IDLE_EXIT_MS = parseNumberEnv('EVOMAP_MCP_IDLE_EXIT_MS', 5 * 60_000);
29+
const MAX_FRAME_BYTES = Number(process.env.EVOMAP_MCP_MAX_FRAME_BYTES) || 16 * 1024 * 1024;
2930
let proxyStartPromise = null;
3031
const CODEX_GUIDANCE_START = '<!-- evolver-codex-guidance:start -->';
3132
const CODEX_GUIDANCE_END = '<!-- evolver-codex-guidance:end -->';
3233

3334
function log(...a) { process.stderr.write('[evolver-proxy-mcp] ' + a.join(' ') + '\n'); }
3435

36+
function parseNumberEnv(name, fallback) {
37+
const raw = process.env[name];
38+
if (raw === undefined || raw === '') return fallback;
39+
const value = Number(raw);
40+
return Number.isFinite(value) ? value : fallback;
41+
}
42+
3543
function codexGuidanceSection(language) {
3644
if (language === 'zh') {
3745
return `${CODEX_GUIDANCE_START}
@@ -479,7 +487,16 @@ const TOOL_BY_NAME = Object.fromEntries(TOOLS.map(t => [t.name, t]));
479487

480488
// ---- JSON-RPC plumbing ---------------------------------------------------
481489

482-
function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); }
490+
let outputFraming = 'jsonl';
491+
492+
function send(msg) {
493+
const payload = JSON.stringify(msg);
494+
if (outputFraming === 'content-length') {
495+
process.stdout.write(`Content-Length: ${Buffer.byteLength(payload, 'utf8')}\r\n\r\n${payload}`);
496+
return;
497+
}
498+
process.stdout.write(payload + '\n');
499+
}
483500
function reply(id, result) { send({ jsonrpc: '2.0', id, result }); }
484501
function replyError(id, code, message) { send({ jsonrpc: '2.0', id, error: { code, message } }); }
485502

@@ -550,10 +567,10 @@ process.once('SIGTERM', () => shutdown('SIGTERM'));
550567
process.once('SIGINT', () => shutdown('SIGINT'));
551568
process.once('SIGHUP', () => shutdown('SIGHUP'));
552569

553-
const rl = createInterface({ input: process.stdin });
554-
rl.on('line', (line) => {
570+
function handleJsonRpcText(text, framing) {
555571
armIdleExit();
556-
const trimmed = line.trim();
572+
outputFraming = framing;
573+
const trimmed = text.trim();
557574
if (!trimmed) return;
558575
let req;
559576
try { req = JSON.parse(trimmed); } catch { log('dropping non-JSON line'); return; }
@@ -564,8 +581,84 @@ rl.on('line', (line) => {
564581
if (req && req.id != null) replyError(req.id, -32603, `Internal error: ${e.message}`);
565582
})
566583
.finally(() => { pending--; armIdleExit(); maybeExit(); });
584+
}
585+
586+
let inputBuffer = Buffer.alloc(0);
587+
let inputEnded = false;
588+
589+
function headerEndOffset(buffer) {
590+
const crlf = buffer.indexOf('\r\n\r\n');
591+
if (crlf >= 0) return { headerEnd: crlf, bodyStart: crlf + 4 };
592+
const lf = buffer.indexOf('\n\n');
593+
if (lf >= 0) return { headerEnd: lf, bodyStart: lf + 2 };
594+
return null;
595+
}
596+
597+
function contentLengthFrom(headerText) {
598+
for (const line of headerText.split(/\r?\n/)) {
599+
const match = line.match(/^Content-Length:\s*(\d+)\s*$/i);
600+
if (match) return Number(match[1]);
601+
}
602+
return null;
603+
}
604+
605+
function startsWithHeaderFrame(value) {
606+
const text = Buffer.isBuffer(value)
607+
? value.toString('utf8', 0, Math.min(value.length, 128))
608+
: String(value);
609+
return /^[A-Za-z-]+:\s*/.test(text);
610+
}
611+
612+
function processInputBuffer() {
613+
while (inputBuffer.length > 0) {
614+
if (startsWithHeaderFrame(inputBuffer)) {
615+
const offsets = headerEndOffset(inputBuffer);
616+
if (!offsets) return;
617+
const headerText = inputBuffer.subarray(0, offsets.headerEnd).toString('ascii');
618+
const length = contentLengthFrom(headerText);
619+
if (!Number.isFinite(length) || length < 0 || length > MAX_FRAME_BYTES) {
620+
log('dropping invalid Content-Length frame');
621+
inputBuffer = Buffer.alloc(0);
622+
return;
623+
}
624+
if (inputBuffer.length < offsets.bodyStart + length) return;
625+
const body = inputBuffer.subarray(offsets.bodyStart, offsets.bodyStart + length).toString('utf8');
626+
inputBuffer = inputBuffer.subarray(offsets.bodyStart + length);
627+
handleJsonRpcText(body, 'content-length');
628+
continue;
629+
}
630+
631+
const newline = inputBuffer.indexOf('\n');
632+
if (newline < 0) return;
633+
const line = inputBuffer.subarray(0, newline).toString('utf8');
634+
inputBuffer = inputBuffer.subarray(newline + 1);
635+
handleJsonRpcText(line, 'jsonl');
636+
}
637+
}
638+
639+
function finishInput() {
640+
if (inputEnded) return;
641+
inputEnded = true;
642+
processInputBuffer();
643+
const leftover = inputBuffer.toString('utf8').trim();
644+
if (leftover && !startsWithHeaderFrame(leftover)) {
645+
handleJsonRpcText(leftover, 'jsonl');
646+
} else if (leftover) {
647+
log('stdin closed with a partial Content-Length frame');
648+
}
649+
inputBuffer = Buffer.alloc(0);
650+
closed = true;
651+
maybeExit();
652+
}
653+
654+
process.stdin.on('data', (chunk) => {
655+
armIdleExit();
656+
inputBuffer = Buffer.concat([inputBuffer, chunk]);
657+
processInputBuffer();
567658
});
568-
rl.on('close', () => { closed = true; maybeExit(); });
659+
process.stdin.on('error', (err) => log('stdin error:', err.message));
660+
process.stdin.on('end', finishInput);
661+
process.stdin.on('close', finishInput);
569662

570663
log(`ready (server ${SERVER.version}); proxy base ${readProxySettings().url}`);
571664
armIdleExit();

0 commit comments

Comments
 (0)