From e285dc0f66ade069672ee07c26f3e44ec74f7431 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 12:17:27 +0800 Subject: [PATCH] feat: add README.md --- README.md | 482 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 482 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..63a71d8 --- /dev/null +++ b/README.md @@ -0,0 +1,482 @@ +# spark-executor-mcp + +`spark-executor-mcp` 是一个基于 **FastAPI + fastapi-mcp** 的 Python MCP 服务集合,主要用于让 LLM Agent 以受控方式操作 Spark-on-YARN:保存集群连接、写入或选择 PySpark 脚本、准备提交、二次确认提交、查询状态、读取日志和终止作业。 + +项目同时内置一个受沙箱限制的 `files_mcp` 服务,用于在指定目录内执行文件读写、查询、移动、复制等操作。 + +## 核心能力 + +### Spark Executor MCP + +- 管理 Spark/YARN 连接配置。 +- 将 LLM 生成的 PySpark 代码写入服务端文件系统。 +- 以 **两步提交** 方式运行 Spark 作业: + 1. `prepare_submit_job`:只生成待确认提交记录,不执行 `spark-submit`。 + 2. `confirm_submit_job`:用户二次确认后才真正调用 `spark-submit`。 +- 查询 Spark/YARN 作业状态、终态结果和聚合日志。 +- 通过本地 `job_id` 或 YARN `application_id` 查询同一个作业。 +- Kill 正在运行的 YARN application。 +- 持久化连接、待提交作业和已提交作业记录。 + +### Files MCP + +- 在 `FILES_MCP_ROOT` 指定的沙箱目录内创建、读取、更新、删除文件。 +- 支持目录列举、路径状态查询、glob 搜索、移动和复制。 +- 路径会经过沙箱校验,避免访问沙箱外文件。 +- 文本内容限制为 UTF-8,单文件读写默认有大小上限。 + +### 可插拔 MCP 服务 + +- 根应用启动时会自动发现并挂载内置 MCP 服务。 +- 可通过 `MCP_SERVICES` 环境变量选择启用哪些服务。 +- 新增 MCP 服务只需要提供一个 FastAPI 子应用和 `SERVICE: McpService` 声明。 + +## 技术栈 + +| 类别 | 技术 | +| --- | --- | +| 语言 | Python 3.12+ | +| Web 框架 | FastAPI | +| MCP 暴露 | fastapi-mcp | +| 数据模型 | Pydantic v2 | +| HTTP 客户端 | httpx、httpx-kerberos | +| Spark 集成 | `spark-submit`、YARN REST API、`yarn logs` | +| 日志 | loguru | +| 进程管理 | uvicorn、gunicorn | +| 包管理 | uv | +| 测试 | pytest、pytest-mock | +| 容器 | Docker、Docker Compose | + +## 快速开始 + +### 1. 安装依赖 + +```bash +uv sync +``` + +项目依赖在 `pyproject.toml` 和 `uv.lock` 中固定,默认 PyPI 镜像配置为清华源。 + +### 2. 启动服务 + +开发环境直接运行: + +```bash +uv run main.py +``` + +默认监听: + +```text +0.0.0.0:8000 +``` + +健康检查: + +```bash +curl http://localhost:8000/health +``` + +预期返回: + +```json +{"status":"ok"} +``` + +### 3. MCP 端点 + +服务启动后会挂载内置 MCP 服务: + +| 服务 | MCP endpoint | 说明 | +| --- | --- | --- | +| Spark Executor | `http://localhost:8000/spark-executor-mcp` | Spark-on-YARN 作业管理 | +| Files MCP | `http://localhost:8000/files-mcp` | 沙箱文件系统工具 | + +MCP 客户端需要先执行 `initialize` 握手,拿到 `mcp-session-id` 后,再调用 `tools/list` 和 `tools/call`。不要跳过握手。 + +## MCP 工具概览 + +### Spark Executor 工具 + +| 工具 | 作用 | +| --- | --- | +| `save_connection` | 保存或更新一个 Spark/YARN 连接配置 | +| `list_connections` | 列出所有连接配置 | +| `get_connection` | 按名称读取连接配置 | +| `delete_connection` | 删除连接配置 | +| `write_job_file` | 将 LLM 已生成的 PySpark 代码写入服务端文件 | +| `read_job_file` | 读取已存在的 PySpark 脚本内容 | +| `update_job_file` | 覆盖更新已存在的 PySpark 脚本 | +| `prepare_submit_job` | 创建待确认提交记录,不执行 `spark-submit` | +| `update_pending_job` | 在提交前更新待确认作业参数 | +| `list_pending_jobs` | 列出待提交/已提交/已取消/失败的 pending 记录 | +| `get_pending_job` | 查看单个 pending 记录 | +| `cancel_pending_job` | 取消还未提交的 pending 记录 | +| `confirm_submit_job` | 真正执行 `spark-submit` | +| `get_job_status` | 查询 YARN 当前状态 | +| `get_job_result` | 查询终态结果视图 | +| `get_job_logs` | 拉取 YARN 聚合日志 | +| `kill_job` | Kill YARN application | + +### Files MCP 工具 + +| 工具 | 作用 | +| --- | --- | +| `create_file` | 在沙箱内创建文本文件 | +| `read_file` | 读取沙箱内文本文件 | +| `update_file` | 覆盖更新文本文件 | +| `delete_file` | 删除文件或目录 | +| `list_dir` | 列出目录内容 | +| `stat` | 查看路径类型、大小、mtime、权限等信息 | +| `search` | 使用 glob pattern 搜索路径 | +| `move_file` | 移动或重命名文件/目录 | +| `copy_file` | 复制文件/目录 | + +## Spark 作业提交流程 + +Spark 作业提交是故意设计成两步,不要绕过。 + +### 1. 保存连接 + +先保存一个连接配置,后续提交作业时通过 `connection` 名称引用它。 + +示例参数: + +```json +{ + "name": "prod-yarn", + "master": "yarn", + "deploy_mode": "cluster", + "yarn_rm_url": "http://yarn-rm.example.com:8088", + "spark_conf": { + "spark.yarn.queue": "default" + } +} +``` + +调用工具: + +```text +save_connection +``` + +### 2. 写入或选择 PySpark 脚本 + +如果脚本由 LLM 生成,必须先调用: + +```text +write_job_file +``` + +该工具会把代码写入 `SPARK_EXECUTOR_JOBS_DIR`,并返回真实存在于服务端文件系统中的 `script_path`。 + +如果脚本已经存在,需要确保传入的是容器或服务进程能访问到的真实路径。只存在于 Agent 上下文里的“虚构路径”会被拒绝。 + +### 3. 准备提交 + +调用: + +```text +prepare_submit_job +``` + +这个步骤会: + +- 读取并快照指定 `Connection` 的 `master`、`deploy_mode`、`spark_conf`、`yarn_rm_url`。 +- 创建 `PendingSubmission` 持久化记录。 +- 返回 `pending_id`。 +- **不会调用 `spark-submit`。** + +注意:如果准备后再修改连接配置,已经创建的 pending job 不会被重新定向到新连接。快照就是快照,别让状态到处乱飞。 + +### 4. 二次确认提交 + +用户确认后调用: + +```text +confirm_submit_job +``` + +参数只需要 `pending_id`。该步骤才会真正调用 `spark-submit`,成功后会创建 `Job` 记录,并返回本地 `job_id` 和 YARN `application_id`。 + +### 5. 查询、读日志或终止 + +后续工具的 `job_id` 参数同时支持: + +- 本地 12 位十六进制 `job_id`。 +- YARN `application_id`,例如 `application_17400000001_0001`。 + +可用工具: + +```text +get_job_status +get_job_result +get_job_logs +kill_job +``` + +## 配置项 + +### 应用配置 + +| 环境变量 | 默认值 | 说明 | +| --- | --- | --- | +| `SPARK_EXECUTOR_DATA_DIR` | `./data` | 连接、pending job、job 记录和日志的基础目录 | +| `SPARK_EXECUTOR_JOBS_DIR` | `/jobs` | `write_job_file` 写入 PySpark 脚本的位置 | +| `SPARK_EXECUTOR_LOG_DIR` | `/logs` | loguru 文件日志目录 | +| `YARN_RESOURCE_MANAGER_URL` | 空 | 当 Connection 或 Job 快照中没有 `yarn_rm_url` 时的 fallback | +| `SPARK_EXECUTOR_LOG_LEVEL` | `DEBUG` | stderr 和 info 日志级别;debug 文件始终记录 DEBUG | +| `SPARK_EXECUTOR_SPARK_SUBMIT_BIN` | `spark-submit` | 调用 Spark 客户端的命令名或 wrapper 路径 | +| `SPARK_EXECUTOR_SSL_VERIFY_DEFAULT` | `true` | YARN REST 默认是否校验证书 | +| `SPARK_EXECUTOR_SSL_CA_BUNDLE_DEFAULT` | 空 | YARN REST 默认 CA bundle | +| `MCP_SERVICES` | 未设置 | 服务白名单;例如 `spark_executor,files`。未设置表示启用全部内置服务,空字符串表示不启用任何服务 | +| `FILES_MCP_ROOT` | Docker 中为 `/app/data/files` | Files MCP 能访问的唯一沙箱根目录 | + +### Gunicorn 配置 + +| 环境变量 | 默认值 | 说明 | +| --- | --- | --- | +| `GUNICORN_BIND` | `0.0.0.0:8000` | 监听地址 | +| `GUNICORN_WORKERS` | `2` | worker 进程数 | +| `GUNICORN_THREADS` | `1` | 每个 worker 的线程数 | +| `GUNICORN_TIMEOUT` | `120` | 请求超时时间,YARN 日志可能较慢 | +| `GUNICORN_GRACEFUL_TIMEOUT` | `30` | 优雅退出超时 | +| `GUNICORN_KEEPALIVE` | `5` | keepalive 秒数 | +| `GUNICORN_LOGLEVEL` | `info` | gunicorn 日志级别 | + +> **重要:生产上建议 `GUNICORN_WORKERS=1`,除非你在前面放了 sticky-session 反向代理。** +> +> MCP session 当前存储在 worker 进程内存里。多 worker 下,`initialize` 可能落到 worker A,下一次 `tools/list` 落到 worker B,然后 B 不认识这个 `mcp-session-id`,就会返回 `Session not found` 或 `Invalid or expired session ID`。这不是玄学,是进程内状态被 round-robin 打散了。 + +## 数据持久化 + +默认数据目录是 `./data/`,可通过 `SPARK_EXECUTOR_DATA_DIR` 覆盖。 + +| 路径 | 说明 | +| --- | --- | +| `data/connections.json` | 保存的 `Connection` 记录 | +| `data/pending_jobs.json` | `PendingSubmission` 记录 | +| `data/jobs.json` | 已提交 `Job` 记录 | +| `data/jobs.json.lock` | `jobs.json` 跨进程写入锁 | +| `data/jobs/` | 默认 PySpark 脚本写入目录 | +| `data/logs/debug/YYYY-MM-DD.log` | DEBUG 日志,按天轮转,保留 30 天,gzip 压缩 | +| `data/logs/info/YYYY-MM-DD.log` | INFO 日志,按天轮转,保留 30 天,gzip 压缩 | +| `data/files/` | Docker 默认 Files MCP 沙箱目录 | + +`connections.json`、`pending_jobs.json` 和 `jobs.json` 都使用简单 JSON 文件持久化。当前这是务实方案:足够简单,方便排查,也方便未来迁移到 SQLite。 + +## Docker 部署 + +### 1. 准备 Hadoop/YARN 配置 + +在项目根目录创建或挂载: + +```text +./hadoop-conf/ +``` + +至少应包含目标集群匹配的: + +```text +core-site.xml +yarn-site.xml +hdfs-site.xml +``` + +这些文件会以只读方式挂载到容器内: + +```text +/etc/hadoop/conf +``` + +### 2. 准备环境变量 + +复制模板: + +```bash +cp .env.example .env +``` + +按部署环境修改 `.env`,至少确认: + +```text +YARN_RESOURCE_MANAGER_URL= +SPARK_EXECUTOR_DATA_DIR= +SPARK_EXECUTOR_JOBS_DIR= +FILES_MCP_ROOT= +GUNICORN_WORKERS=1 +``` + +`GUNICORN_WORKERS=1` 不是装饰品。除非你真的配置了 sticky-session,否则不要把它调大。 + +### 3. 启动容器 + +```bash +docker compose up -d --build +``` + +查看日志: + +```bash +docker compose logs -f mcp-server +``` + +停止服务: + +```bash +docker compose down +``` + +## 测试 + +运行完整测试: + +```bash +uv run pytest +``` + +运行单个测试文件: + +```bash +uv run pytest tests/unit/test_submit_tool.py -v +``` + +按名称过滤: + +```bash +uv run pytest -v -k cancel_pending +``` + +测试目录分为: + +```text +tests/unit/ # 单元测试 +tests/integration/ # FastAPI / MCP 集成测试 +``` + +## 目录结构 + +```text +main.py # 根 FastAPI app;lifespan 中发现并挂载 MCP 服务 +common/ + config.py # 环境变量配置入口 + factory.py # FastApiMCP 初始化封装 + logging.py # loguru 配置 + mcp_service.py # MCP 服务注册、发现、过滤 + sql_guard.py # PySpark/SQL 安全策略 +spark_executor/ + server.py # Spark Executor FastAPI 子应用和 MCP tool routes + service.py # Spark Executor MCP 服务声明 + models.py # Pydantic 数据模型 + core/ # spark-submit、YARN client、持久化 store、日志解析 + tools/ # MCP 工具业务函数和请求模型 +files_mcp/ + server.py # Files MCP FastAPI 子应用和 MCP tool routes + service.py # Files MCP 服务声明 + core/ # 文件系统操作和路径沙箱校验 + tools/ # 文件工具业务函数和请求模型 +data/ # 默认运行时数据目录,gitignored +tests/ # 单元测试和集成测试 +Dockerfile # 生产镜像 +docker-compose.yml # 本地/生产容器启动模板 +gunicorn.conf.py # gunicorn 运行配置 +pyproject.toml # Python 项目和依赖声明 +uv.lock # uv 锁文件 +``` + +## 开发维护说明 + +### 新增一个 MCP 服务 + +新增服务的约定很简单,不要搞复杂框架: + +1. 创建一个 Python package,例如 `my_service/`。 +2. 在 package 内提供 FastAPI app。 +3. 新增 `my_service/service.py`,导出 `SERVICE: McpService`。 +4. 把 `"my_service.service"` 加到 `common/mcp_service.py` 的 `BUILTIN_SERVICES`。 +5. 启动时根应用会自动导入、校验并挂载。 + +示例: + +```python +from common.mcp_service import McpService +from my_service import app + +SERVICE = McpService( + name="my_service", + app=app, + mount_path="/my-service-mcp", +) +``` + +### 新增 MCP 工具路由 + +所有 MCP 工具路由都应使用 **Pydantic body model**,不要用 query 参数传复杂对象。 + +正确形态: + +```python +@app.post("/tool_name", operation_id="tool_name") +def _tool_name(req: ToolNameRequest): + return tool_name(**req.model_dump()) +``` + +原因很简单:`fastapi-mcp` 的 `tools/call` 会把参数作为 JSON body 传给 FastAPI。复杂 dict 如果走 query 参数,会变成字符串,最后 422。 + +### 错误处理约定 + +工具函数内部保持干净: + +- 找不到资源时抛 `KeyError`。 +- 非法状态或非法参数抛 `ValueError`。 +- 路由层由 FastAPI exception handler 转成 HTTP 状态码。 + +当前约定: + +| 异常 | HTTP 状态 | +| --- | --- | +| `KeyError` | 404 | +| `ValueError` | 400 | +| Pydantic validation error | 422 | +| `FileExistsError`(Files MCP) | 409 | + +不要在每个 route body 里手写一堆 `try/except`。那是噪音,不是设计。 + +### 文件头约定 + +新增 Python 文件时保持当前项目风格: + +```python +# coding=utf-8 +""" +@Time :YYYY/M/D +@Author :tao.chen +""" +``` + +### 日志约定 + +使用项目统一 logger: + +```python +from common.logging import logger +``` + +不要在业务模块里自己创建一套 logger。 + +## 注意事项 + +1. **两步提交是核心设计,不是可选项。** `prepare_submit_job` 只创建 pending 记录,`confirm_submit_job` 才能执行 `spark-submit`。 +2. **不要提交虚构脚本路径。** LLM 生成的 PySpark 必须先用 `write_job_file` 写入服务端文件系统。 +3. **pending job 快照连接配置。** prepare 后再修改 Connection,不会影响已经创建的 pending job。 +4. **MCP 请求需要 session affinity。** gunicorn 多 worker 会打散内存 session;默认部署请设 `GUNICORN_WORKERS=1`。 +5. **`job_id` 参数兼容两种 ID。** 本地 `job_id` 和 YARN `application_id` 都可以传。 +6. **Files MCP 只能访问沙箱目录。** 想让它访问某个目录,就把那个目录挂载并设置为 `FILES_MCP_ROOT`,不要绕路径校验。 +7. **新工具必须使用 Pydantic body model。** 这是 fastapi-mcp 的调用形态决定的,不是个人偏好。 + +## 当前状态 + +- Spark Executor MCP:已实现连接管理、两步提交、作业状态/结果/日志/Kill、PySpark 文件写入/读取/更新。 +- Files MCP:已实现沙箱文件 CRUD、搜索、移动、复制。 +- 持久化:当前使用 JSON 文件,`jobs.json` 带文件锁;后续可迁移到 SQLite。 +- 测试:包含单元测试和集成测试,修改后运行 `uv run pytest`。