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>
123 lines
3.5 KiB
Go
123 lines
3.5 KiB
Go
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() }
|