mcp-python

This commit is contained in:
2025-10-13 15:37:31 +08:00
parent 28639f9cbf
commit b19352382b
5 changed files with 387 additions and 80 deletions

2
.idea/misc.xml generated
View File

@@ -3,5 +3,5 @@
<component name="Black"> <component name="Black">
<option name="sdkName" value="Python 3.12" /> <option name="sdkName" value="Python 3.12" />
</component> </component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.11" project-jdk-type="Python SDK" /> <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (test1)" project-jdk-type="Python SDK" />
</project> </project>

View File

@@ -2,7 +2,7 @@
<module type="PYTHON_MODULE" version="4"> <module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" /> <content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Python 3.11" jdkType="Python SDK" /> <orderEntry type="jdk" jdkName="Python 3.12 (test1)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
</module> </module>

View File

@@ -8,21 +8,24 @@ from system_tools import create_system_mcp # 如果暂时不用可先不挂
# 先创建 MCP 实例 # 先创建 MCP 实例
test_mcp = create_test_mcp() test_mcp = create_test_mcp()
# system_mcp = create_system_mcp() system_mcp = create_system_mcp()
# 关键:在 Starlette 的 lifespan 中启动 MCP 的 session manager # 关键:在 Starlette 的 lifespan 中启动 MCP 的 session manager
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
async def lifespan(app: Starlette): async def lifespan(app: Starlette):
async with contextlib.AsyncExitStack() as stack: async with contextlib.AsyncExitStack() as stack:
await stack.enter_async_context(test_mcp.session_manager.run()) # await stack.enter_async_context(test_mcp.session_manager.run())
# await stack.enter_async_context(system_mcp.session_manager.run()) await stack.enter_async_context(system_mcp.session_manager.run())
yield # 服务器运行期间 yield # 服务器运行期间
# 退出时自动清理 # 退出时自动清理
app = Starlette( app = Starlette(
lifespan=lifespan, lifespan=lifespan,
routes=[ routes=[
Mount("/test", app=test_mcp.streamable_http_app()), # Mount("/test", app=test_mcp.streamable_http_app()),
# Mount("/system", app=system_mcp.streamable_http_app()), Mount("/system", app=system_mcp.streamable_http_app()),
], ],
) )
# 启动代码为uvicorn main:app --host 0.0.0.0 --port 8000
# url为http://localhost:8000/system

View File

