Initial commit: TrueNAS MCP Server and Desktop Proxy

This project provides a Model Context Protocol (MCP) server for TrueNAS
that enables AI models to interact with TrueNAS API using natural language.

Features:
- Server component (truenas-mcp) runs on TrueNAS via systemd
- Desktop proxy (truenas-mcp-proxy) bridges Claude Desktop to remote server
- SSE/HTTP transport for network communication
- Read-only tools for system info, health, pools, datasets, and shares

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Kris Moore
2026-02-04 09:30:53 -05:00
co-authored by Claude Sonnet 4.5
commit 06ad891b05
19 changed files with 2970 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
# Binaries (root directory only)
/truenas-mcp
/truenas-mcp-proxy
/truenas-mcp-proxy-*
*.exe
*.dll
*.so
*.dylib
# Test binary
*.test
# Output of go coverage
*.out
# Dependency directories
vendor/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
+22
View File
@@ -0,0 +1,22 @@
Proprietary Software License
Copyright (c) 2026 TrueNAS Inc. All rights reserved.
This software and associated documentation files (the "Software") are proprietary
and confidential to TrueNAS Inc.
All rights reserved. No part of this Software may be reproduced, distributed, or
transmitted in any form or by any means, including photocopying, recording, or
other electronic or mechanical methods, without the prior written permission of
TrueNAS Inc., except in the case of brief quotations embodied in critical reviews
and certain other noncommercial uses permitted by copyright law.
For permission requests, contact TrueNAS Inc. at:
https://www.truenas.com/
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+72
View File
@@ -0,0 +1,72 @@
.PHONY: build build-linux clean test lint run deploy build-proxy build-proxy-darwin build-proxy-linux build-proxy-windows build-proxy-all
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:
@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
clean:
@echo "Cleaning..."
rm -f $(BUILD_DIR)/$(BINARY_NAME)
rm -f $(BUILD_DIR)/$(PROXY_BINARY_NAME)*
test:
@echo "Running tests..."
go test -v ./...
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"'
+359
View File
@@ -0,0 +1,359 @@
# 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
+348
View File
@@ -0,0 +1,348 @@
# 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.
## Features
Read-only tools for common TrueNAS operations:
- **system_info** - Get system information (version, hostname, platform)
- **system_health** - Check system health and alerts
- **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
## Architecture
This project provides two binaries:
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
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
## Building
### Server (runs on TrueNAS)
```bash
# Download dependencies
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
```
## Installation & Deployment
### 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
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
```
**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
```
**Linux:**
```bash
sudo cp truenas-mcp-proxy-linux-amd64 /usr/local/bin/truenas-mcp-proxy
sudo chmod +x /usr/local/bin/truenas-mcp-proxy
```
**Windows:**
```powershell
copy truenas-mcp-proxy-windows-amd64.exe C:\Windows\System32\truenas-mcp-proxy.exe
```
#### Configure Claude Desktop
Edit your Claude Desktop configuration file:
**macOS:**
```bash
vi ~/Library/Application\ Support/Claude/claude_desktop_config.json
```
**Linux:**
```bash
vi ~/.config/Claude/claude_desktop_config.json
```
**Windows:**
```
%APPDATA%\Claude\claude_desktop_config.json
```
Add the TrueNAS MCP server configuration:
```json
{
"mcpServers": {
"truenas": {
"command": "truenas-mcp-proxy",
"args": [
"--server-url", "http://YOUR-TRUENAS-IP:8089",
"--api-key", "your-secure-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.
#### Restart Claude Desktop
Quit Claude Desktop completely and restart it. The MCP server connection will be established automatically.
### Step 3: Verify the Connection
In Claude Desktop, you should now be able to ask TrueNAS questions:
- "What version of TrueNAS is running?"
- "Show me all storage pools and their health"
- "List all datasets"
- "What shares are configured?"
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
```
### 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)
- `--version` - Print version and exit
For more details, see [PROXY.md](PROXY.md).
## 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
### Recommended Production Setup
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)
## Example Usage
Once connected via an MCP client:
- "What version of TrueNAS is running?"
- "Show me all storage pools and their health status"
- "List all datasets in the tank pool"
- "What SMB shares are configured?"
- "Are there any system alerts?"
## Development
```bash
# Run linters
make lint
# Run tests
make test
# Clean build artifacts
make clean
```
## Next Steps
- Add more read-only tools (services, network, disks)
- Implement write operations (with safety checks)
- Add API endpoint discovery tool
- TLS support (or document reverse proxy setup)
- Rate limiting
- Audit logging
+65
View File
@@ -0,0 +1,65 @@
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")
}
}
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"flag"
"log"
"os"
"github.com/truenas/truenas-mcp/mcp"
"github.com/truenas/truenas-mcp/tools"
"github.com/truenas/truenas-mcp/truenas"
)
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")
)
const (
Version = "0.1.0"
)
func main() {
flag.Parse()
if *version {
log.Printf("truenas-mcp version %s", Version)
os.Exit(0)
}
// API key is required for security
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.")
}
}
// 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")
}
}
// Initialize TrueNAS client
socketPath := os.Getenv("TRUENAS_SOCKET")
if socketPath == "" {
socketPath = "/var/run/middleware/middlewared.sock"
}
client, err := truenas.NewClient(socketPath, *truenasAPIKey)
if err != nil {
log.Fatalf("Failed to create TrueNAS client: %v", err)
}
// Authenticate with TrueNAS middleware
if err := client.Authenticate(); err != nil {
log.Fatalf("Failed to authenticate with TrueNAS: %v", err)
}
log.Println("Successfully authenticated with TrueNAS middleware")
// Initialize tool registry with TrueNAS client
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)
}
}
+11
View File
@@ -0,0 +1,11 @@
module github.com/truenas/truenas-mcp
go 1.22
require github.com/gorilla/websocket v1.5.1
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
)
+18
View File
@@ -0,0 +1,18 @@
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/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/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=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+29
View File
@@ -0,0 +1,29 @@
{
"mcpServers": {
"truenas-local": {
"comment": "Direct SSE connection (local TrueNAS only)",
"url": "http://localhost:8080/sse",
"headers": {
"Authorization": "Bearer your-secure-api-key-here"
}
},
"truenas-remote": {
"comment": "Proxy method (recommended for remote TrueNAS)",
"command": "truenas-mcp-proxy",
"args": [
"--server-url", "http://192.168.0.31:8089",
"--api-key", "your-secure-key-here"
]
},
"truenas-remote-secure": {
"comment": "Proxy with environment variable for API key (more secure)",
"command": "truenas-mcp-proxy",
"args": [
"--server-url", "http://192.168.0.31:8089"
],
"env": {
"TRUENAS_MCP_API_KEY": "your-secure-key-here"
}
}
}
}
+195
View File
@@ -0,0 +1,195 @@
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
@@ -0,0 +1,324 @@
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)
})
}
+71
View File
@@ -0,0 +1,71 @@
package mcp
// JSON-RPC 2.0 message types
type Request struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id,omitempty"`
Method string `json:"method"`
Params map[string]interface{} `json:"params,omitempty"`
}
type Response struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id,omitempty"`
Result interface{} `json:"result,omitempty"`
Error *Error `json:"error,omitempty"`
}
type Error struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// MCP-specific types
type InitializeResult struct {
ProtocolVersion string `json:"protocolVersion"`
ServerInfo ServerInfo `json:"serverInfo"`
Capabilities Capabilities `json:"capabilities"`
}
type ServerInfo struct {
Name string `json:"name"`
Version string `json:"version"`
}
type Capabilities struct {
Tools map[string]interface{} `json:"tools,omitempty"`
}
type Tool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]interface{} `json:"inputSchema"`
}
type ToolsListResult struct {
Tools []Tool `json:"tools"`
}
type ToolCallParams struct {
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments,omitempty"`
}
type ToolCallResult struct {
Content []ContentBlock `json:"content"`
IsError bool `json:"isError,omitempty"`
}
type ContentBlock struct {
Type string `json:"type"`
Text string `json:"text"`
}
// ToolRegistry interface for tool management
type ToolRegistry interface {
ListTools() []Tool
CallTool(name string, args map[string]interface{}) (string, error)
}
+63
View File
@@ -0,0 +1,63 @@
package proxy
import (
"errors"
"flag"
"os"
"time"
)
// Config holds proxy configuration
type Config struct {
ServerURL string
APIKey string
Timeout time.Duration
Debug bool
Insecure bool
}
// LoadConfig loads configuration from flags and environment variables
func LoadConfig() (*Config, error) {
cfg := &Config{}
// Define flags
serverURL := flag.String("server-url", "", "TrueNAS MCP server URL (e.g., http://192.168.0.31:8089)")
apiKey := flag.String("api-key", "", "API key for authentication")
timeout := flag.Duration("timeout", 30*time.Second, "Request timeout")
debug := flag.Bool("debug", false, "Enable debug logging")
insecure := flag.Bool("insecure", false, "Skip TLS certificate verification (not recommended)")
version := flag.Bool("version", false, "Print version and exit")
flag.Parse()
// Handle version flag
if *version {
return nil, errors.New("version requested")
}
// Load from flags or environment variables
cfg.ServerURL = *serverURL
if cfg.ServerURL == "" {
cfg.ServerURL = os.Getenv("TRUENAS_MCP_SERVER_URL")
}
cfg.APIKey = *apiKey
if cfg.APIKey == "" {
cfg.APIKey = os.Getenv("TRUENAS_MCP_API_KEY")
}
cfg.Timeout = *timeout
cfg.Debug = *debug
cfg.Insecure = *insecure
// Validate required fields
if cfg.ServerURL == "" {
return nil, errors.New("server URL is required (use --server-url or TRUENAS_MCP_SERVER_URL)")
}
if cfg.APIKey == "" {
return nil, errors.New("API key is required (use --api-key or TRUENAS_MCP_API_KEY)")
}
return cfg, nil
}
+379
View File
@@ -0,0 +1,379 @@
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
}
}
}
+85
View File
@@ -0,0 +1,85 @@
package proxy
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"os"
"sync"
"github.com/truenas/truenas-mcp/mcp"
)
// StdioHandler manages stdin/stdout communication
type StdioHandler struct {
stdin *bufio.Scanner
stdoutMutex sync.Mutex
debug bool
}
// NewStdioHandler creates a new stdio handler
func NewStdioHandler(debug bool) *StdioHandler {
return &StdioHandler{
stdin: bufio.NewScanner(os.Stdin),
debug: debug,
}
}
// ReadRequest reads a JSON-RPC request from stdin
func (h *StdioHandler) ReadRequest() (*mcp.Request, error) {
if !h.stdin.Scan() {
if err := h.stdin.Err(); err != nil {
return nil, fmt.Errorf("stdin read error: %w", err)
}
return nil, io.EOF
}
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 {
return nil, fmt.Errorf("failed to parse JSON-RPC request: %w", err)
}
return &req, nil
}
// WriteResponse writes a JSON-RPC response to stdout
func (h *StdioHandler) WriteResponse(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))
}
_, err = fmt.Fprintf(os.Stdout, "%s\n", data)
if err != nil {
return fmt.Errorf("failed to write to stdout: %w", err)
}
return nil
}
// WriteError writes a JSON-RPC error response to stdout
func (h *StdioHandler) WriteError(id interface{}, code int, message string) error {
resp := &mcp.Response{
JSONRPC: "2.0",
ID: id,
Error: &mcp.Error{
Code: code,
Message: message,
},
}
return h.WriteResponse(resp)
}
+580
View File
@@ -0,0 +1,580 @@
package tools
import (
"encoding/json"
"fmt"
"github.com/truenas/truenas-mcp/mcp"
"github.com/truenas/truenas-mcp/truenas"
)
type Registry struct {
client *truenas.Client
tools map[string]Tool
}
type Tool struct {
Definition mcp.Tool
Handler func(*truenas.Client, map[string]interface{}) (string, error)
}
func NewRegistry(client *truenas.Client) *Registry {
r := &Registry{
client: client,
tools: make(map[string]Tool),
}
r.registerTools()
return r
}
func (r *Registry) registerTools() {
// System info tool
r.tools["system_info"] = Tool{
Definition: mcp.Tool{
Name: "system_info",
Description: "Get TrueNAS system information including version, hostname, and platform details",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
},
Handler: handleSystemInfo,
}
// System health tool
r.tools["system_health"] = Tool{
Definition: mcp.Tool{
Name: "system_health",
Description: "Get system health status including alerts and diagnostics",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
},
Handler: handleSystemHealth,
}
// Storage pools query
r.tools["query_pools"] = Tool{
Definition: mcp.Tool{
Name: "query_pools",
Description: "Query storage pools with their status, capacity, and health information",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
},
Handler: handleQueryPools,
}
// Dataset query
r.tools["query_datasets"] = Tool{
Definition: mcp.Tool{
Name: "query_datasets",
Description: "Query datasets with optional filtering. Provide 'pool' parameter to filter by pool name.",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"pool": map[string]interface{}{
"type": "string",
"description": "Optional: Filter datasets by pool name",
},
},
},
},
Handler: handleQueryDatasets,
}
// Shares query
r.tools["query_shares"] = Tool{
Definition: mcp.Tool{
Name: "query_shares",
Description: "Query SMB and NFS shares configuration",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"share_type": map[string]interface{}{
"type": "string",
"enum": []string{"smb", "nfs", "all"},
"description": "Type of shares to query (default: all)",
"default": "all",
},
},
},
},
Handler: handleQueryShares,
}
// Alert list with filtering
r.tools["list_alerts"] = Tool{
Definition: mcp.Tool{
Name: "list_alerts",
Description: "List system alerts with optional filtering by dismissed status",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"dismissed": map[string]interface{}{
"type": "boolean",
"description": "Filter by dismissed status (true=dismissed only, false=active only, omit=all)",
},
},
},
},
Handler: handleListAlerts,
}
// Dismiss alert
r.tools["dismiss_alert"] = Tool{
Definition: mcp.Tool{
Name: "dismiss_alert",
Description: "Dismiss a system alert by UUID",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"uuid": map[string]interface{}{
"type": "string",
"description": "UUID of the alert to dismiss",
},
},
"required": []string{"uuid"},
},
},
Handler: handleDismissAlert,
}
// Restore alert
r.tools["restore_alert"] = Tool{
Definition: mcp.Tool{
Name: "restore_alert",
Description: "Restore (un-dismiss) a previously dismissed alert by UUID",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"uuid": map[string]interface{}{
"type": "string",
"description": "UUID of the alert to restore",
},
},
"required": []string{"uuid"},
},
},
Handler: handleRestoreAlert,
}
// System reporting metrics
r.tools["get_system_metrics"] = Tool{
Definition: mcp.Tool{
Name: "get_system_metrics",
Description: "Get system performance metrics (CPU, memory, load average)",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"graphs": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{
"type": "string",
"enum": []string{"cpu", "memory", "load"},
},
"description": "Metrics to retrieve (default: all)",
},
"unit": map[string]interface{}{
"type": "string",
"enum": []string{"HOUR", "DAY", "WEEK", "MONTH", "YEAR"},
"description": "Time range for metrics (default: HOUR)",
"default": "HOUR",
},
},
},
},
Handler: handleGetSystemMetrics,
}
// Network reporting metrics
r.tools["get_network_metrics"] = Tool{
Definition: mcp.Tool{
Name: "get_network_metrics",
Description: "Get network interface traffic metrics",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"interface": map[string]interface{}{
"type": "string",
"description": "Network interface name (e.g., 'eth0'). If omitted, returns all interfaces.",
},
"unit": map[string]interface{}{
"type": "string",
"enum": []string{"HOUR", "DAY", "WEEK", "MONTH", "YEAR"},
"description": "Time range for metrics (default: HOUR)",
"default": "HOUR",
},
},
},
},
Handler: handleGetNetworkMetrics,
}
// Disk I/O reporting metrics
r.tools["get_disk_metrics"] = Tool{
Definition: mcp.Tool{
Name: "get_disk_metrics",
Description: "Get disk I/O performance metrics",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"disk": map[string]interface{}{
"type": "string",
"description": "Disk name (e.g., 'sda'). If omitted, returns all disks.",
},
"unit": map[string]interface{}{
"type": "string",
"enum": []string{"HOUR", "DAY", "WEEK", "MONTH", "YEAR"},
"description": "Time range for metrics (default: HOUR)",
"default": "HOUR",
},
},
},
},
Handler: handleGetDiskMetrics,
}
}
func (r *Registry) ListTools() []mcp.Tool {
tools := make([]mcp.Tool, 0, len(r.tools))
for _, tool := range r.tools {
tools = append(tools, tool.Definition)
}
return tools
}
func (r *Registry) CallTool(name string, args map[string]interface{}) (string, error) {
tool, exists := r.tools[name]
if !exists {
return "", fmt.Errorf("unknown tool: %s", name)
}
return tool.Handler(r.client, args)
}
// Tool handlers
func handleSystemInfo(client *truenas.Client, args map[string]interface{}) (string, error) {
result, err := client.Call("system.info")
if err != nil {
return "", err
}
var info map[string]interface{}
if err := json.Unmarshal(result, &info); err != nil {
return "", fmt.Errorf("failed to parse response: %w", err)
}
formatted, err := json.MarshalIndent(info, "", " ")
if err != nil {
return "", err
}
return string(formatted), nil
}
func handleSystemHealth(client *truenas.Client, args map[string]interface{}) (string, error) {
// Get alerts
result, err := client.Call("alert.list")
if err != nil {
return "", err
}
var alerts []map[string]interface{}
if err := json.Unmarshal(result, &alerts); err != nil {
return "", fmt.Errorf("failed to parse alerts: %w", err)
}
response := map[string]interface{}{
"alerts": alerts,
"alert_count": len(alerts),
"health_check": "OK",
}
if len(alerts) > 0 {
response["health_check"] = "ALERTS_PRESENT"
}
formatted, err := json.MarshalIndent(response, "", " ")
if err != nil {
return "", err
}
return string(formatted), nil
}
func handleQueryPools(client *truenas.Client, args map[string]interface{}) (string, error) {
result, err := client.Call("pool.query")
if err != nil {
return "", err
}
var pools []map[string]interface{}
if err := json.Unmarshal(result, &pools); err != nil {
return "", fmt.Errorf("failed to parse pools (raw response: %s): %w", string(result), err)
}
formatted, err := json.MarshalIndent(pools, "", " ")
if err != nil {
return "", err
}
return string(formatted), nil
}
func handleQueryDatasets(client *truenas.Client, args map[string]interface{}) (string, error) {
// Build query filters
var filters []interface{}
if pool, ok := args["pool"].(string); ok && pool != "" {
filters = append(filters, []interface{}{"name", "^", pool})
}
result, err := client.Call("pool.dataset.query", filters)
if err != nil {
return "", err
}
var datasets []map[string]interface{}
if err := json.Unmarshal(result, &datasets); err != nil {
return "", fmt.Errorf("failed to parse datasets: %w", err)
}
formatted, err := json.MarshalIndent(datasets, "", " ")
if err != nil {
return "", err
}
return string(formatted), nil
}
func handleQueryShares(client *truenas.Client, args map[string]interface{}) (string, error) {
shareType := "all"
if st, ok := args["share_type"].(string); ok && st != "" {
shareType = st
}
response := make(map[string]interface{})
// Query SMB shares
if shareType == "smb" || shareType == "all" {
result, err := client.Call("sharing.smb.query")
if err != nil {
return "", fmt.Errorf("failed to query SMB shares: %w", err)
}
var smbShares []map[string]interface{}
if err := json.Unmarshal(result, &smbShares); err != nil {
return "", fmt.Errorf("failed to parse SMB shares: %w", err)
}
response["smb_shares"] = smbShares
}
// Query NFS shares
if shareType == "nfs" || shareType == "all" {
result, err := client.Call("sharing.nfs.query")
if err != nil {
return "", fmt.Errorf("failed to query NFS shares: %w", err)
}
var nfsShares []map[string]interface{}
if err := json.Unmarshal(result, &nfsShares); err != nil {
return "", fmt.Errorf("failed to parse NFS shares: %w", err)
}
response["nfs_shares"] = nfsShares
}
formatted, err := json.MarshalIndent(response, "", " ")
if err != nil {
return "", err
}
return string(formatted), nil
}
// Alert management handlers
func handleListAlerts(client *truenas.Client, args map[string]interface{}) (string, error) {
// alert.list doesn't take filter parameters in the same way as other queries
// It just returns all alerts, so we'll filter in post-processing if needed
result, err := client.Call("alert.list")
if err != nil {
return "", err
}
var alerts []map[string]interface{}
if err := json.Unmarshal(result, &alerts); err != nil {
return "", fmt.Errorf("failed to parse alerts: %w", err)
}
// Post-filter by dismissed status if requested
if dismissed, ok := args["dismissed"].(bool); ok {
filtered := make([]map[string]interface{}, 0)
for _, alert := range alerts {
if isDismissed, ok := alert["dismissed"].(bool); ok && isDismissed == dismissed {
filtered = append(filtered, alert)
}
}
alerts = filtered
}
formatted, err := json.MarshalIndent(alerts, "", " ")
if err != nil {
return "", err
}
return string(formatted), nil
}
func handleDismissAlert(client *truenas.Client, args map[string]interface{}) (string, error) {
uuid, ok := args["uuid"].(string)
if !ok || uuid == "" {
return "", fmt.Errorf("uuid parameter is required")
}
result, err := client.Call("alert.dismiss", uuid)
if err != nil {
return "", err
}
return fmt.Sprintf("Alert %s dismissed successfully: %s", uuid, string(result)), nil
}
func handleRestoreAlert(client *truenas.Client, args map[string]interface{}) (string, error) {
uuid, ok := args["uuid"].(string)
if !ok || uuid == "" {
return "", fmt.Errorf("uuid parameter is required")
}
result, err := client.Call("alert.restore", uuid)
if err != nil {
return "", err
}
return fmt.Sprintf("Alert %s restored successfully: %s", uuid, string(result)), nil
}
// Reporting handlers
func handleGetSystemMetrics(client *truenas.Client, args map[string]interface{}) (string, error) {
unit := "HOUR"
if u, ok := args["unit"].(string); ok && u != "" {
unit = u
}
// Default graphs if not specified
graphs := []string{"cpu", "memory", "load"}
if g, ok := args["graphs"].([]interface{}); ok && len(g) > 0 {
graphs = make([]string, len(g))
for i, v := range g {
if s, ok := v.(string); ok {
graphs[i] = s
}
}
}
response := make(map[string]interface{})
for _, graph := range graphs {
var apiGraph string
switch graph {
case "cpu":
apiGraph = "cpu"
case "memory":
apiGraph = "memory"
case "load":
apiGraph = "load"
default:
continue
}
result, err := client.Call("reporting.get_data", []interface{}{
map[string]interface{}{
"name": apiGraph,
"identifier": nil,
},
}, map[string]interface{}{"unit": unit})
if err != nil {
response[graph] = map[string]string{"error": err.Error()}
continue
}
var data interface{}
if err := json.Unmarshal(result, &data); err != nil {
response[graph] = map[string]string{"error": fmt.Sprintf("parse error: %v", err)}
continue
}
response[graph] = data
}
formatted, err := json.MarshalIndent(response, "", " ")
if err != nil {
return "", err
}
return string(formatted), nil
}
func handleGetNetworkMetrics(client *truenas.Client, args map[string]interface{}) (string, error) {
unit := "HOUR"
if u, ok := args["unit"].(string); ok && u != "" {
unit = u
}
iface, _ := args["interface"].(string)
result, err := client.Call("reporting.get_data", []interface{}{
map[string]interface{}{
"name": "interface",
"identifier": iface,
},
}, map[string]interface{}{"unit": unit})
if err != nil {
return "", err
}
var data interface{}
if err := json.Unmarshal(result, &data); err != nil {
return "", fmt.Errorf("failed to parse network metrics: %w", err)
}
formatted, err := json.MarshalIndent(data, "", " ")
if err != nil {
return "", err
}
return string(formatted), nil
}
func handleGetDiskMetrics(client *truenas.Client, args map[string]interface{}) (string, error) {
unit := "HOUR"
if u, ok := args["unit"].(string); ok && u != "" {
unit = u
}
disk, _ := args["disk"].(string)
result, err := client.Call("reporting.get_data", []interface{}{
map[string]interface{}{
"name": "disk",
"identifier": disk,
},
}, map[string]interface{}{"unit": unit})
if err != nil {
return "", err
}
var data interface{}
if err := json.Unmarshal(result, &data); err != nil {
return "", fmt.Errorf("failed to parse disk metrics: %w", err)
}
formatted, err := json.MarshalIndent(data, "", " ")
if err != nil {
return "", err
}
return string(formatted), nil
}
+30
View File
@@ -0,0 +1,30 @@
[Unit]
Description=TrueNAS MCP Server
Documentation=https://github.com/truenas/truenas-mcp
After=network.target middlewared.service
Wants=middlewared.service
[Service]
Type=simple
ExecStart=/usr/local/bin/truenas-mcp -listen 0.0.0.0:8080
Restart=on-failure
RestartSec=5s
# Environment
Environment="TRUENAS_SOCKET=/var/run/middleware/middleware.sock"
Environment="TRUENAS_MCP_API_KEY=change-me-to-secure-key"
# Security settings
# Note: May need to adjust User/Group if middleware socket requires specific permissions
# User=nobody
# Group=nogroup
NoNewPrivileges=true
PrivateTmp=true
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=truenas-mcp
[Install]
WantedBy=multi-user.target
+215
View File
@@ -0,0 +1,215 @@
package truenas
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
)
type Client struct {
socketPath string
apiKey string
conn *websocket.Conn
requestID atomic.Uint64
authenticated bool
}
type ConnectRequest struct {
Msg string `json:"msg"`
Version string `json:"version"`
Support []string `json:"support"`
}
type ConnectResponse struct {
Msg string `json:"msg"`
Session string `json:"session"`
}
type APIRequest struct {
ID string `json:"id"`
Msg string `json:"msg"`
Method string `json:"method"`
Params []interface{} `json:"params,omitempty"`
}
type APIResponse struct {
ID string `json:"id"`
Msg string `json:"msg"`
Result json.RawMessage `json:"result,omitempty"`
Error *APIError `json:"error,omitempty"`
}
type APIError struct {
Code int `json:"code"`
Message string `json:"message"`
Trace interface{} `json:"trace,omitempty"` // Can be string or object
}
func NewClient(socketPath, apiKey string) (*Client, error) {
return &Client{
socketPath: socketPath,
apiKey: apiKey,
}, nil
}
func (c *Client) connect() error {
if c.conn != nil {
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)
if err != nil {
return fmt.Errorf("failed to connect to websocket: %w", err)
}
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 = nil
return fmt.Errorf("failed to send connect message: %w", err)
}
// 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)
}
log.Printf("Received connect response: %+v", connectResp)
if connectResp.Msg != "connected" {
c.conn = nil
return fmt.Errorf("unexpected connect response: %s", connectResp.Msg)
}
return nil
}
func (c *Client) Authenticate() error {
if err := c.connect(); err != nil {
return err
}
log.Println("Authenticating with TrueNAS middleware...")
// Call auth.login_with_api_key
result, err := c.Call("auth.login_with_api_key", c.apiKey)
if err != nil {
return fmt.Errorf("authentication failed: %w", err)
}
// The result should be true if authentication succeeded
var success bool
if err := json.Unmarshal(result, &success); err != nil {
return fmt.Errorf("failed to parse authentication response: %w", err)
}
if !success {
return fmt.Errorf("authentication returned false")
}
c.authenticated = true
log.Println("TrueNAS middleware authentication successful")
return nil
}
func (c *Client) Call(method string, params ...interface{}) (json.RawMessage, error) {
if err := c.connect(); err != nil {
return nil, err
}
id := fmt.Sprintf("%d", c.requestID.Add(1))
req := APIRequest{
ID: id,
Msg: "method",
Method: method,
Params: params,
}
reqJSON, _ := json.Marshal(req)
log.Printf("Sending request: %s", string(reqJSON))
if err := c.conn.WriteJSON(req); err != nil {
c.conn = nil // Reset connection on error
c.authenticated = false
return nil, fmt.Errorf("failed to send request: %w", err)
}
// Read response
var resp APIResponse
if err := c.conn.ReadJSON(&resp); err != nil {
c.conn = nil // Reset connection on error
c.authenticated = false
return nil, fmt.Errorf("failed to read response: %w", err)
}
respJSON, _ := json.Marshal(resp)
log.Printf("Received response: %s", string(respJSON))
// Check for explicit failure message
if resp.Msg == "failed" {
if resp.Error != nil {
return nil, formatAPIError(resp.Error)
}
return nil, fmt.Errorf("API call failed with no error details")
}
if resp.Error != nil {
return nil, formatAPIError(resp.Error)
}
log.Printf("Result length: %d bytes", len(resp.Result))
return resp.Result, nil
}
func (c *Client) Close() error {
c.authenticated = false
if c.conn != nil {
return c.conn.Close()
}
return nil
}
// formatAPIError formats an API error into a readable error message
func formatAPIError(apiErr *APIError) error {
errMsg := fmt.Sprintf("API error: %s (code %d)", apiErr.Message, apiErr.Code)
if apiErr.Trace != nil {
// Try to format trace if it's available
if traceStr, ok := apiErr.Trace.(string); ok && traceStr != "" {
errMsg = fmt.Sprintf("%s\nTrace: %s", errMsg, traceStr)
}
}
return fmt.Errorf("%s", errMsg)
}