// boocode-codecontext-shim — wraps codecontext's stdio MCP server with an // HTTP/JSON facade so the BooCode Node server can call codecontext over the // container network instead of speaking MCP directly. One process per // container, holds a single codecontext child via os/exec; concurrent HTTP // requests are serialized onto the child because codecontext's internal // CodeContextMCPServer.graph swaps per target_dir (see recon report // 2026-05-21). // // MCP framing is newline-delimited JSON (NDJSON), not LSP-style // Content-Length — per the MCP stdio transport spec: // https://spec.modelcontextprotocol.io/specification/server/transports // // No third-party deps. Stdlib only. package main import ( "bufio" "context" "encoding/json" "errors" "fmt" "io" "log" "net/http" "os" "os/exec" "os/signal" "sync" "sync/atomic" "syscall" "time" ) // ---- JSON-RPC types ---- // rpcMessage is shared by request, response, and notification. Notifications // omit ID; requests omit Result/Error; responses omit Method/Params. omitempty // + the zero int 0 sentinel works for ID because we never SEND id=0 // (nextID starts at 0 and atomic.AddInt32 returns 1 on the first call). type rpcMessage struct { JSONRPC string `json:"jsonrpc"` ID int `json:"id,omitempty"` Method string `json:"method,omitempty"` Params json.RawMessage `json:"params,omitempty"` Result json.RawMessage `json:"result,omitempty"` Error *rpcError `json:"error,omitempty"` } type rpcError struct { Code int `json:"code"` Message string `json:"message"` } // callToolResult is the MCP tools/call response shape. codecontext returns // markdown wrapped in a TextContent entry. type callToolResult struct { Content []struct { Type string `json:"type"` Text string `json:"text"` } `json:"content"` IsError bool `json:"isError,omitempty"` } // ---- Globals ---- var ( child *exec.Cmd childStdin io.WriteCloser childStdout *bufio.Reader // Serialize tools/call so codecontext's per-call graph rebuild doesn't // race itself when concurrent HTTP requests target different projects. // Initialize/notifications/initialized run before HTTP starts so they // don't need this lock. callMu sync.Mutex pendingMu sync.Mutex pending = make(map[int]chan *rpcMessage) nextID int32 ) // ---- MCP framing (NDJSON) ---- func writeMessage(w io.Writer, msg *rpcMessage) error { body, err := json.Marshal(msg) if err != nil { return err } // Single write keeps the message atomic across concurrent writers. // (We don't actually have concurrent writers here — callMu serializes — // but the +'\n' append needs to be in one syscall regardless.) _, err = w.Write(append(body, '\n')) return err } func readerLoop(r *bufio.Reader) { for { line, err := r.ReadBytes('\n') if err != nil { if errors.Is(err, io.EOF) { log.Printf("reader: EOF (child closed stdout)") } else { log.Printf("reader: %v", err) } return } var msg rpcMessage if err := json.Unmarshal(line, &msg); err != nil { log.Printf("reader: malformed JSON: %v (line=%q)", err, line) continue } if msg.ID == 0 { // Server-initiated notification or progress update; nothing to // dispatch. codecontext doesn't currently send these but the // MCP spec allows them. continue } pendingMu.Lock() ch, ok := pending[msg.ID] if ok { delete(pending, msg.ID) } pendingMu.Unlock() if ok { ch <- &msg } } } func call(ctx context.Context, method string, params any) (*rpcMessage, error) { id := int(atomic.AddInt32(&nextID, 1)) ch := make(chan *rpcMessage, 1) pendingMu.Lock() pending[id] = ch pendingMu.Unlock() paramsJSON, err := json.Marshal(params) if err != nil { pendingMu.Lock() delete(pending, id) pendingMu.Unlock() return nil, err } msg := &rpcMessage{ JSONRPC: "2.0", ID: id, Method: method, Params: paramsJSON, } if err := writeMessage(childStdin, msg); err != nil { pendingMu.Lock() delete(pending, id) pendingMu.Unlock() return nil, fmt.Errorf("write: %w", err) } select { case resp := <-ch: return resp, nil case <-ctx.Done(): pendingMu.Lock() delete(pending, id) pendingMu.Unlock() return nil, ctx.Err() } } func notify(method string, params any) error { paramsJSON, err := json.Marshal(params) if err != nil { return err } msg := &rpcMessage{ JSONRPC: "2.0", Method: method, Params: paramsJSON, } return writeMessage(childStdin, msg) } // ---- Child lifecycle ---- func startChild() error { // `codecontext mcp` with --watch=true (the default) keeps fsnotify // running on the indexed directory; the per-call target_dir swap // invalidates and re-indexes on demand. `--target=/opt/projects` is the // initial scan target — codecontext rebuilds the graph against whatever // target_dir each call carries, so this is just a valid bootstrap path // (the default "." is the alpine root and trips on transient /proc fds). child = exec.Command("codecontext", "mcp", "--target=/opt/projects", "--watch=true") var err error childStdin, err = child.StdinPipe() if err != nil { return fmt.Errorf("stdin pipe: %w", err) } stdout, err := child.StdoutPipe() if err != nil { return fmt.Errorf("stdout pipe: %w", err) } childStdout = bufio.NewReader(stdout) // codecontext's own log.SetOutput(os.Stderr) keeps its diagnostic noise // off the JSON-RPC channel; we just pass-through to our own stderr. child.Stderr = os.Stderr if err := child.Start(); err != nil { return fmt.Errorf("start: %w", err) } log.Printf("started codecontext pid=%d", child.Process.Pid) go readerLoop(childStdout) // Supervise the child. When codecontext exits (crash, OOM, externally // pkill'd), child.Wait() returns and we tear the shim down so the // container's `restart: unless-stopped` policy recreates us with a // fresh child. Without this goroutine the dead child becomes a zombie // (Signal(0) on a zombie returns nil, so the health endpoint would lie) // and HTTP requests would queue forever waiting on responses that will // never come. Discovered during B.1 kill-restart testing. go func() { err := child.Wait() log.Printf("codecontext exited: %v — shim shutting down", err) os.Exit(1) }() return nil } func killChild() { if child == nil || child.Process == nil { return } log.Printf("killing codecontext pid=%d", child.Process.Pid) _ = child.Process.Signal(syscall.SIGTERM) done := make(chan error, 1) go func() { done <- child.Wait() }() select { case <-done: log.Printf("codecontext exited") case <-time.After(5 * time.Second): log.Printf("codecontext did not exit on SIGTERM; sending SIGKILL") _ = child.Process.Kill() <-done } } // MCP handshake: client sends initialize, server replies, client follows // with the notifications/initialized notification. After that, tools/call // is accepted. func initializeMCP(ctx context.Context) error { initParams := map[string]any{ "protocolVersion": "2024-11-05", "capabilities": map[string]any{}, "clientInfo": map[string]any{ "name": "boocode-codecontext-shim", "version": "0.1.0", }, } resp, err := call(ctx, "initialize", initParams) if err != nil { return fmt.Errorf("initialize: %w", err) } if resp.Error != nil { return fmt.Errorf("initialize error %d: %s", resp.Error.Code, resp.Error.Message) } if err := notify("notifications/initialized", map[string]any{}); err != nil { return fmt.Errorf("notifications/initialized: %w", err) } log.Printf("MCP handshake complete (server result=%s)", string(resp.Result)) return nil } // ---- HTTP ---- func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(body) } func handleHealth(w http.ResponseWriter, r *http.Request) { if child == nil || child.Process == nil { http.Error(w, "no child", http.StatusServiceUnavailable) return } // Signal 0 doesn't actually deliver — it just returns an error if the // process is gone. Cheaper than parsing /proc. if err := child.Process.Signal(syscall.Signal(0)); err != nil { http.Error(w, "child dead: "+err.Error(), http.StatusServiceUnavailable) return } _, _ = io.WriteString(w, "ok") } func makeToolHandler(toolName string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { start := time.Now() targetDir := "-" status := "ok" defer func() { log.Printf("%s target_dir=%q duration_ms=%d status=%s", toolName, targetDir, time.Since(start).Milliseconds(), status) }() var args json.RawMessage if err := json.NewDecoder(r.Body).Decode(&args); err != nil { status = "bad_request" writeJSON(w, http.StatusBadRequest, map[string]any{ "result": nil, "error": "invalid JSON body: " + err.Error(), }) return } // Sniff target_dir purely for the access log; pass args through opaque. var argsMap map[string]any if json.Unmarshal(args, &argsMap) == nil { if td, ok := argsMap["target_dir"].(string); ok { targetDir = td } } ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) defer cancel() callMu.Lock() resp, err := call(ctx, "tools/call", map[string]any{ "name": toolName, "arguments": args, }) callMu.Unlock() if err != nil { status = "rpc_error" writeJSON(w, http.StatusBadGateway, map[string]any{ "result": nil, "error": err.Error(), }) return } if resp.Error != nil { status = "mcp_error" writeJSON(w, http.StatusOK, map[string]any{ "result": nil, "error": resp.Error.Message, }) return } var ctr callToolResult if err := json.Unmarshal(resp.Result, &ctr); err != nil { status = "parse_error" writeJSON(w, http.StatusOK, map[string]any{ "result": nil, "error": "parse result: " + err.Error(), }) return } // codecontext only emits text content. Concatenate (single-entry in // practice, but the schema allows multiple). var buf []byte for _, c := range ctr.Content { if c.Type == "text" { buf = append(buf, c.Text...) } } text := string(buf) if ctr.IsError { status = "tool_error" writeJSON(w, http.StatusOK, map[string]any{ "result": nil, "error": text, }) return } writeJSON(w, http.StatusOK, map[string]any{ "result": text, "error": nil, }) } } // ---- main ---- func main() { log.SetOutput(os.Stderr) log.SetFlags(log.LstdFlags | log.Lmicroseconds) log.Println("boocode-codecontext-shim starting") if err := startChild(); err != nil { log.Fatalf("startChild: %v", err) } initCtx, initCancel := context.WithTimeout(context.Background(), 30*time.Second) if err := initializeMCP(initCtx); err != nil { initCancel() killChild() log.Fatalf("initializeMCP: %v", err) } initCancel() sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT) mux := http.NewServeMux() // Go 1.22+ method-prefix routing. Any non-listed method → 405 automatically. mux.HandleFunc("GET /health", handleHealth) mux.HandleFunc("POST /v1/get_codebase_overview", makeToolHandler("get_codebase_overview")) mux.HandleFunc("POST /v1/get_file_analysis", makeToolHandler("get_file_analysis")) mux.HandleFunc("POST /v1/get_symbol_info", makeToolHandler("get_symbol_info")) mux.HandleFunc("POST /v1/search_symbols", makeToolHandler("search_symbols")) mux.HandleFunc("POST /v1/get_dependencies", makeToolHandler("get_dependencies")) mux.HandleFunc("POST /v1/watch_changes", makeToolHandler("watch_changes")) mux.HandleFunc("POST /v1/get_semantic_neighborhoods", makeToolHandler("get_semantic_neighborhoods")) mux.HandleFunc("POST /v1/get_framework_analysis", makeToolHandler("get_framework_analysis")) server := &http.Server{ Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second, } go func() { log.Println("listening on :8080") if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("ListenAndServe: %v", err) } }() <-sigChan log.Println("shutdown signal received") shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) _ = server.Shutdown(shutdownCtx) shutdownCancel() killChild() log.Println("exit") }