From 41a6038e504c9d4268a03595570832b68a8a87a3 Mon Sep 17 00:00:00 2001 From: koko <1429659362@qq.com> Date: Mon, 13 Oct 2025 13:09:06 +0800 Subject: [PATCH] mcp-uvicorn --- .idea/misc.xml | 2 +- .idea/solidstate-tools.iml | 2 +- mcp/lifespan.py | 50 ++++++++++++++++ mcp/main.py | 28 +++++++++ mcp/system_tools.py | 114 +++++++++++++++++++++++++++++++++++++ mcp/test_tools.py | 19 +++++++ 6 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 mcp/lifespan.py create mode 100644 mcp/main.py create mode 100644 mcp/system_tools.py create mode 100644 mcp/test_tools.py diff --git a/.idea/misc.xml b/.idea/misc.xml index 419adf3..06ede0d 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,5 +3,5 @@ - + \ No newline at end of file diff --git a/.idea/solidstate-tools.iml b/.idea/solidstate-tools.iml index 8073316..909438d 100644 --- a/.idea/solidstate-tools.iml +++ b/.idea/solidstate-tools.iml @@ -2,7 +2,7 @@ - + \ No newline at end of file diff --git a/mcp/lifespan.py b/mcp/lifespan.py new file mode 100644 index 0000000..d5e75d7 --- /dev/null +++ b/mcp/lifespan.py @@ -0,0 +1,50 @@ +import asyncssh +from contextlib import asynccontextmanager +from collections.abc import AsyncIterator +from dataclasses import dataclass +from starlette.applications import Starlette +# --- 1. SSH 连接参数 --- +REMOTE_HOST = '202.121.182.208' +REMOTE_USER = 'koko125' +# 确保路径正确,Windows 路径建议使用正斜杠 '/' +PRIVATE_KEY_PATH = 'D:/tool/tool/id_rsa.txt' +INITIAL_WORKING_DIRECTORY = f'/cluster/home/{REMOTE_USER}/sandbox' + + +# --- 2. 定义共享上下文 --- +# 这个数据类将持有所有模块需要共享的资源 +@dataclass +class SharedAppContext: + """在整个服务器生命周期内持有的共享资源""" + ssh_connection: asyncssh.SSHClientConnection + sandbox_path: str + + +# --- 3. 创建生命周期管理器 --- +# 这是整个架构的核心,负责在服务器启动时连接SSH,在关闭时断开 +@asynccontextmanager +async def shared_lifespan(app: Starlette) -> AsyncIterator[SharedAppContext]: + """ + 为整个应用管理共享资源的生命周期。 + """ + print("主应用启动,正在建立共享 SSH 连接...") + conn = None + try: + # 建立 SSH 连接 + conn = await asyncssh.connect( + REMOTE_HOST, + username=REMOTE_USER, + client_keys=[PRIVATE_KEY_PATH] + ) + print(f"SSH 连接到 {REMOTE_HOST} 成功!") + + # 使用 yield 将创建好的共享资源上下文传递给 Starlette 应用 + # 服务器会在此处暂停并开始处理请求 + yield {"shared_context": SharedAppContext(ssh_connection=conn, sandbox_path=INITIAL_WORKING_DIRECTORY)} + + finally: + # 当服务器关闭时,yield 之后的代码会被执行 + if conn: + conn.close() + await conn.wait_closed() + print("共享 SSH 连接已关闭。") diff --git a/mcp/main.py b/mcp/main.py new file mode 100644 index 0000000..16e67e7 --- /dev/null +++ b/mcp/main.py @@ -0,0 +1,28 @@ +# main.py +import contextlib +from starlette.applications import Starlette +from starlette.routing import Mount + +from test_tools import create_test_mcp +from system_tools import create_system_mcp # 如果暂时不用可先不挂 + +# 先创建 MCP 实例 +test_mcp = create_test_mcp() +# system_mcp = create_system_mcp() + +# 关键:在 Starlette 的 lifespan 中启动 MCP 的 session manager +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with contextlib.AsyncExitStack() as stack: + await stack.enter_async_context(test_mcp.session_manager.run()) + # await stack.enter_async_context(system_mcp.session_manager.run()) + yield # 服务器运行期间 + # 退出时自动清理 + +app = Starlette( + lifespan=lifespan, + routes=[ + Mount("/test", app=test_mcp.streamable_http_app()), + # Mount("/system", app=system_mcp.streamable_http_app()), + ], +) \ No newline at end of file diff --git a/mcp/system_tools.py b/mcp/system_tools.py new file mode 100644 index 0000000..d453d4c --- /dev/null +++ b/mcp/system_tools.py @@ -0,0 +1,114 @@ +# system_tools.py +import os +import asyncssh +from mcp.server.fastmcp import FastMCP, Context +from mcp.server.session import ServerSession +from lifespan import SharedAppContext # 导入共享上下文的类型定义 + + +def create_system_mcp() -> FastMCP: + """创建一个包含系统操作工具的 MCP 实例。""" + + system_mcp = FastMCP( + name="System Tools", + instructions="用于在远程服务器上进行基本文件和目录操作的工具集。", + # 将 MCP 实例的 HTTP 路径设置为根,以便挂载在 /system 下 [1] + streamable_http_path="/" + ) + + @system_mcp.tool() + async def list_files(ctx: Context[ServerSession, dict], path: str = ".") -> str: + """ + 列出远程服务器安全沙箱路径下的文件和目录。 + Args: + path: 要查看的相对路径,默认为当前沙箱目录。 + """ + try: + # 从上下文中获取共享的 SSH 连接和沙箱路径 [1] + shared_ctx: SharedAppContext = ctx.request_context.lifespan_context["shared_context"] + + conn = shared_ctx.ssh_connection + sandbox_path = shared_ctx.sandbox_path + + # 安全检查:防止目录穿越攻击 + if ".." in path.split('/'): + return "错误: 不允许使用 '..' 访问上级目录。" + + # 安全地拼接路径 + target_path = os.path.join(sandbox_path, path) + await ctx.info(f"正在列出安全路径: {target_path}") + + # 使用 conn.run() 执行 ls -l 命令 + command_to_run = f'ls -la {conn.escape(target_path)}' + result = await conn.run(command_to_run, check=True) + return result.stdout.strip() + except asyncssh.ProcessError as e: + error_message = f"命令执行失败: {e.stderr.strip()}" + await ctx.error(error_message) + return error_message + except Exception as e: + error_message = f"执行 ls 命令时发生未知错误: {e}" + await ctx.error(error_message) + return error_message + + @system_mcp.tool() + async def read_file(ctx: Context[ServerSession, dict], file_path: str) -> str: + """ + 读取远程服务器安全沙箱内指定文件的内容。 + Args: + file_path: 相对于沙箱目录的文件路径。 + """ + try: + shared_ctx: SharedAppContext = ctx.request_context.lifespan_context["shared_context"] + + conn = shared_ctx.ssh_connection + sandbox_path = shared_ctx.sandbox_path + # 安全检查:防止目录穿越 + if ".." in file_path.split('/'): + return "错误: 不允许使用 '..' 访问上级目录。" + + target_path = os.path.join(sandbox_path, file_path) + await ctx.info(f"正在读取远程文件: {target_path}") + + async with conn.start_sftp_client() as sftp: + async with sftp.open(target_path, 'r') as f: + content = await f.read() + return content + except FileNotFoundError: + error_message = f"读取文件失败: 文件 '{file_path}' 不存在。" + await ctx.error(error_message) + return error_message + except Exception as e: + await ctx.error(f"读取文件时发生未知错误: {e}") + return f"错误: {e}" + + @system_mcp.tool() + async def write_file(ctx: Context[ServerSession, dict], file_path: str, content: str) -> str: + """ + 向远程服务器安全沙箱内的文件写入内容。如果文件已存在,则会覆盖它。 + Args: + file_path: 相对于沙箱目录的文件路径。 + content: 要写入文件的文本内容。 + """ + try: + shared_ctx: SharedAppContext = ctx.request_context.lifespan_context["shared_context"] + + conn = shared_ctx.ssh_connection + sandbox_path = shared_ctx.sandbox_path + + # 安全检查 + if ".." in file_path.split('/'): + return "错误: 不允许使用 '..' 访问上级目录。" + + target_path = os.path.join(sandbox_path, file_path) + await ctx.info(f"正在向远程文件写入: {target_path}") + + async with conn.start_sftp_client() as sftp: + async with sftp.open(target_path, 'w') as f: + await f.write(content) + return f"内容已成功写入到沙箱文件 '{file_path}'。" + except Exception as e: + await ctx.error(f"写入文件时发生未知错误: {e}") + return f"错误: {e}" + + return system_mcp diff --git a/mcp/test_tools.py b/mcp/test_tools.py new file mode 100644 index 0000000..a5d59f2 --- /dev/null +++ b/mcp/test_tools.py @@ -0,0 +1,19 @@ +# test_tools.py +from mcp.server.fastmcp import FastMCP + + +def create_test_mcp() -> FastMCP: + """创建一个只包含最简单工具的 MCP 实例,用于测试连接。""" + + test_mcp = FastMCP( + name="Test Tools", + instructions="用于测试服务器连接是否通畅。", + streamable_http_path="/" + ) + + @test_mcp.tool() + def ping() -> str: + """一个简单的工具,用于确认服务器是否响应。""" + return "pong" + + return test_mcp \ No newline at end of file