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:
224
internal/gateway/gateway.go
Normal file
224
internal/gateway/gateway.go
Normal file
@@ -0,0 +1,224 @@
|
||||
// Package gateway implements the agent-facing edge: the single MCP endpoint
|
||||
// every AI agent connects to. It is an MCP *server* to agents (Streamable HTTP
|
||||
// + JSON-RPC 2.0) and delegates tool aggregation/dispatch to the Router.
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/mcp"
|
||||
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/router"
|
||||
)
|
||||
|
||||
// Gateway serves the aggregated MCP endpoint plus health/metrics.
|
||||
type Gateway struct {
|
||||
info mcp.Implementation
|
||||
mcpPath string
|
||||
router Router
|
||||
log *slog.Logger
|
||||
|
||||
metrics gwMetrics
|
||||
}
|
||||
|
||||
// Router is the subset of *router.Router the gateway depends on (kept small so
|
||||
// it is trivially mockable in tests).
|
||||
type Router interface {
|
||||
ListTools() []mcp.ToolDefinition
|
||||
CallTool(ctx context.Context, qualified string, args json.RawMessage) (json.RawMessage, *mcp.RPCError, error)
|
||||
Statuses() []router.Status
|
||||
}
|
||||
|
||||
type gwMetrics struct {
|
||||
requests atomic.Int64
|
||||
toolCalls atomic.Int64
|
||||
toolErrs atomic.Int64
|
||||
}
|
||||
|
||||
// Options configures a Gateway.
|
||||
type Options struct {
|
||||
Name string
|
||||
Version string
|
||||
MCPPath string
|
||||
Router Router
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a Gateway.
|
||||
func New(opts Options) *Gateway {
|
||||
log := opts.Logger
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
path := opts.MCPPath
|
||||
if path == "" {
|
||||
path = "/mcp"
|
||||
}
|
||||
return &Gateway{
|
||||
info: mcp.Implementation{Name: opts.Name, Version: opts.Version},
|
||||
mcpPath: path,
|
||||
router: opts.Router,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns the HTTP handler exposing the MCP endpoint and ops routes.
|
||||
func (g *Gateway) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(g.mcpPath, g.handleMCP)
|
||||
mux.HandleFunc("/healthz", g.handleHealth)
|
||||
mux.HandleFunc("/metrics", g.handleMetrics)
|
||||
mux.HandleFunc("/", g.handleRoot)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (g *Gateway) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"name": g.info.Name,
|
||||
"version": g.info.Version,
|
||||
"mcp": g.mcpPath,
|
||||
"protocol": mcp.ProtocolVersion,
|
||||
})
|
||||
}
|
||||
|
||||
func (g *Gateway) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
statuses := g.router.Statuses()
|
||||
ready := 0
|
||||
for _, s := range statuses {
|
||||
if s.Ready {
|
||||
ready++
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"upstreams_total": len(statuses),
|
||||
"upstreams_ready": ready,
|
||||
"tools": len(g.router.ListTools()),
|
||||
"upstream_statuses": statuses,
|
||||
})
|
||||
}
|
||||
|
||||
// handleMetrics exposes a minimal Prometheus text exposition. A full
|
||||
// client_golang integration lands with the Metrics module (Phase 4).
|
||||
func (g *Gateway) handleMetrics(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
||||
fmt.Fprintf(w, "# HELP nexus_gateway_requests_total Total MCP requests received.\n")
|
||||
fmt.Fprintf(w, "# TYPE nexus_gateway_requests_total counter\n")
|
||||
fmt.Fprintf(w, "nexus_gateway_requests_total %d\n", g.metrics.requests.Load())
|
||||
fmt.Fprintf(w, "# HELP nexus_gateway_tool_calls_total Total tools/call requests.\n")
|
||||
fmt.Fprintf(w, "# TYPE nexus_gateway_tool_calls_total counter\n")
|
||||
fmt.Fprintf(w, "nexus_gateway_tool_calls_total %d\n", g.metrics.toolCalls.Load())
|
||||
fmt.Fprintf(w, "# HELP nexus_gateway_tool_errors_total Total failed tools/call requests.\n")
|
||||
fmt.Fprintf(w, "# TYPE nexus_gateway_tool_errors_total counter\n")
|
||||
fmt.Fprintf(w, "nexus_gateway_tool_errors_total %d\n", g.metrics.toolErrs.Load())
|
||||
fmt.Fprintf(w, "# HELP nexus_gateway_tools Registered tools currently exposed.\n")
|
||||
fmt.Fprintf(w, "# TYPE nexus_gateway_tools gauge\n")
|
||||
fmt.Fprintf(w, "nexus_gateway_tools %d\n", len(g.router.ListTools()))
|
||||
}
|
||||
|
||||
// handleMCP is the single MCP endpoint. It accepts a JSON-RPC request or
|
||||
// notification and responds with application/json.
|
||||
func (g *Gateway) handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
g.metrics.requests.Add(1)
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 32<<20))
|
||||
if err != nil {
|
||||
writeRPC(w, mcp.NewError(nil, mcp.CodeParseError, "read body: "+err.Error()))
|
||||
return
|
||||
}
|
||||
var msg mcp.Message
|
||||
if err := json.Unmarshal(body, &msg); err != nil {
|
||||
writeRPC(w, mcp.NewError(nil, mcp.CodeParseError, "invalid JSON-RPC: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Notifications get 202 with no body.
|
||||
if msg.IsNotification() {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
resp := g.dispatch(r.Context(), &msg)
|
||||
writeRPC(w, resp)
|
||||
}
|
||||
|
||||
func (g *Gateway) dispatch(ctx context.Context, msg *mcp.Message) *mcp.Message {
|
||||
switch msg.Method {
|
||||
case mcp.MethodInitialize:
|
||||
res := mcp.InitializeResult{
|
||||
ProtocolVersion: mcp.ProtocolVersion,
|
||||
Capabilities: mcp.Capabilities{Tools: &mcp.ToolsCapability{ListChanged: true}},
|
||||
ServerInfo: g.info,
|
||||
Instructions: "MCP Nexus aggregates every upstream MCP server behind one endpoint. Tools are namespaced as {namespace}.{tool}.",
|
||||
}
|
||||
out, _ := mcp.NewResult(msg.ID, res)
|
||||
return out
|
||||
|
||||
case mcp.MethodPing:
|
||||
out, _ := mcp.NewResult(msg.ID, struct{}{})
|
||||
return out
|
||||
|
||||
case mcp.MethodToolsList:
|
||||
res := mcp.ListToolsResult{Tools: g.router.ListTools()}
|
||||
out, _ := mcp.NewResult(msg.ID, res)
|
||||
return out
|
||||
|
||||
case mcp.MethodToolsCall:
|
||||
return g.handleToolCall(ctx, msg)
|
||||
|
||||
default:
|
||||
return mcp.NewError(msg.ID, mcp.CodeMethodNotFound, "unknown method: "+msg.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) handleToolCall(ctx context.Context, msg *mcp.Message) *mcp.Message {
|
||||
g.metrics.toolCalls.Add(1)
|
||||
var params mcp.CallToolParams
|
||||
if err := msg.UnmarshalParams(¶ms); err != nil {
|
||||
g.metrics.toolErrs.Add(1)
|
||||
return mcp.NewError(msg.ID, mcp.CodeInvalidParams, "invalid params: "+err.Error())
|
||||
}
|
||||
if params.Name == "" {
|
||||
g.metrics.toolErrs.Add(1)
|
||||
return mcp.NewError(msg.ID, mcp.CodeInvalidParams, "tool name is required")
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
raw, rpcErr, err := g.router.CallTool(ctx, params.Name, params.Arguments)
|
||||
if err != nil {
|
||||
g.metrics.toolErrs.Add(1)
|
||||
g.log.Warn("tool call transport error", "tool", params.Name, "error", err)
|
||||
return mcp.NewError(msg.ID, mcp.CodeInternalError, err.Error())
|
||||
}
|
||||
if rpcErr != nil {
|
||||
g.metrics.toolErrs.Add(1)
|
||||
return &mcp.Message{JSONRPC: mcp.JSONRPCVersion, ID: msg.ID, Error: rpcErr}
|
||||
}
|
||||
g.log.Info("tool call", "tool", params.Name, "ms", time.Since(start).Milliseconds())
|
||||
// Pass the upstream MCP result through unmodified.
|
||||
return &mcp.Message{JSONRPC: mcp.JSONRPCVersion, ID: msg.ID, Result: raw}
|
||||
}
|
||||
|
||||
func writeRPC(w http.ResponseWriter, msg *mcp.Message) {
|
||||
writeJSON(w, http.StatusOK, msg)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
Reference in New Issue
Block a user