Remove old two-binary architecture files

- Delete PROXY.md documentation
- Remove cmd/truenas-mcp-proxy/ directory
- Remove SSE server and client code (mcp/sse_*.go)
- Remove old proxy.go bridge code
- Update .gitignore to remove proxy binary patterns

All functionality now consolidated into single truenas-mcp binary.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Kris Moore
2026-02-05 13:53:28 -05:00
co-authored by Claude Sonnet 4.5
parent 6b9effae32
commit 6cf556bf85
6 changed files with 0 additions and 1323 deletions
-2
View File
@@ -1,8 +1,6 @@
# Binaries (root directory only)
/truenas-mcp
/truenas-mcp-*
/truenas-mcp-proxy
/truenas-mcp-proxy-*
*.exe
*.dll
*.so
-359
View File
@@ -1,359 +0,0 @@
# TrueNAS MCP Proxy
The `truenas-mcp-proxy` is a desktop client that bridges Claude Desktop's stdio interface to the TrueNAS MCP server's SSE transport. This eliminates the need for SSH while maintaining secure API key authentication.
## Architecture
```
┌─────────────────┐
│ Claude Desktop │
└────────┬────────┘
│ stdio (JSON-RPC)
┌────────▼─────────────────────────┐
│ truenas-mcp-proxy (desktop) │
│ - Reads stdin │
│ - POSTs to /messages │
│ - Listens to SSE stream │
│ - Writes to stdout │
└──────────────┬───────────────────┘
│ HTTP/SSE + Bearer token
┌──────────────▼───────────────────┐
│ truenas-mcp (TrueNAS) │
│ - GET /sse (SSE stream) │
│ - POST /messages (requests) │
└──────────────────────────────────┘
```
## Installation
### macOS
```bash
# For Apple Silicon (M1/M2/M3)
cp truenas-mcp-proxy-darwin-arm64 /usr/local/bin/truenas-mcp-proxy
# For Intel Macs
cp truenas-mcp-proxy-darwin-amd64 /usr/local/bin/truenas-mcp-proxy
# Make executable
chmod +x /usr/local/bin/truenas-mcp-proxy
```
### Linux
```bash
cp truenas-mcp-proxy-linux-amd64 /usr/local/bin/truenas-mcp-proxy
chmod +x /usr/local/bin/truenas-mcp-proxy
```
### Windows
Copy `truenas-mcp-proxy-windows-amd64.exe` to a directory in your PATH, or reference it directly in Claude Desktop config.
## Configuration
### Claude Desktop Setup
Edit your Claude Desktop configuration file:
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
- **Linux**: `~/.config/Claude/claude_desktop_config.json`
Add the TrueNAS MCP server:
```json
{
"mcpServers": {
"truenas": {
"command": "truenas-mcp-proxy",
"args": [
"--server-url", "http://192.168.0.31:8089",
"--api-key", "your-secure-key-here"
]
}
}
}
```
### Environment Variables (More Secure)
Instead of putting the API key in the config file, use environment variables:
```json
{
"mcpServers": {
"truenas": {
"command": "truenas-mcp-proxy",
"args": [
"--server-url", "http://192.168.0.31:8089"
],
"env": {
"TRUENAS_MCP_API_KEY": "your-secure-key-here"
}
}
}
}
```
## Command-Line Options
### Required Flags
- `--server-url` - URL of TrueNAS MCP server (e.g., `http://192.168.0.31:8089`)
- Alternative: `TRUENAS_MCP_SERVER_URL` environment variable
- `--api-key` - API key for authentication
- Alternative: `TRUENAS_MCP_API_KEY` environment variable
### Optional Flags
- `--timeout` - Request timeout duration (default: `30s`)
- Examples: `10s`, `1m`, `500ms`
- `--debug` - Enable verbose debug logging to stderr
- Useful for troubleshooting connection issues
- `--insecure` - Skip TLS certificate verification
- Only use for development with self-signed certificates
- NOT recommended for production
- `--version` - Print version and exit
## Usage Examples
### Basic Usage
```bash
truenas-mcp-proxy \
--server-url http://192.168.0.31:8089 \
--api-key my-secret-key
```
### With Environment Variables
```bash
export TRUENAS_MCP_SERVER_URL=http://192.168.0.31:8089
export TRUENAS_MCP_API_KEY=my-secret-key
truenas-mcp-proxy
```
### With Debug Logging
```bash
truenas-mcp-proxy \
--server-url http://192.168.0.31:8089 \
--api-key my-secret-key \
--debug
```
### Testing Manually
You can test the proxy manually with JSON-RPC requests:
```bash
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"test","version":"1.0"}}}' | \
truenas-mcp-proxy \
--server-url http://192.168.0.31:8089 \
--api-key testkey
```
## How It Works
### Request Flow
1. Claude Desktop sends JSON-RPC request to proxy via stdin
2. Proxy POSTs request to TrueNAS server's `/messages` endpoint
3. Server processes request and broadcasts response via SSE
4. Proxy receives response on SSE stream
5. Proxy correlates response by ID and writes to stdout
6. Claude Desktop receives response
### Connection Management
- Proxy connects to server's `/sse` endpoint on startup
- Uses `r3labs/sse` library with automatic reconnection
- Maintains request/response correlation across reconnects
- Requests timeout after 30s (configurable) if no response
### Error Handling
- **Connection failures**: Automatic reconnection with backoff
- **Timeout**: Returns JSON-RPC error after timeout period
- **Invalid JSON**: Returns parse error to Claude Desktop
- **Server errors**: Forwards error response from server
- **Stdin EOF**: Graceful shutdown
## Security Considerations
### API Key Protection
- Store API keys in environment variables, not config files
- Never commit API keys to version control
- Use strong, randomly generated keys (e.g., `openssl rand -hex 32`)
- Rotate keys periodically
### TLS/HTTPS
For production deployments:
1. Use HTTPS URLs for `--server-url`
2. Run TrueNAS server behind reverse proxy with valid TLS certificate
3. Only use `--insecure` flag for local development
Example nginx config for TLS termination:
```nginx
server {
listen 443 ssl http2;
server_name truenas.example.com;
ssl_certificate /etc/ssl/certs/truenas.crt;
ssl_certificate_key /etc/ssl/private/truenas.key;
location / {
proxy_pass http://127.0.0.1:8089;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
}
}
```
### Network Security
- Use firewall rules to restrict access to TrueNAS server port
- Consider VPN or SSH tunnel for remote access
- Monitor server logs for suspicious activity
## Troubleshooting
### Proxy Won't Connect
```bash
# Test with debug logging
truenas-mcp-proxy \
--server-url http://192.168.0.31:8089 \
--api-key testkey \
--debug
```
Check for:
- Network connectivity to server
- Correct server URL and port
- Server is running and listening
- Firewall rules allowing connection
### Authentication Errors
Verify:
- API key matches server configuration
- Authorization header is being sent
- Server logs show authentication attempts
### Timeout Errors
Increase timeout:
```bash
truenas-mcp-proxy \
--server-url http://192.168.0.31:8089 \
--api-key testkey \
--timeout 60s
```
Check:
- Server is responding to requests
- Network latency is not excessive
- Server logs for processing errors
### Claude Desktop Integration Issues
1. Verify proxy binary is executable and in PATH
2. Check Claude Desktop logs for errors
3. Test proxy manually (see Usage Examples)
4. Restart Claude Desktop after config changes
### Server Connection Test
Test server connectivity without proxy:
```bash
# Test SSE endpoint
curl -N -H "Authorization: Bearer testkey" \
http://192.168.0.31:8089/sse
# Test messages endpoint
curl -X POST \
-H "Authorization: Bearer testkey" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
http://192.168.0.31:8089/messages
```
## Development
### Building from Source
```bash
# Build for current platform
make build-proxy
# Build for all platforms
make build-proxy-all
```
### Running Tests
```bash
go test ./proxy/...
```
### Project Structure
```
cmd/truenas-mcp-proxy/
main.go # Entry point
proxy/
config.go # Configuration management
stdio.go # Stdin/stdout handling
proxy.go # Main proxy logic
mcp/
sse_client.go # SSE client implementation
types.go # Shared types
```
## Comparison with SSH Method
### Proxy Method (Recommended)
**Pros:**
- No SSH required
- Simpler Claude Desktop configuration
- Direct HTTP/SSE connection
- Better error handling
- Automatic reconnection
**Cons:**
- Requires separate binary installation
- Network firewall configuration needed
### Direct SSE Method (Local Only)
**Pros:**
- No proxy binary needed
- Simplest setup for local TrueNAS
**Cons:**
- Only works with local TrueNAS
- Claude Desktop must support SSE transport
- No request correlation for multi-client scenarios
## Future Enhancements
- Connection pooling for improved performance
- Request retry with exponential backoff
- Structured logging with levels
- Metrics/observability endpoints
- Configuration file support (`~/.truenas-mcp-proxy.yaml`)
- Homebrew formula for easy installation on macOS
- Windows installer package
-65
View File
@@ -1,65 +0,0 @@
package main
import (
"log"
"os"
"os/signal"
"syscall"
"github.com/truenas/truenas-mcp/proxy"
)
const (
Version = "0.1.0"
)
func main() {
// Load configuration
config, err := proxy.LoadConfig()
if err != nil {
if err.Error() == "version requested" {
log.Printf("truenas-mcp-proxy version %s", Version)
os.Exit(0)
}
log.Fatalf("Configuration error: %v", err)
}
if config.Debug {
log.Printf("truenas-mcp-proxy v%s starting...", Version)
log.Printf("Server URL: %s", config.ServerURL)
log.Printf("Timeout: %s", config.Timeout)
if config.Insecure {
log.Printf("WARNING: TLS certificate verification disabled")
}
}
// Create proxy
p := proxy.NewProxy(config)
// Handle shutdown signals
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
// Start proxy in goroutine
errChan := make(chan error, 1)
go func() {
errChan <- p.Run()
}()
// Wait for completion or signal
select {
case err := <-errChan:
if err != nil {
log.Fatalf("Proxy error: %v", err)
}
case sig := <-sigChan:
if config.Debug {
log.Printf("Received signal: %v", sig)
}
p.Shutdown()
}
if config.Debug {
log.Printf("Proxy shutdown complete")
}
}
-194
View File
@@ -1,194 +0,0 @@
package mcp
import (
"encoding/json"
"log"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/r3labs/sse/v2"
)
// SSEClient manages connection to an MCP SSE server
type SSEClient struct {
client *sse.Client
onMessage func(*Response)
onEndpoint func(string)
connected atomic.Bool
debugLog bool
url string
apiKey string
reconnecting atomic.Bool
shutdownChan chan struct{}
shutdownOnce sync.Once
subscriptionsMu sync.Mutex
}
// NewSSEClient creates a new SSE client
func NewSSEClient(debugLog bool) *SSEClient {
return &SSEClient{
debugLog: debugLog,
shutdownChan: make(chan struct{}),
}
}
// Connect establishes connection to the SSE endpoint
func (c *SSEClient) Connect(url, apiKey string) error {
c.url = url
c.apiKey = apiKey
return c.connect()
}
func (c *SSEClient) connect() error {
c.subscriptionsMu.Lock()
defer c.subscriptionsMu.Unlock()
client := sse.NewClient(c.url)
// Add authorization header
if c.apiKey != "" {
client.Headers = map[string]string{
"Authorization": "Bearer " + c.apiKey,
}
}
// Don't log connection attempts by default
client.Connection.Transport = &http.Transport{}
// Set connection callback to monitor disconnections
debugLog := c.debugLog
client.OnDisconnect(func(client *sse.Client) {
if debugLog {
log.Printf("[SSE] Disconnected from server")
}
})
c.client = client
c.connected.Store(true)
// Subscribe to endpoint event in a goroutine (non-blocking)
go func() {
if c.debugLog {
log.Printf("[SSE] Starting endpoint subscription...")
}
err := client.Subscribe("endpoint", func(msg *sse.Event) {
if c.debugLog {
log.Printf("[SSE] Received endpoint event: %s", string(msg.Data))
}
if c.onEndpoint != nil {
c.onEndpoint(string(msg.Data))
}
})
if err != nil {
log.Printf("[SSE] Endpoint subscription error: %v", err)
c.connected.Store(false)
c.scheduleReconnect()
}
}()
// Subscribe to message event in a goroutine (non-blocking)
go func() {
if c.debugLog {
log.Printf("[SSE] Starting message subscription...")
}
err := client.Subscribe("message", func(msg *sse.Event) {
if c.debugLog {
log.Printf("[SSE] Received message event: %s", string(msg.Data))
}
if c.onMessage != nil {
var resp Response
if err := json.Unmarshal(msg.Data, &resp); err != nil {
// Ignore non-JSON messages (like endpoint paths that might leak through)
if c.debugLog {
log.Printf("Skipping non-JSON SSE message: %s", string(msg.Data))
}
return
}
c.onMessage(&resp)
}
})
if err != nil {
log.Printf("[SSE] Message subscription error: %v", err)
c.connected.Store(false)
c.scheduleReconnect()
}
}()
// Give subscriptions time to start
if c.debugLog {
log.Printf("[SSE] Subscriptions started")
}
return nil
}
// scheduleReconnect attempts to reconnect after a delay
func (c *SSEClient) scheduleReconnect() {
// Only one reconnection attempt at a time
if !c.reconnecting.CompareAndSwap(false, true) {
return
}
go func() {
defer c.reconnecting.Store(false)
backoff := 1 * time.Second
maxBackoff := 30 * time.Second
for {
select {
case <-c.shutdownChan:
return
case <-time.After(backoff):
if c.debugLog {
log.Printf("[SSE] Attempting to reconnect...")
}
if err := c.connect(); err != nil {
if c.debugLog {
log.Printf("[SSE] Reconnection failed: %v", err)
}
// Exponential backoff
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
continue
}
log.Printf("[SSE] Reconnected successfully")
return
}
}
}()
}
// SetMessageHandler sets the callback for message events
func (c *SSEClient) SetMessageHandler(handler func(*Response)) {
c.onMessage = handler
}
// SetEndpointHandler sets the callback for endpoint events
func (c *SSEClient) SetEndpointHandler(handler func(string)) {
c.onEndpoint = handler
}
// IsConnected returns true if client is connected
func (c *SSEClient) IsConnected() bool {
return c.connected.Load()
}
// Close disconnects the client
func (c *SSEClient) Close() error {
c.shutdownOnce.Do(func() {
close(c.shutdownChan)
})
c.connected.Store(false)
if c.client != nil {
c.client.Unsubscribe(make(chan *sse.Event))
}
return nil
}
-324
View File
@@ -1,324 +0,0 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"sync"
"time"
)
type SSEServer struct {
registry ToolRegistry
listenAddr string
apiKey string
clients sync.Map // clientID -> chan Response
}
type clientConnection struct {
id string
messages chan Response
done chan struct{}
}
func NewSSEServer(registry ToolRegistry, listenAddr string, apiKey string) *SSEServer {
return &SSEServer{
registry: registry,
listenAddr: listenAddr,
apiKey: apiKey,
}
}
func (s *SSEServer) Run() error {
mux := http.NewServeMux()
// SSE endpoint - server sends messages to client
mux.HandleFunc("/sse", s.handleSSE)
// Messages endpoint - client sends messages to server
mux.HandleFunc("/messages", s.handleMessages)
// Health check endpoint
mux.HandleFunc("/health", s.handleHealth)
server := &http.Server{
Addr: s.listenAddr,
Handler: s.corsMiddleware(s.authMiddleware(mux)),
ReadTimeout: 30 * time.Second,
WriteTimeout: 0, // No write timeout for SSE streaming
IdleTimeout: 120 * time.Second,
// Enable TCP keepalive for long-lived SSE connections
ConnContext: func(ctx context.Context, c net.Conn) context.Context {
if tc, ok := c.(*net.TCPConn); ok {
tc.SetKeepAlive(true)
tc.SetKeepAlivePeriod(30 * time.Second)
}
return ctx
},
}
log.Printf("SSE server listening on %s", s.listenAddr)
return server.ListenAndServe()
}
func (s *SSEServer) handleSSE(w http.ResponseWriter, r *http.Request) {
// Verify SSE support
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}
// Set SSE headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
// Create client connection
clientID := fmt.Sprintf("client-%d", time.Now().UnixNano())
client := &clientConnection{
id: clientID,
messages: make(chan Response, 100), // Increase buffer for large responses
done: make(chan struct{}),
}
s.clients.Store(clientID, client)
defer func() {
s.clients.Delete(clientID)
close(client.done)
}()
log.Printf("Client connected: %s", clientID)
// Send initial endpoint event
endpointEvent := fmt.Sprintf("event: endpoint\ndata: /messages\n\n")
if _, err := fmt.Fprint(w, endpointEvent); err != nil {
log.Printf("Error sending endpoint event: %v", err)
return
}
flusher.Flush()
// Stream messages to client
for {
select {
case <-r.Context().Done():
log.Printf("Client disconnected: %s", clientID)
return
case msg := <-client.messages:
data, err := json.Marshal(msg)
if err != nil {
log.Printf("Error marshaling message: %v", err)
continue
}
// Send SSE message
event := fmt.Sprintf("event: message\ndata: %s\n\n", data)
if _, err := fmt.Fprint(w, event); err != nil {
log.Printf("Error sending message: %v", err)
return
}
flusher.Flush()
}
}
}
func (s *SSEServer) handleMessages(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Read request body
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
// Parse JSON-RPC request
var req Request
if err := json.Unmarshal(body, &req); err != nil {
s.sendErrorToAllClients(nil, -32700, "Parse error", err.Error())
w.WriteHeader(http.StatusAccepted)
return
}
// Process request
s.handleRequest(&req)
// Return 202 Accepted (response will be sent via SSE)
w.WriteHeader(http.StatusAccepted)
}
func (s *SSEServer) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "healthy",
"version": "0.1.0",
})
}
func (s *SSEServer) handleRequest(req *Request) {
switch req.Method {
case "initialize":
s.handleInitialize(req)
case "tools/list":
s.handleToolsList(req)
case "tools/call":
s.handleToolsCall(req)
default:
s.sendErrorToAllClients(req.ID, -32601, "Method not found", fmt.Sprintf("Unknown method: %s", req.Method))
}
}
func (s *SSEServer) handleInitialize(req *Request) {
result := InitializeResult{
ProtocolVersion: "2024-11-05",
ServerInfo: ServerInfo{
Name: "truenas-mcp",
Version: "0.1.0",
},
Capabilities: Capabilities{
Tools: map[string]interface{}{},
},
}
s.sendResponseToAllClients(req.ID, result)
}
func (s *SSEServer) handleToolsList(req *Request) {
tools := s.registry.ListTools()
result := ToolsListResult{
Tools: tools,
}
s.sendResponseToAllClients(req.ID, result)
}
func (s *SSEServer) handleToolsCall(req *Request) {
// Extract tool call params
paramsJSON, err := json.Marshal(req.Params)
if err != nil {
s.sendErrorToAllClients(req.ID, -32602, "Invalid params", err.Error())
return
}
var params ToolCallParams
if err := json.Unmarshal(paramsJSON, &params); err != nil {
s.sendErrorToAllClients(req.ID, -32602, "Invalid params", err.Error())
return
}
// Call the tool
resultText, err := s.registry.CallTool(params.Name, params.Arguments)
if err != nil {
result := ToolCallResult{
Content: []ContentBlock{
{
Type: "text",
Text: fmt.Sprintf("Error: %v", err),
},
},
IsError: true,
}
s.sendResponseToAllClients(req.ID, result)
return
}
result := ToolCallResult{
Content: []ContentBlock{
{
Type: "text",
Text: resultText,
},
},
}
s.sendResponseToAllClients(req.ID, result)
}
func (s *SSEServer) sendResponseToAllClients(id interface{}, result interface{}) {
resp := Response{
JSONRPC: "2.0",
ID: id,
Result: result,
}
s.broadcastResponse(resp)
}
func (s *SSEServer) sendErrorToAllClients(id interface{}, code int, message string, data interface{}) {
resp := Response{
JSONRPC: "2.0",
ID: id,
Error: &Error{
Code: code,
Message: message,
Data: data,
},
}
s.broadcastResponse(resp)
}
func (s *SSEServer) broadcastResponse(resp Response) {
s.clients.Range(func(key, value interface{}) bool {
client := value.(*clientConnection)
select {
case client.messages <- resp:
// Successfully queued
case <-client.done:
// Client disconnected, skip
case <-time.After(30 * time.Second):
// Increase timeout to 30s to accommodate large responses
log.Printf("Timeout queueing message for client %s", client.id)
}
return true
})
}
// CORS middleware
func (s *SSEServer) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
// Authentication middleware
func (s *SSEServer) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip auth for health endpoint
if r.URL.Path == "/health" {
next.ServeHTTP(w, r)
return
}
// If no API key is configured, allow all requests
if s.apiKey == "" {
next.ServeHTTP(w, r)
return
}
// Check Authorization header
authHeader := r.Header.Get("Authorization")
expectedAuth := fmt.Sprintf("Bearer %s", s.apiKey)
if authHeader != expectedAuth {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
-379
View File
@@ -1,379 +0,0 @@
package proxy
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/truenas/truenas-mcp/mcp"
)
// Proxy manages the stdio-to-SSE bridge
type Proxy struct {
config *Config
sseClient *mcp.SSEClient
httpClient *http.Client
stdio *StdioHandler
pendingReqs sync.Map // map[interface{}]chan *mcp.Response
messagesURL string
shutdownChan chan struct{}
shutdownOnce sync.Once
wg sync.WaitGroup
stdinClosed atomic.Bool
activeReqs atomic.Int32
}
// NewProxy creates a new proxy instance
func NewProxy(config *Config) *Proxy {
transport := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: config.Insecure,
},
}
return &Proxy{
config: config,
httpClient: &http.Client{
Timeout: config.Timeout,
Transport: transport,
},
stdio: NewStdioHandler(config.Debug),
sseClient: mcp.NewSSEClient(config.Debug),
shutdownChan: make(chan struct{}),
}
}
// Run starts the proxy
func (p *Proxy) Run() error {
// Set up SSE handlers
p.sseClient.SetEndpointHandler(p.handleEndpoint)
p.sseClient.SetMessageHandler(p.handleSSEMessage)
// Connect to SSE endpoint
sseURL := p.config.ServerURL + "/sse"
if p.config.Debug {
log.Printf("[PROXY] Connecting to SSE endpoint: %s", sseURL)
}
if err := p.sseClient.Connect(sseURL, p.config.APIKey); err != nil {
return fmt.Errorf("failed to connect to SSE endpoint: %w", err)
}
// Start request timeout cleaner
p.wg.Add(1)
go p.timeoutCleaner()
// Start stdin reader
p.wg.Add(1)
go p.stdinReader()
// Wait for shutdown
<-p.shutdownChan
p.wg.Wait()
return nil
}
// Shutdown gracefully stops the proxy
func (p *Proxy) Shutdown() {
p.shutdownOnce.Do(func() {
if p.config.Debug {
log.Printf("[PROXY] Shutting down...")
}
close(p.shutdownChan)
if err := p.sseClient.Close(); err != nil {
log.Printf("Error closing SSE client: %v", err)
}
})
}
// handleEndpoint is called when the SSE endpoint URL is received
func (p *Proxy) handleEndpoint(url string) {
// Only accept the first endpoint event (should be the /messages path)
// Ignore subsequent events that might be responses
if p.messagesURL != "" {
if p.config.Debug {
log.Printf("[PROXY] Ignoring duplicate endpoint event: %s", url)
}
return
}
// Construct full URL from server base URL and endpoint path
p.messagesURL = p.config.ServerURL + url
if p.config.Debug {
log.Printf("[PROXY] Messages endpoint: %s", p.messagesURL)
}
}
// handleSSEMessage is called when a message is received via SSE
func (p *Proxy) handleSSEMessage(resp *mcp.Response) {
if p.config.Debug {
log.Printf("[PROXY] Received response for request ID: %v", resp.ID)
}
// Find pending request
if pending, ok := p.pendingReqs.LoadAndDelete(resp.ID); ok {
ch := pending.(chan *mcp.Response)
select {
case ch <- resp:
// Response delivered
default:
// Channel full or closed
if p.config.Debug {
log.Printf("[PROXY] Failed to deliver response for ID %v", resp.ID)
}
}
} else {
if p.config.Debug {
log.Printf("[PROXY] No pending request for ID %v", resp.ID)
}
}
}
// stdinReader reads requests from stdin and sends them to the server
func (p *Proxy) stdinReader() {
defer p.wg.Done()
if p.config.Debug {
log.Printf("[PROXY] Stdin reader started")
}
for {
select {
case <-p.shutdownChan:
return
default:
}
if p.config.Debug {
log.Printf("[PROXY] Waiting for stdin...")
}
req, err := p.stdio.ReadRequest()
if err != nil {
if err == io.EOF {
if p.config.Debug {
log.Printf("[PROXY] Stdin closed, waiting for pending requests to complete")
}
p.stdinClosed.Store(true)
// Check if there are pending requests
if p.activeReqs.Load() == 0 {
if p.config.Debug {
log.Printf("[PROXY] No pending requests, shutting down")
}
p.Shutdown()
}
return
}
log.Printf("Error reading from stdin: %v", err)
if err := p.stdio.WriteError(nil, -32700, "Parse error"); err != nil {
log.Printf("Failed to write error response: %v", err)
}
continue
}
// Handle request
if p.config.Debug {
log.Printf("[PROXY] Received request ID=%v method=%s", req.ID, req.Method)
}
// Check if this is a notification (no ID means no response expected)
if req.ID == nil {
if p.config.Debug {
log.Printf("[PROXY] Notification (no response needed): %s", req.Method)
}
// Just forward to server, don't wait for response
p.wg.Add(1)
go p.sendRequestNoResponse(req)
continue
}
// Increment activeReqs BEFORE starting goroutine to avoid race condition
p.activeReqs.Add(1)
p.wg.Add(1)
go p.handleRequest(req)
}
}
// handleRequest processes a single request
func (p *Proxy) handleRequest(req *mcp.Request) {
defer func() {
p.activeReqs.Add(-1)
p.wg.Done()
// If stdin is closed and no more active requests, shutdown
if p.stdinClosed.Load() && p.activeReqs.Load() == 0 {
if p.config.Debug {
log.Printf("[PROXY] All requests completed, shutting down")
}
p.Shutdown()
}
}()
if p.config.Debug {
log.Printf("[PROXY] Handling request ID=%v", req.ID)
}
// Wait for messages endpoint
if p.messagesURL == "" {
timeout := time.After(5 * time.Second)
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-timeout:
if err := p.stdio.WriteError(req.ID, -32603, "Timeout waiting for server endpoint"); err != nil {
log.Printf("Failed to write error: %v", err)
}
return
case <-ticker.C:
if p.messagesURL != "" {
goto ready
}
}
}
}
ready:
// Create response channel
respChan := make(chan *mcp.Response, 1)
p.pendingReqs.Store(req.ID, respChan)
// Send request to server
if err := p.sendRequest(req); err != nil {
p.pendingReqs.Delete(req.ID)
if err := p.stdio.WriteError(req.ID, -32603, fmt.Sprintf("Failed to send request: %v", err)); err != nil {
log.Printf("Failed to write error: %v", err)
}
return
}
// Wait for response with timeout
select {
case resp := <-respChan:
if err := p.stdio.WriteResponse(resp); err != nil {
log.Printf("Failed to write response: %v", err)
}
case <-time.After(p.config.Timeout):
p.pendingReqs.Delete(req.ID)
if err := p.stdio.WriteError(req.ID, -32603, "Request timeout"); err != nil {
log.Printf("Failed to write timeout error: %v", err)
}
case <-p.shutdownChan:
p.pendingReqs.Delete(req.ID)
}
}
// sendRequestNoResponse sends a notification to the server without expecting a response
func (p *Proxy) sendRequestNoResponse(req *mcp.Request) {
defer p.wg.Done()
// Wait for messages endpoint
timeout := time.After(5 * time.Second)
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for p.messagesURL == "" {
select {
case <-timeout:
log.Printf("Timeout waiting for server endpoint")
return
case <-ticker.C:
}
}
if err := p.sendRequest(req); err != nil {
log.Printf("Failed to send notification: %v", err)
}
}
// sendRequest sends a request to the server's /messages endpoint with retry logic
func (p *Proxy) sendRequest(req *mcp.Request) error {
data, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
maxRetries := 3
retryDelay := 1 * time.Second
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
if p.config.Debug {
log.Printf("[PROXY] Retry attempt %d/%d after %v delay", attempt, maxRetries, retryDelay)
}
time.Sleep(retryDelay)
retryDelay *= 2 // Exponential backoff
}
if p.config.Debug {
log.Printf("[PROXY] Sending request to %s (attempt %d/%d)", p.messagesURL, attempt+1, maxRetries+1)
}
httpReq, err := http.NewRequest("POST", p.messagesURL, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("failed to create HTTP request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
if p.config.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+p.config.APIKey)
}
resp, err := p.httpClient.Do(httpReq)
if err != nil {
if attempt < maxRetries {
if p.config.Debug {
log.Printf("[PROXY] Request failed: %v, will retry...", err)
}
continue
}
return fmt.Errorf("failed to send request after %d attempts: %w", maxRetries+1, err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusAccepted {
return nil
}
// If it's a connection error or server error, retry
if resp.StatusCode >= 500 && attempt < maxRetries {
if p.config.Debug {
log.Printf("[PROXY] Server error (status %d), will retry...", resp.StatusCode)
}
continue
}
return fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body))
}
return fmt.Errorf("failed after %d attempts", maxRetries+1)
}
// timeoutCleaner periodically cleans up timed-out requests
func (p *Proxy) timeoutCleaner() {
defer p.wg.Done()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-p.shutdownChan:
return
case <-ticker.C:
// Cleanup is handled by request timeout goroutines
}
}
}