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';
1718import { connect } from 'node:net' ;
1819import { homedir } from 'node:os' ;
1920import { join } from 'node:path' ;
20- import { createInterface } from 'node:readline' ;
2121
2222const SERVER = { name : 'evolver-proxy' , version : '0.1.0' } ;
2323const DEFAULT_PROTOCOL = '2025-06-18' ;
@@ -26,6 +26,7 @@ const PROXY_HEALTH_TIMEOUT_MS = Number(process.env.EVOMAP_MCP_PROXY_HEALTH_TIMEO
2626const PROXY_AUTOSTART = String ( process . env . EVOMAP_MCP_PROXY_AUTOSTART || '1' ) !== '0' ;
2727const PROXY_START_TIMEOUT_MS = Number ( process . env . EVOMAP_MCP_PROXY_START_TIMEOUT_MS ) || 15_000 ;
2828const 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 ;
2930let proxyStartPromise = null ;
3031const CODEX_GUIDANCE_START = '<!-- evolver-codex-guidance:start -->' ;
3132const 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+ }
483493function reply ( id , result ) { send ( { jsonrpc : '2.0' , id, result } ) ; }
484494function replyError ( id , code , message ) { send ( { jsonrpc : '2.0' , id, error : { code, message } } ) ; }
485495
@@ -550,10 +560,10 @@ process.once('SIGTERM', () => shutdown('SIGTERM'));
550560process . once ( 'SIGINT' , ( ) => shutdown ( 'SIGINT' ) ) ;
551561process . 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 ( / ^ C o n t e n t - L e n g t h : \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 - Z a - 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
570656log ( `ready (server ${ SERVER . version } ); proxy base ${ readProxySettings ( ) . url } ` ) ;
571657armIdleExit ( ) ;
0 commit comments