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>
110 lines
3.3 KiB
Go
110 lines
3.3 KiB
Go
// 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)
|
|
}
|