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:
co-authored by
Claude Sonnet 4.5
parent
6b9effae32
commit
6cf556bf85
@@ -1,8 +1,6 @@
|
||||
# Binaries (root directory only)
|
||||
/truenas-mcp
|
||||
/truenas-mcp-*
|
||||
/truenas-mcp-proxy
|
||||
/truenas-mcp-proxy-*
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¶ms); 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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user