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

110 lines
2.9 KiB
Go

// Command nexus is the MCP Nexus control plane binary.
//
// Subcommands:
//
// nexus serve Run the aggregating MCP gateway (the control plane).
// nexus connect Bridge a local stdio agent to a Nexus gateway endpoint.
// nexus demo-mcp Run the built-in demo MCP server over stdio.
// nexus version Print version information.
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/app"
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/bridge"
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/config"
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/demomcp"
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/version"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
cmd := os.Args[1]
args := os.Args[2:]
var err error
switch cmd {
case "serve":
err = runServe(args)
case "connect":
err = runConnect(args)
case "demo-mcp":
err = demomcp.Run(os.Stdin, os.Stdout)
case "version", "--version", "-v":
fmt.Println("mcp-nexus", version.String())
case "help", "-h", "--help":
usage()
default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", cmd)
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func runServe(args []string) error {
fs := flag.NewFlagSet("serve", flag.ExitOnError)
cfgPath := fs.String("config", "config.yaml", "path to config file")
logFormat := fs.String("log", "text", "log format: text|json")
_ = fs.Parse(args)
log := newLogger(*logFormat)
cfg, err := config.Load(*cfgPath)
if err != nil {
return err
}
if cfg.Gateway.Version == "" {
cfg.Gateway.Version = version.Version
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
return app.Serve(ctx, cfg, log)
}
func runConnect(args []string) error {
fs := flag.NewFlagSet("connect", flag.ExitOnError)
url := fs.String("url", "http://localhost:8080/mcp", "Nexus gateway MCP endpoint")
_ = fs.Parse(args)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
return bridge.Run(ctx, *url, os.Stdin, os.Stdout)
}
func newLogger(format string) *slog.Logger {
// Logs go to stderr so stdio-transport subcommands keep stdout clean.
var h slog.Handler
if format == "json" {
h = slog.NewJSONHandler(os.Stderr, nil)
} else {
h = slog.NewTextHandler(os.Stderr, nil)
}
return slog.New(h)
}
func usage() {
fmt.Fprint(os.Stderr, `mcp-nexus — the control plane for MCP
Usage:
nexus serve [-config config.yaml] [-log text|json] Run the aggregating MCP gateway
nexus connect [-url http://localhost:8080/mcp] Bridge a local stdio agent to a gateway
nexus demo-mcp Run the built-in demo MCP server (stdio)
nexus version Print version
`)
}