From 82a11e104c38d16ba890abd8450dbf705b11cdd2 Mon Sep 17 00:00:00 2001 From: Steve White Date: Tue, 8 Apr 2025 17:45:14 -0500 Subject: [PATCH] initial commit --- README.md | 113 +++++++++ pyproject.toml | 24 ++ setup.py | 8 + src/mcpssh.egg-info/PKG-INFO | 12 + src/mcpssh.egg-info/SOURCES.txt | 12 + src/mcpssh.egg-info/dependency_links.txt | 1 + src/mcpssh.egg-info/requires.txt | 8 + src/mcpssh.egg-info/top_level.txt | 1 + src/mcpssh/__init__.py | 3 + src/mcpssh/__main__.py | 6 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 215 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 215 bytes .../__pycache__/__main__.cpython-312.pyc | Bin 0 -> 309 bytes .../__pycache__/__main__.cpython-313.pyc | Bin 0 -> 311 bytes src/mcpssh/__pycache__/server.cpython-312.pyc | Bin 0 -> 9695 bytes src/mcpssh/__pycache__/server.cpython-313.pyc | Bin 0 -> 9576 bytes src/mcpssh/server.py | 222 ++++++++++++++++++ test_mcp.py | 85 +++++++ test_ssh.py | 23 ++ tests/__init__.py | 0 tests/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 144 bytes tests/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 144 bytes .../test_server.cpython-312-pytest-7.4.4.pyc | Bin 0 -> 8497 bytes .../test_server.cpython-313-pytest-8.3.5.pyc | Bin 0 -> 8490 bytes tests/test_server.py | 198 ++++++++++++++++ 25 files changed, 716 insertions(+) create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 setup.py create mode 100644 src/mcpssh.egg-info/PKG-INFO create mode 100644 src/mcpssh.egg-info/SOURCES.txt create mode 100644 src/mcpssh.egg-info/dependency_links.txt create mode 100644 src/mcpssh.egg-info/requires.txt create mode 100644 src/mcpssh.egg-info/top_level.txt create mode 100644 src/mcpssh/__init__.py create mode 100644 src/mcpssh/__main__.py create mode 100644 src/mcpssh/__pycache__/__init__.cpython-312.pyc create mode 100644 src/mcpssh/__pycache__/__init__.cpython-313.pyc create mode 100644 src/mcpssh/__pycache__/__main__.cpython-312.pyc create mode 100644 src/mcpssh/__pycache__/__main__.cpython-313.pyc create mode 100644 src/mcpssh/__pycache__/server.cpython-312.pyc create mode 100644 src/mcpssh/__pycache__/server.cpython-313.pyc create mode 100644 src/mcpssh/server.py create mode 100644 test_mcp.py create mode 100644 test_ssh.py create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/__init__.cpython-312.pyc create mode 100644 tests/__pycache__/__init__.cpython-313.pyc create mode 100644 tests/__pycache__/test_server.cpython-312-pytest-7.4.4.pyc create mode 100644 tests/__pycache__/test_server.cpython-313-pytest-8.3.5.pyc create mode 100644 tests/test_server.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..c9474cc --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# MCP SSH Server + +An Anthropic Model Context Protocol (MCP) server that provides SSH access to remote systems, enabling full access to remote virtual machines in a sandbox environment. + +## Features + +- Uses stdio for MCP communication +- Provides SSH connection to remote servers +- Enables command execution on remote systems +- Secure public key authentication +- Configurable via environment variables or MCP config + +## Installation + +```bash +pip install -e . +``` + +## Configuration + +The SSH server can be configured using environment variables or the MCP JSON configuration: + +| Environment Variable | Description | Default | +|----------------------|-------------|---------| +| `MCP_SSH_HOSTNAME` | SSH server hostname or IP address | None | +| `MCP_SSH_PORT` | SSH server port | 22 | +| `MCP_SSH_USERNAME` | SSH username | None | +| `MCP_SSH_KEY_FILENAME` | Path to SSH private key file | None | + +### Claude Desktop MCP Configuration + +Add the following to your Claude Desktop MCP configuration file: + +```json +{ + "tools": [ + { + "name": "mcpssh", + "path": "python -m mcpssh", + "environment": { + "MCP_SSH_HOSTNAME": "example.com", + "MCP_SSH_PORT": "22", + "MCP_SSH_USERNAME": "user", + "MCP_SSH_KEY_FILENAME": "/path/to/private_key" + } + } + ] +} +``` + +## Usage + +This server implements the Anthropic MCP protocol and provides the following tools: + +- `ssh_connect`: Connect to an SSH server using public key authentication (using config or explicit parameters) +- `ssh_execute`: Execute a command on the SSH server +- `ssh_disconnect`: Disconnect from the SSH server + +### Example + +```python +from mcp import ClientSession, StdioServerParameters +from mcpssh.server import SSHServerMCP + +# Start the server in a subprocess +server_params = StdioServerParameters( + command="python", + args=["-m", "mcpssh"], + env={ + "MCP_SSH_HOSTNAME": "example.com", + "MCP_SSH_PORT": "22", + "MCP_SSH_USERNAME": "user", + "MCP_SSH_KEY_FILENAME": "/path/to/private_key" + } +) + +# Use with an MCP client +with ClientSession(server_params) as client: + # Connect to SSH server + client.ssh_connect() + + # Execute a command + result = client.ssh_execute(command="ls -la") + print(result["stdout"]) + + # Disconnect + client.ssh_disconnect() +``` + +### Direct Server Usage + +```python +from mcpssh.server import SSHServerMCP + +# Initialize and run the server +server = SSHServerMCP( + hostname="example.com", + port=22, + username="user", + key_filename="/path/to/private_key" +) + +# Run the server with stdio transport +server.run(transport="stdio") +``` + +## Security Note + +This tool provides full access to a remote system. It should only be used with virtual machines in sandbox environments where security implications are well understood. + +## License + +MIT \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f423e1c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "mcpssh" +version = "0.1.0" +description = "Anthropic MCP server that provides SSH access to remote systems" +requires-python = ">=3.8" +dependencies = [ + "paramiko>=3.0.0", + "pydantic>=2.0.0", + "mcp>=1.6.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "black>=23.0.0", + "mypy>=1.0.0", +] + +[tool.setuptools] +packages = ["mcpssh"] \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..fc639a0 --- /dev/null +++ b/setup.py @@ -0,0 +1,8 @@ +"""Setup script for mcpssh.""" + +from setuptools import setup + +setup( + name="mcpssh", + package_dir={"": "src"}, +) \ No newline at end of file diff --git a/src/mcpssh.egg-info/PKG-INFO b/src/mcpssh.egg-info/PKG-INFO new file mode 100644 index 0000000..93596b6 --- /dev/null +++ b/src/mcpssh.egg-info/PKG-INFO @@ -0,0 +1,12 @@ +Metadata-Version: 2.4 +Name: mcpssh +Version: 0.1.0 +Summary: Anthropic MCP server that provides SSH access to remote systems +Requires-Python: >=3.8 +Requires-Dist: paramiko>=3.0.0 +Requires-Dist: pydantic>=2.0.0 +Requires-Dist: mcp>=1.6.0 +Provides-Extra: dev +Requires-Dist: pytest>=7.0.0; extra == "dev" +Requires-Dist: black>=23.0.0; extra == "dev" +Requires-Dist: mypy>=1.0.0; extra == "dev" diff --git a/src/mcpssh.egg-info/SOURCES.txt b/src/mcpssh.egg-info/SOURCES.txt new file mode 100644 index 0000000..275472c --- /dev/null +++ b/src/mcpssh.egg-info/SOURCES.txt @@ -0,0 +1,12 @@ +README.md +pyproject.toml +setup.py +src/mcpssh/__init__.py +src/mcpssh/__main__.py +src/mcpssh/server.py +src/mcpssh.egg-info/PKG-INFO +src/mcpssh.egg-info/SOURCES.txt +src/mcpssh.egg-info/dependency_links.txt +src/mcpssh.egg-info/requires.txt +src/mcpssh.egg-info/top_level.txt +tests/test_server.py \ No newline at end of file diff --git a/src/mcpssh.egg-info/dependency_links.txt b/src/mcpssh.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/mcpssh.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/mcpssh.egg-info/requires.txt b/src/mcpssh.egg-info/requires.txt new file mode 100644 index 0000000..8600b64 --- /dev/null +++ b/src/mcpssh.egg-info/requires.txt @@ -0,0 +1,8 @@ +paramiko>=3.0.0 +pydantic>=2.0.0 +mcp>=1.6.0 + +[dev] +pytest>=7.0.0 +black>=23.0.0 +mypy>=1.0.0 diff --git a/src/mcpssh.egg-info/top_level.txt b/src/mcpssh.egg-info/top_level.txt new file mode 100644 index 0000000..5c7cea7 --- /dev/null +++ b/src/mcpssh.egg-info/top_level.txt @@ -0,0 +1 @@ +mcpssh diff --git a/src/mcpssh/__init__.py b/src/mcpssh/__init__.py new file mode 100644 index 0000000..859b8b7 --- /dev/null +++ b/src/mcpssh/__init__.py @@ -0,0 +1,3 @@ +"""MCP SSH Server module.""" + +__version__ = "0.1.0" \ No newline at end of file diff --git a/src/mcpssh/__main__.py b/src/mcpssh/__main__.py new file mode 100644 index 0000000..2865c83 --- /dev/null +++ b/src/mcpssh/__main__.py @@ -0,0 +1,6 @@ +"""Main entry point for the MCP SSH server.""" + +from .server import main + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/mcpssh/__pycache__/__init__.cpython-312.pyc b/src/mcpssh/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e8ba3714001ca846d654ea92de20898431c2226b GIT binary patch literal 215 zcmX@j%ge<81iran(~W`jV-N=h7@>^MJV3^Dh7^VEfg%6}KR8nW literal 0 HcmV?d00001 diff --git a/src/mcpssh/__pycache__/__init__.cpython-313.pyc b/src/mcpssh/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5e4ac0ec47573d7a212cb6ea79826c88dfb086e6 GIT binary patch literal 215 zcmey&%ge<81iran(~W`jV-N=h7@>^MJV3@&hG2#whG51b#&jl4<|;8?=KzJ^U=M}h z)S|M~B8A-il+v73y((4%JwrVMKTW1v?D6p_`N{F|x47fufhvkK^Yh~4S2BDC8G6e^ zKP*3|G&i+aKiJXNNZ;At#Z@0@qJD02L2+@0esNJUj2R!FnU`4-AFo$Xd5gmaVvSu9 eJJ1l2^NM+a#0O?ZM#h^AG7tDf8@Y>Efg%7Fh7^VY{oLe&;^GYb;-X|26K1$xLFFwD zo80`A(wtPgB5t4|AV(C-0*MdIjEsyo8T6hqNIYQX?x?!NEP0cKqt)#LGXslM5hqXu E03*^;5C8xG literal 0 HcmV?d00001 diff --git a/src/mcpssh/__pycache__/__main__.cpython-313.pyc b/src/mcpssh/__pycache__/__main__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ca2e466a9ac20bb48d2f038f5ed430d184884d93 GIT binary patch literal 311 zcmey&%ge<81TDE=)AND!V-N=hn4pZ$20+GChG2#whG52ECT~VBrXnTi`T(6+= z7Kcr4eoARhs$CH`&=8O#ie-Vs2WCb_#+wX!PZ=a0FmrcQU1pZN$->d<_JNs!MXHDs Gr~&}3p;2J~ literal 0 HcmV?d00001 diff --git a/src/mcpssh/__pycache__/server.cpython-312.pyc b/src/mcpssh/__pycache__/server.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1576b0fbe84167f01e2754cbaa04afff10aa8504 GIT binary patch literal 9695 zcmbtaTWlNGnLa}f$ssu;Wzp8vl17#yn~5#ixmb?ljU($~Te4}{a@v?p!_b_OO@$YA zW@KBeWT@Q~mAeh>0K2fV?ZV2Ui`uZ!DA)k;z9lWNyTHDXma?QLDUu@lk~g(d(I5-# z?*E^;kYcD^H+uk{>wnIfIp@EA!+-PnJOt8TUissN#_fcBjtw(8jLO?BoQJq zqAkfp7+TsQHd@*vc3QF#7D{{4k#a_yDObdm;v!ti9dV~T5f7u!^+vov!zTHZFXBu2 zBYxWNNCr|hks4ZeCby(&Bee{%5y>{uWj;))gx+1HmRY;!x(iQ<$(Z@L-YUnY7z%yNzLAup4PbIswBtMj2yCQ?jtcpI++orq~;h(NJ%loXbyFH zQc|Ge8j2|@?1z0x>5`O;8&5+E&SMBru#-YVRvO=3m6Z(a3Os*ykN)1Y zA*dH)su0hl(^6bj1T`bX1X)UDR7p_i$#iSn#f+k+V<|~vCo?h}!xW6rHvgJ59UV_3 zC2Yk_R>k*W;fI&E_V1A0Bs0tmi9$_JGd5@$j6%qa9a_fqLyLu$aqWbtZgb8A6%n(g zm*%Cy45v9VLne5$UMeR7rMg)wTf4^WE0yNjCbObgz&_UV02A4>`Qzz?nusM6S&8zJ zq91U#M=+9A>60%g2dx&4numH&I8%PyR{sWKJokfi3#@GzGA~0PHd`A=%5UkQFd9=Y z;yoi1z=(kNXORzuLmn9qOU5&iw_sKanZ`}VmXn!eB0jD8`li%OpD2#%O}D{ol8Dl!NXhYP`zgpw^bo&#vgccwxU~TacdvZPMQ>A1z@(1|QcMdH7pa+A0TuqxCl(}czR zr4gV+fvQN*Yq5Q42zpJ8QtHILstC|M=B}|Wur~^&wcfO?gQ$LUfleatm}OK;OeTWn zNU0Rag*TxLSftlF=u9Fr3bWn(d-RXIaX zvO;*y%2Se%7&rA6C^bQuipN3x#;20Ig`rqdk%WwTQIanwloEH}VXN`l*=!xkg29)# zmL$s=`JfOU0%53wSJYg_V72Ko>Rin|cqJ}T^PoACnF|*rS#!{3LN>Vx2^dL1B_{}S zGZb<=WZBv><}`MA4EmJ}WWbxe*WFm;Yp#!68!7N@dA_aK*mA4ot(M}p#+&EfJZH9A z+6yh+`IhcgH}MJU#NqR=@}zag{J>Ac#bEuHp9C8IKy2>DPx#vFC$62yHSb$$Ukc_L zpIPDee-do?&s7Huex)?PoXVe=`nO=UWzV(b7+ zR{j|>YWHfkUcdU2tDqrn9(wc8Uq4^m(V5$OGWYT;`TA(CCc4Uc-GfZAbJs6N-X1A* z9?Ewfy8rFv&cQt2K0kP4|HA%aL+dU7TmC{rZ@!^-x#8LQjW{Vw3RozLu`vyV$M%Rl@rGU#@#dO(UwPLB`TiPcNNb>d!SESmB>7whH(9 zmzcXlAJpc910V4NRAUV?Un#BdD!=GEaHNI&y2agJZ~t{ySAVVjH#`IN-_$xF4{xx* zsJu`Wt-T7_W(rH*0Sc>J%>gZ@2~>9#$y6eSb7+my7{i|3)}PFP9olGIy1~V=>x@KA zizALicNlvA3K~ztLi9p4TT^zMl<*AnD#!|Rrb!vUS{LG@ddVzKq6;G!RGd8akcqeijoqS6Vx-I-b*%6HVvZ*>gi{B>@}kY>i1{2mq!Yda^g}9 z?1ULH(5N8flb?lmkPl+^9A@}RG6qSyr-U-2^HCrg1?8C0!)pb&i^dm!f#X(?As@yj zvaHTVeOSZn9Q>4*A^Q=q(XQXMb}SC$_Mcm6jm#aJzj#BrmRe>b51ZN++1!);D@_A) z1M|CXJaui)GCS}PX_wL~t%D`f!G}9`-ez+LUs~C5Ztld5T?X18_OOD)8T&VWW()2sM@DAYZ@eB?_Iy^j3g+1IZuJw=PiX=p zmWm|0MbAC22?a73lT@FJAz4KddC~VsbGKJagr?{h1Lbq4?V+033vi)d1Bei8{4?>P zGJS1=M#8w9NeNQ=QbNw85w%3U4-w>e;)3N8qmNj^jSRWKjz^trT7!+q(xcA)zd@%* zSt*OSrGe1`e9EGa64q)!(xWgCJ$7pBY`E{_pvId$qo>ZC{en7H7SW4igJ&qwR+DIC z@LY80_=!Q<>je}Eyk`o4W*ZDMjCvxLriw#9IdGze;iBb!$TT{|aIUi*gPVm=rHj-2 zxQUoh%zau1th#;-7;=^9Ljec^rY0doL1xqUGr&d7mQgg9arK)0f~0CSqBI_xN~#ok z!xNIxu4=s5jh9{`GOtzJYL1s*mBxYZ5LMCD8^!lMjTt(fA*UXpR09Xy(P%swQ^2e% zsQ8fcggM=Y$Sw~zvjww4GYN}*RaApQQY?oLU0Wb zRkVj52;qB2-Wk3#{7(2zIQQJi<=tT%Y$w!OIJUTmv;>> zcaBg1uPE%=bZ+O~2c41kj=Vqo?(qBJcf+~GoasUGv=#3w2%jx~|*b z%^f>G?_1&GhyYd$q;MczHW13!g>rje$c5AMzK^&JHJzGslDc)&72g1(SSANPRA5xX zR6_+prSepr0ZnC^wZRrp!Jmc7u%GwoTHTaeyI;%k| zCGZ3R7SZ){+ZZW>R&4O80^Z;rXQHNH_N?;&Q&YttO(57;k<6%3hHp2bXU10A3!coY z?;7H>zuHU{J(Bs*xlLEyhSEVSgLc!HVuPKq8dV{gGm-+@MaXQxTVpDCPm`r@5xT=~ z`SL^AmXrDi0r0oJHk4G}f@JCJGiSTdbNR04=DA{E+x72X`~HJK&w^NJ?a8;)uuRBRi@W!!)i?{h)@R^ms{$gFj{LzQO=7n$HdgZNGZV#`t^j5ao7VCf6`gZG* zf2I9!v8^-r)M)PXi-psX{OL&UJLi{A$8)1%Zl9D7j_3IC|7d=!xUHqQWgF&=kFC2M z+daC9_7lFQ;BC%(n+x8KytiY;`&hB9V`Rk2lUY>|9nf%9w56%BAn0JTzj_3F|7VG) z8S9nA!xBd{q*kMB3(91BHzIzTp!E zn*J)>Z1_F@faFIYGr+_`pzT4RZBZ@k?9K1&E$r-ju(J23d?2)5}P(k>&Siv0vWd|$I=Qq=3$UOe1FX@Po-hR zsmB{-m#(p?7+5L%DEI;V6m%)TV|EgM(_g(ZHw4bA!~5gm9}F+l&kcXXw$t}Rq!(ww znYU0-4osbrNoy|rHVI$pWdvp6%fqyyIpJ?A*n_VovzSrTj$SERI2k{e!}tGbF_u;n zahbzDg!nW&e1ezJQPez8>((D!_F{+kd`wBi!H7ddsG(ate0|jBgnw8qgw_d`>$P^~G;`zlI>Bkp(G_-0btPa2b5B+|9g8%>k literal 0 HcmV?d00001 diff --git a/src/mcpssh/__pycache__/server.cpython-313.pyc b/src/mcpssh/__pycache__/server.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..40952b714993b403ce495a1488976a17f815c5bf GIT binary patch literal 9576 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 Wi%YpxYx4tM`1|oSE8%tE{eJ;K*EoUz literal 0 HcmV?d00001 diff --git a/src/mcpssh/server.py b/src/mcpssh/server.py new file mode 100644 index 0000000..6e09bb3 --- /dev/null +++ b/src/mcpssh/server.py @@ -0,0 +1,222 @@ +"""MCP SSH Server implementation.""" + +import json +import logging +import os +import sys +from typing import Dict, List, Optional, Any, Iterator + +import paramiko +from pydantic import BaseModel, Field +from mcp import types +from mcp.server import FastMCP + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +class SSHSession: + """SSH Session that connects to a remote server.""" + + def __init__(self, hostname: str, port: int, username: str, key_filename: str): + """Initialize SSH session. + + Args: + hostname: Remote server hostname + port: SSH port + username: SSH username + key_filename: Path to SSH key file + """ + self.hostname = hostname + self.port = port + self.username = username + self.key_filename = key_filename + self.client = paramiko.SSHClient() + self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + self.connected = False + + def connect(self) -> bool: + """Connect to SSH server. + + Returns: + True if connection successful, False otherwise + """ + try: + self.client.connect( + hostname=self.hostname, + port=self.port, + username=self.username, + key_filename=self.key_filename + ) + self.connected = True + return True + except Exception as e: + logger.error(f"SSH connection error: {e}") + return False + + def execute_command(self, command: str) -> Dict[str, Any]: + """Execute a command on the remote server. + + Args: + command: Command to execute + + Returns: + Dictionary with stdout, stderr, and exit_code + """ + if not self.connected: + if not self.connect(): + return {"stdout": "", "stderr": "Not connected to SSH server", "exit_code": -1} + + try: + stdin, stdout, stderr = self.client.exec_command(command) + exit_code = stdout.channel.recv_exit_status() + + return { + "stdout": stdout.read().decode("utf-8"), + "stderr": stderr.read().decode("utf-8"), + "exit_code": exit_code + } + except Exception as e: + logger.error(f"Command execution error: {e}") + return {"stdout": "", "stderr": str(e), "exit_code": -1} + + def close(self) -> None: + """Close SSH connection.""" + if self.connected: + self.client.close() + self.connected = False + + +class SSHConnectionParams(BaseModel): + """Parameters for SSH connection.""" + + hostname: Optional[str] = Field(None, description="SSH server hostname or IP address") + port: Optional[int] = Field(None, description="SSH server port") + username: Optional[str] = Field(None, description="SSH username") + key_filename: Optional[str] = Field(None, description="Path to SSH private key file") + + +class CommandParams(BaseModel): + """Parameters for executing a command.""" + + command: str = Field(..., description="Command to execute on remote server") + + +class SSHServerMCP(FastMCP): + """MCP server that provides SSH access to remote systems.""" + + def __init__(self, hostname=None, port=None, username=None, key_filename=None, server_name=None, tool_prefix=None): + """Initialize SSH server. + + Args: + hostname: SSH server hostname from environment or config + port: SSH server port from environment or config + username: SSH username from environment or config + key_filename: Path to SSH key file from environment or config + server_name: Custom name for the server instance (for multiple servers) + tool_prefix: Prefix for tool names (e.g., 'server1_' for 'server1_ssh_connect') + """ + # Get server name from environment variable or parameter + server_name = server_name or os.environ.get("MCP_SSH_SERVER_NAME", "SSH Server") + super().__init__(name=server_name) + self.ssh_session: Optional[SSHSession] = None + + # Get configuration from environment variables or passed parameters + self.default_hostname = hostname or os.environ.get("MCP_SSH_HOSTNAME") + self.default_port = port or int(os.environ.get("MCP_SSH_PORT", 22)) + self.default_username = username or os.environ.get("MCP_SSH_USERNAME") + self.default_key_filename = key_filename or os.environ.get("MCP_SSH_KEY_FILENAME") + + # Get tool prefix from parameter or environment variable + self.tool_prefix = tool_prefix or os.environ.get("MCP_SSH_TOOL_PREFIX", "") + + # Register tools with optional prefix + connect_name = f"{self.tool_prefix}ssh_connect" + execute_name = f"{self.tool_prefix}ssh_execute" + disconnect_name = f"{self.tool_prefix}ssh_disconnect" + + self.add_tool(self.ssh_connect, name=connect_name, description=f"Connect to SSH server: {server_name}") + self.add_tool(self.ssh_execute, name=execute_name, description=f"Execute a command on SSH server: {server_name}") + self.add_tool(self.ssh_disconnect, name=disconnect_name, description=f"Disconnect from SSH server: {server_name}") + + def ssh_connect(self, params: SSHConnectionParams) -> Dict[str, Any]: + """Connect to an SSH server. + + Args: + params: SSH connection parameters (ignored for security-critical fields) + + Returns: + Result of connection attempt + """ + # Always use config/environment variables for security-critical fields + hostname = self.default_hostname + username = self.default_username + key_filename = self.default_key_filename + + # Only allow port to be optionally specified in the request + port = self.default_port if self.default_port else params.port + + # Validate that we have all required parameters + if not all([hostname, username, key_filename]): + missing = [] + if not hostname: missing.append("hostname") + if not username: missing.append("username") + if not key_filename: missing.append("key_filename") + return {"success": False, "message": f"Missing required parameters: {', '.join(missing)}"} + + self.ssh_session = SSHSession( + hostname=hostname, + port=port, + username=username, + key_filename=key_filename + ) + + if self.ssh_session.connect(): + return {"success": True, "message": f"Connected to {hostname}"} + else: + return {"success": False, "message": "Failed to connect to SSH server"} + + def ssh_execute(self, params: CommandParams) -> Dict[str, Any]: + """Execute a command on the SSH server. + + Args: + params: Command parameters + + Returns: + Command execution result + """ + if not self.ssh_session or not self.ssh_session.connected: + return {"success": False, "message": "Not connected to SSH server"} + + return self.ssh_session.execute_command(params.command) + + def ssh_disconnect(self) -> Dict[str, Any]: + """Disconnect from the SSH server. + + Returns: + Disconnect result + """ + if not self.ssh_session: + return {"success": True, "message": "Not connected to SSH server"} + + self.ssh_session.close() + return {"success": True, "message": "Disconnected from SSH server"} + + +def main(): + """Run the MCP SSH server.""" + # Get custom server name and tool prefix from environment if specified + server_name = os.environ.get("MCP_SSH_SERVER_NAME") + tool_prefix = os.environ.get("MCP_SSH_TOOL_PREFIX") + + # Create server with config from environment variables + server = SSHServerMCP( + server_name=server_name, + tool_prefix=tool_prefix + ) + + # Run the server with stdio transport + server.run(transport="stdio") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/test_mcp.py b/test_mcp.py new file mode 100644 index 0000000..f41b745 --- /dev/null +++ b/test_mcp.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python +""" +Test script that simulates an MCP client using the SSH server. +This helps verify that the server properly responds to MCP requests. +""" +import json +import os +import sys +import subprocess +import tempfile + +# Set required environment variables for the subprocess +env = os.environ.copy() +env["MCP_SSH_HOSTNAME"] = "10.0.1.232" +env["MCP_SSH_USERNAME"] = "stwhite" +env["MCP_SSH_KEY_FILENAME"] = "~/.ssh/id_ed25519" + +# Create temporary files for input/output +with tempfile.NamedTemporaryFile('w+') as input_file, tempfile.NamedTemporaryFile('w+') as output_file: + # Write an MCP request to connect to SSH server + mcp_request = { + "type": "request", + "id": "1", + "method": "ssh_connect", + "params": {} + } + input_file.write(json.dumps(mcp_request) + "\n") + input_file.flush() + + # Run the MCP server process with stdin/stdout redirected + try: + print("Starting MCP SSH server...") + process = subprocess.Popen( + [sys.executable, "-m", "mcpssh"], + env=env, + stdin=open(input_file.name, 'r'), + stdout=open(output_file.name, 'w'), + stderr=subprocess.PIPE, + text=True + ) + + # Give it a moment to process + stderr_output = process.stderr.read() + if stderr_output: + print("Error output from server:") + print(stderr_output) + + # Kill the process (we're just testing initialization) + process.terminate() + process.wait() + + # Read server's response + output_file.seek(0) + response_lines = output_file.readlines() + + if not response_lines: + print("No response received from server. This likely indicates an initialization issue.") + else: + for line in response_lines: + try: + response = json.loads(line.strip()) + print("Server response:") + print(json.dumps(response, indent=2)) + except json.JSONDecodeError: + print(f"Non-JSON response: {line.strip()}") + + except Exception as e: + print(f"Error running MCP server: {e}") + +print("\nTest complete.") +print("For Claude Desktop, use this configuration:") +print(""" +"mcpssh": { + "command": "/Volumes/SAM2/CODE/MCP/mcpssh/venv/bin/python", + "args": [ + "-m", + "mcpssh" + ], + "env": { + "MCP_SSH_HOSTNAME": "10.0.1.232", + "MCP_SSH_USERNAME": "stwhite", + "MCP_SSH_KEY_FILENAME": "~/.ssh/id_ed25519" + } +} +""") diff --git a/test_ssh.py b/test_ssh.py new file mode 100644 index 0000000..b4addd0 --- /dev/null +++ b/test_ssh.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +""" +Test script for the MCP SSH server. +""" +import os +import sys +from mcpssh.server import SSHServerMCP + +# Set required environment variables for testing +os.environ["MCP_SSH_HOSTNAME"] = "10.0.1.232" # Replace with your server +os.environ["MCP_SSH_USERNAME"] = "stwhite" # Replace with your username +os.environ["MCP_SSH_KEY_FILENAME"] = "~/.ssh/id_ed25519" # Replace with your key path +# os.environ["MCP_SSH_PORT"] = "22" # Optional, defaults to 22 + +# Create and run the server +server = SSHServerMCP() +print("Server created with configuration:") +print(f"Hostname: {server.default_hostname}") +print(f"Port: {server.default_port}") +print(f"Username: {server.default_username}") +print(f"Key filename: {server.default_key_filename}") +print("\nServer ready. In a real scenario, server.run(transport='stdio') would be called.") +print("To fully integrate with Claude, add the configuration to Claude Desktop.") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..401ae7c94229fd67a76662922a30b14d8b4c7f11 GIT binary patch literal 144 zcmX@j%ge<81k-cBri19mAOanHW&w&!XQ*V*Wb|9fP{ah}eFmxdrLP~BpHrHfTC5-J z=xe0!?C;{L@9P|(pPO7zT%4g_l3H9+tREkrnU`4-AFo$Xd5gm)H$SB`C)KWq6{w#P Rh>JmtkIamWj77{q7667BAaei! literal 0 HcmV?d00001 diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1637e70f82e52d6aef1fa628dd9637a2fc8e0d61 GIT binary patch literal 144 zcmey&%ge<81k-cBri19mAOZ#$p^VQgK*m&tbOudEzm*I{OhDdekkl=G{jmI;(%jTy z{a{C5BYkIo7gv2>=K%fOVS;*w(h`1s7c%#!$cy@JYH95%W6DWy57c15f} T{UGy;L5z>gjEsy$%s>_Zir64% literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_server.cpython-312-pytest-7.4.4.pyc b/tests/__pycache__/test_server.cpython-312-pytest-7.4.4.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c7c94905e977b8a79a16d9106137253411e0a133 GIT binary patch literal 8497 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_server.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_server.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ed252fb65556a6d8ccd94a805d0195c6e75031fc GIT binary patch literal 8490 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 literal 0 HcmV?d00001 diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..84e8476 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,198 @@ +"""Tests for the MCP SSH server.""" + +import unittest +from unittest.mock import patch, MagicMock + +from mcpssh.server import SSHServerMCP, SSHSession, SSHConnectionParams, CommandParams + + +class TestSSHSession(unittest.TestCase): + """Test SSH session class.""" + + @patch('paramiko.SSHClient') + def test_connect_success(self, mock_ssh_client): + """Test successful SSH connection.""" + # Setup + mock_client = MagicMock() + mock_ssh_client.return_value = mock_client + + # Execute + session = SSHSession("example.com", 22, "username", "/path/to/key") + result = session.connect() + + # Verify + self.assertTrue(result) + self.assertTrue(session.connected) + mock_client.connect.assert_called_once_with( + hostname="example.com", + port=22, + username="username", + key_filename="/path/to/key" + ) + + @patch('paramiko.SSHClient') + def test_connect_failure(self, mock_ssh_client): + """Test failed SSH connection.""" + # Setup + mock_client = MagicMock() + mock_client.connect.side_effect = Exception("Connection failed") + mock_ssh_client.return_value = mock_client + + # Execute + session = SSHSession("example.com", 22, "username", "/path/to/key") + result = session.connect() + + # Verify + self.assertFalse(result) + self.assertFalse(session.connected) + + @patch('paramiko.SSHClient') + def test_execute_command_success(self, mock_ssh_client): + """Test successful command execution.""" + # Setup + mock_client = MagicMock() + mock_stdout = MagicMock() + mock_stdout.read.return_value = b"command output" + mock_stdout.channel.recv_exit_status.return_value = 0 + mock_stderr = MagicMock() + mock_stderr.read.return_value = b"" + mock_client.exec_command.return_value = (None, mock_stdout, mock_stderr) + mock_ssh_client.return_value = mock_client + + # Execute + session = SSHSession("example.com", 22, "username", "/path/to/key") + session.connected = True # Skip connection + session.client = mock_client + result = session.execute_command("ls -la") + + # Verify + self.assertEqual(result["stdout"], "command output") + self.assertEqual(result["stderr"], "") + self.assertEqual(result["exit_code"], 0) + + @patch('paramiko.SSHClient') + def test_close(self, mock_ssh_client): + """Test closing SSH connection.""" + # Setup + mock_client = MagicMock() + mock_ssh_client.return_value = mock_client + + # Execute + session = SSHSession("example.com", 22, "username", "/path/to/key") + session.connected = True + session.client = mock_client + session.close() + + # Verify + self.assertFalse(session.connected) + mock_client.close.assert_called_once() + + +class TestSSHServerMCP(unittest.TestCase): + """Test SSH server MCP implementation.""" + + def setUp(self): + """Set up test environment.""" + self.server = SSHServerMCP() + + @patch('mcpssh.server.SSHSession') + def test_ssh_connect_success(self, mock_ssh_session): + """Test successful SSH connection.""" + # Setup + mock_session = MagicMock() + mock_session.connect.return_value = True + mock_ssh_session.return_value = mock_session + + # Execute + params = { + "hostname": "example.com", + "port": 22, + "username": "username", + "key_filename": "/path/to/key" + } + result = self.server.ssh_connect(SSHConnectionParams(**params)) + + # Verify + self.assertTrue(result["success"]) + self.assertEqual(result["message"], "Connected to example.com") + + @patch('mcpssh.server.SSHSession') + def test_ssh_connect_failure(self, mock_ssh_session): + """Test failed SSH connection.""" + # Setup + mock_session = MagicMock() + mock_session.connect.return_value = False + mock_ssh_session.return_value = mock_session + + # Execute + params = { + "hostname": "example.com", + "port": 22, + "username": "username", + "key_filename": "/path/to/key" + } + result = self.server.ssh_connect(SSHConnectionParams(**params)) + + # Verify + self.assertFalse(result["success"]) + self.assertEqual(result["message"], "Failed to connect to SSH server") + + def test_ssh_execute_not_connected(self): + """Test command execution when not connected.""" + # Execute + params = {"command": "ls -la"} + result = self.server.ssh_execute(CommandParams(**params)) + + # Verify + self.assertFalse(result["success"]) + self.assertEqual(result["message"], "Not connected to SSH server") + + @patch('mcpssh.server.SSHSession') + def test_ssh_execute_success(self, mock_ssh_session): + """Test successful command execution.""" + # Setup + mock_session = MagicMock() + mock_session.connected = True + mock_session.execute_command.return_value = { + "stdout": "command output", + "stderr": "", + "exit_code": 0 + } + + self.server.ssh_session = mock_session + + # Execute + params = {"command": "ls -la"} + result = self.server.ssh_execute(CommandParams(**params)) + + # Verify + self.assertEqual(result["stdout"], "command output") + self.assertEqual(result["stderr"], "") + self.assertEqual(result["exit_code"], 0) + + def test_ssh_disconnect_not_connected(self): + """Test disconnection when not connected.""" + # Execute + result = self.server.ssh_disconnect() + + # Verify + self.assertTrue(result["success"]) + self.assertEqual(result["message"], "Not connected to SSH server") + + def test_ssh_disconnect_success(self): + """Test successful disconnection.""" + # Setup + mock_session = MagicMock() + self.server.ssh_session = mock_session + + # Execute + result = self.server.ssh_disconnect() + + # Verify + self.assertTrue(result["success"]) + self.assertEqual(result["message"], "Disconnected from SSH server") + mock_session.close.assert_called_once() + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file