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:
109
cmd/nexus/main.go
Normal file
109
cmd/nexus/main.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// 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
|
||||
`)
|
||||
}
|
||||
Reference in New Issue
Block a user