# Directory Structure ``` ├── .gitignore ├── .python-version ├── pyproject.toml ├── README.md ├── tailscale.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- ``` 1 | 3.13 2 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Virtual environments 10 | .venv 11 | .aider.* 12 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # tailscale-mcp 2 | 3 | Super small MCP server that allows Claude to query Tailscale status by running 4 | the `tailscale` CLI on macOS. 5 | 6 | VERY DRAFTY! 7 | 8 | ## Requirements 9 | 10 | - Python 11 | - Tailscale installed at `/Applications/Tailscale.app/Contents/MacOS/Tailscale` 12 | - [uv](https://github.com/astral/uv) for dependency management 13 | 14 | ## Running the Server 15 | 16 | ### STDIO Transport (Default) 17 | 18 | Run the server with stdio transport (default): 19 | ```bash 20 | python tailscale.py 21 | ``` 22 | 23 | ### HTTP/SSE Transport 24 | 25 | Run the server with HTTP transport on a specific port: 26 | ```bash 27 | python tailscale.py --transport http --port 4001 28 | ``` 29 | ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml 1 | [project] 2 | name = "tailscale-mcp" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.13" 7 | dependencies = [ 8 | "httpx>=0.28.1", 9 | "mcp[cli]>=1.2.0", 10 | ] 11 | ``` -------------------------------------------------------------------------------- /tailscale.py: -------------------------------------------------------------------------------- ```python 1 | from typing import Any 2 | import subprocess 3 | import json 4 | from dataclasses import dataclass 5 | from mcp.server.fastmcp import FastMCP 6 | import argparse 7 | 8 | # Initialize FastMCP server 9 | mcp = FastMCP("tailscale") 10 | 11 | # Constants 12 | TAILSCALE_PATH = "/Applications/Tailscale.app/Contents/MacOS/Tailscale" 13 | 14 | @dataclass 15 | class TailscaleDevice: 16 | ip: str 17 | name: str 18 | dns_name: str 19 | user: str 20 | os: str 21 | status: str 22 | last_seen: str 23 | rx_bytes: int 24 | tx_bytes: int 25 | 26 | def parse_tailscale_status(json_data: dict) -> list[TailscaleDevice]: 27 | """Parse the JSON output of tailscale status into structured data.""" 28 | devices = [] 29 | 30 | # Add self device 31 | self_device = json_data["Self"] 32 | devices.append(TailscaleDevice( 33 | ip=self_device["TailscaleIPs"][0], 34 | name=self_device["HostName"], 35 | dns_name=self_device["DNSName"], 36 | user=json_data["User"][str(self_device["UserID"])]["LoginName"], 37 | os=self_device["OS"], 38 | status="online" if self_device["Online"] else "offline", 39 | last_seen="current device", 40 | rx_bytes=self_device["RxBytes"], 41 | tx_bytes=self_device["TxBytes"] 42 | )) 43 | 44 | # Add peer devices 45 | for peer in json_data["Peer"].values(): 46 | devices.append(TailscaleDevice( 47 | ip=peer["TailscaleIPs"][0], 48 | name=peer["HostName"], 49 | dns_name=peer["DNSName"], 50 | user=json_data["User"][str(peer["UserID"])]["LoginName"], 51 | os=peer["OS"], 52 | status="online" if peer["Online"] else "offline", 53 | last_seen=peer["LastSeen"], 54 | rx_bytes=peer["RxBytes"], 55 | tx_bytes=peer["TxBytes"] 56 | )) 57 | 58 | return devices 59 | 60 | def run_tailscale_command(args: list[str]) -> dict: 61 | """Run a Tailscale command and return its JSON output.""" 62 | try: 63 | result = subprocess.run( 64 | [TAILSCALE_PATH] + args + ["--json"], 65 | capture_output=True, 66 | text=True, 67 | check=True 68 | ) 69 | return json.loads(result.stdout) 70 | except subprocess.CalledProcessError as e: 71 | raise Exception(f"Error running Tailscale command: {e.stderr}") 72 | except json.JSONDecodeError as e: 73 | raise Exception(f"Error parsing Tailscale JSON output: {str(e)}") 74 | 75 | @mcp.tool() 76 | async def get_tailscale_status() -> str: 77 | """Get the status of all Tailscale devices in your network.""" 78 | try: 79 | output = run_tailscale_command(["status"]) 80 | devices = parse_tailscale_status(output) 81 | 82 | if not devices: 83 | return "No Tailscale devices found." 84 | 85 | # Format the response in a readable way 86 | response_parts = ["Your Tailscale Network Devices:"] 87 | for device in devices: 88 | traffic = "" 89 | if device.rx_bytes > 0 or device.tx_bytes > 0: 90 | traffic = f"\nTraffic: rx {device.rx_bytes:,} bytes, tx {device.tx_bytes:,} bytes" 91 | 92 | status_info = f""" 93 | Device: {device.name} 94 | DNS Name: {device.dns_name} 95 | IP: {device.ip} 96 | User: {device.user} 97 | OS: {device.os} 98 | Status: {device.status} 99 | Last seen: {device.last_seen}{traffic} 100 | """ 101 | response_parts.append(status_info) 102 | 103 | return "\n---\n".join(response_parts) 104 | except Exception as e: 105 | return f"Error getting Tailscale status: {str(e)}" 106 | 107 | @mcp.tool() 108 | async def get_device_info(device_name: str) -> str: 109 | """Get detailed information about a specific Tailscale device. 110 | 111 | Args: 112 | device_name: The name of the Tailscale device to query 113 | """ 114 | try: 115 | output = run_tailscale_command(["status"]) 116 | devices = parse_tailscale_status(output) 117 | 118 | for device in devices: 119 | if device.name.lower() == device_name.lower(): 120 | traffic = "" 121 | if device.rx_bytes > 0 or device.tx_bytes > 0: 122 | traffic = f"\nTraffic:\n Received: {device.rx_bytes:,} bytes\n Transmitted: {device.tx_bytes:,} bytes" 123 | 124 | return f""" 125 | Detailed information for {device.name}: 126 | DNS Name: {device.dns_name} 127 | IP Address: {device.ip} 128 | User: {device.user} 129 | Operating System: {device.os} 130 | Current Status: {device.status} 131 | Last Seen: {device.last_seen}{traffic} 132 | """ 133 | 134 | return f"No device found with name: {device_name}" 135 | except Exception as e: 136 | return f"Error getting device info: {str(e)}" 137 | 138 | if __name__ == "__main__": 139 | parser = argparse.ArgumentParser(description='Run Tailscale MCP server') 140 | parser.add_argument('--transport', choices=['stdio', 'http'], default='stdio', 141 | help='Transport type (stdio or http)') 142 | parser.add_argument('--port', type=int, default=3000, 143 | help='Port for HTTP server (only used with --transport http)') 144 | args = parser.parse_args() 145 | 146 | if args.transport == 'http': 147 | mcp.settings.port = args.port 148 | mcp.run(transport='sse') 149 | else: 150 | # Run with stdio transport 151 | mcp.run(transport='stdio') ```