@@ -1,10 +1,13 @@
# system_tools.py # system_tools.py
import os import os
import posixpath
import stat as pystat
from typing import Any
import asyncssh import asyncssh
from mcp.server.fastmcp import FastMCP, Context from mcp.server.fastmcp import FastMCP, Context
from mcp.server.session import ServerSession from mcp.server.session import ServerSession
from lifespan import SharedAppContext # 导入共享上下文的类型定义 from lifespan import SharedAppContext # 保持你的现有导入
def create_system_mcp() -> FastMCP: def create_system_mcp() -> FastMCP:
"""创建一个包含系统操作工具的 MCP 实例。""" """创建一个包含系统操作工具的 MCP 实例。"""
@@ -16,99 +19,390 @@ def create_system_mcp() -> FastMCP:
streamable_http_path="/" streamable_http_path="/"
) )
def _safe_join(sandbox_root: str, relative_path: str) -> str:
"""
将用户提供的相对路径映射到沙箱根目录内的规范化绝对路径。
- 统一使用 POSIX 语义(远端 Linux
- 禁止使用以 '/' 开头的绝对路径
- 禁止 '..' 越界,确保最终路径仍在沙箱内
"""
rel = (relative_path or ".").strip()
# 禁止绝对路径,转为相对
if rel.startswith("/"):
rel = rel.lstrip("/")
# 规范化拼接
combined = posixpath.normpath(posixpath.join(sandbox_root, rel))
# 统一尾部斜杠处理,避免边界判断遗漏
root_norm = sandbox_root.rstrip("/")
# 确保仍在沙箱内
if combined != root_norm and not combined.startswith(root_norm + "/"):
raise ValueError("路径越界:仅允许访问沙箱目录内部")
# 禁止路径中出现 '..'(进一步加固)
parts = [p for p in combined.split("/") if p]
if ".." in parts:
raise ValueError("非法路径:不允许使用 '..' 跨目录")
return combined
# —— 重构后的三个工具list_files / read_file / write_file ——
@system_mcp.tool() @system_mcp.tool()
async def list_files(ctx: Context[ServerSession, dict], path: str = ".") -> str: async def list_files(ctx: Context[ServerSession, dict], path: str = ".") -> list[dict[str, Any]]:
""" """
列出远程服务器安全沙箱路径下的文件和目录。 列出远程沙箱目录中的文件和目录(结构化输出)
Args: Returns:
path: 要查看的相对路径,默认为当前沙箱目录。 list[dict]: [{name, path, is_dir, size, permissions, mtime}]
"""
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: try:
shared_ctx: SharedAppContext = ctx.request_context.lifespan_context["shared_context"] shared_ctx: SharedAppContext = ctx.request_context.lifespan_context["shared_context"]
conn = shared_ctx.ssh_connection conn = shared_ctx.ssh_connection
sandbox_path = shared_ctx.sandbox_path sandbox_root = shared_ctx.sandbox_path
# 安全检查:防止目录穿越
if ".." in file_path.split('/'):
return "错误: 不允许使用 '..' 访问上级目录。"
target_path = os.path.join(sandbox_path, file_path) target = _safe_join(sandbox_root, path)
await ctx.info(f"正在读取远程文件: {target_path}") await ctx.info(f"列出目录:{target}")
items: list[dict[str, Any]] = []
async with conn.start_sftp_client() as sftp: async with conn.start_sftp_client() as sftp:
async with sftp.open(target_path, 'r') as f: # 先列出文件名,再逐个 stat 获取属性
content = await f.read() names = await sftp.listdir(target) # list[str]
return content for name in names:
item_abs = posixpath.join(target, name)
attrs = None
try:
attrs = await sftp.stat(item_abs)
except Exception:
pass
perms = int(attrs.permissions or 0) if attrs else 0
is_dir = bool(pystat.S_ISDIR(perms))
size = int(attrs.size or 0) if attrs and attrs.size is not None else 0
mtime = int(attrs.mtime or 0) if attrs and attrs.mtime is not None else 0
items.append({
"name": name,
# 返回相对路径,便于后续工具继续在沙箱内操作
"path": posixpath.join(path.rstrip("/"), name) if path != "/" else name,
"is_dir": is_dir,
"size": size,
"permissions": perms, # 九进制权限位,例如 0o755以整型传递
"mtime": mtime, # 秒
})
await ctx.debug(f"目录项数量:{len(items)}")
return items
except FileNotFoundError: except FileNotFoundError:
error_message = f"读取文件失败: 文件 '{file_path}' 不存在。" msg = f"目录不存在或不可访问:{path}"
await ctx.error(error_message) await ctx.error(msg)
return error_message return [{"error": msg}]
except Exception as e: except Exception as e:
await ctx.error(f"读取文件时发生未知错误: {e}") msg = f"list_files 失败:{e}"
return f"错误: {e}" await ctx.error(msg)
return [{"error": msg}]
@system_mcp.tool() @system_mcp.tool()
async def write_file(ctx: Context[ServerSession, dict], file_path: str, content: str) -> str: async def read_file(ctx: Context[ServerSession, dict], file_path: str, encoding: str = "utf-8") -> str:
""" """
向远程服务器安全沙箱内的文件写入内容。如果文件已存在,则会覆盖它 读取远程沙箱内指定文件内容
Args: Args:
file_path: 相对于沙箱目录的文件路径 file_path: 相对于沙箱的文件路径
content: 要写入文件的文本内容。 encoding: 文本编码(默认 utf-8
Returns:
文件文本内容(字符串)
""" """
try: try:
shared_ctx: SharedAppContext = ctx.request_context.lifespan_context["shared_context"] shared_ctx: SharedAppContext = ctx.request_context.lifespan_context["shared_context"]
conn = shared_ctx.ssh_connection conn = shared_ctx.ssh_connection
sandbox_path = shared_ctx.sandbox_path sandbox_root = shared_ctx.sandbox_path
# 安全检查 target = _safe_join(sandbox_root, file_path)
if ".." in file_path.split('/'): await ctx.info(f"读取文件:{target}")
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 conn.start_sftp_client() as sftp:
async with sftp.open(target_path, 'w') as f: # 以二进制读取,按 encoding 解码为文本
await f.write(content) async with sftp.open(target, "rb") as f:
return f"内容已成功写入到沙箱文件 '{file_path}'" data = await f.read()
try:
content = data.decode(encoding)
except Exception:
# 如果解码失败,回退为原始字节的 repr
content = data.decode(encoding, errors="replace")
return content
except FileNotFoundError:
msg = f"读取失败:文件不存在 '{file_path}'"
await ctx.error(msg)
return msg
except Exception as e: except Exception as e:
await ctx.error(f"写入文件时发生未知错误: {e}") msg = f"read_file 失败:{e}"
await ctx.error(msg)
return msg
@system_mcp.tool()
async def write_file(
ctx: Context[ServerSession, dict],
file_path: str,
content: str,
encoding: str = "utf-8",
create_parents: bool = True,
) -> dict[str, Any]:
"""
写入远程沙箱文件(默认按需创建父目录)。
Args:
file_path: 相对于沙箱根的文件路径
content: 要写入的文本内容
encoding: 写入编码(默认 utf-8
create_parents: True 则自动创建父目录
Returns:
dict: { path, bytes_written }
"""
try:
shared_ctx: SharedAppContext = ctx.request_context.lifespan_context["shared_context"]
conn = shared_ctx.ssh_connection
sandbox_root = shared_ctx.sandbox_path
target = _safe_join(sandbox_root, file_path)
await ctx.info(f"写入文件:{target}")
# 可选:创建父目录
if create_parents:
parent = posixpath.dirname(target)
if parent and parent != sandbox_root:
# mkdir -p更快且不易出错
await conn.run(f"mkdir -p {conn.escape(parent)}", check=True)
data = content.encode(encoding)
async with conn.start_sftp_client() as sftp:
async with sftp.open(target, "wb") as f:
await f.write(data)
await ctx.debug(f"写入完成:{len(data)} 字节")
return {"path": file_path, "bytes_written": len(data)}
except Exception as e:
msg = f"write_file 失败:{e}"
await ctx.error(msg)
return {"error": msg}
@system_mcp.tool()
async def make_dir(ctx: Context[ServerSession, dict], path: str, parents: bool = True) -> str:
"""
创建目录(默认支持递归创建父级 -p
Args:
path: 相对于沙箱根的目录路径
parents: True 表示使用 `mkdir -p`False 则只创建最后一级
"""
try:
shared_ctx: SharedAppContext = ctx.request_context.lifespan_context["shared_context"]
conn = shared_ctx.ssh_connection
sandbox_root = shared_ctx.sandbox_path
target = _safe_join(sandbox_root, path)
if parents:
await conn.run(f"mkdir -p {conn.escape(target)}", check=True)
else:
async with conn.start_sftp_client() as sftp:
await sftp.mkdir(target)
await ctx.info(f"目录已创建: {target}")
return f"目录已创建: {path}"
except Exception as e:
await ctx.error(f"创建目录失败: {e}")
return f"错误: {e}" return f"错误: {e}"
@system_mcp.tool()
async def delete_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_root = shared_ctx.sandbox_path
target = _safe_join(sandbox_root, file_path)
async with conn.start_sftp_client() as sftp:
await sftp.remove(target)
await ctx.info(f"文件已删除: {target}")
return f"文件已删除: {file_path}"
except FileNotFoundError:
msg = f"删除失败:文件不存在 '{file_path}'"
await ctx.error(msg)
return msg
except Exception as e:
await ctx.error(f"删除文件失败: {e}")
return f"错误: {e}"
@system_mcp.tool()
async def delete_dir(ctx: Context[ServerSession, dict], dir_path: str, recursive: bool = False) -> str:
"""
删除目录。
Args:
dir_path: 相对于沙箱根的目录路径
recursive: True 则递归删除rm -rfFalse 仅删除空目录rmdir
"""
try:
shared_ctx: SharedAppContext = ctx.request_context.lifespan_context["shared_context"]
conn = shared_ctx.ssh_connection
sandbox_root = shared_ctx.sandbox_path
target = _safe_join(sandbox_root, dir_path)
if recursive:
await conn.run(f"rm -rf {conn.escape(target)}", check=True)
else:
async with conn.start_sftp_client() as sftp:
await sftp.rmdir(target)
await ctx.info(f"目录已删除: {target}")
return f"目录已删除: {dir_path}"
except FileNotFoundError:
msg = f"删除失败:目录不存在 '{dir_path}' 或非空"
await ctx.error(msg)
return msg
except Exception as e:
await ctx.error(f"删除目录失败: {e}")
return f"错误: {e}"
@system_mcp.tool()
async def move_path(ctx: Context[ServerSession, dict], src: str, dst: str, overwrite: bool = True) -> str:
"""
移动/重命名文件或目录。
Args:
src: 源路径(相对于沙箱根)
dst: 目标路径(相对于沙箱根)
overwrite: True 使用 mv -f 覆盖同名目标
"""
try:
shared_ctx: SharedAppContext = ctx.request_context.lifespan_context["shared_context"]
conn = shared_ctx.ssh_connection
sandbox_root = shared_ctx.sandbox_path
src_abs = _safe_join(sandbox_root, src)
dst_abs = _safe_join(sandbox_root, dst)
flags = "-f" if overwrite else ""
await conn.run(f"mv {flags} {conn.escape(src_abs)} {conn.escape(dst_abs)}", check=True)
await ctx.info(f"已移动: {src_abs} -> {dst_abs}")
return f"已移动: {src} -> {dst}"
except FileNotFoundError:
msg = f"移动失败:源不存在 '{src}'"
await ctx.error(msg)
return msg
except Exception as e:
await ctx.error(f"移动失败: {e}")
return f"错误: {e}"
@system_mcp.tool()
async def copy_path(
ctx: Context[ServerSession, dict],
src: str,
dst: str,
recursive: bool = True,
overwrite: bool = True,
) -> str:
"""
复制文件或目录。
Args:
src: 源路径(相对于沙箱根)
dst: 目标路径(相对于沙箱根)
recursive: True 则使用 -r 递归复制目录
overwrite: True 使用 -f 覆盖同名目标
"""
try:
shared_ctx: SharedAppContext = ctx.request_context.lifespan_context["shared_context"]
conn = shared_ctx.ssh_connection
sandbox_root = shared_ctx.sandbox_path
src_abs = _safe_join(sandbox_root, src)
dst_abs = _safe_join(sandbox_root, dst)
flags = []
if recursive:
flags.append("-r")
if overwrite:
flags.append("-f")
await conn.run(f"cp {' '.join(flags)} {conn.escape(src_abs)} {conn.escape(dst_abs)}", check=True)
await ctx.info(f"已复制: {src_abs} -> {dst_abs}")
return f"已复制: {src} -> {dst}"
except FileNotFoundError:
msg = f"复制失败:源不存在 '{src}'"
await ctx.error(msg)
return msg
except Exception as e:
await ctx.error(f"复制失败: {e}")
return f"错误: {e}"
@system_mcp.tool()
async def exists(ctx: Context[ServerSession, dict], path: str) -> bool:
"""
判断路径(文件/目录)是否存在。
Args:
path: 相对于沙箱根的路径
Returns:
True/False
"""
try:
shared_ctx: SharedAppContext = ctx.request_context.lifespan_context["shared_context"]
conn = shared_ctx.ssh_connection
sandbox_root = shared_ctx.sandbox_path
target = _safe_join(sandbox_root, path)
async with conn.start_sftp_client() as sftp:
await sftp.stat(target)
return True
except FileNotFoundError:
return False
except Exception as e:
await ctx.error(f"exists 检查失败: {e}")
return False
@system_mcp.tool()
async def stat_path(ctx: Context[ServerSession, dict], path: str) -> dict:
"""
查看远程路径属性(结构化输出)。
Args:
path: 相对于沙箱根的路径
Returns:
dict: { path, size, is_dir, permissions, mtime }
"""
try:
shared_ctx: SharedAppContext = ctx.request_context.lifespan_context["shared_context"]
conn = shared_ctx.ssh_connection
sandbox_root = shared_ctx.sandbox_path
target = _safe_join(sandbox_root, path)
async with conn.start_sftp_client() as sftp:
attrs = await sftp.stat(target)
perms = attrs.permissions or 0
return {
"path": target,
"size": int(attrs.size or 0),
"is_dir": bool(pystat.S_ISDIR(perms)),
"permissions": perms, # 九进制权限位0o755
"mtime": int(attrs.mtime or 0), # 秒
}
except FileNotFoundError:
msg = f"路径不存在: {path}"
await ctx.error(msg)
return {"error": msg}
except Exception as e:
await ctx.error(f"stat 失败: {e}")
return {"error": str(e)}
return system_mcp return system_mcp

10
mcp/uvicorn/main.py Normal file
View File

@@ -0,0 +1,10 @@
# main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def read_root():
return {"message": "Hello, World!"}