Skip to content

Commit 57bde2f

Browse files
committed
fix: support content-length evolver mcp frames
1 parent 689736e commit 57bde2f

2 files changed

Lines changed: 94 additions & 8 deletions

File tree

plugins/evolver/.mcp.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"mcpServers": {
33
"evolver-proxy": {
4-
"command": "/opt/homebrew/bin/node",
4+
"command": "/opt/homebrew/opt/node@22/bin/node",
55
"args": ["./mcp/evolver-proxy.mjs"],
66
"cwd": ".",
77
"env": {

plugins/evolver/mcp/evolver-proxy.mjs

Lines changed: 93 additions & 7 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,7 +18,6 @@ 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';
@@ -26,6 +26,7 @@ const PROXY_HEALTH_TIMEOUT_MS = Number(process.env.EVOMAP_MCP_PROXY_HEALTH_TIMEO
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;
2828
const MCP_IDLE_EXIT_MS = Number(process.env.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 -->';
@@ -479,7 +480,16 @@ const TOOL_BY_NAME = Object.fromEntries(TOOLS.map(t => [t.name, t]));
479480

480481
// ---- JSON-RPC plumbing ---------------------------------------------------
481482

482-
function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); }
483+
let outputFraming = 'jsonl';
484+
485+
function send(msg) {
486+
const payload = JSON.stringify(msg);
487+
if (outputFraming === 'content-length') {
488+
process.stdout.write(`Content-Length: ${Buffer.byteLength(payload, 'utf8')}\r\n\r\n${payload}`);
489+
return;
490+
}
491+
process.stdout.write(payload + '\n');
492+
}
483493
function reply(id, result) { send({ jsonrpc: '2.0', id, result }); }
484494
function replyError(id, code, message) { send({ jsonrpc: '2.0', id, error: { code, message } }); }
485495

@@ -550,10 +560,10 @@ process.once('SIGTERM', () => shutdown('SIGTERM'));
550560
process.once('SIGINT', () => shutdown('SIGINT'));
551561
process.once('SIGHUP', () => shutdown('SIGHUP'));
552562

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

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

0 commit comments

Comments
 (0)