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:
+27
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¶ms); err != nil {
|
||||
s.sendErrorToAllClients(req.ID, -32602, "Invalid params", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Call the tool
|
||||
resultText, err := s.registry.CallTool(params.Name, params.Arguments)
|
||||
if err != nil {
|
||||
result := ToolCallResult{
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "text",
|
||||
Text: fmt.Sprintf("Error: %v", err),
|
||||
},
|
||||
},
|
||||
IsError: true,
|
||||
}
|
||||
s.sendResponseToAllClients(req.ID, result)
|
||||
return
|
||||
}
|
||||
|
||||
result := ToolCallResult{
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "text",
|
||||
Text: resultText,
|
||||
},
|
||||
},
|
||||
}
|
||||
s.sendResponseToAllClients(req.ID, result)
|
||||
}
|
||||
|
||||
func (s *SSEServer) sendResponseToAllClients(id interface{}, result interface{}) {
|
||||
resp := Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Result: result,
|
||||
}
|
||||
s.broadcastResponse(resp)
|
||||
}
|
||||
|
||||
func (s *SSEServer) sendErrorToAllClients(id interface{}, code int, message string, data interface{}) {
|
||||
resp := Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Error: &Error{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Data: data,
|
||||
},
|
||||
}
|
||||
s.broadcastResponse(resp)
|
||||
}
|
||||
|
||||
func (s *SSEServer) broadcastResponse(resp Response) {
|
||||
s.clients.Range(func(key, value interface{}) bool {
|
||||
client := value.(*clientConnection)
|
||||
select {
|
||||
case client.messages <- resp:
|
||||
// Successfully queued
|
||||
case <-client.done:
|
||||
// Client disconnected, skip
|
||||
case <-time.After(30 * time.Second):
|
||||
// Increase timeout to 30s to accommodate large responses
|
||||
log.Printf("Timeout queueing message for client %s", client.id)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// CORS middleware
|
||||
func (s *SSEServer) corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Authentication middleware
|
||||
func (s *SSEServer) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip auth for health endpoint
|
||||
if r.URL.Path == "/health" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// If no API key is configured, allow all requests
|
||||
if s.apiKey == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Check Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
expectedAuth := fmt.Sprintf("Bearer %s", s.apiKey)
|
||||
|
||||
if authHeader != expectedAuth {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user