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>
443 lines
12 KiB
Go
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")
|
|
}
|