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>
137 lines
3.7 KiB
Go
137 lines
3.7 KiB
Go
// Package demomcp is a tiny, dependency-free MCP server that speaks the stdio
|
|
// transport. It lets you exercise the Nexus Gateway end-to-end with no external
|
|
// MCP servers installed, and doubles as a hermetic test fixture.
|
|
package demomcp
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"time"
|
|
|
|
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/mcp"
|
|
)
|
|
|
|
// ServerInfo is the demo server's advertised identity.
|
|
var ServerInfo = mcp.Implementation{Name: "nexus-demo-mcp", Version: "0.1.0"}
|
|
|
|
// nowFunc is overridable in tests.
|
|
var nowFunc = time.Now
|
|
|
|
// Run serves MCP over the given reader/writer (typically os.Stdin/os.Stdout)
|
|
// until the input is closed.
|
|
func Run(in io.Reader, out io.Writer) error {
|
|
scanner := bufio.NewScanner(in)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 8<<20)
|
|
enc := json.NewEncoder(out)
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
var msg mcp.Message
|
|
if err := json.Unmarshal(line, &msg); err != nil {
|
|
continue
|
|
}
|
|
if msg.IsNotification() {
|
|
continue // e.g. notifications/initialized — nothing to reply
|
|
}
|
|
resp := dispatch(&msg)
|
|
if resp != nil {
|
|
if err := enc.Encode(resp); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return scanner.Err()
|
|
}
|
|
|
|
func dispatch(msg *mcp.Message) *mcp.Message {
|
|
switch msg.Method {
|
|
case mcp.MethodInitialize:
|
|
res := mcp.InitializeResult{
|
|
ProtocolVersion: mcp.ProtocolVersion,
|
|
Capabilities: mcp.Capabilities{Tools: &mcp.ToolsCapability{}},
|
|
ServerInfo: ServerInfo,
|
|
}
|
|
m, _ := mcp.NewResult(msg.ID, res)
|
|
return m
|
|
case mcp.MethodPing:
|
|
m, _ := mcp.NewResult(msg.ID, struct{}{})
|
|
return m
|
|
case mcp.MethodToolsList:
|
|
m, _ := mcp.NewResult(msg.ID, mcp.ListToolsResult{Tools: toolDefs()})
|
|
return m
|
|
case mcp.MethodToolsCall:
|
|
return callTool(msg)
|
|
default:
|
|
return mcp.NewError(msg.ID, mcp.CodeMethodNotFound, "unknown method: "+msg.Method)
|
|
}
|
|
}
|
|
|
|
func toolDefs() []mcp.ToolDefinition {
|
|
return []mcp.ToolDefinition{
|
|
{
|
|
Name: "echo",
|
|
Description: "Echo back the provided message.",
|
|
InputSchema: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{"message": map[string]any{"type": "string"}},
|
|
"required": []any{"message"},
|
|
},
|
|
},
|
|
{
|
|
Name: "add",
|
|
Description: "Add two numbers and return the sum.",
|
|
InputSchema: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"a": map[string]any{"type": "number"},
|
|
"b": map[string]any{"type": "number"},
|
|
},
|
|
"required": []any{"a", "b"},
|
|
},
|
|
},
|
|
{
|
|
Name: "now",
|
|
Description: "Return the current server time in RFC3339.",
|
|
InputSchema: map[string]any{"type": "object", "properties": map[string]any{}},
|
|
},
|
|
}
|
|
}
|
|
|
|
func callTool(msg *mcp.Message) *mcp.Message {
|
|
var params mcp.CallToolParams
|
|
if err := msg.UnmarshalParams(¶ms); err != nil {
|
|
return mcp.NewError(msg.ID, mcp.CodeInvalidParams, err.Error())
|
|
}
|
|
switch params.Name {
|
|
case "echo":
|
|
var args struct {
|
|
Message string `json:"message"`
|
|
}
|
|
_ = json.Unmarshal(params.Arguments, &args)
|
|
m, _ := mcp.NewResult(msg.ID, mcp.TextResult(args.Message, false))
|
|
return m
|
|
case "add":
|
|
var args struct {
|
|
A float64 `json:"a"`
|
|
B float64 `json:"b"`
|
|
}
|
|
if err := json.Unmarshal(params.Arguments, &args); err != nil {
|
|
m, _ := mcp.NewResult(msg.ID, mcp.TextResult("invalid arguments: "+err.Error(), true))
|
|
return m
|
|
}
|
|
m, _ := mcp.NewResult(msg.ID, mcp.TextResult(fmt.Sprintf("%g", args.A+args.B), false))
|
|
return m
|
|
case "now":
|
|
m, _ := mcp.NewResult(msg.ID, mcp.TextResult(nowFunc().UTC().Format(time.RFC3339), false))
|
|
return m
|
|
default:
|
|
m, _ := mcp.NewResult(msg.ID, mcp.TextResult("unknown tool: "+params.Name, true))
|
|
return m
|
|
}
|
|
}
|