feat: Phase 0/1 — runnable MCP gateway (aggregation + namespaced routing)

Implements the walking skeleton from the roadmap: agents connect to one HTTP
endpoint and Nexus aggregates upstream MCP servers behind it with namespaced
tools. Written in Go (single static binary), no external services required.

What works end-to-end (verified live + hermetic e2e tests):
- MCP protocol layer: JSON-RPC 2.0 + initialize/tools.list/tools.call
  (internal/mcp), with stdio (subprocess) and Streamable HTTP client
  transports, and a reusable stream transport for in-process wiring.
- Router + Dynamic Tool Registry: connects/initializes upstreams in parallel,
  loads tools, namespaces them as {namespace}.{tool}, dispatches tools/call to
  the owning upstream; refreshes on list_changed. A failed upstream stays
  not-ready without taking down the gateway (data plane stays up).
- Gateway: single MCP endpoint (POST /mcp) that is an MCP server to agents,
  plus /healthz and a minimal /metrics exposition.
- CLI (cmd/nexus): `serve`, `connect` (stdio<->HTTP bridge for local agents),
  `demo-mcp` (built-in zero-dep demo server: echo/add/now), `version`.
- Config: declarative YAML with env expansion + validation.
- Store: persistence interfaces + in-memory impl (SQLite lands later).

Foundations for later phases: domain types (ARCHITECTURE §7), two-plane
split, event-driven refresh seam.

Tooling: Makefile (build/test/vet/fmt with version ldflags), config.example
.yaml. GOPATH moved to /go so it doesn't collide with the module root at /root;
.gitignore whitelist extended to track Go sources while ignoring build output.

Tests: unit (registry/namespacing) + full e2e (client -> demo server over
pipes -> router -> HTTP gateway: initialize, aggregated tools/list, tool-call
routing, unknown-tool error). go vet + gofmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
drjones
2026-07-07 09:43:08 +00:00
parent 8d3ffef920
commit 72020b1901
25 changed files with 2450 additions and 0 deletions

View File

@@ -0,0 +1,241 @@
package mcp
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os/exec"
"strconv"
"sync"
"sync/atomic"
)
// StdioConfig configures a subprocess-backed stdio MCP transport.
type StdioConfig struct {
Command string
Args []string
Env []string // additional environment (KEY=VALUE), appended to os env
Dir string
// Stderr, if non-nil, receives the child process's stderr.
Stderr io.Writer
}
// streamTransport speaks newline-delimited JSON-RPC over any reader/writer
// pair, correlating responses with requests by id. It backs both the
// subprocess stdio transport and in-process pipe-connected transports.
type streamTransport struct {
w io.WriteCloser
enc *json.Encoder
r io.Reader
// process control (nil for non-subprocess transports)
proc interface{ Kill() error }
waitFn func() error
mu sync.Mutex
counter int64
pending map[string]chan *Message
closed bool
exitErr error
notify atomic.Pointer[func(*Message)]
done chan struct{}
}
func newStreamTransport(r io.Reader, w io.WriteCloser) *streamTransport {
t := &streamTransport{
w: w,
enc: json.NewEncoder(w),
r: r,
pending: make(map[string]chan *Message),
done: make(chan struct{}),
}
go t.readLoop()
return t
}
// NewStreamTransport creates a transport over an existing reader/writer pair,
// e.g. an in-process pipe. It performs no process management.
func NewStreamTransport(r io.Reader, w io.WriteCloser) Transport {
return newStreamTransport(r, w)
}
// NewStdioTransport spawns the configured command and serves MCP over its
// stdio. The process is terminated by Close.
func NewStdioTransport(cfg StdioConfig) (Transport, error) {
if cfg.Command == "" {
return nil, errors.New("stdio transport: empty command")
}
cmd := exec.Command(cfg.Command, cfg.Args...)
cmd.Dir = cfg.Dir
if len(cfg.Env) > 0 {
cmd.Env = append(cmd.Environ(), cfg.Env...)
}
cmd.Stderr = cfg.Stderr
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("stdio transport: stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("stdio transport: stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("stdio transport: start %q: %w", cfg.Command, err)
}
t := &streamTransport{
w: stdin,
enc: json.NewEncoder(stdin),
r: stdout,
waitFn: cmd.Wait,
pending: make(map[string]chan *Message),
done: make(chan struct{}),
}
if cmd.Process != nil {
t.proc = cmd.Process
}
go t.readLoop()
return t, nil
}
func (t *streamTransport) SetNotificationHandler(h func(*Message)) {
if h == nil {
t.notify.Store(nil)
return
}
t.notify.Store(&h)
}
func (t *streamTransport) readLoop() {
scanner := bufio.NewScanner(t.r)
// MCP messages can be large (tool schemas); allow up to 16 MiB lines.
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if len(bytes.TrimSpace(line)) == 0 {
continue
}
var m Message
if err := json.Unmarshal(line, &m); err != nil {
continue // ignore non-JSON noise
}
if m.IsResponse() {
key := normalizeID(m.ID)
t.mu.Lock()
ch := t.pending[key]
delete(t.pending, key)
t.mu.Unlock()
if ch != nil {
ch <- &m
}
continue
}
if m.Method != "" {
if hp := t.notify.Load(); hp != nil {
(*hp)(&m)
}
}
}
// input closed: peer is gone. Fail all in-flight calls.
var exitErr error
if t.waitFn != nil {
exitErr = t.waitFn()
}
t.mu.Lock()
t.closed = true
t.exitErr = exitErr
pending := t.pending
t.pending = map[string]chan *Message{}
t.mu.Unlock()
for _, ch := range pending {
close(ch)
}
close(t.done)
}
func (t *streamTransport) Call(ctx context.Context, method string, params any) (*Message, error) {
id := atomic.AddInt64(&t.counter, 1)
idRaw := json.RawMessage(strconv.FormatInt(id, 10))
req, err := NewRequest(idRaw, method, params)
if err != nil {
return nil, err
}
key := normalizeID(idRaw)
ch := make(chan *Message, 1)
t.mu.Lock()
if t.closed {
t.mu.Unlock()
return nil, t.closedErr()
}
t.pending[key] = ch
err = t.enc.Encode(req)
t.mu.Unlock()
if err != nil {
t.mu.Lock()
delete(t.pending, key)
t.mu.Unlock()
return nil, fmt.Errorf("stdio write %s: %w", method, err)
}
select {
case <-ctx.Done():
t.mu.Lock()
delete(t.pending, key)
t.mu.Unlock()
return nil, ctx.Err()
case resp, ok := <-ch:
if !ok {
return nil, t.closedErr()
}
return resp, nil
case <-t.done:
return nil, t.closedErr()
}
}
func (t *streamTransport) Notify(_ context.Context, method string, params any) error {
note, err := NewNotification(method, params)
if err != nil {
return err
}
t.mu.Lock()
defer t.mu.Unlock()
if t.closed {
return t.closedErr()
}
return t.enc.Encode(note)
}
func (t *streamTransport) closedErr() error {
if t.exitErr != nil {
return fmt.Errorf("mcp stdio transport closed: %w", t.exitErr)
}
return errors.New("mcp stdio transport closed")
}
func (t *streamTransport) Close() error {
t.mu.Lock()
closed := t.closed
t.mu.Unlock()
_ = t.w.Close()
if t.proc != nil {
_ = t.proc.Kill()
}
if !closed {
<-t.done
}
return nil
}
// normalizeID produces a stable map key from a JSON-RPC id, tolerating
// whitespace differences in how peers echo the id.
func normalizeID(raw json.RawMessage) string {
return string(bytes.TrimSpace(raw))
}