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:
78
internal/bridge/bridge.go
Normal file
78
internal/bridge/bridge.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Package bridge implements `nexus connect`: a stdio<->HTTP shim so local
|
||||
// agents that only speak the MCP stdio transport (Claude Desktop, Cursor, …)
|
||||
// can attach to the Nexus Gateway's Streamable HTTP endpoint. Each line of
|
||||
// JSON-RPC on stdin is POSTed to the gateway; the response is written to stdout.
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Run bridges stdin/stdout to the MCP endpoint at url until stdin closes.
|
||||
func Run(ctx context.Context, url string, in io.Reader, out io.Writer) error {
|
||||
client := &http.Client{Timeout: 5 * time.Minute}
|
||||
scanner := bufio.NewScanner(in)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 16<<20)
|
||||
enc := json.NewEncoder(out)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := bytes.TrimSpace(scanner.Bytes())
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
// Preserve the raw frame; forward verbatim.
|
||||
frame := make([]byte, len(line))
|
||||
copy(frame, line)
|
||||
|
||||
respBody, isNotification, err := forward(ctx, client, url, frame)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bridge forward: %w", err)
|
||||
}
|
||||
if isNotification || len(respBody) == 0 {
|
||||
continue // notifications produce no stdout frame
|
||||
}
|
||||
var msg json.RawMessage = respBody
|
||||
if err := enc.Encode(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func forward(ctx context.Context, client *http.Client, url string, frame []byte) (body []byte, notification bool, err error) {
|
||||
// Detect notification (no id) to avoid emitting an empty stdout frame.
|
||||
var probe struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
_ = json.Unmarshal(frame, &probe)
|
||||
notification = probe.Method != "" && len(probe.ID) == 0
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(frame))
|
||||
if err != nil {
|
||||
return nil, notification, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, notification, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||
if err != nil {
|
||||
return nil, notification, err
|
||||
}
|
||||
if resp.StatusCode == http.StatusAccepted {
|
||||
return nil, true, nil
|
||||
}
|
||||
return bytes.TrimSpace(b), notification, nil
|
||||
}
|
||||
Reference in New Issue
Block a user