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';
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' ;
2424const PROXY_FETCH_TIMEOUT_MS = Number ( process . env . EVOMAP_MCP_PROXY_TIMEOUT_MS ) || 45_000 ;
2525const PROXY_HEALTH_TIMEOUT_MS = Number ( process . env . EVOMAP_MCP_PROXY_HEALTH_TIMEOUT_MS ) || 2_000 ;
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 ;
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 ;
2930let proxyStartPromise = null ;
3031const CODEX_GUIDANCE_START = '<!-- evolver-codex-guidance:start -->' ;
3132const CODEX_GUIDANCE_END = '<!-- evolver-codex-guidance:end -->' ;
3233
3334function 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+
3543function 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+ }
483500function reply ( id , result ) { send ( { jsonrpc : '2.0' , id, result } ) ; }
484501function replyError ( id , code , message ) { send ( { jsonrpc : '2.0' , id, error : { code, message } } ) ; }
485502
@@ -550,10 +567,10 @@ process.once('SIGTERM', () => shutdown('SIGTERM'));
550567process . once ( 'SIGINT' , ( ) => shutdown ( 'SIGINT' ) ) ;
551568process . 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 ( / ^ C o n t e n t - L e n g t h : \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 - Z a - 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
570663log ( `ready (server ${ SERVER . version } ); proxy base ${ readProxySettings ( ) . url } ` ) ;
571664armIdleExit ( ) ;
0 commit comments