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:
64
internal/app/build.go
Normal file
64
internal/app/build.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Package app wires configuration into the running control/data plane. In
|
||||
// Phase 1 it builds the Router from static upstream config; later phases feed
|
||||
// instances from discovery + the reconciler instead.
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/config"
|
||||
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/domain"
|
||||
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/mcp"
|
||||
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/router"
|
||||
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/version"
|
||||
)
|
||||
|
||||
// nexusInfo identifies Nexus to upstream MCP servers.
|
||||
func nexusInfo() mcp.Implementation {
|
||||
return mcp.Implementation{Name: "mcp-nexus", Version: version.Version}
|
||||
}
|
||||
|
||||
// BuildRouter constructs a Router with an upstream client per config entry.
|
||||
// Transports are created but not connected; call ConnectAll afterwards.
|
||||
func BuildRouter(cfg *config.Config, log *slog.Logger) (*router.Router, error) {
|
||||
r := router.New(nexusInfo(), log)
|
||||
for _, u := range cfg.Upstreams {
|
||||
transport, err := buildTransport(u)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upstream %q: %w", u.Namespace, err)
|
||||
}
|
||||
client := mcp.NewClient(transport, nexusInfo())
|
||||
r.Add(u.Namespace, client)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func buildTransport(u config.UpstreamConfig) (mcp.Transport, error) {
|
||||
switch domain.TransportKind(u.Transport) {
|
||||
case domain.TransportStdio:
|
||||
return mcp.NewStdioTransport(mcp.StdioConfig{
|
||||
Command: u.Command,
|
||||
Args: u.Args,
|
||||
Env: u.Env,
|
||||
Dir: u.Dir,
|
||||
Stderr: prefixWriter{ns: u.Namespace},
|
||||
})
|
||||
case domain.TransportHTTP:
|
||||
return mcp.NewHTTPTransport(mcp.HTTPConfig{
|
||||
Endpoint: u.Endpoint,
|
||||
Headers: u.Headers,
|
||||
})
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown transport %q", u.Transport)
|
||||
}
|
||||
}
|
||||
|
||||
// prefixWriter tags upstream stderr lines with their namespace on our stderr.
|
||||
type prefixWriter struct{ ns string }
|
||||
|
||||
func (p prefixWriter) Write(b []byte) (int, error) {
|
||||
fmt.Fprintf(os.Stderr, "[upstream:%s] %s", p.ns, b)
|
||||
return len(b), nil
|
||||
}
|
||||
69
internal/app/serve.go
Normal file
69
internal/app/serve.go
Normal file
@@ -0,0 +1,69 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user