iflow-mcp_mrnugget-tailscale-mcp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iflow_mcp_mrnugget_tailscale_mcp-0.1.0.dist-info/METADATA +37 -0
- iflow_mcp_mrnugget_tailscale_mcp-0.1.0.dist-info/RECORD +6 -0
- iflow_mcp_mrnugget_tailscale_mcp-0.1.0.dist-info/WHEEL +5 -0
- iflow_mcp_mrnugget_tailscale_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_mrnugget_tailscale_mcp-0.1.0.dist-info/top_level.txt +1 -0
- tailscale.py +193 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: iflow-mcp_mrnugget-tailscale-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: httpx>=0.28.1
|
|
8
|
+
Requires-Dist: mcp[cli]>=1.2.0
|
|
9
|
+
|
|
10
|
+
# tailscale-mcp
|
|
11
|
+
|
|
12
|
+
Super small MCP server that allows Claude to query Tailscale status by running
|
|
13
|
+
the `tailscale` CLI on macOS.
|
|
14
|
+
|
|
15
|
+
VERY DRAFTY!
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
- Python
|
|
20
|
+
- Tailscale installed at `/Applications/Tailscale.app/Contents/MacOS/Tailscale`
|
|
21
|
+
- [uv](https://github.com/astral/uv) for dependency management
|
|
22
|
+
|
|
23
|
+
## Running the Server
|
|
24
|
+
|
|
25
|
+
### STDIO Transport (Default)
|
|
26
|
+
|
|
27
|
+
Run the server with stdio transport (default):
|
|
28
|
+
```bash
|
|
29
|
+
python tailscale.py
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### HTTP/SSE Transport
|
|
33
|
+
|
|
34
|
+
Run the server with HTTP transport on a specific port:
|
|
35
|
+
```bash
|
|
36
|
+
python tailscale.py --transport http --port 4001
|
|
37
|
+
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
tailscale.py,sha256=_dmMJZ46J8fFQIni5o8rMzsa5yTrkgJ9Ld7jkQUB6L8,5946
|
|
2
|
+
iflow_mcp_mrnugget_tailscale_mcp-0.1.0.dist-info/METADATA,sha256=4qypCkBABIIDFHyBJbjiXoRnSxgb6_rjHILmbZIC0Hc,821
|
|
3
|
+
iflow_mcp_mrnugget_tailscale_mcp-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
4
|
+
iflow_mcp_mrnugget_tailscale_mcp-0.1.0.dist-info/entry_points.txt,sha256=H5dlySkaWdzBcw_p0QcmeFvKT7BxjMYRrpTlRHHG6KE,45
|
|
5
|
+
iflow_mcp_mrnugget_tailscale_mcp-0.1.0.dist-info/top_level.txt,sha256=sSjXPegf2Lhd8o8WmdY7jhvYWb4nrHMGVjOB-P2Qf08,10
|
|
6
|
+
iflow_mcp_mrnugget_tailscale_mcp-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tailscale
|
tailscale.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
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
|
+
import os
|
|
8
|
+
|
|
9
|
+
# Initialize FastMCP server
|
|
10
|
+
mcp = FastMCP("tailscale")
|
|
11
|
+
|
|
12
|
+
# Constants
|
|
13
|
+
TAILSCALE_PATH = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
|
|
14
|
+
|
|
15
|
+
# Mock data for testing when Tailscale CLI is not available
|
|
16
|
+
MOCK_TAILSCALE_STATUS = {
|
|
17
|
+
"Self": {
|
|
18
|
+
"TailscaleIPs": ["100.100.100.100"],
|
|
19
|
+
"HostName": "test-device",
|
|
20
|
+
"DNSName": "test-device.tailnet.ts.net",
|
|
21
|
+
"UserID": 0,
|
|
22
|
+
"OS": "linux",
|
|
23
|
+
"Online": True,
|
|
24
|
+
"RxBytes": 1024000,
|
|
25
|
+
"TxBytes": 2048000
|
|
26
|
+
},
|
|
27
|
+
"Peer": {
|
|
28
|
+
"1": {
|
|
29
|
+
"TailscaleIPs": ["100.100.100.101"],
|
|
30
|
+
"HostName": "peer-device",
|
|
31
|
+
"DNSName": "peer-device.tailnet.ts.net",
|
|
32
|
+
"UserID": 0,
|
|
33
|
+
"OS": "macos",
|
|
34
|
+
"Online": True,
|
|
35
|
+
"LastSeen": "2024-01-01T00:00:00Z",
|
|
36
|
+
"RxBytes": 512000,
|
|
37
|
+
"TxBytes": 1024000
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"User": {
|
|
41
|
+
"0": {
|
|
42
|
+
"LoginName": "testuser@example.com"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class TailscaleDevice:
|
|
49
|
+
ip: str
|
|
50
|
+
name: str
|
|
51
|
+
dns_name: str
|
|
52
|
+
user: str
|
|
53
|
+
os: str
|
|
54
|
+
status: str
|
|
55
|
+
last_seen: str
|
|
56
|
+
rx_bytes: int
|
|
57
|
+
tx_bytes: int
|
|
58
|
+
|
|
59
|
+
def parse_tailscale_status(json_data: dict) -> list[TailscaleDevice]:
|
|
60
|
+
"""Parse the JSON output of tailscale status into structured data."""
|
|
61
|
+
devices = []
|
|
62
|
+
|
|
63
|
+
# Add self device
|
|
64
|
+
self_device = json_data["Self"]
|
|
65
|
+
devices.append(TailscaleDevice(
|
|
66
|
+
ip=self_device["TailscaleIPs"][0],
|
|
67
|
+
name=self_device["HostName"],
|
|
68
|
+
dns_name=self_device["DNSName"],
|
|
69
|
+
user=json_data["User"][str(self_device["UserID"])]["LoginName"],
|
|
70
|
+
os=self_device["OS"],
|
|
71
|
+
status="online" if self_device["Online"] else "offline",
|
|
72
|
+
last_seen="current device",
|
|
73
|
+
rx_bytes=self_device["RxBytes"],
|
|
74
|
+
tx_bytes=self_device["TxBytes"]
|
|
75
|
+
))
|
|
76
|
+
|
|
77
|
+
# Add peer devices
|
|
78
|
+
for peer in json_data["Peer"].values():
|
|
79
|
+
devices.append(TailscaleDevice(
|
|
80
|
+
ip=peer["TailscaleIPs"][0],
|
|
81
|
+
name=peer["HostName"],
|
|
82
|
+
dns_name=peer["DNSName"],
|
|
83
|
+
user=json_data["User"][str(peer["UserID"])]["LoginName"],
|
|
84
|
+
os=peer["OS"],
|
|
85
|
+
status="online" if peer["Online"] else "offline",
|
|
86
|
+
last_seen=peer["LastSeen"],
|
|
87
|
+
rx_bytes=peer["RxBytes"],
|
|
88
|
+
tx_bytes=peer["TxBytes"]
|
|
89
|
+
))
|
|
90
|
+
|
|
91
|
+
return devices
|
|
92
|
+
|
|
93
|
+
def run_tailscale_command(args: list[str]) -> dict:
|
|
94
|
+
"""Run a Tailscale command and return its JSON output."""
|
|
95
|
+
try:
|
|
96
|
+
# Check if Tailscale CLI is available
|
|
97
|
+
if not os.path.exists(TAILSCALE_PATH):
|
|
98
|
+
# Use mock data for testing
|
|
99
|
+
return MOCK_TAILSCALE_STATUS
|
|
100
|
+
|
|
101
|
+
result = subprocess.run(
|
|
102
|
+
[TAILSCALE_PATH] + args + ["--json"],
|
|
103
|
+
capture_output=True,
|
|
104
|
+
text=True,
|
|
105
|
+
check=True
|
|
106
|
+
)
|
|
107
|
+
return json.loads(result.stdout)
|
|
108
|
+
except subprocess.CalledProcessError as e:
|
|
109
|
+
raise Exception(f"Error running Tailscale command: {e.stderr}")
|
|
110
|
+
except json.JSONDecodeError as e:
|
|
111
|
+
raise Exception(f"Error parsing Tailscale JSON output: {str(e)}")
|
|
112
|
+
|
|
113
|
+
@mcp.tool()
|
|
114
|
+
async def get_tailscale_status() -> str:
|
|
115
|
+
"""Get the status of all Tailscale devices in your network."""
|
|
116
|
+
try:
|
|
117
|
+
output = run_tailscale_command(["status"])
|
|
118
|
+
devices = parse_tailscale_status(output)
|
|
119
|
+
|
|
120
|
+
if not devices:
|
|
121
|
+
return "No Tailscale devices found."
|
|
122
|
+
|
|
123
|
+
# Format the response in a readable way
|
|
124
|
+
response_parts = ["Your Tailscale Network Devices:"]
|
|
125
|
+
for device in devices:
|
|
126
|
+
traffic = ""
|
|
127
|
+
if device.rx_bytes > 0 or device.tx_bytes > 0:
|
|
128
|
+
traffic = f"\nTraffic: rx {device.rx_bytes:,} bytes, tx {device.tx_bytes:,} bytes"
|
|
129
|
+
|
|
130
|
+
status_info = f"""
|
|
131
|
+
Device: {device.name}
|
|
132
|
+
DNS Name: {device.dns_name}
|
|
133
|
+
IP: {device.ip}
|
|
134
|
+
User: {device.user}
|
|
135
|
+
OS: {device.os}
|
|
136
|
+
Status: {device.status}
|
|
137
|
+
Last seen: {device.last_seen}{traffic}
|
|
138
|
+
"""
|
|
139
|
+
response_parts.append(status_info)
|
|
140
|
+
|
|
141
|
+
return "\n---\n".join(response_parts)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
return f"Error getting Tailscale status: {str(e)}"
|
|
144
|
+
|
|
145
|
+
@mcp.tool()
|
|
146
|
+
async def get_device_info(device_name: str) -> str:
|
|
147
|
+
"""Get detailed information about a specific Tailscale device.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
device_name: The name of the Tailscale device to query
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
output = run_tailscale_command(["status"])
|
|
154
|
+
devices = parse_tailscale_status(output)
|
|
155
|
+
|
|
156
|
+
for device in devices:
|
|
157
|
+
if device.name.lower() == device_name.lower():
|
|
158
|
+
traffic = ""
|
|
159
|
+
if device.rx_bytes > 0 or device.tx_bytes > 0:
|
|
160
|
+
traffic = f"\nTraffic:\n Received: {device.rx_bytes:,} bytes\n Transmitted: {device.tx_bytes:,} bytes"
|
|
161
|
+
|
|
162
|
+
return f"""
|
|
163
|
+
Detailed information for {device.name}:
|
|
164
|
+
DNS Name: {device.dns_name}
|
|
165
|
+
IP Address: {device.ip}
|
|
166
|
+
User: {device.user}
|
|
167
|
+
Operating System: {device.os}
|
|
168
|
+
Current Status: {device.status}
|
|
169
|
+
Last Seen: {device.last_seen}{traffic}
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
return f"No device found with name: {device_name}"
|
|
173
|
+
except Exception as e:
|
|
174
|
+
return f"Error getting device info: {str(e)}"
|
|
175
|
+
|
|
176
|
+
def main():
|
|
177
|
+
"""Main entry point for the MCP server."""
|
|
178
|
+
parser = argparse.ArgumentParser(description='Run Tailscale MCP server')
|
|
179
|
+
parser.add_argument('--transport', choices=['stdio', 'http'], default='stdio',
|
|
180
|
+
help='Transport type (stdio or http)')
|
|
181
|
+
parser.add_argument('--port', type=int, default=3000,
|
|
182
|
+
help='Port for HTTP server (only used with --transport http)')
|
|
183
|
+
args = parser.parse_args()
|
|
184
|
+
|
|
185
|
+
if args.transport == 'http':
|
|
186
|
+
mcp.settings.port = args.port
|
|
187
|
+
mcp.run(transport='sse')
|
|
188
|
+
else:
|
|
189
|
+
# Run with stdio transport
|
|
190
|
+
mcp.run(transport='stdio')
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
main()
|