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:
72
internal/store/store.go
Normal file
72
internal/store/store.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Package store defines MCP Nexus's persistence interfaces (ARCHITECTURE §8)
|
||||
// and an in-memory implementation used for Phase 1 and tests. A SQLite-backed
|
||||
// implementation lands in a later brick behind the same interfaces.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/domain"
|
||||
)
|
||||
|
||||
// InstanceStore persists MCP instances (the observed/desired state the
|
||||
// reconciler manages).
|
||||
type InstanceStore interface {
|
||||
PutInstance(ctx context.Context, inst domain.MCPInstance) error
|
||||
GetInstance(ctx context.Context, id string) (domain.MCPInstance, bool, error)
|
||||
ListInstances(ctx context.Context) ([]domain.MCPInstance, error)
|
||||
DeleteInstance(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// Store aggregates all persistence surfaces. It will grow as later modules
|
||||
// (registry, identity, secrets, audit) come online.
|
||||
type Store interface {
|
||||
InstanceStore
|
||||
}
|
||||
|
||||
// Memory is a thread-safe in-memory Store.
|
||||
type Memory struct {
|
||||
mu sync.RWMutex
|
||||
instances map[string]domain.MCPInstance
|
||||
}
|
||||
|
||||
// NewMemory creates an empty in-memory store.
|
||||
func NewMemory() *Memory {
|
||||
return &Memory{instances: make(map[string]domain.MCPInstance)}
|
||||
}
|
||||
|
||||
func (m *Memory) PutInstance(_ context.Context, inst domain.MCPInstance) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.instances[inst.ID] = inst
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) GetInstance(_ context.Context, id string) (domain.MCPInstance, bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
inst, ok := m.instances[id]
|
||||
return inst, ok, nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListInstances(_ context.Context) ([]domain.MCPInstance, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]domain.MCPInstance, 0, len(m.instances))
|
||||
for _, inst := range m.instances {
|
||||
out = append(out, inst)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) DeleteInstance(_ context.Context, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.instances, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ Store = (*Memory)(nil)
|
||||
Reference in New Issue
Block a user