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:
122
internal/mcp/client.go
Normal file
122
internal/mcp/client.go
Normal file
@@ -0,0 +1,122 @@
|
||||
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() }
|
||||
173
internal/mcp/http_transport.go
Normal file
173
internal/mcp/http_transport.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// HTTPConfig configures a Streamable HTTP client transport.
|
||||
type HTTPConfig struct {
|
||||
Endpoint string
|
||||
Headers map[string]string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// httpTransport talks MCP over Streamable HTTP: each outbound message is an
|
||||
// HTTP POST whose response is either a single JSON message or an SSE stream.
|
||||
type httpTransport struct {
|
||||
endpoint string
|
||||
headers map[string]string
|
||||
client *http.Client
|
||||
|
||||
counter int64
|
||||
sessionID atomic.Value // string
|
||||
notify atomic.Pointer[func(*Message)]
|
||||
}
|
||||
|
||||
// NewHTTPTransport creates a Streamable HTTP transport to endpoint.
|
||||
func NewHTTPTransport(cfg HTTPConfig) (Transport, error) {
|
||||
if cfg.Endpoint == "" {
|
||||
return nil, fmt.Errorf("http transport: empty endpoint")
|
||||
}
|
||||
c := cfg.Client
|
||||
if c == nil {
|
||||
c = http.DefaultClient
|
||||
}
|
||||
return &httpTransport{endpoint: cfg.Endpoint, headers: cfg.Headers, client: c}, nil
|
||||
}
|
||||
|
||||
func (t *httpTransport) SetNotificationHandler(h func(*Message)) {
|
||||
if h == nil {
|
||||
t.notify.Store(nil)
|
||||
return
|
||||
}
|
||||
t.notify.Store(&h)
|
||||
}
|
||||
|
||||
func (t *httpTransport) post(ctx context.Context, msg *Message) (*http.Response, error) {
|
||||
body, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
for k, v := range t.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if sid, ok := t.sessionID.Load().(string); ok && sid != "" {
|
||||
req.Header.Set("Mcp-Session-Id", sid)
|
||||
}
|
||||
return t.client.Do(req)
|
||||
}
|
||||
|
||||
func (t *httpTransport) Call(ctx context.Context, method string, params any) (*Message, error) {
|
||||
id := atomic.AddInt64(&t.counter, 1)
|
||||
idRaw := json.RawMessage(fmt.Sprintf("%d", id))
|
||||
req, err := NewRequest(idRaw, method, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := t.post(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if sid := resp.Header.Get("Mcp-Session-Id"); sid != "" {
|
||||
t.sessionID.Store(sid)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("http %s: status %d: %s", method, resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
want := normalizeID(idRaw)
|
||||
switch {
|
||||
case strings.HasPrefix(ct, "text/event-stream"):
|
||||
return t.readSSE(resp.Body, want)
|
||||
default:
|
||||
var m Message
|
||||
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
|
||||
return nil, fmt.Errorf("http %s: decode: %w", method, err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
}
|
||||
|
||||
// readSSE consumes an SSE stream, dispatching peer notifications to the handler
|
||||
// and returning the response whose id matches want.
|
||||
func (t *httpTransport) readSSE(r io.Reader, want string) (*Message, error) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
||||
var data strings.Builder
|
||||
flush := func() (*Message, bool, error) {
|
||||
if data.Len() == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
payload := data.String()
|
||||
data.Reset()
|
||||
var m Message
|
||||
if err := json.Unmarshal([]byte(payload), &m); err != nil {
|
||||
return nil, false, nil // ignore non-JSON events
|
||||
}
|
||||
if m.IsResponse() && normalizeID(m.ID) == want {
|
||||
return &m, true, nil
|
||||
}
|
||||
if m.Method != "" {
|
||||
if hp := t.notify.Load(); hp != nil {
|
||||
(*hp)(&m)
|
||||
}
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" { // event delimiter
|
||||
if m, ok, _ := flush(); ok {
|
||||
return m, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
data.WriteString(strings.TrimSpace(line[len("data:"):]))
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m, ok, _ := flush(); ok {
|
||||
return m, nil
|
||||
}
|
||||
return nil, fmt.Errorf("sse stream ended without response for id %s", want)
|
||||
}
|
||||
|
||||
func (t *httpTransport) Notify(ctx context.Context, method string, params any) error {
|
||||
note, err := NewNotification(method, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := t.post(ctx, note)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("http notify %s: status %d", method, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *httpTransport) Close() error { return nil }
|
||||
109
internal/mcp/jsonrpc.go
Normal file
109
internal/mcp/jsonrpc.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// 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)
|
||||
}
|
||||
106
internal/mcp/protocol.go
Normal file
106
internal/mcp/protocol.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package mcp
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// ProtocolVersion is the MCP protocol revision Nexus advertises. Nexus is
|
||||
// lenient on the client's requested version and echoes a supported one.
|
||||
const ProtocolVersion = "2025-06-18"
|
||||
|
||||
// Method names used by MCP.
|
||||
const (
|
||||
MethodInitialize = "initialize"
|
||||
MethodInitialized = "notifications/initialized"
|
||||
MethodPing = "ping"
|
||||
MethodToolsList = "tools/list"
|
||||
MethodToolsCall = "tools/call"
|
||||
MethodToolListChged = "notifications/tools/list_changed"
|
||||
)
|
||||
|
||||
// Implementation identifies a client or server implementation.
|
||||
type Implementation struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// ToolsCapability describes tool-related server capabilities.
|
||||
type ToolsCapability struct {
|
||||
ListChanged bool `json:"listChanged,omitempty"`
|
||||
}
|
||||
|
||||
// Capabilities is the (subset of) MCP capabilities Nexus cares about.
|
||||
type Capabilities struct {
|
||||
Tools *ToolsCapability `json:"tools,omitempty"`
|
||||
Resources *struct {
|
||||
ListChanged bool `json:"listChanged,omitempty"`
|
||||
Subscribe bool `json:"subscribe,omitempty"`
|
||||
} `json:"resources,omitempty"`
|
||||
Prompts *struct {
|
||||
ListChanged bool `json:"listChanged,omitempty"`
|
||||
} `json:"prompts,omitempty"`
|
||||
}
|
||||
|
||||
// InitializeParams are the params for the initialize request.
|
||||
type InitializeParams struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
Capabilities Capabilities `json:"capabilities"`
|
||||
ClientInfo Implementation `json:"clientInfo"`
|
||||
}
|
||||
|
||||
// InitializeResult is the result of an initialize request.
|
||||
type InitializeResult struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
Capabilities Capabilities `json:"capabilities"`
|
||||
ServerInfo Implementation `json:"serverInfo"`
|
||||
Instructions string `json:"instructions,omitempty"`
|
||||
}
|
||||
|
||||
// ToolDefinition is a tool as described by tools/list.
|
||||
type ToolDefinition struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
InputSchema map[string]any `json:"inputSchema,omitempty"`
|
||||
}
|
||||
|
||||
// ListToolsParams are the params for tools/list.
|
||||
type ListToolsParams struct {
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
}
|
||||
|
||||
// ListToolsResult is the result of tools/list.
|
||||
type ListToolsResult struct {
|
||||
Tools []ToolDefinition `json:"tools"`
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
}
|
||||
|
||||
// CallToolParams are the params for tools/call.
|
||||
type CallToolParams struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
// ContentBlock is one item of tool result content. Only the common fields are
|
||||
// modeled; unknown fields survive round-trips because callers pass the raw
|
||||
// result through where possible.
|
||||
type ContentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
// For image/audio/resource blocks these carry through opaquely.
|
||||
Data string `json:"data,omitempty"`
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
Resource json.RawMessage `json:"resource,omitempty"`
|
||||
}
|
||||
|
||||
// CallToolResult is the result of tools/call.
|
||||
type CallToolResult struct {
|
||||
Content []ContentBlock `json:"content"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
// TextResult is a convenience constructor for a single text-content result.
|
||||
func TextResult(text string, isError bool) CallToolResult {
|
||||
return CallToolResult{
|
||||
Content: []ContentBlock{{Type: "text", Text: text}},
|
||||
IsError: isError,
|
||||
}
|
||||
}
|
||||
241
internal/mcp/stdio_transport.go
Normal file
241
internal/mcp/stdio_transport.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// StdioConfig configures a subprocess-backed stdio MCP transport.
|
||||
type StdioConfig struct {
|
||||
Command string
|
||||
Args []string
|
||||
Env []string // additional environment (KEY=VALUE), appended to os env
|
||||
Dir string
|
||||
// Stderr, if non-nil, receives the child process's stderr.
|
||||
Stderr io.Writer
|
||||
}
|
||||
|
||||
// streamTransport speaks newline-delimited JSON-RPC over any reader/writer
|
||||
// pair, correlating responses with requests by id. It backs both the
|
||||
// subprocess stdio transport and in-process pipe-connected transports.
|
||||
type streamTransport struct {
|
||||
w io.WriteCloser
|
||||
enc *json.Encoder
|
||||
r io.Reader
|
||||
|
||||
// process control (nil for non-subprocess transports)
|
||||
proc interface{ Kill() error }
|
||||
waitFn func() error
|
||||
|
||||
mu sync.Mutex
|
||||
counter int64
|
||||
pending map[string]chan *Message
|
||||
closed bool
|
||||
exitErr error
|
||||
|
||||
notify atomic.Pointer[func(*Message)]
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newStreamTransport(r io.Reader, w io.WriteCloser) *streamTransport {
|
||||
t := &streamTransport{
|
||||
w: w,
|
||||
enc: json.NewEncoder(w),
|
||||
r: r,
|
||||
pending: make(map[string]chan *Message),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go t.readLoop()
|
||||
return t
|
||||
}
|
||||
|
||||
// NewStreamTransport creates a transport over an existing reader/writer pair,
|
||||
// e.g. an in-process pipe. It performs no process management.
|
||||
func NewStreamTransport(r io.Reader, w io.WriteCloser) Transport {
|
||||
return newStreamTransport(r, w)
|
||||
}
|
||||
|
||||
// NewStdioTransport spawns the configured command and serves MCP over its
|
||||
// stdio. The process is terminated by Close.
|
||||
func NewStdioTransport(cfg StdioConfig) (Transport, error) {
|
||||
if cfg.Command == "" {
|
||||
return nil, errors.New("stdio transport: empty command")
|
||||
}
|
||||
cmd := exec.Command(cfg.Command, cfg.Args...)
|
||||
cmd.Dir = cfg.Dir
|
||||
if len(cfg.Env) > 0 {
|
||||
cmd.Env = append(cmd.Environ(), cfg.Env...)
|
||||
}
|
||||
cmd.Stderr = cfg.Stderr
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stdio transport: stdin pipe: %w", err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stdio transport: stdout pipe: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("stdio transport: start %q: %w", cfg.Command, err)
|
||||
}
|
||||
|
||||
t := &streamTransport{
|
||||
w: stdin,
|
||||
enc: json.NewEncoder(stdin),
|
||||
r: stdout,
|
||||
waitFn: cmd.Wait,
|
||||
pending: make(map[string]chan *Message),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
if cmd.Process != nil {
|
||||
t.proc = cmd.Process
|
||||
}
|
||||
go t.readLoop()
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *streamTransport) SetNotificationHandler(h func(*Message)) {
|
||||
if h == nil {
|
||||
t.notify.Store(nil)
|
||||
return
|
||||
}
|
||||
t.notify.Store(&h)
|
||||
}
|
||||
|
||||
func (t *streamTransport) readLoop() {
|
||||
scanner := bufio.NewScanner(t.r)
|
||||
// MCP messages can be large (tool schemas); allow up to 16 MiB lines.
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(bytes.TrimSpace(line)) == 0 {
|
||||
continue
|
||||
}
|
||||
var m Message
|
||||
if err := json.Unmarshal(line, &m); err != nil {
|
||||
continue // ignore non-JSON noise
|
||||
}
|
||||
if m.IsResponse() {
|
||||
key := normalizeID(m.ID)
|
||||
t.mu.Lock()
|
||||
ch := t.pending[key]
|
||||
delete(t.pending, key)
|
||||
t.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- &m
|
||||
}
|
||||
continue
|
||||
}
|
||||
if m.Method != "" {
|
||||
if hp := t.notify.Load(); hp != nil {
|
||||
(*hp)(&m)
|
||||
}
|
||||
}
|
||||
}
|
||||
// input closed: peer is gone. Fail all in-flight calls.
|
||||
var exitErr error
|
||||
if t.waitFn != nil {
|
||||
exitErr = t.waitFn()
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.closed = true
|
||||
t.exitErr = exitErr
|
||||
pending := t.pending
|
||||
t.pending = map[string]chan *Message{}
|
||||
t.mu.Unlock()
|
||||
for _, ch := range pending {
|
||||
close(ch)
|
||||
}
|
||||
close(t.done)
|
||||
}
|
||||
|
||||
func (t *streamTransport) Call(ctx context.Context, method string, params any) (*Message, error) {
|
||||
id := atomic.AddInt64(&t.counter, 1)
|
||||
idRaw := json.RawMessage(strconv.FormatInt(id, 10))
|
||||
req, err := NewRequest(idRaw, method, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := normalizeID(idRaw)
|
||||
ch := make(chan *Message, 1)
|
||||
|
||||
t.mu.Lock()
|
||||
if t.closed {
|
||||
t.mu.Unlock()
|
||||
return nil, t.closedErr()
|
||||
}
|
||||
t.pending[key] = ch
|
||||
err = t.enc.Encode(req)
|
||||
t.mu.Unlock()
|
||||
if err != nil {
|
||||
t.mu.Lock()
|
||||
delete(t.pending, key)
|
||||
t.mu.Unlock()
|
||||
return nil, fmt.Errorf("stdio write %s: %w", method, err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.mu.Lock()
|
||||
delete(t.pending, key)
|
||||
t.mu.Unlock()
|
||||
return nil, ctx.Err()
|
||||
case resp, ok := <-ch:
|
||||
if !ok {
|
||||
return nil, t.closedErr()
|
||||
}
|
||||
return resp, nil
|
||||
case <-t.done:
|
||||
return nil, t.closedErr()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *streamTransport) Notify(_ context.Context, method string, params any) error {
|
||||
note, err := NewNotification(method, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.closed {
|
||||
return t.closedErr()
|
||||
}
|
||||
return t.enc.Encode(note)
|
||||
}
|
||||
|
||||
func (t *streamTransport) closedErr() error {
|
||||
if t.exitErr != nil {
|
||||
return fmt.Errorf("mcp stdio transport closed: %w", t.exitErr)
|
||||
}
|
||||
return errors.New("mcp stdio transport closed")
|
||||
}
|
||||
|
||||
func (t *streamTransport) Close() error {
|
||||
t.mu.Lock()
|
||||
closed := t.closed
|
||||
t.mu.Unlock()
|
||||
_ = t.w.Close()
|
||||
if t.proc != nil {
|
||||
_ = t.proc.Kill()
|
||||
}
|
||||
if !closed {
|
||||
<-t.done
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeID produces a stable map key from a JSON-RPC id, tolerating
|
||||
// whitespace differences in how peers echo the id.
|
||||
func normalizeID(raw json.RawMessage) string {
|
||||
return string(bytes.TrimSpace(raw))
|
||||
}
|
||||
19
internal/mcp/transport.go
Normal file
19
internal/mcp/transport.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package mcp
|
||||
|
||||
import "context"
|
||||
|
||||
// Transport is a bidirectional MCP channel to a single peer. Implementations
|
||||
// correlate requests with responses internally so callers see simple
|
||||
// request/response semantics.
|
||||
//
|
||||
// Call returns the response Message (which may itself carry a JSON-RPC error in
|
||||
// Message.Error); the returned error is reserved for transport-level failures
|
||||
// (process died, connection refused, context cancelled, malformed frame).
|
||||
type Transport interface {
|
||||
Call(ctx context.Context, method string, params any) (*Message, error)
|
||||
Notify(ctx context.Context, method string, params any) error
|
||||
// SetNotificationHandler registers a handler for peer-initiated
|
||||
// notifications/requests (e.g. tools/list_changed). May be nil.
|
||||
SetNotificationHandler(func(*Message))
|
||||
Close() error
|
||||
}
|
||||
Reference in New Issue
Block a user