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:
drjones
2026-07-07 09:43:08 +00:00
parent 8d3ffef920
commit 72020b1901
25 changed files with 2450 additions and 0 deletions

122
internal/mcp/client.go Normal file
View File

@@ -0,0 +1,122 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"sync"
)
// Client is an MCP client bound to a single upstream server via a Transport.
// It performs the initialize handshake and exposes tools/list and tools/call.
type Client struct {
transport Transport
info Implementation
mu sync.RWMutex
initialized bool
serverInfo Implementation
serverCaps Capabilities
}
// NewClient wraps a transport. clientInfo identifies Nexus to the upstream.
func NewClient(t Transport, clientInfo Implementation) *Client {
return &Client{transport: t, info: clientInfo}
}
// SetNotificationHandler forwards peer notifications (e.g. list_changed).
func (c *Client) SetNotificationHandler(h func(*Message)) {
c.transport.SetNotificationHandler(h)
}
// Initialize performs the MCP initialize handshake and sends the initialized
// notification. It is safe to call once per client.
func (c *Client) Initialize(ctx context.Context) error {
params := InitializeParams{
ProtocolVersion: ProtocolVersion,
Capabilities: Capabilities{},
ClientInfo: c.info,
}
resp, err := c.transport.Call(ctx, MethodInitialize, params)
if err != nil {
return fmt.Errorf("initialize: %w", err)
}
if resp.Error != nil {
return fmt.Errorf("initialize: %w", resp.Error)
}
var res InitializeResult
if err := resp.UnmarshalResult(&res); err != nil {
return fmt.Errorf("initialize: decode result: %w", err)
}
c.mu.Lock()
c.initialized = true
c.serverInfo = res.ServerInfo
c.serverCaps = res.Capabilities
c.mu.Unlock()
if err := c.transport.Notify(ctx, MethodInitialized, nil); err != nil {
return fmt.Errorf("initialized notification: %w", err)
}
return nil
}
// ServerInfo returns the upstream's advertised identity (valid after Initialize).
func (c *Client) ServerInfo() Implementation {
c.mu.RLock()
defer c.mu.RUnlock()
return c.serverInfo
}
// ListTools returns all tools exposed by the upstream, following pagination.
func (c *Client) ListTools(ctx context.Context) ([]ToolDefinition, error) {
var all []ToolDefinition
cursor := ""
for {
resp, err := c.transport.Call(ctx, MethodToolsList, ListToolsParams{Cursor: cursor})
if err != nil {
return nil, fmt.Errorf("tools/list: %w", err)
}
if resp.Error != nil {
return nil, fmt.Errorf("tools/list: %w", resp.Error)
}
var res ListToolsResult
if err := resp.UnmarshalResult(&res); err != nil {
return nil, fmt.Errorf("tools/list: decode: %w", err)
}
all = append(all, res.Tools...)
if res.NextCursor == "" {
break
}
cursor = res.NextCursor
}
return all, nil
}
// CallTool invokes a tool by its upstream (un-namespaced) name. The raw result
// message is returned so the caller can pass MCP content through unmodified;
// callToolResult is a decoded convenience view.
func (c *Client) CallTool(ctx context.Context, name string, arguments json.RawMessage) (json.RawMessage, *RPCError, error) {
resp, err := c.transport.Call(ctx, MethodToolsCall, CallToolParams{Name: name, Arguments: arguments})
if err != nil {
return nil, nil, fmt.Errorf("tools/call %s: %w", name, err)
}
if resp.Error != nil {
return nil, resp.Error, nil
}
return resp.Result, nil, nil
}
// Ping issues an MCP ping, used for liveness checks.
func (c *Client) Ping(ctx context.Context) error {
resp, err := c.transport.Call(ctx, MethodPing, struct{}{})
if err != nil {
return err
}
if resp.Error != nil {
return resp.Error
}
return nil
}
// Close shuts down the underlying transport.
func (c *Client) Close() error { return c.transport.Close() }