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:
116
internal/config/config.go
Normal file
116
internal/config/config.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Package config loads MCP Nexus's declarative YAML configuration. Per
|
||||
// ARCHITECTURE tenet #2, configuration is the primary way to express desired
|
||||
// state; in later phases discovery/recipes will also feed it.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/domain"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config is the top-level configuration document.
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Gateway GatewayConfig `yaml:"gateway"`
|
||||
Upstreams []UpstreamConfig `yaml:"upstreams"`
|
||||
}
|
||||
|
||||
// ServerConfig configures the HTTP listener.
|
||||
type ServerConfig struct {
|
||||
Address string `yaml:"address"`
|
||||
}
|
||||
|
||||
// GatewayConfig configures the agent-facing MCP server identity.
|
||||
type GatewayConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Version string `yaml:"version"`
|
||||
// MCPPath is the HTTP path the aggregated MCP endpoint is served on.
|
||||
MCPPath string `yaml:"mcp_path"`
|
||||
}
|
||||
|
||||
// UpstreamConfig statically declares an upstream MCP server (Phase 1). Later
|
||||
// phases populate instances via discovery + installer instead.
|
||||
type UpstreamConfig struct {
|
||||
Namespace string `yaml:"namespace"`
|
||||
Transport string `yaml:"transport"` // "stdio" | "http"
|
||||
Command string `yaml:"command,omitempty"`
|
||||
Args []string `yaml:"args,omitempty"`
|
||||
Env []string `yaml:"env,omitempty"`
|
||||
Dir string `yaml:"dir,omitempty"`
|
||||
Endpoint string `yaml:"endpoint,omitempty"`
|
||||
|
||||
Headers map[string]string `yaml:"headers,omitempty"`
|
||||
}
|
||||
|
||||
// Default returns a config populated with sensible defaults.
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
Server: ServerConfig{Address: ":8080"},
|
||||
Gateway: GatewayConfig{Name: "MCP Nexus", Version: "0.1.0-dev", MCPPath: "/mcp"},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads, expands environment variables in, and validates a config file.
|
||||
func Load(path string) (*Config, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
expanded := os.ExpandEnv(string(raw))
|
||||
|
||||
cfg := Default()
|
||||
if err := yaml.Unmarshal([]byte(expanded), cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
cfg.applyDefaults()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) applyDefaults() {
|
||||
if c.Server.Address == "" {
|
||||
c.Server.Address = ":8080"
|
||||
}
|
||||
if c.Gateway.Name == "" {
|
||||
c.Gateway.Name = "MCP Nexus"
|
||||
}
|
||||
if c.Gateway.MCPPath == "" {
|
||||
c.Gateway.MCPPath = "/mcp"
|
||||
}
|
||||
if !strings.HasPrefix(c.Gateway.MCPPath, "/") {
|
||||
c.Gateway.MCPPath = "/" + c.Gateway.MCPPath
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks the config for internal consistency.
|
||||
func (c *Config) Validate() error {
|
||||
seen := map[string]bool{}
|
||||
for i, u := range c.Upstreams {
|
||||
if u.Namespace == "" {
|
||||
return fmt.Errorf("upstreams[%d]: namespace is required", i)
|
||||
}
|
||||
if seen[u.Namespace] {
|
||||
return fmt.Errorf("upstreams[%d]: duplicate namespace %q", i, u.Namespace)
|
||||
}
|
||||
seen[u.Namespace] = true
|
||||
switch domain.TransportKind(u.Transport) {
|
||||
case domain.TransportStdio:
|
||||
if u.Command == "" {
|
||||
return fmt.Errorf("upstreams[%d] (%s): stdio transport requires command", i, u.Namespace)
|
||||
}
|
||||
case domain.TransportHTTP:
|
||||
if u.Endpoint == "" {
|
||||
return fmt.Errorf("upstreams[%d] (%s): http transport requires endpoint", i, u.Namespace)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("upstreams[%d] (%s): unknown transport %q (want stdio|http)", i, u.Namespace, u.Transport)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user