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

109
internal/mcp/jsonrpc.go Normal file
View File

@@ -0,0 +1,109 @@
// Package mcp implements the Model Context Protocol (MCP) over JSON-RPC 2.0,
// providing both a client (Nexus -> upstream MCP servers) and the primitives
// used by the agent-facing MCP server (agents -> Nexus Gateway).
package mcp
import (
"encoding/json"
"fmt"
)
// JSONRPCVersion is the only JSON-RPC version MCP uses.
const JSONRPCVersion = "2.0"
// Standard JSON-RPC 2.0 error codes.
const (
CodeParseError = -32700
CodeInvalidRequest = -32600
CodeMethodNotFound = -32601
CodeInvalidParams = -32602
CodeInternalError = -32603
)
// Message is a single JSON-RPC 2.0 frame. It is deliberately permissive so it
// can represent requests, responses, and notifications on the wire; use the
// helpers to interpret it.
type Message struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
}
// IsRequest reports whether the message is a request (has method and id).
func (m *Message) IsRequest() bool { return m.Method != "" && len(m.ID) > 0 }
// IsNotification reports whether the message is a notification (method, no id).
func (m *Message) IsNotification() bool { return m.Method != "" && len(m.ID) == 0 }
// IsResponse reports whether the message is a response (result or error, id).
func (m *Message) IsResponse() bool { return m.Method == "" && len(m.ID) > 0 }
// RPCError is a JSON-RPC 2.0 error object.
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"`
}
func (e *RPCError) Error() string {
return fmt.Sprintf("jsonrpc error %d: %s", e.Code, e.Message)
}
// NewRequest builds a request message with the given id, method, and params.
func NewRequest(id json.RawMessage, method string, params any) (*Message, error) {
m := &Message{JSONRPC: JSONRPCVersion, ID: id, Method: method}
if params != nil {
b, err := json.Marshal(params)
if err != nil {
return nil, err
}
m.Params = b
}
return m, nil
}
// NewNotification builds a notification message (no id).
func NewNotification(method string, params any) (*Message, error) {
m := &Message{JSONRPC: JSONRPCVersion, Method: method}
if params != nil {
b, err := json.Marshal(params)
if err != nil {
return nil, err
}
m.Params = b
}
return m, nil
}
// NewResult builds a successful response for the given request id.
func NewResult(id json.RawMessage, result any) (*Message, error) {
b, err := json.Marshal(result)
if err != nil {
return nil, err
}
return &Message{JSONRPC: JSONRPCVersion, ID: id, Result: b}, nil
}
// NewError builds an error response for the given request id.
func NewError(id json.RawMessage, code int, msg string) *Message {
return &Message{JSONRPC: JSONRPCVersion, ID: id, Error: &RPCError{Code: code, Message: msg}}
}
// UnmarshalParams decodes the params of the message into v.
func (m *Message) UnmarshalParams(v any) error {
if len(m.Params) == 0 {
return nil
}
return json.Unmarshal(m.Params, v)
}
// UnmarshalResult decodes the result of the message into v.
func (m *Message) UnmarshalResult(v any) error {
if len(m.Result) == 0 {
return nil
}
return json.Unmarshal(m.Result, v)
}