261 lines
7.4 KiB
Go
261 lines
7.4 KiB
Go
|
|
// Package bridge connects an HTTP-side MCP client (e.g. Claude.ai over
|
||
|
|
// streamable HTTP) to a stdio-side MCP server (e.g. forgejo-mcp managed
|
||
|
|
// by supervisor) by piping JSON-RPC messages.
|
||
|
|
//
|
||
|
|
// The bridge is protocol-opaque: it parses each line only enough to extract
|
||
|
|
// the JSON-RPC `id` field for response routing. Everything else passes
|
||
|
|
// through verbatim. This is the design.md §5.3 "raw bytes through"
|
||
|
|
// approach — MCP is JSON-RPC 2.0 on both transports, so we can pipe
|
||
|
|
// without understanding semantics.
|
||
|
|
//
|
||
|
|
// One Bridge wraps one child. Multiple HTTP requests can be in flight
|
||
|
|
// concurrently; responses are routed back to the right HTTP handler by
|
||
|
|
// matching their `id` field against pending waiters.
|
||
|
|
package bridge
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bufio"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"log/slog"
|
||
|
|
"net/http"
|
||
|
|
"sync"
|
||
|
|
"sync/atomic"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Bridge owns the message routing for a single supervised child.
|
||
|
|
type Bridge struct {
|
||
|
|
stdin io.Writer
|
||
|
|
stdout *bufio.Reader
|
||
|
|
done <-chan struct{}
|
||
|
|
log *slog.Logger
|
||
|
|
|
||
|
|
writeMu sync.Mutex // serializes writes to stdin
|
||
|
|
pending sync.Map // string(id JSON) -> chan []byte
|
||
|
|
started atomic.Bool
|
||
|
|
closed atomic.Bool
|
||
|
|
}
|
||
|
|
|
||
|
|
// New constructs a Bridge over the supplied pipes. The caller is
|
||
|
|
// responsible for keeping stdin/stdout alive for the bridge's lifetime
|
||
|
|
// (typically by holding the supervisor.Child).
|
||
|
|
func New(stdin io.Writer, stdout *bufio.Reader, done <-chan struct{}, log *slog.Logger) *Bridge {
|
||
|
|
if log == nil {
|
||
|
|
log = slog.New(slog.DiscardHandler)
|
||
|
|
}
|
||
|
|
return &Bridge{stdin: stdin, stdout: stdout, done: done, log: log}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Start kicks off the reader goroutine. Must be called exactly once
|
||
|
|
// before HandleSSE is used; calling twice is a programmer error and is
|
||
|
|
// silently ignored.
|
||
|
|
func (b *Bridge) Start() {
|
||
|
|
if !b.started.CompareAndSwap(false, true) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
go b.readLoop()
|
||
|
|
}
|
||
|
|
|
||
|
|
// HandleSSE forwards the request body to the child as one newline-framed
|
||
|
|
// JSON-RPC line, then streams the matching response back as a single SSE
|
||
|
|
// event. Returns when the response is delivered, the client disconnects,
|
||
|
|
// or the child exits.
|
||
|
|
//
|
||
|
|
// Errors before SSE headers go out: written as plain HTTP errors with the
|
||
|
|
// appropriate status. Errors after headers: best-effort write of an SSE
|
||
|
|
// `error` event, then return.
|
||
|
|
func (b *Bridge) HandleSSE(w http.ResponseWriter, r *http.Request) {
|
||
|
|
body, err := io.ReadAll(r.Body)
|
||
|
|
if err != nil {
|
||
|
|
http.Error(w, "read body: "+err.Error(), http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if len(body) == 0 {
|
||
|
|
http.Error(w, "empty body", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
idKey, ok := extractIDKey(body)
|
||
|
|
if !ok {
|
||
|
|
// No id → notification. Forward without waiting; reply 204.
|
||
|
|
// (MCP notifications don't expect a response.)
|
||
|
|
if err := b.send(body); err != nil {
|
||
|
|
http.Error(w, "send: "+err.Error(), http.StatusBadGateway)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
w.WriteHeader(http.StatusNoContent)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Register a waiter before sending so a fast child can't reply before
|
||
|
|
// we're ready to receive.
|
||
|
|
respCh := make(chan []byte, 1)
|
||
|
|
if _, loaded := b.pending.LoadOrStore(idKey, respCh); loaded {
|
||
|
|
http.Error(w, "duplicate in-flight id", http.StatusConflict)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
defer b.pending.Delete(idKey)
|
||
|
|
|
||
|
|
if err := b.send(body); err != nil {
|
||
|
|
http.Error(w, "send: "+err.Error(), http.StatusBadGateway)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// SSE headers must go out before the first event.
|
||
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
||
|
|
w.Header().Set("Cache-Control", "no-cache")
|
||
|
|
w.Header().Set("Connection", "keep-alive")
|
||
|
|
w.WriteHeader(http.StatusOK)
|
||
|
|
flusher, _ := w.(http.Flusher)
|
||
|
|
if flusher != nil {
|
||
|
|
flusher.Flush()
|
||
|
|
}
|
||
|
|
|
||
|
|
select {
|
||
|
|
case resp := <-respCh:
|
||
|
|
writeSSEEvent(w, "message", resp)
|
||
|
|
if flusher != nil {
|
||
|
|
flusher.Flush()
|
||
|
|
}
|
||
|
|
case <-r.Context().Done():
|
||
|
|
// Client disconnected. Just return — child keeps running, future
|
||
|
|
// requests on the same bridge are unaffected.
|
||
|
|
b.log.Debug("client disconnected mid-stream", slog.String("id", idKey))
|
||
|
|
case <-b.done:
|
||
|
|
writeSSEEvent(w, "error", []byte(`{"reason":"child_exited"}`))
|
||
|
|
if flusher != nil {
|
||
|
|
flusher.Flush()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// send writes one newline-framed message to the child. Concurrent writes
|
||
|
|
// to the underlying pipe are not safe (interleaved bytes), so we serialize
|
||
|
|
// here.
|
||
|
|
func (b *Bridge) send(msg []byte) error {
|
||
|
|
b.writeMu.Lock()
|
||
|
|
defer b.writeMu.Unlock()
|
||
|
|
if _, err := b.stdin.Write(msg); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if len(msg) == 0 || msg[len(msg)-1] != '\n' {
|
||
|
|
if _, err := b.stdin.Write([]byte{'\n'}); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// readLoop reads one JSON-RPC message per line from the child's stdout
|
||
|
|
// and routes each to the waiting handler (if any). Lines without an id
|
||
|
|
// (server-initiated notifications) are dropped on the floor for now —
|
||
|
|
// phase 5 introduces the GET-stream channel for those.
|
||
|
|
func (b *Bridge) readLoop() {
|
||
|
|
for {
|
||
|
|
line, err := b.stdout.ReadString('\n')
|
||
|
|
if line != "" {
|
||
|
|
b.routeResponse(line)
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
if errors.Is(err, io.EOF) {
|
||
|
|
b.log.Debug("child stdout closed; bridge reader exiting")
|
||
|
|
} else {
|
||
|
|
b.log.Warn("bridge reader error", slog.String("err", err.Error()))
|
||
|
|
}
|
||
|
|
b.closed.Store(true)
|
||
|
|
// Wake any pending waiters so handlers don't hang forever after
|
||
|
|
// the child dies.
|
||
|
|
b.pending.Range(func(k, v any) bool {
|
||
|
|
if ch, ok := v.(chan []byte); ok {
|
||
|
|
select {
|
||
|
|
case <-ch:
|
||
|
|
default:
|
||
|
|
close(ch)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return true
|
||
|
|
})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (b *Bridge) routeResponse(line string) {
|
||
|
|
idKey, ok := extractIDKey([]byte(line))
|
||
|
|
if !ok {
|
||
|
|
// Server-initiated notification or malformed message — log and skip.
|
||
|
|
b.log.Debug("bridge: dropping un-routable line", slog.Int("len", len(line)))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
v, ok := b.pending.LoadAndDelete(idKey)
|
||
|
|
if !ok {
|
||
|
|
// No waiter — request was likely abandoned (client disconnected).
|
||
|
|
// Logging at debug; this is normal.
|
||
|
|
b.log.Debug("bridge: response with no waiter", slog.String("id", idKey))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
ch, _ := v.(chan []byte)
|
||
|
|
if ch == nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
// Non-blocking send: the caller created the channel with buffer 1, so
|
||
|
|
// this is guaranteed to succeed unless we're racing a Close.
|
||
|
|
select {
|
||
|
|
case ch <- []byte(line):
|
||
|
|
default:
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// extractIDKey parses the JSON-RPC `id` field and returns its raw JSON
|
||
|
|
// representation as a routing key. Returns ok=false when the message has
|
||
|
|
// no id (notification) or fails to parse.
|
||
|
|
//
|
||
|
|
// JSON-RPC ids are string, number, or null. Comparing the raw JSON token
|
||
|
|
// avoids quirks like distinguishing 1 from "1".
|
||
|
|
func extractIDKey(data []byte) (string, bool) {
|
||
|
|
var probe struct {
|
||
|
|
ID json.RawMessage `json:"id"`
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(data, &probe); err != nil {
|
||
|
|
return "", false
|
||
|
|
}
|
||
|
|
if len(probe.ID) == 0 || string(probe.ID) == "null" {
|
||
|
|
return "", false
|
||
|
|
}
|
||
|
|
return string(probe.ID), true
|
||
|
|
}
|
||
|
|
|
||
|
|
// writeSSEEvent writes one Server-Sent Event to w. The data may be
|
||
|
|
// multi-line — we re-frame it per the SSE spec (each \n becomes a
|
||
|
|
// separate `data:` line). For our typical single-line JSON, this is a
|
||
|
|
// no-op.
|
||
|
|
func writeSSEEvent(w io.Writer, event string, data []byte) {
|
||
|
|
fmt.Fprintf(w, "event: %s\n", event)
|
||
|
|
for _, line := range splitLines(data) {
|
||
|
|
fmt.Fprintf(w, "data: %s\n", line)
|
||
|
|
}
|
||
|
|
_, _ = w.Write([]byte("\n"))
|
||
|
|
}
|
||
|
|
|
||
|
|
func splitLines(data []byte) []string {
|
||
|
|
// Strip a single trailing newline (typical for child output).
|
||
|
|
if n := len(data); n > 0 && data[n-1] == '\n' {
|
||
|
|
data = data[:n-1]
|
||
|
|
}
|
||
|
|
if len(data) == 0 {
|
||
|
|
return []string{""}
|
||
|
|
}
|
||
|
|
out := []string{}
|
||
|
|
start := 0
|
||
|
|
for i, b := range data {
|
||
|
|
if b == '\n' {
|
||
|
|
out = append(out, string(data[start:i]))
|
||
|
|
start = i + 1
|
||
|
|
}
|
||
|
|
}
|
||
|
|
out = append(out, string(data[start:]))
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|