Refactor to single-binary architecture with direct TrueNAS WebSocket connection

Major architectural change from two-binary (server + proxy) to single native
binary that runs entirely on desktop and connects directly to TrueNAS.

Key changes:
- Single binary eliminates need for TrueNAS server deployment
- Direct WebSocket connection to TrueNAS middleware over wss://
- Security: Only allow wss:// connections (TrueNAS revokes API keys over ws://)
- Self-signed certificate support enabled by default
- MCP notification support (handles notifications/initialized)
- Simplified configuration: just hostname and API key
- Cross-platform builds: macOS (ARM64/AMD64), Linux, Windows

Architecture benefits:
- No deployment to TrueNAS required
- Runs entirely on user's desktop
- Simpler setup and configuration
- Better security with mandatory encryption
- Smaller codebase (~2000 lines of SSE code removed)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Kris Moore
2026-02-05 11:47:49 -05:00
co-authored by Claude Sonnet 4.5
parent b3ebc47885
commit 37a75dc39c
8 changed files with 515 additions and 351 deletions
+15 -51
View File
@@ -1,50 +1,30 @@
.PHONY: build build-linux clean test lint run deploy build-proxy build-proxy-darwin build-proxy-linux build-proxy-windows build-proxy-all
.PHONY: build build-all clean test lint
BINARY_NAME=truenas-mcp
PROXY_BINARY_NAME=truenas-mcp-proxy
BUILD_DIR=.
TARGET_HOST=root@10.220.171.151
TARGET_PATH=/usr/local/bin/truenas-mcp
# Build server for local platform
# Build for local platform
build:
@echo "Building $(BINARY_NAME) for local platform..."
go build -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/truenas-mcp
# Cross-compile server for Linux x86_64
build-linux:
@echo "Building $(BINARY_NAME) for Linux x86_64..."
GOOS=linux GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/truenas-mcp
# Build proxy for local platform
build-proxy:
@echo "Building $(PROXY_BINARY_NAME) for local platform..."
go build -o $(BUILD_DIR)/$(PROXY_BINARY_NAME) ./cmd/truenas-mcp-proxy
# Cross-compile proxy for macOS (both architectures)
build-proxy-darwin:
@echo "Building $(PROXY_BINARY_NAME) for macOS amd64..."
GOOS=darwin GOARCH=amd64 go build -o $(BUILD_DIR)/$(PROXY_BINARY_NAME)-darwin-amd64 ./cmd/truenas-mcp-proxy
@echo "Building $(PROXY_BINARY_NAME) for macOS arm64..."
GOOS=darwin GOARCH=arm64 go build -o $(BUILD_DIR)/$(PROXY_BINARY_NAME)-darwin-arm64 ./cmd/truenas-mcp-proxy
# Cross-compile proxy for Linux
build-proxy-linux:
@echo "Building $(PROXY_BINARY_NAME) for Linux amd64..."
GOOS=linux GOARCH=amd64 go build -o $(BUILD_DIR)/$(PROXY_BINARY_NAME)-linux-amd64 ./cmd/truenas-mcp-proxy
# Cross-compile proxy for Windows
build-proxy-windows:
@echo "Building $(PROXY_BINARY_NAME) for Windows amd64..."
GOOS=windows GOARCH=amd64 go build -o $(BUILD_DIR)/$(PROXY_BINARY_NAME)-windows-amd64.exe ./cmd/truenas-mcp-proxy
# Build all proxy platforms
build-proxy-all: build-proxy-darwin build-proxy-linux build-proxy-windows
# Build for all platforms
build-all:
@echo "Building for all platforms..."
@echo "Building for macOS (ARM64)..."
GOOS=darwin GOARCH=arm64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./cmd/truenas-mcp
@echo "Building for macOS (AMD64)..."
GOOS=darwin GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 ./cmd/truenas-mcp
@echo "Building for Linux (AMD64)..."
GOOS=linux GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./cmd/truenas-mcp
@echo "Building for Windows (AMD64)..."
GOOS=windows GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./cmd/truenas-mcp
@echo "All builds complete!"
clean:
@echo "Cleaning..."
rm -f $(BUILD_DIR)/$(BINARY_NAME)
rm -f $(BUILD_DIR)/$(PROXY_BINARY_NAME)*
rm -f $(BUILD_DIR)/$(BINARY_NAME)-*
test:
@echo "Running tests..."
@@ -54,19 +34,3 @@ lint:
@echo "Running linters..."
go vet ./...
go fmt ./...
run: build
@echo "Running $(BINARY_NAME)..."
./$(BINARY_NAME)
# Deploy to TrueNAS test system
deploy: build-linux
@echo "Deploying to $(TARGET_HOST)..."
scp $(BUILD_DIR)/$(BINARY_NAME) $(TARGET_HOST):$(TARGET_PATH)
ssh $(TARGET_HOST) 'chmod +x $(TARGET_PATH)'
@echo "Deployed to $(TARGET_PATH)"
# Test connection to TrueNAS
test-remote:
@echo "Testing connection to TrueNAS..."
ssh $(TARGET_HOST) '$(TARGET_PATH) --version || echo "Binary not found or not executable"'
+152 -214
View File
@@ -1,6 +1,6 @@
# TrueNAS MCP Server
A Model Context Protocol (MCP) server for TrueNAS that enables AI models to interact with the TrueNAS API using natural language queries over HTTP/SSE.
A Model Context Protocol (MCP) server for TrueNAS that enables AI models to interact with the TrueNAS API using natural language queries.
## Features
@@ -11,6 +11,8 @@ Read-only tools for common TrueNAS operations:
- **query_pools** - Query storage pools with status and capacity
- **query_datasets** - Query datasets with optional pool filtering
- **query_shares** - Query SMB and NFS share configurations
- **list_alerts** - List system alerts with filtering
- **dismiss_alert** / **restore_alert** - Manage system alerts
- **get_system_metrics** - Get CPU, memory, and load performance metrics
- **get_network_metrics** - Get network interface traffic metrics
- **get_disk_metrics** - Get disk I/O performance metrics
@@ -22,23 +24,39 @@ Write operations (requires confirmation):
## Architecture
This project provides two binaries:
**Single native binary** that runs on your desktop and connects directly to TrueNAS:
1. **truenas-mcp** - Server running on TrueNAS
- **Transport**: Server-Sent Events (SSE) over HTTP/HTTPS
- **Protocol**: JSON-RPC 2.0 following MCP specification
- **TrueNAS Client**: WebSocket over Unix socket to middleware
- **Security**: API key authentication, CORS support
```
┌──────────────────┐
│ Claude Desktop │
└────────┬─────────┘
│ stdio (JSON-RPC)
┌────────▼───────────────────┐
│ truenas-mcp │ (Your Desktop)
│ - stdio interface │
│ - Tool registry │
│ - WebSocket client │
└────────┬───────────────────┘
│ WebSocket (ws:// or wss://)
│ + TrueNAS API key auth
┌────────▼──────────────────┐
│ TrueNAS Middleware │
│ - WebSocket HTTP endpoint │
│ - Port 80 (ws) or 443 (wss)
└───────────────────────────┘
```
2. **truenas-mcp-proxy** - Desktop proxy for Claude Desktop
- Bridges stdio (JSON-RPC) to SSE transport
- Runs on user's desktop, connects to remote TrueNAS server
- No SSH required - uses HTTP/SSE with API key authentication
**Key Benefits:**
- ✅ No deployment to TrueNAS required
- Runs entirely on your desktop
- ✅ Secure WebSocket connection (wss://) to TrueNAS middleware
- ✅ Self-signed certificate support (works with TrueNAS defaults)
- ✅ Cross-platform support (macOS, Linux, Windows)
- ✅ Simple configuration with hostname or full WebSocket URL
- ✅ API key protection (requires encrypted connections)
## Building
### Server (runs on TrueNAS)
```bash
# Download dependencies
go mod download
@@ -46,109 +64,49 @@ go mod download
# Build for local platform
make build
# Cross-compile for Linux x86_64 (TrueNAS)
make build-linux
```
### Proxy (runs on desktop)
```bash
# Build for current platform
make build-proxy
# Build for all platforms (macOS, Linux, Windows)
make build-proxy-all
make build-all
```
## Installation & Deployment
## Installation
### Step 1: Deploy to TrueNAS
#### Build the binary locally
```bash
# Build the TrueNAS server binary for Linux
make build-linux
```
This creates `truenas-mcp` binary compiled for Linux x86_64.
#### Deploy to TrueNAS
```bash
# Stop the service if already running
ssh root@your-truenas 'systemctl stop truenas-mcp'
# Copy the binary to TrueNAS
scp truenas-mcp root@your-truenas:/usr/local/bin/truenas-mcp
# Set executable permissions
ssh root@your-truenas 'chmod +x /usr/local/bin/truenas-mcp'
# Copy the systemd service file
scp truenas-mcp.service root@your-truenas:/etc/systemd/system/
# Reload systemd and enable the service
ssh root@your-truenas 'systemctl daemon-reload'
ssh root@your-truenas 'systemctl enable truenas-mcp'
ssh root@your-truenas 'systemctl start truenas-mcp'
# Verify the service is running
ssh root@your-truenas 'systemctl status truenas-mcp'
```
#### Configure the service
Edit the service file on TrueNAS to set your API key and listen address:
```bash
ssh root@your-truenas
vi /etc/systemd/system/truenas-mcp.service
```
Update the `ExecStart` line to include your desired configuration:
```ini
ExecStart=/usr/local/bin/truenas-mcp -listen 0.0.0.0:8089 -api-key your-secure-key-here
```
Then reload and restart:
```bash
systemctl daemon-reload
systemctl restart truenas-mcp
```
### Step 2: Setup Claude Desktop Proxy
#### Install the proxy binary
### Step 1: Download or Build Binary
Choose the appropriate binary for your platform:
**macOS (Apple Silicon):**
```bash
sudo cp truenas-mcp-proxy-darwin-arm64 /usr/local/bin/truenas-mcp-proxy
sudo chmod +x /usr/local/bin/truenas-mcp-proxy
sudo cp truenas-mcp-darwin-arm64 /usr/local/bin/truenas-mcp
sudo chmod +x /usr/local/bin/truenas-mcp
```
**macOS (Intel):**
```bash
sudo cp truenas-mcp-proxy-darwin-amd64 /usr/local/bin/truenas-mcp-proxy
sudo chmod +x /usr/local/bin/truenas-mcp-proxy
sudo cp truenas-mcp-darwin-amd64 /usr/local/bin/truenas-mcp
sudo chmod +x /usr/local/bin/truenas-mcp
```
**Linux:**
```bash
sudo cp truenas-mcp-proxy-linux-amd64 /usr/local/bin/truenas-mcp-proxy
sudo chmod +x /usr/local/bin/truenas-mcp-proxy
sudo cp truenas-mcp-linux-amd64 /usr/local/bin/truenas-mcp
sudo chmod +x /usr/local/bin/truenas-mcp
```
**Windows:**
```powershell
copy truenas-mcp-proxy-windows-amd64.exe C:\Windows\System32\truenas-mcp-proxy.exe
copy truenas-mcp-windows-amd64.exe C:\Windows\System32\truenas-mcp.exe
```
#### Configure Claude Desktop
### Step 2: Get TrueNAS API Key
1. Log into your TrueNAS web interface
2. Go to **System Settings → API Keys**
3. Click **Add** to create a new API key
4. Give it a name (e.g., "Claude Desktop MCP")
5. Make sure it has appropriate permissions (admin recommended)
6. **Copy the API key** - you'll need it for configuration
### Step 3: Configure Claude Desktop
Edit your Claude Desktop configuration file:
@@ -173,23 +131,54 @@ Add the TrueNAS MCP server configuration:
{
"mcpServers": {
"truenas": {
"command": "truenas-mcp-proxy",
"command": "truenas-mcp",
"args": [
"--server-url", "http://YOUR-TRUENAS-IP:8089",
"--api-key", "your-secure-key-here"
"--truenas-url", "truenas.local",
"--api-key", "your-api-key-here"
]
}
}
}
```
Replace `YOUR-TRUENAS-IP` with your TrueNAS IP address and `your-secure-key-here` with the API key you configured in the systemd service.
**Configuration options:**
#### Restart Claude Desktop
**Option 1: Hostname (auto-detects wss:// or ws://):**
```json
"args": [
"--truenas-url", "192.168.0.31",
"--api-key", "18-NoKVv1EyfStph6AGaOZPpD8nu3GLsTeEYXrRxCNXEv0oi3aHJgfFeCBgFUxx467P"
]
```
Quit Claude Desktop completely and restart it. The MCP server connection will be established automatically.
**Option 2: Full WebSocket URL (explicit protocol):**
```json
"args": [
"--truenas-url", "wss://truenas.local/websocket",
"--api-key", "your-api-key-here"
]
```
### Step 3: Verify the Connection
**Option 3: Using environment variables:**
```json
{
"mcpServers": {
"truenas": {
"command": "truenas-mcp",
"env": {
"TRUENAS_URL": "192.168.0.31",
"TRUENAS_API_KEY": "your-api-key-here"
}
}
}
}
```
### Step 4: Restart Claude Desktop
Quit Claude Desktop completely and restart it. The MCP connection will be established automatically.
### Step 5: Verify the Connection
In Claude Desktop, you should now be able to ask TrueNAS questions:
@@ -197,131 +186,80 @@ In Claude Desktop, you should now be able to ask TrueNAS questions:
- "Show me all storage pools and their health"
- "List all datasets"
- "What shares are configured?"
- "Show me system metrics for the past hour"
You can verify the server is running on TrueNAS:
```bash
# Check service status
ssh root@your-truenas 'systemctl status truenas-mcp'
# View logs
ssh root@your-truenas 'journalctl -u truenas-mcp -f'
# Test the health endpoint
curl http://YOUR-TRUENAS-IP:8089/health
```
## Running
### Command-line options
```bash
# Start with defaults (localhost:8080, no auth)
./truenas-mcp
# Specify listen address and API key
./truenas-mcp -listen 0.0.0.0:8443 -api-key your-secret-key
# Or use environment variables
TRUENAS_MCP_API_KEY=your-secret-key ./truenas-mcp -listen :8080
# Custom TrueNAS socket path
TRUENAS_SOCKET=/custom/path/middleware.sock ./truenas-mcp
```
## Command-Line Options
### Flags
- `-listen` - Listen address (default: `localhost:8080`)
- `-api-key` - API key for authentication (can also use `TRUENAS_MCP_API_KEY` env var)
- `-version` - Print version and exit
## MCP Client Configuration
### Option 1: Direct SSE Connection (local TrueNAS only)
If TrueNAS is running locally, you can connect directly via SSE:
```json
{
"mcpServers": {
"truenas": {
"url": "http://localhost:8080/sse",
"headers": {
"Authorization": "Bearer your-api-key-here"
}
}
}
}
```
### Option 2: Proxy Client (recommended for remote TrueNAS)
For remote TrueNAS servers, use the proxy binary:
1. Install the proxy binary:
```bash
# macOS (arm64)
cp truenas-mcp-proxy-darwin-arm64 /usr/local/bin/truenas-mcp-proxy
chmod +x /usr/local/bin/truenas-mcp-proxy
# macOS (amd64)
cp truenas-mcp-proxy-darwin-amd64 /usr/local/bin/truenas-mcp-proxy
chmod +x /usr/local/bin/truenas-mcp-proxy
# Linux
cp truenas-mcp-proxy-linux-amd64 /usr/local/bin/truenas-mcp-proxy
chmod +x /usr/local/bin/truenas-mcp-proxy
```
2. Configure Claude Desktop (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
```json
{
"mcpServers": {
"truenas": {
"command": "truenas-mcp-proxy",
"args": [
"--server-url", "http://192.168.0.31:8089",
"--api-key", "your-secure-key-here"
]
}
}
}
```
3. Restart Claude Desktop
### Proxy Configuration Options
The proxy supports these flags:
- `--server-url` - Remote server URL (required, or use `TRUENAS_MCP_SERVER_URL` env var)
- `--api-key` - Authentication key (required, or use `TRUENAS_MCP_API_KEY` env var)
- `--timeout` - Request timeout (default: 30s)
- `--debug` - Enable verbose logging
- `--insecure` - Skip TLS verification (for self-signed certs, not recommended)
- `--truenas-url` - TrueNAS hostname or WebSocket URL (required, or use `TRUENAS_URL` env var)
- Hostname: `truenas.local` or `192.168.0.31` (uses `wss://` on port 443)
- Full URL: `wss://truenas.local/websocket` (custom port/path)
- ⚠️ **Note**: `ws://` (unencrypted) not recommended - will cause API key revocation
- `--api-key` - TrueNAS API key for authentication (required, or use `TRUENAS_API_KEY` env var)
- `--insecure` - Skip TLS verification (not needed - self-signed certs accepted by default)
- `--debug` - Enable debug logging
- `--version` - Print version and exit
For more details, see [PROXY.md](PROXY.md).
### Examples
```bash
# Basic usage with hostname
./truenas-mcp --truenas-url 192.168.0.31 --api-key your-api-key
# With full WebSocket URL
./truenas-mcp --truenas-url wss://truenas.local/websocket --api-key your-api-key
# Using environment variables
export TRUENAS_URL=192.168.0.31
export TRUENAS_API_KEY=your-api-key
./truenas-mcp
# With debug logging
./truenas-mcp --truenas-url 192.168.0.31 --api-key your-api-key --debug
```
## Connection Details
### How It Works
The binary connects directly to TrueNAS middleware's WebSocket endpoint:
1. **Uses secure WebSocket (wss://)**: Connects to `wss://your-truenas:443/websocket`
2. **Self-signed certs accepted**: Works with TrueNAS default self-signed certificates
3. **Authenticates via API key**: Uses `auth.login_with_api_key` method
### ⚠️ Security Requirement
**IMPORTANT**: TrueNAS **requires** encrypted connections (`wss://`) for API key authentication. Using unencrypted `ws://` will cause your API key to be **revoked** as a security measure. This binary defaults to `wss://` to protect your credentials.
### Troubleshooting
**Connection Issues:**
- Verify TrueNAS is accessible from your machine
- Check firewall allows ports 80 (ws) or 443 (wss)
- Verify API key is valid and has admin permissions
**Authentication Failures:**
- Generate a new API key in TrueNAS System Settings → API Keys
- Ensure the key has appropriate permissions
- Check that the key wasn't accidentally truncated when copying
## Security
- **Authentication**: Required via API key in `Authorization: Bearer <key>` header
- **Bind Address**: Default is `localhost:8080` (local-only). Use `0.0.0.0:8080` for network access
- **TLS**: Not built-in. Use reverse proxy (nginx, caddy) for HTTPS
- **Read-only**: All current tools are read-only queries
- **Authentication**: TrueNAS API key required for all operations
- **TLS/SSL**: Supports both wss:// (encrypted) and ws:// (unencrypted)
- **Self-signed certificates**: Accepted by default (common for TrueNAS)
- **Network**: Client-only (no listening ports, all connections outbound)
- **API Key Storage**: Recommend using environment variables instead of command-line args
### Recommended Production Setup
### Security Best Practices
1. Run behind reverse proxy with TLS
2. Use strong API key
3. Enable firewall rules
4. Run as non-root user (if middleware socket permits)
5. Monitor logs via systemd journal
## API Endpoints
- `GET /sse` - SSE stream for server-to-client messages
- `POST /messages` - Client-to-server messages (JSON-RPC)
- `GET /health` - Health check endpoint (no auth required)
1. **Use secure WebSocket (wss://)** when possible
2. **Generate dedicated API key** for MCP use only
3. **Use environment variables** for API keys in Claude Desktop config
4. **Restrict API key permissions** to minimum required
5. **Rotate API keys periodically**
## Example Usage
+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"truenas": {
"command": "/usr/local/bin/truenas-mcp",
"args": [
"--truenas-url", "192.168.0.31",
"--api-key", "19-ZLHNjv0Hu0l8VwB9pXYQldIg2uCoLZzRaNhMyrZ47iYl1pE8RBvIBXs2N5Zdizsw"
]
}
}
}
+223 -32
View File
@@ -1,9 +1,14 @@
package main
import (
"bufio"
"crypto/tls"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"sync"
"github.com/truenas/truenas-mcp/mcp"
"github.com/truenas/truenas-mcp/tools"
@@ -11,53 +16,51 @@ import (
)
var (
listenAddr = flag.String("listen", "localhost:8080", "Listen address (host:port)")
apiKey = flag.String("api-key", "", "API key for MCP client authentication (required)")
truenasAPIKey = flag.String("truenas-api-key", "", "TrueNAS API key for middleware authentication (required)")
version = flag.Bool("version", false, "Print version and exit")
truenasURL = flag.String("truenas-url", "", "TrueNAS hostname or WebSocket URL (e.g., 'truenas.local' or 'ws://10.0.0.1/websocket')")
apiKey = flag.String("api-key", "", "TrueNAS API key for middleware authentication")
insecure = flag.Bool("insecure", false, "Skip TLS certificate verification (for self-signed certs)")
versionFlg = flag.Bool("version", false, "Print version and exit")
debug = flag.Bool("debug", false, "Enable debug logging")
)
const (
Version = "0.1.0"
Version = "0.2.0"
)
func main() {
flag.Parse()
if *version {
log.Printf("truenas-mcp version %s", Version)
if *versionFlg {
fmt.Printf("truenas-mcp version %s\n", Version)
os.Exit(0)
}
// API key is required for security
// Get configuration from flags or environment variables
if *truenasURL == "" {
*truenasURL = os.Getenv("TRUENAS_URL")
}
if *apiKey == "" {
apiKey = new(string)
*apiKey = os.Getenv("TRUENAS_MCP_API_KEY")
if *apiKey == "" {
log.Println("WARNING: No API key specified via -api-key flag or TRUENAS_MCP_API_KEY env var.")
log.Println("Authentication is disabled. This is not recommended for production.")
}
*apiKey = os.Getenv("TRUENAS_API_KEY")
}
// TrueNAS API key is required for middleware authentication
if *truenasAPIKey == "" {
truenasAPIKey = new(string)
*truenasAPIKey = os.Getenv("TRUENAS_API_KEY")
if *truenasAPIKey == "" {
log.Fatal("TrueNAS API key required via -truenas-api-key flag or TRUENAS_API_KEY env var")
}
if *truenasURL == "" || *apiKey == "" {
log.Fatal("Both --truenas-url and --api-key are required (or set TRUENAS_URL and TRUENAS_API_KEY env vars)")
}
// Initialize TrueNAS client
socketPath := os.Getenv("TRUENAS_SOCKET")
if socketPath == "" {
socketPath = "/var/run/middleware/middlewared.sock"
// Configure TLS - accept self-signed certs by default (common for TrueNAS)
tlsConfig := &tls.Config{
InsecureSkipVerify: true,
}
if *insecure {
log.Println("TLS certificate verification disabled (self-signed certs accepted)")
}
client, err := truenas.NewClient(socketPath, *truenasAPIKey)
// Create TrueNAS client
client, err := truenas.NewClient(*truenasURL, *apiKey, tlsConfig)
if err != nil {
log.Fatalf("Failed to create TrueNAS client: %v", err)
}
defer client.Close()
// Authenticate with TrueNAS middleware
if err := client.Authenticate(); err != nil {
@@ -65,13 +68,201 @@ func main() {
}
log.Println("Successfully authenticated with TrueNAS middleware")
// Initialize tool registry with TrueNAS client
// Create tool registry
registry := tools.NewRegistry(client)
// Start SSE server
log.Printf("Starting TrueNAS MCP Server v%s on %s...", Version, *listenAddr)
server := mcp.NewSSEServer(registry, *listenAddr, *apiKey)
if err := server.Run(); err != nil {
log.Fatalf("Server error: %v", err)
// Start stdio handler
handler := NewStdioHandler(registry, *debug)
if err := handler.Run(); err != nil {
log.Fatalf("Stdio handler error: %v", err)
}
}
// StdioHandler manages stdio communication for MCP protocol
type StdioHandler struct {
registry mcp.ToolRegistry
stdin *bufio.Scanner
stdoutMutex sync.Mutex
debug bool
}
func NewStdioHandler(registry mcp.ToolRegistry, debug bool) *StdioHandler {
return &StdioHandler{
registry: registry,
stdin: bufio.NewScanner(os.Stdin),
debug: debug,
}
}
func (h *StdioHandler) Run() error {
if h.debug {
log.Println("Starting stdio handler...")
}
for h.stdin.Scan() {
line := h.stdin.Bytes()
if h.debug {
log.Printf("[STDIN] %s", string(line))
}
var req mcp.Request
if err := json.Unmarshal(line, &req); err != nil {
if h.debug {
log.Printf("Parse error: %v", err)
}
h.sendError(nil, -32700, fmt.Sprintf("Parse error: %v", err))
continue
}
if h.debug {
log.Printf("Handling method: %s (id: %v)", req.Method, req.ID)
}
resp := h.handleRequest(&req)
// Only send response if not nil (notifications don't get responses)
if resp != nil {
if err := h.sendResponse(resp); err != nil {
log.Printf("Failed to send response: %v", err)
}
}
}
if err := h.stdin.Err(); err != nil {
return fmt.Errorf("stdin error: %w", err)
}
return nil
}
func (h *StdioHandler) handleRequest(req *mcp.Request) *mcp.Response {
switch req.Method {
case "initialize":
return h.handleInitialize(req)
case "notifications/initialized":
// This is a notification from the client after initialization
// Notifications don't require a response
return nil
case "tools/list":
return h.handleToolsList(req)
case "tools/call":
return h.handleToolsCall(req)
default:
// Only return error if this is a request (has an ID)
if req.ID != nil {
return h.createErrorResponse(req.ID, -32601, "Method not found")
}
// For notifications, no response needed
return nil
}
}
func (h *StdioHandler) handleInitialize(req *mcp.Request) *mcp.Response {
result := mcp.InitializeResult{
ProtocolVersion: "2024-11-05",
ServerInfo: mcp.ServerInfo{
Name: "truenas-mcp",
Version: Version,
},
Capabilities: mcp.Capabilities{
Tools: map[string]interface{}{},
},
}
return &mcp.Response{
JSONRPC: "2.0",
ID: req.ID,
Result: result,
}
}
func (h *StdioHandler) handleToolsList(req *mcp.Request) *mcp.Response {
tools := h.registry.ListTools()
result := mcp.ToolsListResult{
Tools: tools,
}
return &mcp.Response{
JSONRPC: "2.0",
ID: req.ID,
Result: result,
}
}
func (h *StdioHandler) handleToolsCall(req *mcp.Request) *mcp.Response {
// Extract tool call parameters
var params mcp.ToolCallParams
paramsBytes, err := json.Marshal(req.Params)
if err != nil {
return h.createErrorResponse(req.ID, -32602, fmt.Sprintf("Invalid params: %v", err))
}
if err := json.Unmarshal(paramsBytes, &params); err != nil {
return h.createErrorResponse(req.ID, -32602, fmt.Sprintf("Invalid params: %v", err))
}
// Call the tool
result, err := h.registry.CallTool(params.Name, params.Arguments)
if err != nil {
return &mcp.Response{
JSONRPC: "2.0",
ID: req.ID,
Result: mcp.ToolCallResult{
Content: []mcp.ContentBlock{
{
Type: "text",
Text: fmt.Sprintf("Error: %v", err),
},
},
IsError: true,
},
}
}
return &mcp.Response{
JSONRPC: "2.0",
ID: req.ID,
Result: mcp.ToolCallResult{
Content: []mcp.ContentBlock{
{
Type: "text",
Text: result,
},
},
},
}
}
func (h *StdioHandler) createErrorResponse(id interface{}, code int, message string) *mcp.Response {
return &mcp.Response{
JSONRPC: "2.0",
ID: id,
Error: &mcp.Error{
Code: code,
Message: message,
},
}
}
func (h *StdioHandler) sendResponse(resp *mcp.Response) error {
h.stdoutMutex.Lock()
defer h.stdoutMutex.Unlock()
data, err := json.Marshal(resp)
if err != nil {
return fmt.Errorf("failed to marshal response: %w", err)
}
if h.debug {
log.Printf("[STDOUT] %s", string(data))
}
fmt.Printf("%s\n", data)
return nil
}
func (h *StdioHandler) sendError(id interface{}, code int, message string) {
resp := h.createErrorResponse(id, code, message)
if err := h.sendResponse(resp); err != nil {
log.Printf("Failed to send error response: %v", err)
}
}
+4 -2
View File
@@ -2,10 +2,12 @@ module github.com/truenas/truenas-mcp
go 1.22
require github.com/gorilla/websocket v1.5.1
require (
github.com/gorilla/websocket v1.5.1
github.com/r3labs/sse/v2 v2.10.0
)
require (
github.com/r3labs/sse/v2 v2.10.0 // indirect
golang.org/x/net v0.17.0 // indirect
gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
)
+4
View File
@@ -1,10 +1,13 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/r3labs/sse/v2 v2.10.0 h1:hFEkLLFY4LDifoHdiCN/LlGBAdVJYsANaLqNYa1l/v0=
github.com/r3labs/sse/v2 v2.10.0/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -15,4 +18,5 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y=
gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-1
View File
@@ -166,7 +166,6 @@ func (c *SSEClient) scheduleReconnect() {
}()
}
// SetMessageHandler sets the callback for message events
func (c *SSEClient) SetMessageHandler(handler func(*Response)) {
c.onMessage = handler
+106 -51
View File
@@ -1,11 +1,11 @@
package truenas
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"log"
"net"
"strings"
"sync/atomic"
"time"
@@ -13,11 +13,12 @@ import (
)
type Client struct {
socketPath string
apiKey string
conn *websocket.Conn
requestID atomic.Uint64
authenticated bool
endpoint string
apiKey string
tlsConfig *tls.Config
conn *websocket.Conn
requestID atomic.Uint64
authenticated bool
}
type ConnectRequest struct {
@@ -51,10 +52,17 @@ type APIError struct {
Trace interface{} `json:"trace,omitempty"` // Can be string or object
}
func NewClient(socketPath, apiKey string) (*Client, error) {
func NewClient(endpoint, apiKey string, tlsConfig *tls.Config) (*Client, error) {
if endpoint == "" {
return nil, fmt.Errorf("endpoint cannot be empty")
}
if apiKey == "" {
return nil, fmt.Errorf("apiKey cannot be empty")
}
return &Client{
socketPath: socketPath,
apiKey: apiKey,
endpoint: endpoint,
apiKey: apiKey,
tlsConfig: tlsConfig,
}, nil
}
@@ -63,56 +71,90 @@ func (c *Client) connect() error {
return nil
}
// Create dialer for Unix socket
dialer := &net.Dialer{
Timeout: 10 * time.Second,
}
// Connect WebSocket over Unix socket
wsDialer := websocket.Dialer{
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.Dial("unix", c.socketPath)
},
HandshakeTimeout: 10 * time.Second,
}
// TrueNAS middleware WebSocket endpoint
conn, _, err := wsDialer.Dial("ws://localhost/websocket", nil)
// Build connection URLs - will return error if ws:// is specified
urls, err := c.buildConnectionURLs()
if err != nil {
return fmt.Errorf("failed to connect to websocket: %w", err)
return err
}
c.conn = conn
c.authenticated = false
// Send connect message as per TrueNAS WebSocket protocol
connectMsg := ConnectRequest{
Msg: "connect",
Version: "1",
Support: []string{"1"},
wsDialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
TLSClientConfig: c.tlsConfig, // Always use TLS config (only wss:// allowed)
}
log.Printf("Sending connect message: %+v", connectMsg)
if err := c.conn.WriteJSON(connectMsg); err != nil {
c.conn = nil
return fmt.Errorf("failed to send connect message: %w", err)
var lastErr error
for _, url := range urls {
log.Printf("Connecting to %s...", url)
conn, _, err := wsDialer.Dial(url, nil)
if err != nil {
log.Printf("Connection failed: %v", err)
lastErr = err
continue // Try next URL
}
c.conn = conn
c.authenticated = false
// Send connect message as per TrueNAS WebSocket protocol
connectMsg := ConnectRequest{
Msg: "connect",
Version: "1",
Support: []string{"1"},
}
log.Printf("Sending connect message: %+v", connectMsg)
if err := c.conn.WriteJSON(connectMsg); err != nil {
c.conn.Close()
c.conn = nil
lastErr = fmt.Errorf("failed to send connect message: %w", err)
continue
}
// Read connect response
var connectResp ConnectResponse
if err := c.conn.ReadJSON(&connectResp); err != nil {
c.conn.Close()
c.conn = nil
lastErr = fmt.Errorf("failed to read connect response: %w", err)
continue
}
log.Printf("Received connect response: %+v", connectResp)
if connectResp.Msg != "connected" {
c.conn.Close()
c.conn = nil
lastErr = fmt.Errorf("unexpected connect response: %s", connectResp.Msg)
continue
}
log.Printf("Successfully connected via %s", url)
return nil
}
// Read connect response
var connectResp ConnectResponse
if err := c.conn.ReadJSON(&connectResp); err != nil {
c.conn = nil
return fmt.Errorf("failed to read connect response: %w", err)
return fmt.Errorf("all connection attempts failed: %w", lastErr)
}
// buildConnectionURLs returns URLs to try in order
func (c *Client) buildConnectionURLs() ([]string, error) {
// SECURITY: Reject ws:// URLs entirely - TrueNAS will revoke API keys used over unencrypted connections
if strings.HasPrefix(c.endpoint, "ws://") {
return nil, fmt.Errorf("SECURITY ERROR: ws:// (unencrypted) connections are not allowed. TrueNAS will revoke API keys used over ws://. Use wss:// instead")
}
log.Printf("Received connect response: %+v", connectResp)
if connectResp.Msg != "connected" {
c.conn = nil
return fmt.Errorf("unexpected connect response: %s", connectResp.Msg)
// If full wss:// URL provided, use it
if strings.HasPrefix(c.endpoint, "wss://") {
return []string{c.endpoint}, nil
}
return nil
// Otherwise, ONLY use wss:// (secure connection required for API key authentication)
hostname := c.endpoint
// Remove port if specified (we'll add the correct port)
if idx := strings.LastIndex(hostname, ":"); idx != -1 {
hostname = hostname[:idx]
}
return []string{fmt.Sprintf("wss://%s:443/websocket", hostname)}, nil
}
func (c *Client) Authenticate() error {
@@ -122,8 +164,8 @@ func (c *Client) Authenticate() error {
log.Println("Authenticating with TrueNAS middleware...")
// Call auth.login_with_api_key
result, err := c.Call("auth.login_with_api_key", c.apiKey)
// Call auth.login_with_api_key using raw call (bypass auth check)
result, err := c.callRaw("auth.login_with_api_key", c.apiKey)
if err != nil {
return fmt.Errorf("authentication failed: %w", err)
}
@@ -144,10 +186,23 @@ func (c *Client) Authenticate() error {
}
func (c *Client) Call(method string, params ...interface{}) (json.RawMessage, error) {
// Ensure we're connected
if err := c.connect(); err != nil {
return nil, err
}
// Ensure we're authenticated (will re-authenticate if connection was reset)
if !c.authenticated {
if err := c.Authenticate(); err != nil {
return nil, fmt.Errorf("re-authentication failed: %w", err)
}
}
return c.callRaw(method, params...)
}
// callRaw performs the actual API call without authentication check
func (c *Client) callRaw(method string, params ...interface{}) (json.RawMessage, error) {
id := fmt.Sprintf("%d", c.requestID.Add(1))
req := APIRequest{