Files
boocode/codecontext/shim.go
indifferentketchup a8c84ecfe4 chore+docs: config, agent registry, codecontext, v2.6 spec, changelog
Working-tree config/doc changes (.gitignore, CLAUDE.md, AGENTS.md removal + data/AGENTS.md, codecontext Dockerfile/shim — pre-existing) plus this session's v2-6 persistent-agent-sessions openspec proposal/design/tasks (planning only; feature unimplemented, reserves the v2.6.0 tag) and the v2.5.2 CHANGELOG entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 03:12:31 +00:00

443 lines
12 KiB
Go

// 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", "--respect-gitignore")
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")
}