#
tokens: 2101/50000 5/5 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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')
```