Files
drjones 72020b1901 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>
2026-07-07 09:43:08 +00:00

70 lines
1.7 KiB
Go

package app
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/config"
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/gateway"
)
// Serve builds the router from config, connects upstreams, and runs the HTTP
// gateway until ctx is cancelled, then shuts down gracefully.
func Serve(ctx context.Context, cfg *config.Config, log *slog.Logger) error {
r, err := BuildRouter(cfg, log)
if err != nil {
return err
}
defer r.Close()
log.Info("connecting upstreams", "count", len(cfg.Upstreams))
connectCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
r.ConnectAll(connectCtx)
cancel()
for _, s := range r.Statuses() {
if s.Ready {
log.Info("upstream ready", "namespace", s.Namespace, "server", s.Server)
} else {
log.Warn("upstream unavailable", "namespace", s.Namespace, "error", s.Error)
}
}
gw := gateway.New(gateway.Options{
Name: cfg.Gateway.Name,
Version: cfg.Gateway.Version,
MCPPath: cfg.Gateway.MCPPath,
Router: r,
Logger: log,
})
srv := &http.Server{
Addr: cfg.Server.Address,
Handler: gw.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
errCh := make(chan error, 1)
go func() {
log.Info("gateway listening",
"address", cfg.Server.Address,
"mcp_endpoint", cfg.Gateway.MCPPath,
"tools", len(r.ListTools()))
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case <-ctx.Done():
log.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
case err := <-errCh:
return err
}
}