diff --git a/.gitignore b/.gitignore index f3601b6..b7a99ef 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ wheels/ # Logs *.log *.out -.pytest_cache/ \ No newline at end of file +.pytest_cache/ +volumes \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index d33ae1c..ff9c14d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ COPY pyproject.toml uv.lock /app ENV UV_PYPI_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple # 安装 Python 依赖 # 优先使用 uv(如有 uv.lock),否则用 pip 安装 requirements.txt -RUN pip install -i https://mirrors.aliyun.com/pypi/simple uv && uv sync +RUN uv sync # 复制项目文件到容器 COPY ./src/ /app diff --git a/README.md b/README.md index 75ff3e0..f368f62 100644 --- a/README.md +++ b/README.md @@ -1,183 +1,184 @@ -# Customized-Elasticsearch-MCP-Server - -基于 Python `FastMCP` 和 `FastAPI` 的 自定义Elasticsearch MCP 服务Demo。 - -## 特性 -- 支持关键词搜索、二次过滤、按 ID 查询 -- 基于 FastMCP 提供 MCP 协议工具:`search_news`、`search_news_with_secondary_filter`、`read_single_news` -- Prometheus 监控集成(`starlette_prometheus`) -- Docker 与 Docker Compose 支持 -- 包含单元测试和集成测试 -![](images/mcp-server.png) - -## 目录结构 - -``` -. -├── src -│ └── news_mcp_server -│ ├── app.py # FastAPI 应用入口 -│ ├── mcp_server.py # FastMCP 服务定义 -│ ├── clients -│ │ └── elastic_client.py # 异步 ES 客户端 -│ ├── services -│ │ └── news_service.py # 业务逻辑封装 -│ ├── middlewares -│ │ ├── auth.py # 简单 Token 鉴权 -│ │ ├── monitor.py # Prometheus 中间件 -│ │ └── session.py # Session 中间件 -│ ├── schemas -│ │ └── news.py # Pydantic 数据模型 -│ └── config -│ └── settings.py # 环境配置 -├── tests -│ ├── unit # 单元测试 -│ └── integration # 集成测试 -├── Dockerfile # 容器构建配置 -├── docker-compose.yml # Docker Compose 配置 -├── Makefile # 常用命令集 -├── env.sample # 环境变量示例 -├── pyproject.toml # Python 项目配置 -└── README.md # 项目说明 -``` - -## 环境变量 - -复制 `env.sample` 为 `.env` 并配置: - -```bash -cp env.sample .env -# 编辑 .env,至少需要以下变量: -# ES_API_KEY Elasticsearch API Key(必填) -# ES_HOST Elasticsearch 主机 URL(含端口)(必填) -# ES_INDEX Elasticsearch 索引名称(必填) -# API_KEY MCP 服务访问令牌(Bearer Token)(必填) -``` - - -## 安装与运行 - -### 本地开发 - -1. 克隆项目并进入目录: - ```bash - git clone https://github.com/Coolgiserz/customized-elasticsearch-mcp-server - cd customized-elasticsearch-mcp-server - ``` -2. 创建并激活虚拟环境: - ```bash - python3.12 -m venv .venv - source .venv/bin/activate - ``` -3. 安装依赖: - ```bash - pip install -U pip - pip install uv[all] - uv sync - ``` -4. 启动服务(热重载): - ```bash - make dev - # 或者 - uv run uvicorn src.main:app --reload --host 0.0.0.0 --port 9009 - ``` -5. 访问: - - 健康检查: `GET http://localhost:9009/healthcheck` - - Prometheus 指标: `GET http://localhost:9009/metrics` - -### Docker & Docker Compose - -- 构建镜像: - ```bash - make build - ``` -- 使用 Docker Compose 一键启动: - ```bash - make docker-build - make docker-up - ``` -- 停止服务: - ```bash - make docker-down - ``` -- 日志查看: - ```bash - make docker-logs - ``` -- 默认映射端口:`28000 -> 8000` - -## API 使用 - -### Healthcheck - -``` -GET /healthcheck -Response: {"status":"ok"} -``` - -### Prometheus Metrics - -``` -GET /metrics -``` - -### MCP 工具调用示例 - -使用 Python FastMCP 客户端调用 `search_news`: - -```python -import asyncio -from fastmcp import Client - -async def main(): - # 将 URL 替换为实际部署地址 - async with Client("http://localhost:9009/mcp-server") as client: - tools = await client.list_tools() - print("可用工具:", tools) - - result = await client.call_tool("search_news", { - "query": "人工智能", - "max_results": 5, - "date_from": "2024-01-01", - "date_to": "2024-12-31" - }) - print(result) - -asyncio.run(main()) -``` - -## 测试 - -- 单元测试: - ```bash - make test - ``` -- 集成测试: - ```bash - pytest -q -m "integration" - ``` - -## Makefile 常用命令 - -```bash -make init # 初始化依赖 -env sync # 同步依赖(uv sync) -make dev # 启动开发模式 -make lint # 代码检查 -make format # 代码格式化 -make test # 运行测试 -dmake build # 构建 Docker 镜像 -``` - -## TODO -- 引入基于 JWT 或 OAuth2 的更安全鉴权机制 -- 支持多进程/多实例共享会话(如使用 Redis Session Store) -- 优化 Elasticsearch 查询性能,添加缓存层(Redis) -- 集成请求限流和熔断策略,以防止高频或恶意请求 -- 增加端到端集成测试覆盖,并配置 CI/CD 流水线 -- 支持 ES 聚合查询与热门关键词统计功能 -- 提供 Swagger UI 或 Postman 集合示例 - -## License - -本项目采用 MIT 许可证,详见 LICENSE。 +# Customized-Elasticsearch-MCP-Server + +基于 Python `FastMCP` 和 `FastAPI` 的 自定义Elasticsearch MCP 服务Demo。 + +## 特性 +- 支持关键词搜索、二次过滤、按 ID 查询 +- 基于 FastMCP 提供 MCP 协议工具:`search_news`、`search_news_with_secondary_filter`、`read_single_news` +- Prometheus 监控集成(`starlette_prometheus`) +- 基于 Redis 的服务端 Session 存储(`RedisSessionMiddleware`) +- Docker 与 Docker Compose 支持 +- 包含单元测试和集成测试 +![](images/mcp-server.png) + +## 目录结构 + +``` +. +├── src +│ └── news_mcp_server +│ ├── app.py # FastAPI 应用入口 +│ ├── mcp_server.py # FastMCP 服务定义 +│ ├── clients +│ │ └── elastic_client.py # 异步 ES 客户端 +│ ├── services +│ │ └── news_service.py # 业务逻辑封装 +│ ├── middlewares +│ │ ├── auth.py # 简单 Token 鉴权 +│ │ ├── monitor.py # Prometheus 中间件 +│ │ └── session.py # Session 中间件 +│ ├── schemas +│ │ └── news.py # Pydantic 数据模型 +│ └── config +│ └── settings.py # 环境配置 +├── tests +│ ├── unit # 单元测试 +│ └── integration # 集成测试 +├── Dockerfile # 容器构建配置 +├── docker-compose.yml # Docker Compose 配置 +├── Makefile # 常用命令集 +├── env.sample # 环境变量示例 +├── pyproject.toml # Python 项目配置 +└── README.md # 项目说明 +``` + +## 环境变量 + +复制 `env.sample` 为 `.env` 并配置: + +```bash +cp env.sample .env +# 编辑 .env,至少需要以下变量: +# ES_API_KEY Elasticsearch API Key(必填) +# ES_HOST Elasticsearch 主机 URL(含端口)(必填) +# ES_INDEX Elasticsearch 索引名称(必填) +# API_KEY MCP 服务访问令牌(Bearer Token)(必填) +``` + + +## 安装与运行 + +### 本地开发 + +1. 克隆项目并进入目录: + ```bash + git clone https://github.com/Coolgiserz/customized-elasticsearch-mcp-server + cd customized-elasticsearch-mcp-server + ``` +2. 创建并激活虚拟环境: + ```bash + python3.12 -m venv .venv + source .venv/bin/activate + ``` +3. 安装依赖: + ```bash + pip install -U pip + pip install uv[all] + uv sync + ``` +4. 启动服务(热重载): + ```bash + make dev + # 或者 + uv run uvicorn src.main:app --reload --host 0.0.0.0 --port 9009 + ``` +5. 访问: + - 健康检查: `GET http://localhost:9009/healthcheck` + - Prometheus 指标: `GET http://localhost:9009/metrics` + +### Docker & Docker Compose + +- 构建镜像: + ```bash + make build + ``` +- 使用 Docker Compose 一键启动: + ```bash + make docker-build + make docker-up + ``` +- 停止服务: + ```bash + make docker-down + ``` +- 日志查看: + ```bash + make docker-logs + ``` +- 默认映射端口:`28000 -> 8000` + +## API 使用 + +### Healthcheck + +``` +GET /healthcheck +Response: {"status":"ok"} +``` + +### Prometheus Metrics + +``` +GET /metrics +``` + +### MCP 工具调用示例 + +使用 Python FastMCP 客户端调用 `search_news`: + +```python +import asyncio +from fastmcp import Client + +async def main(): + # 将 URL 替换为实际部署地址 + async with Client("http://localhost:9009/mcp-server") as client: + tools = await client.list_tools() + print("可用工具:", tools) + + result = await client.call_tool("search_news", { + "query": "人工智能", + "max_results": 5, + "date_from": "2024-01-01", + "date_to": "2024-12-31" + }) + print(result) + +asyncio.run(main()) +``` + +## 测试 + +- 单元测试: + ```bash + make test + ``` +- 集成测试: + ```bash + pytest -q -m "integration" + ``` + +## Makefile 常用命令 + +```bash +make init # 初始化依赖 +env sync # 同步依赖(uv sync) +make dev # 启动开发模式 +make lint # 代码检查 +make format # 代码格式化 +make test # 运行测试 +dmake build # 构建 Docker 镜像 +``` + +## TODO +- 引入基于 JWT 或 OAuth2 的更安全鉴权机制 +- 支持多进程/多实例共享会话(如使用 Redis Session Store) +- 优化 Elasticsearch 查询性能,添加缓存层(Redis) +- 集成请求限流和熔断策略,以防止高频或恶意请求 +- 增加端到端集成测试覆盖,并配置 CI/CD 流水线 +- 支持 ES 聚合查询与热门关键词统计功能 +- 提供 Swagger UI 或 Postman 集合示例 + +## License + +本项目采用 MIT 许可证,详见 LICENSE。 diff --git a/docker-compose.yml b/docker-compose.yml index cdcbd4f..ad2e25f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,32 @@ -services: - es_news_mcp_server: - image: customized-elasticsearch-mcp-server:latest - container_name: es_news_mcp_server - command: /app/.venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 - working_dir: /app - env_file: - - .env - ports: - - "28000:8000" - healthcheck: - test: ["CMD", "curl", "-f", "http://127.0.0.1:8000/healthcheck"] - interval: 30s - timeout: 15s +services: + es_news_mcp_server: + image: customized-elasticsearch-mcp-server:latest + container_name: es_news_mcp_server + command: /app/.venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 + working_dir: /app + env_file: + - .env + volumes: + - ./volumes:/volumes + ports: + - "28000:8000" + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:8000/healthcheck"] + interval: 30s + timeout: 15s + retries: 3 + restart: always + depends_on: + redis: + condition: service_healthy + redis: + image: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/redis:7.4 + container_name: es_news_mcp_server_redis + ports: + - "6379" + restart: always + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 15s retries: 3 \ No newline at end of file diff --git a/env.sample b/env.sample index 8e85d1c..1100e4c 100644 --- a/env.sample +++ b/env.sample @@ -1,4 +1,13 @@ +# ES 配置 +ES_HOST=xxxxxx +ES_INDEX=xxxxx ES_API_KEY=xxxxxx API_KEY=YOUR_API_KEY -ES_HOST=xxxxxx -ES_INDEX=xxxxx \ No newline at end of file +SESSION_SECREY_KEY=YOUR_SESSION_SECRET_KEY + +# REDIS配置 +REDIS_URL=redis://redis:6379/0 + +# 速率限制配置 +RATE_LIMIT_MAX=100 # 单个 IP 在窗口内最大请求数 +RATE_LIMIT_WINDOW=60 # 限流时间窗口(秒) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a8a18c0..a47dabd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "starlette-prometheus>=0.10.0", "itsdangerous>=2.2.0", "gunicorn>=23.0.0", + "redis>=6.2.0", ] [[tool.uv.index]] diff --git a/src/news_mcp_server/app.py b/src/news_mcp_server/app.py index f247fc4..cd72262 100644 --- a/src/news_mcp_server/app.py +++ b/src/news_mcp_server/app.py @@ -4,14 +4,20 @@ from .mcp_server import mcp_app from .middlewares.auth import SimpleAuthMiddleware from .middlewares.monitor import MonitorMiddleware, metrics -from .middlewares.session import SessionMiddleware +from .middlewares.rate_limit import RedisRateLimitMiddleware from .config.settings import app_settings -middlewares = [Middleware(SessionMiddleware, - secret_key=app_settings.SESSION_SECREY_KEY, - max_age=3600), - Middleware(MonitorMiddleware), - Middleware(SimpleAuthMiddleware) - ] + +middlewares = [ + # IP 速率限制 + Middleware(RedisRateLimitMiddleware, + redis_url=app_settings.REDIS_URL, + max_requests=app_settings.RATE_LIMIT_MAX, + window_seconds=app_settings.RATE_LIMIT_WINDOW), + # 监控中间件 + Middleware(MonitorMiddleware), + # 简单认证 + Middleware(SimpleAuthMiddleware), +] allow_origins = [ "http://localhost:8000", diff --git a/src/news_mcp_server/config/settings.py b/src/news_mcp_server/config/settings.py index 682a437..0441496 100644 --- a/src/news_mcp_server/config/settings.py +++ b/src/news_mcp_server/config/settings.py @@ -10,7 +10,11 @@ class ApplicationSettings(BaseModel): CORS_HEADERS: list = ["*"] CORS_ALLOW_CREDENTIALS: bool = True API_KEY: str | None = os.getenv("NEWS_MCP_API_KEY") - SESSION_SECREY_KEY: str = os.getenv("SESSION_SECREY_KEY") + SESSION_SECRET_KEY: str = os.getenv("SESSION_SECRET_KEY") + REDIS_URL: str = os.getenv("REDIS_URL", "redis://redis:6379/0") + # IP 限流配置 + RATE_LIMIT_MAX: int = int(os.getenv("RATE_LIMIT_MAX", 100)) # 单个 IP 在时间窗口内最大请求数 + RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", 60)) # 限流窗口时长(秒) TRANSPORT: str = "streamable-http" diff --git a/src/news_mcp_server/mcp_server.py b/src/news_mcp_server/mcp_server.py index a5f5f07..6d499ca 100644 --- a/src/news_mcp_server/mcp_server.py +++ b/src/news_mcp_server/mcp_server.py @@ -1,9 +1,11 @@ from typing import List from fastmcp import FastMCP, Context +from fastmcp.server.http import Middleware from pydantic import Field import contextlib from .services.news_service import NewsService from .clients.elastic_client import AsyncElasticClient +from .middlewares.audit import AuditMiddleware import structlog logger = structlog.get_logger(__name__) @@ -13,7 +15,10 @@ class NewsMCP(FastMCP): pass def create_http_app(mcp): - mcp_app = mcp.http_app("/es-news-mcp") + middlewares = [ + Middleware(AuditMiddleware) + ] + mcp_app = mcp.http_app("/es-news-mcp", middleware=middlewares) return mcp_app app_services = {} diff --git a/src/news_mcp_server/middlewares/audit.py b/src/news_mcp_server/middlewares/audit.py new file mode 100644 index 0000000..aed31d7 --- /dev/null +++ b/src/news_mcp_server/middlewares/audit.py @@ -0,0 +1,35 @@ +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response +import time +from ..utils.logger import logger + +class AuditMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next) -> Response: + method = None + params = None + # 尝试解析 JSON-RPC 请求体中的方法名和参数 + if request.method.upper() == "POST": + try: + body = await request.json() + method = body.get("method") + params = body.get("params") + except Exception: + pass + # 获取客户端 IP + client_ip = None + if request.client: + client_ip = request.client.host + start_time = time.time() + response = await call_next(request) + duration = time.time() - start_time + # 审计记录:工具名、参数、客户端IP、状态码、耗时(ms) + logger.info( + "mcp_tool_audit", + method=method, + params=params, + client_ip=client_ip, + status_code=response.status_code, + duration_ms=int(duration * 1000) + ) + return response diff --git a/src/news_mcp_server/middlewares/auth.py b/src/news_mcp_server/middlewares/auth.py index 0865e0b..6c9698a 100644 --- a/src/news_mcp_server/middlewares/auth.py +++ b/src/news_mcp_server/middlewares/auth.py @@ -1,10 +1,10 @@ # @Author: Zhu Guowei # @Date: 2025/6/17 # @Function: -from starlette.middleware.authentication import AuthenticationMiddleware from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import JSONResponse +from starlette import status from structlog import get_logger logger = get_logger(__name__) import os @@ -20,24 +20,26 @@ async def dispatch(self, request: Request, call_next): # 如果未配置 API_KEY,且允许跳过,则直接放行(便于开发环境) host = request.headers.get("HOST") - logger.info(f"host: {host}, {request.headers}") + headers = request.headers + auth_header = headers.get("authorization") + for h in ALLOW_HOSTS: if h in host: return await call_next(request) if not self.api_key: - logger.info(f"request.url: {request.url}") return await call_next(request) # 获取 Authorization头 - auth_header = request.headers.get("authorization") - logger.info(f"url: {request.url}, query_params: {request.query_params}") - logger.info(f"auth-header: {auth_header}") + logger.info(f"simple-auth", host=host, header=auth_header) + if not auth_header or not auth_header.lower().startswith("bearer "): - return JSONResponse({"detail": "Bearer Token Not Provided"}, status_code=401) + logger.warning(f"simple-auth", host=host, detail="Bearer Token Not Provided", header=auth_header) + return JSONResponse({"detail": "Bearer Token Not Provided"}, status_code=status.HTTP_401_UNAUTHORIZED) token = auth_header[7:].strip() if token != self.api_key: - return JSONResponse({"detail": "Invalid Token"}, status_code=403) + logger.warning(f"simple-auth", host=host, detail="Invalid Token", token=token, header=auth_header) + return JSONResponse({"detail": "Invalid Token"}, status_code=status.HTTP_403_FORBIDDEN) # 认证通过,继续处理请求 return await call_next(request) \ No newline at end of file diff --git a/src/news_mcp_server/middlewares/rate_limit.py b/src/news_mcp_server/middlewares/rate_limit.py new file mode 100644 index 0000000..8e6ce47 --- /dev/null +++ b/src/news_mcp_server/middlewares/rate_limit.py @@ -0,0 +1,55 @@ +import time +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette import status +from redis import asyncio as aioredis +from ..utils.logger import logger + + +class RedisRateLimitMiddleware(BaseHTTPMiddleware): + """ + 基于 Redis 的简单 IP 限流中间件。 + 每个 IP 在固定时间窗口内最多允许 max_requests 次请求。 + """ + def __init__(self, app, redis_url: str, max_requests: int, window_seconds: int): + super().__init__(app) + self.redis_url = redis_url + self.max_requests = max_requests + self.window = window_seconds + self._redis = None + + async def _get_redis(self): + if self._redis is None: + # 仅在首次调用时创建 Redis 连接 + self._redis = await aioredis.from_url( + self.redis_url, encoding="utf-8", decode_responses=True + ) + return self._redis + + async def dispatch(self, request: Request, call_next): + # 获取客户端 IP + client_host = request.client.host if request.client else "unknown" + logger.debug("rate-limiter", host=client_host) + # 计算当前时间窗口 + now = int(time.time()) + window_key = now // self.window + key = f"ratelimit:{client_host}:{window_key}" + redis = await self._get_redis() + # 自增计数 + count = await redis.incr(key) + if count == 1: + # 设置过期时间为一个窗口长度 + await redis.expire(key, self.window) + + # 超出限流阈值,返回 429 + if count > self.max_requests: + logger.info("rate-limiter", host=client_host, key=key) + return JSONResponse( + {"detail": "请求过多,请稍后重试"}, + status_code=status.HTTP_429_TOO_MANY_REQUESTS + ) + + # 继续处理请求 + response = await call_next(request) + return response \ No newline at end of file diff --git a/src/news_mcp_server/middlewares/redis_session.py b/src/news_mcp_server/middlewares/redis_session.py new file mode 100644 index 0000000..e4a53e2 --- /dev/null +++ b/src/news_mcp_server/middlewares/redis_session.py @@ -0,0 +1,68 @@ +import json +import uuid +from itsdangerous import TimestampSigner, BadSignature +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response +from redis import asyncio as aioredis + + +class RedisSessionMiddleware(BaseHTTPMiddleware): + """ + TODO 基于 Redis 的服务端 Session 中间件。 + - session 使用 UUID 作为 key 存储在 Redis 中 + - cookie 存储签名后的 session_id + """ + def __init__(self, app, secret_key: str, redis_url: str, cookie_name: str = "session", max_age: int = 14*24*60*60): + super().__init__(app) + if not secret_key: + raise ValueError("SESSION_SECRET_KEY 未配置") + self.signer = TimestampSigner(secret_key) + self.redis_url = redis_url + self.cookie_name = cookie_name + self.max_age = max_age + self._redis = None + + async def _get_redis(self): + if self._redis is None: + self._redis = await aioredis.from_url(self.redis_url, encoding="utf-8", decode_responses=True) + return self._redis + + async def dispatch(self, request: Request, call_next): + # 获取或生成 session_id + session_id = None + cookie = request.cookies.get(self.cookie_name) + if cookie: + try: + unsigned = self.signer.unsign(cookie, max_age=self.max_age) + session_id = unsigned.decode() + except BadSignature: + session_id = None + if not session_id: + session_id = str(uuid.uuid4()) + + # 取 Redis 中的数据 + redis = await self._get_redis() + raw = await redis.get(f"session:{session_id}") + try: + session_data = json.loads(raw) if raw else {} + except json.JSONDecodeError: + session_data = {} + request.scope["session"] = session_data + + # 调用下游 + response: Response = await call_next(request) + + # 写回 Redis + await redis.setex(f"session:{session_id}", self.max_age, json.dumps(request.scope.get('session', {}))) + + # 设置 cookie + signed = self.signer.sign(session_id.encode()).decode() + response.set_cookie( + self.cookie_name, + signed, + max_age=self.max_age, + httponly=True, + samesite="lax" + ) + return response \ No newline at end of file diff --git a/src/news_mcp_server/utils/logger.py b/src/news_mcp_server/utils/logger.py index 0665d31..6dfd220 100644 --- a/src/news_mcp_server/utils/logger.py +++ b/src/news_mcp_server/utils/logger.py @@ -1,2 +1,30 @@ +import os +import logging +from pathlib import Path from structlog import get_logger -logger = get_logger("suwen-news-mcp-server") \ No newline at end of file +from logging.handlers import TimedRotatingFileHandler +BASE_DIR = Path(__file__).parent.parent.parent + + +logger = get_logger("suwen-news-mcp-server") +# === 文件日志处理器 === +LOG_DIR = os.getenv("LOG_DIR", os.path.join(BASE_DIR, "logs")) +os.makedirs(LOG_DIR, exist_ok=True) +LOG_FILE = os.path.join(LOG_DIR, "es_news_mcp_server.log") + +# === 标准库日志根配置 === +root_logger = logging.getLogger() +root_logger.setLevel(logging.INFO) + +# 控制台 Handler(保留 JSON 格式) +console_handler = logging.StreamHandler() +console_handler.setFormatter(logging.Formatter("%(message)s")) + +# 文件 Handler:每日 0 点轮转,保留 7 天 +file_handler = TimedRotatingFileHandler(LOG_FILE, when="midnight", backupCount=7, encoding="utf-8") +file_handler.setFormatter(logging.Formatter("%(message)s")) + +# 仅在首次配置时添加(防止重复) +if not root_logger.handlers: + root_logger.addHandler(console_handler) + root_logger.addHandler(file_handler) diff --git a/uv.lock b/uv.lock index 96520c4..4a1c9ed 100644 --- a/uv.lock +++ b/uv.lock @@ -230,6 +230,7 @@ dependencies = [ { name = "pydantic" }, { name = "pytest-asyncio" }, { name = "python-dotenv" }, + { name = "redis" }, { name = "starlette-prometheus" }, { name = "structlog" }, { name = "uvicorn" }, @@ -246,6 +247,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=1.10.0" }, { name = "pytest-asyncio", specifier = ">=1.0.0" }, { name = "python-dotenv", specifier = ">=0.20.0" }, + { name = "redis", specifier = ">=6.2.0" }, { name = "starlette-prometheus", specifier = ">=0.10.0" }, { name = "structlog", specifier = ">=25.4.0" }, { name = "uvicorn", specifier = ">=0.20.0" }, @@ -798,6 +800,15 @@ wheels = [ { url = "http://mirrors.aliyun.com/pypi/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104" }, ] +[[package]] +name = "redis" +version = "6.2.0" +source = { registry = "http://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "http://mirrors.aliyun.com/pypi/packages/ea/9a/0551e01ba52b944f97480721656578c8a7c46b51b99d66814f85fe3a4f3e/redis-6.2.0.tar.gz", hash = "sha256:e821f129b75dde6cb99dd35e5c76e8c49512a5a0d8dfdc560b2fbd44b85ca977" } +wheels = [ + { url = "http://mirrors.aliyun.com/pypi/packages/13/67/e60968d3b0e077495a8fee89cf3f2373db98e528288a48f1ee44967f6e8c/redis-6.2.0-py3-none-any.whl", hash = "sha256:c8ddf316ee0aab65f04a11229e94a64b2618451dab7a67cb2f77eb799d872d5e" }, +] + [[package]] name = "rich" version = "14.0.0"