tunnel-manager 0.0.2__tar.gz

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.

Potentially problematic release.


This version of tunnel-manager might be problematic. Click here for more details.

@@ -0,0 +1,20 @@
1
+ Copyright (c) 2012-2023 Audel Rouhi
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ include README.md include requirements.txt recursive-include tunnel_manager *.py
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.4
2
+ Name: tunnel-manager
3
+ Version: 0.0.2
4
+ Summary: Create SSH Tunnels to your remote hosts and host as an MCP Server for Agentic AI!
5
+ Author-email: Audel Rouhi <knucklessg1@gmail.com>
6
+ License: MIT
7
+ Classifier: Development Status :: 5 - Production/Stable
8
+ Classifier: License :: Public Domain
9
+ Classifier: Environment :: Console
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: fastmcp>=2.11.3
16
+ Requires-Dist: paramiko>=4.0.0
17
+ Dynamic: license-file
18
+
19
+ # Tunnel Manager
20
+
21
+ ![PyPI - Version](https://img.shields.io/pypi/v/tunnel-manager)
22
+ ![PyPI - Downloads](https://img.shields.io/pypi/dd/tunnel-manager)
23
+ ![GitHub Repo stars](https://img.shields.io/github/stars/Knuckles-Team/tunnel-manager)
24
+ ![GitHub forks](https://img.shields.io/github/forks/Knuckles-Team/tunnel-manager)
25
+ ![GitHub contributors](https://img.shields.io/github/contributors/Knuckles-Team/tunnel-manager)
26
+ ![PyPI - License](https://img.shields.io/pypi/l/tunnel-manager)
27
+ ![GitHub](https://img.shields.io/github/license/Knuckles-Team/tunnel-manager)
28
+
29
+ ![GitHub last commit (by committer)](https://img.shields.io/github/last-commit/Knuckles-Team/tunnel-manager)
30
+ ![GitHub pull requests](https://img.shields.io/github/issues-pr/Knuckles-Team/tunnel-manager)
31
+ ![GitHub closed pull requests](https://img.shields.io/github/issues-pr-closed/Knuckles-Team/tunnel-manager)
32
+ ![GitHub issues](https://img.shields.io/github/issues/Knuckles-Team/tunnel-manager)
33
+
34
+ ![GitHub top language](https://img.shields.io/github/languages/top/Knuckles-Team/tunnel-manager)
35
+ ![GitHub language count](https://img.shields.io/github/languages/count/Knuckles-Team/tunnel-manager)
36
+ ![GitHub repo size](https://img.shields.io/github/repo-size/Knuckles-Team/tunnel-manager)
37
+ ![GitHub repo file count (file type)](https://img.shields.io/github/directory-file-count/Knuckles-Team/tunnel-manager)
38
+ ![PyPI - Wheel](https://img.shields.io/pypi/wheel/tunnel-manager)
39
+ ![PyPI - Implementation](https://img.shields.io/pypi/implementation/tunnel-manager)
40
+
41
+ *Version: 0.0.2*
42
+
43
+ This project provides a Python-based `Tunnel` class for secure SSH connections and file transfers,
44
+ integrated with a FastMCP server (`tunnel_mcp.py`) to expose these capabilities as tools for AI-driven workflows.
45
+ The implementation supports both standard SSH (e.g., for local networks) and
46
+ Teleport's secure access platform, leveraging the `paramiko` library for SSH operations.
47
+
48
+ ## Features
49
+
50
+ ### Tunnel Class
51
+ - **Purpose**: Facilitates secure SSH connections and file transfers to remote hosts.
52
+ - **Key Functionality**:
53
+ - **Run Remote Commands**: Execute shell commands on a remote host and retrieve output.
54
+ - **File Upload/Download**: Transfer files to/from a remote host using SFTP.
55
+ - **Teleport Support**: Seamlessly integrates with Teleport's certificate-based authentication and proxying.
56
+ - **Configuration Flexibility**: Loads SSH settings from `~/.ssh/config` by default, with optional overrides for identity files, certificates, and proxy commands.
57
+ - **Logging**: Optional file-based logging for debugging and auditing.
58
+
59
+ ### FastMCP Server
60
+ - **Purpose**: Exposes `Tunnel` class functionality as a FastMCP server, enabling AI tools to perform remote operations programmatically.
61
+ - **Tools Provided**:
62
+ - `run_remote_command`: Runs a shell command on a remote host and returns output.
63
+ - `upload_file`: Uploads a file to a remote host via SFTP.
64
+ - `download_file`: Downloads a file from a remote host via SFTP.
65
+ - **Transport Options**: Supports `stdio` (for local scripting) and `http` (for networked access) transport modes.
66
+ - **Progress Reporting**: Integrates with FastMCP's `Context` for progress updates during operations.
67
+ - **Logging**: Comprehensive logging to a file (`tunnel_mcp.log` by default).
68
+
69
+
70
+ <details>
71
+ <summary><b>Usage:</b></summary>
72
+
73
+ ## Tunnel Class
74
+ The `Tunnel` class can be used standalone for SSH operations. Example:
75
+
76
+ ```python
77
+ from tunnel_manager import Tunnel
78
+
79
+ # Initialize with a remote host (assumes ~/.ssh/config or explicit params)
80
+ tunnel = Tunnel(
81
+ remote_host="example.com",
82
+ identity_file="/path/to/id_rsa",
83
+ certificate_file="/path/to/cert", # Optional for Teleport
84
+ proxy_command="tsh proxy ssh %h", # Optional for Teleport
85
+ log_file="tunnel.log"
86
+ )
87
+
88
+ # Connect and run a command
89
+ tunnel.connect()
90
+ out, err = tunnel.run_command("ls -la /tmp")
91
+ print(f"Output: {out}\nError: {err}")
92
+
93
+ # Upload a file
94
+ tunnel.send_file("/local/file.txt", "/remote/file.txt")
95
+
96
+ # Download a file
97
+ tunnel.receive_file("/remote/file.txt", "/local/downloaded.txt")
98
+
99
+ # Close the connection
100
+ tunnel.close()
101
+ ```
102
+
103
+
104
+ ## FastMCP Server
105
+ The FastMCP server exposes the `Tunnel` functionality as AI-accessible tools. Start the server with:
106
+
107
+ ```bash
108
+ python tunnel_mcp.py --transport stdio
109
+ ```
110
+
111
+ Or for HTTP transport:
112
+ ```bash
113
+ python tunnel_mcp.py --transport http --host 127.0.0.1 --port 8080
114
+ ```
115
+
116
+ </details>
117
+
118
+ <details>
119
+ <summary><b>Installation Instructions:</b></summary>
120
+
121
+ ## Use with AI
122
+
123
+ Configure `mcp.json`
124
+ ```json
125
+ {
126
+ "mcpServers": {
127
+ "tunnel_manager": {
128
+ "command": "uv",
129
+ "args": [
130
+ "run",
131
+ "--with",
132
+ "tunnel-manager",
133
+ "tunnel-manager-mcp"
134
+ ],
135
+ "env": {
136
+ "TUNNEL_REMOTE_HOST": "user@192.168.1.12", // Optional
137
+ "TUNNEL_REMOTE_PORT": "22", // Optional
138
+ "TUNNEL_IDENTITY_FILE": "", // Optional
139
+ "TUNNEL_CERTIFICATE": "", // Optional
140
+ "TUNNEL_PROXY_COMMAND": "", // Optional
141
+ "TUNNEL_LOG_FILE": "~./tunnel_log.txt" // Optional
142
+ },
143
+ "timeout": 200000
144
+ }
145
+ }
146
+ }
147
+ ```
148
+
149
+ ### Deploy MCP Server as a container
150
+ ```bash
151
+ docker pull knucklessg1/tunnel-manager:latest
152
+ ```
153
+
154
+ Modify the `compose.yml`
155
+
156
+ ```compose
157
+ services:
158
+ tunnel-manager:
159
+ image: knucklessg1/tunnel-manager:latest
160
+ environment:
161
+ - HOST=0.0.0.0
162
+ - PORT=8021
163
+ ports:
164
+ - 8021:8021
165
+ ```
166
+
167
+ ### Install Python Package
168
+
169
+ ```bash
170
+ python -m pip install tunnel-manager
171
+ ```
172
+
173
+ or
174
+
175
+ ```bash
176
+ uv pip install --upgrade tunnel-manager
177
+ ```
178
+
179
+
180
+ </details>
181
+
182
+ <details>
183
+ <summary><b>Repository Owners:</b></summary>
184
+
185
+
186
+ <img width="100%" height="180em" src="https://github-readme-stats.vercel.app/api?username=Knucklessg1&show_icons=true&hide_border=true&&count_private=true&include_all_commits=true" />
187
+
188
+ ![GitHub followers](https://img.shields.io/github/followers/Knucklessg1)
189
+ ![GitHub User's stars](https://img.shields.io/github/stars/Knucklessg1)
190
+ </details>
@@ -0,0 +1,172 @@
1
+ # Tunnel Manager
2
+
3
+ ![PyPI - Version](https://img.shields.io/pypi/v/tunnel-manager)
4
+ ![PyPI - Downloads](https://img.shields.io/pypi/dd/tunnel-manager)
5
+ ![GitHub Repo stars](https://img.shields.io/github/stars/Knuckles-Team/tunnel-manager)
6
+ ![GitHub forks](https://img.shields.io/github/forks/Knuckles-Team/tunnel-manager)
7
+ ![GitHub contributors](https://img.shields.io/github/contributors/Knuckles-Team/tunnel-manager)
8
+ ![PyPI - License](https://img.shields.io/pypi/l/tunnel-manager)
9
+ ![GitHub](https://img.shields.io/github/license/Knuckles-Team/tunnel-manager)
10
+
11
+ ![GitHub last commit (by committer)](https://img.shields.io/github/last-commit/Knuckles-Team/tunnel-manager)
12
+ ![GitHub pull requests](https://img.shields.io/github/issues-pr/Knuckles-Team/tunnel-manager)
13
+ ![GitHub closed pull requests](https://img.shields.io/github/issues-pr-closed/Knuckles-Team/tunnel-manager)
14
+ ![GitHub issues](https://img.shields.io/github/issues/Knuckles-Team/tunnel-manager)
15
+
16
+ ![GitHub top language](https://img.shields.io/github/languages/top/Knuckles-Team/tunnel-manager)
17
+ ![GitHub language count](https://img.shields.io/github/languages/count/Knuckles-Team/tunnel-manager)
18
+ ![GitHub repo size](https://img.shields.io/github/repo-size/Knuckles-Team/tunnel-manager)
19
+ ![GitHub repo file count (file type)](https://img.shields.io/github/directory-file-count/Knuckles-Team/tunnel-manager)
20
+ ![PyPI - Wheel](https://img.shields.io/pypi/wheel/tunnel-manager)
21
+ ![PyPI - Implementation](https://img.shields.io/pypi/implementation/tunnel-manager)
22
+
23
+ *Version: 0.0.2*
24
+
25
+ This project provides a Python-based `Tunnel` class for secure SSH connections and file transfers,
26
+ integrated with a FastMCP server (`tunnel_mcp.py`) to expose these capabilities as tools for AI-driven workflows.
27
+ The implementation supports both standard SSH (e.g., for local networks) and
28
+ Teleport's secure access platform, leveraging the `paramiko` library for SSH operations.
29
+
30
+ ## Features
31
+
32
+ ### Tunnel Class
33
+ - **Purpose**: Facilitates secure SSH connections and file transfers to remote hosts.
34
+ - **Key Functionality**:
35
+ - **Run Remote Commands**: Execute shell commands on a remote host and retrieve output.
36
+ - **File Upload/Download**: Transfer files to/from a remote host using SFTP.
37
+ - **Teleport Support**: Seamlessly integrates with Teleport's certificate-based authentication and proxying.
38
+ - **Configuration Flexibility**: Loads SSH settings from `~/.ssh/config` by default, with optional overrides for identity files, certificates, and proxy commands.
39
+ - **Logging**: Optional file-based logging for debugging and auditing.
40
+
41
+ ### FastMCP Server
42
+ - **Purpose**: Exposes `Tunnel` class functionality as a FastMCP server, enabling AI tools to perform remote operations programmatically.
43
+ - **Tools Provided**:
44
+ - `run_remote_command`: Runs a shell command on a remote host and returns output.
45
+ - `upload_file`: Uploads a file to a remote host via SFTP.
46
+ - `download_file`: Downloads a file from a remote host via SFTP.
47
+ - **Transport Options**: Supports `stdio` (for local scripting) and `http` (for networked access) transport modes.
48
+ - **Progress Reporting**: Integrates with FastMCP's `Context` for progress updates during operations.
49
+ - **Logging**: Comprehensive logging to a file (`tunnel_mcp.log` by default).
50
+
51
+
52
+ <details>
53
+ <summary><b>Usage:</b></summary>
54
+
55
+ ## Tunnel Class
56
+ The `Tunnel` class can be used standalone for SSH operations. Example:
57
+
58
+ ```python
59
+ from tunnel_manager import Tunnel
60
+
61
+ # Initialize with a remote host (assumes ~/.ssh/config or explicit params)
62
+ tunnel = Tunnel(
63
+ remote_host="example.com",
64
+ identity_file="/path/to/id_rsa",
65
+ certificate_file="/path/to/cert", # Optional for Teleport
66
+ proxy_command="tsh proxy ssh %h", # Optional for Teleport
67
+ log_file="tunnel.log"
68
+ )
69
+
70
+ # Connect and run a command
71
+ tunnel.connect()
72
+ out, err = tunnel.run_command("ls -la /tmp")
73
+ print(f"Output: {out}\nError: {err}")
74
+
75
+ # Upload a file
76
+ tunnel.send_file("/local/file.txt", "/remote/file.txt")
77
+
78
+ # Download a file
79
+ tunnel.receive_file("/remote/file.txt", "/local/downloaded.txt")
80
+
81
+ # Close the connection
82
+ tunnel.close()
83
+ ```
84
+
85
+
86
+ ## FastMCP Server
87
+ The FastMCP server exposes the `Tunnel` functionality as AI-accessible tools. Start the server with:
88
+
89
+ ```bash
90
+ python tunnel_mcp.py --transport stdio
91
+ ```
92
+
93
+ Or for HTTP transport:
94
+ ```bash
95
+ python tunnel_mcp.py --transport http --host 127.0.0.1 --port 8080
96
+ ```
97
+
98
+ </details>
99
+
100
+ <details>
101
+ <summary><b>Installation Instructions:</b></summary>
102
+
103
+ ## Use with AI
104
+
105
+ Configure `mcp.json`
106
+ ```json
107
+ {
108
+ "mcpServers": {
109
+ "tunnel_manager": {
110
+ "command": "uv",
111
+ "args": [
112
+ "run",
113
+ "--with",
114
+ "tunnel-manager",
115
+ "tunnel-manager-mcp"
116
+ ],
117
+ "env": {
118
+ "TUNNEL_REMOTE_HOST": "user@192.168.1.12", // Optional
119
+ "TUNNEL_REMOTE_PORT": "22", // Optional
120
+ "TUNNEL_IDENTITY_FILE": "", // Optional
121
+ "TUNNEL_CERTIFICATE": "", // Optional
122
+ "TUNNEL_PROXY_COMMAND": "", // Optional
123
+ "TUNNEL_LOG_FILE": "~./tunnel_log.txt" // Optional
124
+ },
125
+ "timeout": 200000
126
+ }
127
+ }
128
+ }
129
+ ```
130
+
131
+ ### Deploy MCP Server as a container
132
+ ```bash
133
+ docker pull knucklessg1/tunnel-manager:latest
134
+ ```
135
+
136
+ Modify the `compose.yml`
137
+
138
+ ```compose
139
+ services:
140
+ tunnel-manager:
141
+ image: knucklessg1/tunnel-manager:latest
142
+ environment:
143
+ - HOST=0.0.0.0
144
+ - PORT=8021
145
+ ports:
146
+ - 8021:8021
147
+ ```
148
+
149
+ ### Install Python Package
150
+
151
+ ```bash
152
+ python -m pip install tunnel-manager
153
+ ```
154
+
155
+ or
156
+
157
+ ```bash
158
+ uv pip install --upgrade tunnel-manager
159
+ ```
160
+
161
+
162
+ </details>
163
+
164
+ <details>
165
+ <summary><b>Repository Owners:</b></summary>
166
+
167
+
168
+ <img width="100%" height="180em" src="https://github-readme-stats.vercel.app/api?username=Knucklessg1&show_icons=true&hide_border=true&&count_private=true&include_all_commits=true" />
169
+
170
+ ![GitHub followers](https://img.shields.io/github/followers/Knucklessg1)
171
+ ![GitHub User's stars](https://img.shields.io/github/stars/Knucklessg1)
172
+ </details>
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=80.9.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tunnel-manager"
7
+ version = "0.0.2"
8
+ description = "Create SSH Tunnels to your remote hosts and host as an MCP Server for Agentic AI!"
9
+ readme = "README.md"
10
+ authors = [{ name = "Audel Rouhi", email = "knucklessg1@gmail.com" }]
11
+ license = { text = "MIT" }
12
+ classifiers = [
13
+ "Development Status :: 5 - Production/Stable",
14
+ "License :: Public Domain",
15
+ "Environment :: Console",
16
+ "Operating System :: POSIX :: Linux",
17
+ "Programming Language :: Python :: 3",
18
+ ]
19
+ requires-python = ">=3.10"
20
+ dependencies = [
21
+ "fastmcp>=2.11.3",
22
+ "paramiko>=4.0.0",
23
+ ]
24
+
25
+ [project.scripts]
26
+ tunnel-manager-mcp = "tunnel_manager.tunnel_manager_mcp:tunnel_manager_mcp"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["."]
30
+
31
+ [tool.setuptools]
32
+ include-package-data = true
33
+ package-data = { "tunnel_manager" = ["tunnel_manager"] }
@@ -0,0 +1,2 @@
1
+ paramiko>=4.0.0
2
+ fastmcp>=2.11.3
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env python
2
+ # coding: utf-8
3
+
4
+ from tunnel_manager.tunnel_manager import (
5
+ Tunnel,
6
+ )
7
+ from tunnel_manager.tunnel_manager_mcp import tunnel_manager_mcp
8
+
9
+ """
10
+ tunnel-manager
11
+
12
+ Create SSH tunnels to your remote hosts!
13
+ """
14
+
15
+
16
+ __all__ = ["tunnel_manager_mcp", "Tunnel"]
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/python
2
+ # coding: utf-8
3
+ from tunnel_manager.tunnel_manager_mcp import tunnel_manager_mcp
4
+
5
+ if __name__ == "__main__":
6
+ tunnel_manager_mcp()
@@ -0,0 +1,190 @@
1
+ import logging
2
+ import os
3
+ import paramiko
4
+
5
+
6
+ class Tunnel:
7
+ def __init__(
8
+ self,
9
+ remote_host: str,
10
+ port: int = 22,
11
+ identity_file: str = None,
12
+ certificate_file: str = None,
13
+ proxy_command: str = None,
14
+ log_file: str = None,
15
+ ):
16
+ """
17
+ Initialize the Tunnel class.
18
+
19
+ :param remote_host: The hostname or IP of the remote host.
20
+ :param identity_file: Optional path to the private key file (overrides config).
21
+ :param certificate_file: Optional path to the certificate file (overrides config, used for Teleport).
22
+ :param proxy_command: Optional proxy command string (overrides config, used for Teleport proxying).
23
+ :param log_file: Optional path to a log file for recording operations.
24
+ """
25
+ self.remote_host = remote_host
26
+ self.port = port
27
+ self.ssh_client = None
28
+ self.sftp = None
29
+ self.logger = None
30
+
31
+ # Load from ~/.ssh/config if not overridden
32
+ ssh_config_path = os.path.expanduser("~/.ssh/config")
33
+ self.ssh_config = paramiko.SSHConfig()
34
+ if os.path.exists(ssh_config_path):
35
+ with open(ssh_config_path) as f:
36
+ self.ssh_config.parse(f)
37
+ host_config = self.ssh_config.lookup(remote_host) or {}
38
+
39
+ self.identity_file = identity_file or (
40
+ host_config.get("identityfile", [None])[0]
41
+ if "identityfile" in host_config
42
+ else None
43
+ )
44
+ self.certificate_file = certificate_file or host_config.get("certificatefile")
45
+ self.proxy_command = proxy_command or host_config.get("proxycommand")
46
+
47
+ if not self.identity_file:
48
+ raise ValueError(
49
+ "Identity file must be provided either via parameter or in ~/.ssh/config."
50
+ )
51
+
52
+ if log_file:
53
+ logging.basicConfig(
54
+ filename=log_file,
55
+ level=logging.INFO,
56
+ format="%(asctime)s - %(levelname)s - %(message)s",
57
+ )
58
+ self.logger = logging.getLogger(__name__)
59
+ self.logger.info(f"Tunnel initialized for host: {remote_host}")
60
+
61
+ def connect(self):
62
+ """
63
+ Establish the SSH connection if not already connected.
64
+ """
65
+ if (
66
+ self.ssh_client
67
+ and self.ssh_client.get_transport()
68
+ and self.ssh_client.get_transport().is_active()
69
+ ):
70
+ return # Already connected
71
+
72
+ self.ssh_client = paramiko.SSHClient()
73
+ self.ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
74
+
75
+ proxy = None
76
+ if self.proxy_command:
77
+ proxy = paramiko.ProxyCommand(self.proxy_command)
78
+ if self.logger:
79
+ self.logger.info(f"Using proxy command: {self.proxy_command}")
80
+
81
+ private_key = paramiko.RSAKey.from_private_key_file(self.identity_file)
82
+ if self.certificate_file:
83
+ private_key.load_certificate(self.certificate_file)
84
+ if self.logger:
85
+ self.logger.info(f"Loaded certificate: {self.certificate_file}")
86
+
87
+ try:
88
+ self.ssh_client.connect(
89
+ self.remote_host,
90
+ port=self.port,
91
+ pkey=private_key,
92
+ sock=proxy,
93
+ auth_timeout=30,
94
+ look_for_keys=False,
95
+ allow_agent=False,
96
+ )
97
+ if self.logger:
98
+ self.logger.info(f"Connected to {self.remote_host}")
99
+ except Exception as e:
100
+ if self.logger:
101
+ self.logger.error(f"Connection failed: {str(e)}")
102
+ raise
103
+
104
+ def run_command(self, command):
105
+ """
106
+ Run a shell command on the remote host.
107
+
108
+ :param command: The command to execute.
109
+ :return: Tuple of (stdout, stderr) as strings.
110
+ """
111
+ self.connect()
112
+ try:
113
+ stdin, stdout, stderr = self.ssh_client.exec_command(command)
114
+ out = stdout.read().decode("utf-8").strip()
115
+ err = stderr.read().decode("utf-8").strip()
116
+ if self.logger:
117
+ self.logger.info(
118
+ f"Command executed: {command}\nOutput: {out}\nError: {err}"
119
+ )
120
+ return out, err
121
+ except Exception as e:
122
+ if self.logger:
123
+ self.logger.error(f"Command execution failed: {str(e)}")
124
+ raise
125
+
126
+ def send_file(self, local_path, remote_path):
127
+ """
128
+ Send (upload) a file to the remote host.
129
+
130
+ :param local_path: Path to the local file.
131
+ :param remote_path: Path on the remote host.
132
+ """
133
+ self.connect()
134
+ try:
135
+ if not self.sftp:
136
+ self.sftp = self.ssh_client.open_sftp()
137
+ self.sftp.put(local_path, remote_path)
138
+ if self.logger:
139
+ self.logger.info(f"File sent: {local_path} -> {remote_path}")
140
+ except Exception as e:
141
+ if self.logger:
142
+ self.logger.error(f"File send failed: {str(e)}")
143
+ raise
144
+ finally:
145
+ if self.sftp:
146
+ self.sftp.close()
147
+ self.sftp = None
148
+
149
+ def receive_file(self, remote_path, local_path):
150
+ """
151
+ Receive (download) a file from the remote host.
152
+
153
+ :param remote_path: Path on the remote host.
154
+ :param local_path: Path to save the local file.
155
+ """
156
+ self.connect()
157
+ try:
158
+ if not self.sftp:
159
+ self.sftp = self.ssh_client.open_sftp()
160
+ self.sftp.get(remote_path, local_path)
161
+ if self.logger:
162
+ self.logger.info(f"File received: {remote_path} -> {local_path}")
163
+ except Exception as e:
164
+ if self.logger:
165
+ self.logger.error(f"File receive failed: {str(e)}")
166
+ raise
167
+ finally:
168
+ if self.sftp:
169
+ self.sftp.close()
170
+ self.sftp = None
171
+
172
+ def close(self):
173
+ """
174
+ Close the SSH connection.
175
+ """
176
+ if self.ssh_client:
177
+ self.ssh_client.close()
178
+ if self.logger:
179
+ self.logger.info(f"Connection closed for {self.remote_host}")
180
+ self.ssh_client = None
181
+
182
+
183
+ # Example usage (commented out):
184
+ # tunnel = Tunnel("your-remote-host.example.com", log_file="tunnel.log")
185
+ # tunnel.connect()
186
+ # out, err = tunnel.run_command("ls -la")
187
+ # print(out)
188
+ # tunnel.send_file("/local/file.txt", "/remote/file.txt")
189
+ # tunnel.receive_file("/remote/file.txt", "/local/downloaded.txt")
190
+ # tunnel.close()
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/python
2
+ # coding: utf-8
3
+ import argparse
4
+ import os
5
+ import sys
6
+ import logging
7
+ from typing import Optional
8
+ from tunnel_manager.tunnel_manager import Tunnel
9
+ from fastmcp import FastMCP, Context
10
+ from pydantic import Field
11
+
12
+ logging.basicConfig(
13
+ filename="tunnel_mcp.log",
14
+ level=logging.INFO,
15
+ format="%(asctime)s - %(levelname)s - %(message)s",
16
+ )
17
+
18
+ mcp = FastMCP(name="TunnelServer")
19
+
20
+
21
+ @mcp.tool(
22
+ annotations={
23
+ "title": "Run Remote Command",
24
+ "readOnlyHint": True,
25
+ "destructiveHint": True,
26
+ "idempotentHint": False,
27
+ "openWorldHint": False,
28
+ },
29
+ tags={"remote_access"},
30
+ )
31
+ async def run_remote_command(
32
+ remote_host: str = Field(
33
+ description="The remote host to connect to.",
34
+ default=os.environ.get("TUNNEL_REMOTE_HOST", None),
35
+ ),
36
+ remote_port: str = Field(
37
+ description="The remote host's port to connect to.",
38
+ default=os.environ.get("TUNNEL_REMOTE_PORT", None),
39
+ ),
40
+ command: str = Field(
41
+ description="The shell command to run on the remote host.", default=None
42
+ ),
43
+ identity_file: Optional[str] = Field(
44
+ description="Path to the private key file.",
45
+ default=os.environ.get("TUNNEL_IDENTITY_FILE", None),
46
+ ),
47
+ certificate_file: Optional[str] = Field(
48
+ description="Path to the certificate file (for Teleport).",
49
+ default=os.environ.get("TUNNEL_CERTIFICATE", None),
50
+ ),
51
+ proxy_command: Optional[str] = Field(
52
+ description="Proxy command (for Teleport).",
53
+ default=os.environ.get("TUNNEL_PROXY_COMMAND", None),
54
+ ),
55
+ log_file: Optional[str] = Field(
56
+ description="Path to log file for this operation.",
57
+ default=os.environ.get("TUNNEL_LOG_FILE", None),
58
+ ),
59
+ ctx: Context = Field(
60
+ description="MCP context for progress reporting.", default=None
61
+ ),
62
+ ) -> str:
63
+ """Runs a shell command on a remote host via SSH or Teleport."""
64
+ logger = logging.getLogger("TunnelServer")
65
+ logger.debug(
66
+ f"Starting run_remote_command for host: {remote_host}, command: {command}"
67
+ )
68
+
69
+ if not remote_host or not command:
70
+ raise ValueError("remote_host and command must be provided.")
71
+
72
+ try:
73
+ tunnel = Tunnel(
74
+ remote_host, identity_file, certificate_file, proxy_command, log_file
75
+ )
76
+
77
+ if ctx:
78
+ await ctx.report_progress(progress=0, total=100)
79
+ logger.debug("Reported initial progress: 0/100")
80
+
81
+ tunnel.connect()
82
+ out, err = tunnel.run_command(command)
83
+
84
+ if ctx:
85
+ await ctx.report_progress(progress=100, total=100)
86
+ logger.debug("Reported final progress: 100/100")
87
+
88
+ logger.debug(f"Command output: {out}, error: {err}")
89
+ return f"Output:\n{out}\nError:\n{err}"
90
+ except Exception as e:
91
+ logger.error(f"Failed to run command: {str(e)}")
92
+ raise RuntimeError(f"Failed to run command: {str(e)}")
93
+ finally:
94
+ if "tunnel" in locals():
95
+ tunnel.close()
96
+
97
+
98
+ @mcp.tool(
99
+ annotations={
100
+ "title": "Upload File",
101
+ "readOnlyHint": False,
102
+ "destructiveHint": True,
103
+ "idempotentHint": False,
104
+ "openWorldHint": False,
105
+ },
106
+ tags={"remote_access"},
107
+ )
108
+ async def upload_file(
109
+ remote_host: str = Field(
110
+ description="The remote host to connect to.",
111
+ default=os.environ.get("TUNNEL_REMOTE_HOST", None),
112
+ ),
113
+ remote_port: str = Field(
114
+ description="The remote host's port to connect to.",
115
+ default=os.environ.get("TUNNEL_REMOTE_PORT", None),
116
+ ),
117
+ local_path: str = Field(description="Local file path to upload.", default=None),
118
+ remote_path: str = Field(description="Remote destination path.", default=None),
119
+ identity_file: Optional[str] = Field(
120
+ description="Path to the private key file.",
121
+ default=os.environ.get("TUNNEL_IDENTITY_FILE", None),
122
+ ),
123
+ certificate_file: Optional[str] = Field(
124
+ description="Path to the certificate file (for Teleport).",
125
+ default=os.environ.get("TUNNEL_CERTIFICATE", None),
126
+ ),
127
+ proxy_command: Optional[str] = Field(
128
+ description="Proxy command (for Teleport).",
129
+ default=os.environ.get("TUNNEL_PROXY_COMMAND", None),
130
+ ),
131
+ log_file: Optional[str] = Field(
132
+ description="Path to log file for this operation.",
133
+ default=os.environ.get("TUNNEL_LOG_FILE", None),
134
+ ),
135
+ ctx: Context = Field(
136
+ description="MCP context for progress reporting.", default=None
137
+ ),
138
+ ) -> str:
139
+ """Uploads a file to a remote host via SSH or Teleport."""
140
+ logger = logging.getLogger("TunnelServer")
141
+ logger.debug(
142
+ f"Starting upload_file for host: {remote_host}, local: {local_path}, remote: {remote_path}"
143
+ )
144
+
145
+ if not remote_host or not local_path or not remote_path:
146
+ raise ValueError("remote_host, local_path, and remote_path must be provided.")
147
+
148
+ if not os.path.exists(local_path):
149
+ raise ValueError(f"Local file does not exist: {local_path}")
150
+
151
+ try:
152
+ tunnel = Tunnel(
153
+ remote_host, identity_file, certificate_file, proxy_command, log_file
154
+ )
155
+ tunnel.connect()
156
+
157
+ if ctx:
158
+ await ctx.report_progress(progress=0, total=100)
159
+ logger.debug("Reported initial progress: 0/100")
160
+
161
+ sftp = tunnel.ssh_client.open_sftp()
162
+ file_size = os.path.getsize(local_path)
163
+ transferred = 0
164
+
165
+ def progress_callback(transf, total):
166
+ nonlocal transferred
167
+ transferred = transf
168
+
169
+ sftp.put(local_path, remote_path, callback=progress_callback)
170
+
171
+ if ctx:
172
+ await ctx.report_progress(progress=100, total=100)
173
+ logger.debug("Reported final progress: 100/100")
174
+
175
+ sftp.close()
176
+ logger.debug(f"File uploaded: {local_path} -> {remote_path}")
177
+ return f"File uploaded successfully to {remote_path}"
178
+ except Exception as e:
179
+ logger.error(f"Failed to upload file: {str(e)}")
180
+ raise RuntimeError(f"Failed to upload file: {str(e)}")
181
+ finally:
182
+ if "tunnel" in locals():
183
+ tunnel.close()
184
+
185
+
186
+ @mcp.tool(
187
+ annotations={
188
+ "title": "Download File",
189
+ "readOnlyHint": False,
190
+ "destructiveHint": False,
191
+ "idempotentHint": True,
192
+ "openWorldHint": False,
193
+ },
194
+ tags={"remote_access"},
195
+ )
196
+ async def download_file(
197
+ remote_host: str = Field(
198
+ description="The remote host to connect to.",
199
+ default=os.environ.get("TUNNEL_REMOTE_HOST", None),
200
+ ),
201
+ remote_port: str = Field(
202
+ description="The remote host's port to connect to.",
203
+ default=os.environ.get("TUNNEL_REMOTE_PORT", None),
204
+ ),
205
+ remote_path: str = Field(description="Remote file path to download.", default=None),
206
+ local_path: str = Field(description="Local destination path.", default=None),
207
+ identity_file: Optional[str] = Field(
208
+ description="Path to the private key file.",
209
+ default=os.environ.get("TUNNEL_IDENTITY_FILE", None),
210
+ ),
211
+ certificate_file: Optional[str] = Field(
212
+ description="Path to the certificate file (for Teleport).",
213
+ default=os.environ.get("TUNNEL_CERTIFICATE", None),
214
+ ),
215
+ proxy_command: Optional[str] = Field(
216
+ description="Proxy command (for Teleport).",
217
+ default=os.environ.get("TUNNEL_PROXY_COMMAND", None),
218
+ ),
219
+ log_file: Optional[str] = Field(
220
+ description="Path to log file for this operation.",
221
+ default=os.environ.get("TUNNEL_LOG_FILE", None),
222
+ ),
223
+ ctx: Context = Field(
224
+ description="MCP context for progress reporting.", default=None
225
+ ),
226
+ ) -> str:
227
+ """Downloads a file from a remote host via SSH or Teleport."""
228
+ logger = logging.getLogger("TunnelServer")
229
+ logger.debug(
230
+ f"Starting download_file for host: {remote_host}, remote: {remote_path}, local: {local_path}"
231
+ )
232
+
233
+ if not remote_host or not remote_path or not local_path:
234
+ raise ValueError("remote_host, remote_path, and local_path must be provided.")
235
+
236
+ try:
237
+ tunnel = Tunnel(
238
+ remote_host, identity_file, certificate_file, proxy_command, log_file
239
+ )
240
+ tunnel.connect()
241
+
242
+ if ctx:
243
+ await ctx.report_progress(progress=0, total=100)
244
+ logger.debug("Reported initial progress: 0/100")
245
+
246
+ sftp = tunnel.ssh_client.open_sftp()
247
+ remote_attr = sftp.stat(remote_path)
248
+ file_size = remote_attr.st_size
249
+ transferred = 0
250
+
251
+ def progress_callback(transf, total):
252
+ nonlocal transferred
253
+ transferred = transf
254
+
255
+ sftp.get(remote_path, local_path, callback=progress_callback)
256
+
257
+ if ctx:
258
+ await ctx.report_progress(progress=100, total=100)
259
+ logger.debug("Reported final progress: 100/100")
260
+
261
+ sftp.close()
262
+ logger.debug(f"File downloaded: {remote_path} -> {local_path}")
263
+ return f"File downloaded successfully to {local_path}"
264
+ except Exception as e:
265
+ logger.error(f"Failed to download file: {str(e)}")
266
+ raise RuntimeError(f"Failed to download file: {str(e)}")
267
+ finally:
268
+ if "tunnel" in locals():
269
+ tunnel.close()
270
+
271
+
272
+ def tunnel_mcp():
273
+ parser = argparse.ArgumentParser(
274
+ description="Tunnel MCP Server for remote SSH and file operations",
275
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
276
+ )
277
+ parser.add_argument(
278
+ "-t",
279
+ "--transport",
280
+ choices=["stdio", "http"],
281
+ default="stdio",
282
+ help="Transport method for the MCP server (stdio or http).",
283
+ )
284
+ parser.add_argument(
285
+ "--host", default="0.0.0.0", help="Host address for HTTP transport."
286
+ )
287
+ parser.add_argument(
288
+ "-p",
289
+ "--port",
290
+ type=int,
291
+ default=8000,
292
+ help="Port for HTTP transport (0-65535).",
293
+ )
294
+
295
+ args = parser.parse_args()
296
+
297
+ if args.port < 0 or args.port > 65535:
298
+ print(f"Error: Port {args.port} is out of valid range (0-65535).")
299
+ sys.exit(1)
300
+
301
+ logger = logging.getLogger("TunnelServer")
302
+ if args.transport == "stdio":
303
+ logger.info("Starting MCP server with stdio transport")
304
+ mcp.run(transport="stdio")
305
+ elif args.transport == "http":
306
+ logger.info(
307
+ f"Starting MCP server with HTTP transport on {args.host}:{args.port}"
308
+ )
309
+ mcp.run(transport="http", host=args.host, port=args.port)
310
+ else:
311
+ logger.error("Transport not supported")
312
+ sys.exit(1)
313
+
314
+
315
+ if __name__ == "__main__":
316
+ tunnel_mcp()
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.4
2
+ Name: tunnel-manager
3
+ Version: 0.0.2
4
+ Summary: Create SSH Tunnels to your remote hosts and host as an MCP Server for Agentic AI!
5
+ Author-email: Audel Rouhi <knucklessg1@gmail.com>
6
+ License: MIT
7
+ Classifier: Development Status :: 5 - Production/Stable
8
+ Classifier: License :: Public Domain
9
+ Classifier: Environment :: Console
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: fastmcp>=2.11.3
16
+ Requires-Dist: paramiko>=4.0.0
17
+ Dynamic: license-file
18
+
19
+ # Tunnel Manager
20
+
21
+ ![PyPI - Version](https://img.shields.io/pypi/v/tunnel-manager)
22
+ ![PyPI - Downloads](https://img.shields.io/pypi/dd/tunnel-manager)
23
+ ![GitHub Repo stars](https://img.shields.io/github/stars/Knuckles-Team/tunnel-manager)
24
+ ![GitHub forks](https://img.shields.io/github/forks/Knuckles-Team/tunnel-manager)
25
+ ![GitHub contributors](https://img.shields.io/github/contributors/Knuckles-Team/tunnel-manager)
26
+ ![PyPI - License](https://img.shields.io/pypi/l/tunnel-manager)
27
+ ![GitHub](https://img.shields.io/github/license/Knuckles-Team/tunnel-manager)
28
+
29
+ ![GitHub last commit (by committer)](https://img.shields.io/github/last-commit/Knuckles-Team/tunnel-manager)
30
+ ![GitHub pull requests](https://img.shields.io/github/issues-pr/Knuckles-Team/tunnel-manager)
31
+ ![GitHub closed pull requests](https://img.shields.io/github/issues-pr-closed/Knuckles-Team/tunnel-manager)
32
+ ![GitHub issues](https://img.shields.io/github/issues/Knuckles-Team/tunnel-manager)
33
+
34
+ ![GitHub top language](https://img.shields.io/github/languages/top/Knuckles-Team/tunnel-manager)
35
+ ![GitHub language count](https://img.shields.io/github/languages/count/Knuckles-Team/tunnel-manager)
36
+ ![GitHub repo size](https://img.shields.io/github/repo-size/Knuckles-Team/tunnel-manager)
37
+ ![GitHub repo file count (file type)](https://img.shields.io/github/directory-file-count/Knuckles-Team/tunnel-manager)
38
+ ![PyPI - Wheel](https://img.shields.io/pypi/wheel/tunnel-manager)
39
+ ![PyPI - Implementation](https://img.shields.io/pypi/implementation/tunnel-manager)
40
+
41
+ *Version: 0.0.2*
42
+
43
+ This project provides a Python-based `Tunnel` class for secure SSH connections and file transfers,
44
+ integrated with a FastMCP server (`tunnel_mcp.py`) to expose these capabilities as tools for AI-driven workflows.
45
+ The implementation supports both standard SSH (e.g., for local networks) and
46
+ Teleport's secure access platform, leveraging the `paramiko` library for SSH operations.
47
+
48
+ ## Features
49
+
50
+ ### Tunnel Class
51
+ - **Purpose**: Facilitates secure SSH connections and file transfers to remote hosts.
52
+ - **Key Functionality**:
53
+ - **Run Remote Commands**: Execute shell commands on a remote host and retrieve output.
54
+ - **File Upload/Download**: Transfer files to/from a remote host using SFTP.
55
+ - **Teleport Support**: Seamlessly integrates with Teleport's certificate-based authentication and proxying.
56
+ - **Configuration Flexibility**: Loads SSH settings from `~/.ssh/config` by default, with optional overrides for identity files, certificates, and proxy commands.
57
+ - **Logging**: Optional file-based logging for debugging and auditing.
58
+
59
+ ### FastMCP Server
60
+ - **Purpose**: Exposes `Tunnel` class functionality as a FastMCP server, enabling AI tools to perform remote operations programmatically.
61
+ - **Tools Provided**:
62
+ - `run_remote_command`: Runs a shell command on a remote host and returns output.
63
+ - `upload_file`: Uploads a file to a remote host via SFTP.
64
+ - `download_file`: Downloads a file from a remote host via SFTP.
65
+ - **Transport Options**: Supports `stdio` (for local scripting) and `http` (for networked access) transport modes.
66
+ - **Progress Reporting**: Integrates with FastMCP's `Context` for progress updates during operations.
67
+ - **Logging**: Comprehensive logging to a file (`tunnel_mcp.log` by default).
68
+
69
+
70
+ <details>
71
+ <summary><b>Usage:</b></summary>
72
+
73
+ ## Tunnel Class
74
+ The `Tunnel` class can be used standalone for SSH operations. Example:
75
+
76
+ ```python
77
+ from tunnel_manager import Tunnel
78
+
79
+ # Initialize with a remote host (assumes ~/.ssh/config or explicit params)
80
+ tunnel = Tunnel(
81
+ remote_host="example.com",
82
+ identity_file="/path/to/id_rsa",
83
+ certificate_file="/path/to/cert", # Optional for Teleport
84
+ proxy_command="tsh proxy ssh %h", # Optional for Teleport
85
+ log_file="tunnel.log"
86
+ )
87
+
88
+ # Connect and run a command
89
+ tunnel.connect()
90
+ out, err = tunnel.run_command("ls -la /tmp")
91
+ print(f"Output: {out}\nError: {err}")
92
+
93
+ # Upload a file
94
+ tunnel.send_file("/local/file.txt", "/remote/file.txt")
95
+
96
+ # Download a file
97
+ tunnel.receive_file("/remote/file.txt", "/local/downloaded.txt")
98
+
99
+ # Close the connection
100
+ tunnel.close()
101
+ ```
102
+
103
+
104
+ ## FastMCP Server
105
+ The FastMCP server exposes the `Tunnel` functionality as AI-accessible tools. Start the server with:
106
+
107
+ ```bash
108
+ python tunnel_mcp.py --transport stdio
109
+ ```
110
+
111
+ Or for HTTP transport:
112
+ ```bash
113
+ python tunnel_mcp.py --transport http --host 127.0.0.1 --port 8080
114
+ ```
115
+
116
+ </details>
117
+
118
+ <details>
119
+ <summary><b>Installation Instructions:</b></summary>
120
+
121
+ ## Use with AI
122
+
123
+ Configure `mcp.json`
124
+ ```json
125
+ {
126
+ "mcpServers": {
127
+ "tunnel_manager": {
128
+ "command": "uv",
129
+ "args": [
130
+ "run",
131
+ "--with",
132
+ "tunnel-manager",
133
+ "tunnel-manager-mcp"
134
+ ],
135
+ "env": {
136
+ "TUNNEL_REMOTE_HOST": "user@192.168.1.12", // Optional
137
+ "TUNNEL_REMOTE_PORT": "22", // Optional
138
+ "TUNNEL_IDENTITY_FILE": "", // Optional
139
+ "TUNNEL_CERTIFICATE": "", // Optional
140
+ "TUNNEL_PROXY_COMMAND": "", // Optional
141
+ "TUNNEL_LOG_FILE": "~./tunnel_log.txt" // Optional
142
+ },
143
+ "timeout": 200000
144
+ }
145
+ }
146
+ }
147
+ ```
148
+
149
+ ### Deploy MCP Server as a container
150
+ ```bash
151
+ docker pull knucklessg1/tunnel-manager:latest
152
+ ```
153
+
154
+ Modify the `compose.yml`
155
+
156
+ ```compose
157
+ services:
158
+ tunnel-manager:
159
+ image: knucklessg1/tunnel-manager:latest
160
+ environment:
161
+ - HOST=0.0.0.0
162
+ - PORT=8021
163
+ ports:
164
+ - 8021:8021
165
+ ```
166
+
167
+ ### Install Python Package
168
+
169
+ ```bash
170
+ python -m pip install tunnel-manager
171
+ ```
172
+
173
+ or
174
+
175
+ ```bash
176
+ uv pip install --upgrade tunnel-manager
177
+ ```
178
+
179
+
180
+ </details>
181
+
182
+ <details>
183
+ <summary><b>Repository Owners:</b></summary>
184
+
185
+
186
+ <img width="100%" height="180em" src="https://github-readme-stats.vercel.app/api?username=Knucklessg1&show_icons=true&hide_border=true&&count_private=true&include_all_commits=true" />
187
+
188
+ ![GitHub followers](https://img.shields.io/github/followers/Knucklessg1)
189
+ ![GitHub User's stars](https://img.shields.io/github/stars/Knucklessg1)
190
+ </details>
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ requirements.txt
6
+ tunnel_manager/__init__.py
7
+ tunnel_manager/__main__.py
8
+ tunnel_manager/tunnel_manager.py
9
+ tunnel_manager/tunnel_manager_mcp.py
10
+ tunnel_manager.egg-info/PKG-INFO
11
+ tunnel_manager.egg-info/SOURCES.txt
12
+ tunnel_manager.egg-info/dependency_links.txt
13
+ tunnel_manager.egg-info/entry_points.txt
14
+ tunnel_manager.egg-info/requires.txt
15
+ tunnel_manager.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tunnel-manager-mcp = tunnel_manager.tunnel_manager_mcp:tunnel_manager_mcp
@@ -0,0 +1,2 @@
1
+ fastmcp>=2.11.3
2
+ paramiko>=4.0.0
@@ -0,0 +1,2 @@
1
+ dist
2
+ tunnel_manager