jackson vor 2 Wochen
Ursprung
Commit
fa6ae6ccc4

+ 63 - 0
.coveragerc

@@ -0,0 +1,63 @@
+# Coverage.py configuration file
+
+[run]
+# 指定要测量覆盖率的源代码目录
+source = .
+
+# 排除测试、虚拟环境、第三方包和覆盖率运行器本身
+omit =
+    tests/*
+    */__pycache__/*
+    */site-packages/*
+    .venv/*
+    venv/*
+    setup.py
+    run_coverage.py
+
+# 启用分支覆盖率
+branch = True
+
+[report]
+# 报告中显示的精度
+precision = 2
+
+# 报告中排除的行(带有 # pragma: no cover 注释的行)
+exclude_lines =
+    # 标准的排除模式
+    pragma: no cover
+
+    # 不测试调试代码
+    def __repr__
+
+    # 不测试抽象方法
+    raise AssertionError
+    raise NotImplementedError
+
+    # 不测试 if __name__ == '__main__'
+    if __name__ == .__main__.:
+
+    # 不测试类型检查代码
+    if TYPE_CHECKING:
+
+    # 不测试协议方法
+    @abstractmethod
+
+# 报告中跳过空文件
+skip_empty = True
+
+# 报告中跳过覆盖率 100% 的文件
+skip_covered = False
+
+# 按覆盖率从低到高排序,优先暴露薄弱模块
+sort = Cover
+
+[html]
+# HTML 报告输出目录
+directory = htmlcov
+
+# HTML 报告标题
+title = MCP Gateway Coverage Report
+
+[xml]
+# XML 报告输出文件
+output = coverage.xml

+ 21 - 1
.env.example

@@ -31,4 +31,24 @@ FMS_REDIS_PREFIX=fms:mcp:workbuddy:
 # FMS_TOKEN_STORE_PATH=C:/fms-mcp/.mcp_token.json
 
 # Workbuddy 启动脚本路径使用网络 UNC:\\192.168.1.241\mcp\app.py
-# 不要使用开发机映射盘路径 Y:\mcp\app.py。
+# 不要使用开发机映射盘路径 Y:\mcp\app.py。
+
+# ============================================================================
+# Public Gateway mode (for production deployment behind HTTPS)
+# ============================================================================
+# Keep Redis private and do not store real passwords in this example file.
+
+# Gateway mode: 'local' for single-user desktop, 'public' for multi-tenant server
+FMS_GATEWAY_MODE=public
+
+# Public gateway base URL (used for client configuration)
+FMS_GATEWAY_PUBLIC_BASE=https://mcp.example.com
+
+# Gateway session TTL in seconds (default: 2592000 = 30 days)
+FMS_GATEWAY_SESSION_TTL_SECONDS=2592000
+
+# Redis prefix for public mode (must differ from local mode to avoid conflicts)
+FMS_REDIS_PREFIX=fms:mcp:gateway:
+
+# Rate limiting (default: 60 requests/minute per IP)
+# Modify in public_server.py if needed, or disable with enable_rate_limit=False

+ 7 - 1
.gitignore

@@ -1,3 +1,9 @@
 __pycache__
 /project-docs
-.env
+.env
+
+# Coverage reports
+.coverage
+htmlcov/
+coverage.xml
+.coverage.*

+ 290 - 0
COVERAGE_GUIDE.md

@@ -0,0 +1,290 @@
+# 测试覆盖率工具使用指南
+
+## 快速开始
+
+### 1. 安装 coverage
+
+```powershell
+pip install coverage
+```
+
+### 2. 运行覆盖率测试
+
+最简单的方式:
+
+```powershell
+# 运行测试并生成所有报告
+python run_coverage.py
+
+# 运行并自动在浏览器中打开 HTML 报告
+python run_coverage.py --open
+```
+
+## 手动使用 coverage 命令
+
+### 基本命令
+
+```powershell
+# 1. 运行测试并收集覆盖率数据
+python -m coverage run -m unittest discover -s tests -p "test_*.py"
+
+# 2. 显示控制台报告
+python -m coverage report
+
+# 3. 生成 HTML 报告
+python -m coverage html
+
+# 4. 生成 XML 报告(用于 CI/CD)
+python -m coverage xml
+```
+
+### 高级命令
+
+```powershell
+# 显示缺失的行号
+python -m coverage report -m
+
+# 只显示特定文件的覆盖率
+python -m coverage report services/gateway_session_store.py
+
+# 设置覆盖率阈值(低于 80% 会失败)
+python -m coverage report --fail-under=80
+
+# 显示分支覆盖率详情
+python -m coverage report --show-missing
+```
+
+## 查看报告
+
+### 1. 控制台报告
+
+运行 `python -m coverage report` 后直接在终端显示:
+
+```
+Name                                Stmts   Miss Branch BrPart   Cover
+----------------------------------------------------------------------
+services/token_store.py               158     63     50     10  52.40%
+app.py                                129     25     48     16  74.58%
+tools/bind_auth_code.py                18      3      6      3  75.00%
+mcp_protocol.py                       147     20     66     18  80.28%
+...
+----------------------------------------------------------------------
+TOTAL                                 909    132    294     63  80.80%
+```
+
+**指标说明**:
+- **Stmts**: 可执行语句总数
+- **Miss**: 未执行的语句数
+- **Branch**: 分支总数(if/else)
+- **BrPart**: 部分覆盖的分支数
+- **Cover**: 覆盖率百分比
+
+### 2. HTML 报告(推荐)
+
+生成后打开 `htmlcov/index.html`:
+
+```powershell
+# 自动打开
+python run_coverage.py --open
+
+# 或手动打开
+start htmlcov/index.html
+```
+
+**HTML 报告特性**:
+- 📊 可视化的覆盖率统计
+- 📝 逐行查看哪些代码被执行
+- 🎨 颜色编码(绿色=覆盖,红色=未覆盖)
+- 🔍 点击文件查看详细信息
+- 📈 分支覆盖率可视化
+
+### 3. XML 报告
+
+用于 CI/CD 集成(如 GitHub Actions、Jenkins):
+
+```powershell
+python -m coverage xml
+# 生成 coverage.xml
+```
+
+## 配置文件
+
+项目使用 `.coveragerc` 配置覆盖率工具:
+
+```ini
+[run]
+source = .
+omit = tests/*, */__pycache__/*, run_coverage.py
+branch = True
+
+[report]
+precision = 2
+exclude_lines = pragma: no cover
+
+[html]
+directory = htmlcov
+```
+
+**关键配置**:
+- `source`: 要测量的源代码目录
+- `omit`: 排除的文件/目录
+- `branch`: 启用分支覆盖率
+- `exclude_lines`: 排除带有特定注释的行
+
+## 提高覆盖率的技巧
+
+### 1. 识别未覆盖的代码
+
+查看 HTML 报告中的红色行:
+
+```powershell
+python run_coverage.py --open
+```
+
+### 2. 编写针对性测试
+
+对于未覆盖的代码,编写测试:
+
+```python
+def test_error_handling(self):
+    """测试错误处理分支"""
+    with self.assertRaises(ValueError):
+        function_that_raises_error()
+```
+
+### 3. 测试边界条件
+
+```python
+def test_empty_input(self):
+    result = process([])
+    self.assertEqual(result, [])
+
+def test_none_input(self):
+    with self.assertRaises(ValueError):
+        process(None)
+```
+
+### 4. 排除不可测试的代码
+
+使用 `# pragma: no cover` 注释:
+
+```python
+if __name__ == '__main__':  # pragma: no cover
+    main()
+```
+
+## CI/CD 集成
+
+### GitHub Actions
+
+```yaml
+name: Tests with Coverage
+
+on: [push, pull_request]
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      
+      - name: Set up Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: '3.11'
+      
+      - name: Install dependencies
+        run: |
+          pip install -r requirements.txt
+          pip install coverage
+      
+      - name: Run tests with coverage
+        run: |
+          python -m coverage run -m unittest discover -s tests -p "test_*.py"
+          python -m coverage report --fail-under=80
+          python -m coverage xml
+      
+      - name: Upload coverage to Codecov
+        uses: codecov/codecov-action@v3
+        with:
+          file: ./coverage.xml
+```
+
+### GitLab CI
+
+```yaml
+test:
+  script:
+    - pip install coverage
+    - python -m coverage run -m unittest discover -s tests -p "test_*.py"
+    - python -m coverage report --fail-under=80
+    - python -m coverage xml
+  coverage: '/TOTAL.*\s+(\d+%)$/'
+  artifacts:
+    reports:
+      coverage_report:
+        coverage_format: cobertura
+        path: coverage.xml
+```
+
+## 常见问题
+
+### Q: 为什么覆盖率突然下降?
+
+A: 可能原因:
+1. 新增了未测试的代码
+2. 删除了部分测试
+3. 配置文件更改导致测量范围变化
+
+解决:运行 `python -m coverage report -m` 查看缺失的行号。
+
+### Q: 如何只测试某个模块的覆盖率?
+
+A: 使用 `--source` 参数:
+
+```powershell
+python -m coverage run --source=services/gateway_session_store.py -m unittest tests.test_gateway_session_store_unit
+python -m coverage report
+```
+
+### Q: 覆盖率 100% 就够了吗?
+
+A: 不一定。覆盖率只表示代码被执行了,不表示逻辑正确。还需要:
+- 测试边界条件
+- 测试错误处理
+- 测试并发场景
+- 集成测试和端到端测试
+
+### Q: 目标覆盖率应该设多少?
+
+A: 建议:
+- **核心业务逻辑**: 90%+
+- **工具类/辅助函数**: 80%+
+- **整体项目**: 70-80%
+- **新增代码**: 要求 90%+
+
+## 当前项目覆盖率
+
+截至 2026-07-08:
+
+| 类别 | 覆盖率 | 状态 |
+|------|--------|------|
+| 新增核心模块 | 95-100% | ✅ 优秀 |
+| 整体项目 | 80.80% | ✅ 良好 |
+| 目标 | 80%+ | ✅ 已达标 |
+
+**8 个新增核心模块达到 100% 覆盖率**:
+- constants.py
+- services/api_client.py
+- services/gateway_session_store.py
+- services/scoped_api_client.py
+- services/scoped_auth_client.py
+- tools/query_track.py
+- utils/rate_limiter.py
+- utils/security.py
+
+## 参考资源
+
+- [Coverage.py 官方文档](https://coverage.readthedocs.io/)
+- [Python 测试最佳实践](https://docs.python-guide.org/writing/tests/)
+- [测试覆盖率的意义](https://martinfowler.com/bliki/TestCoverage.html)

+ 233 - 139
README.md

@@ -1,29 +1,38 @@
-# Python MCP Gateway
+# Python MCP Gateway
 
-这是一个轻量的 Python MCP Gateway,用来把 Workbuddy 的 MCP 工具调用转发到现有 ThinkPHP 授权接口和工具接口。
+这是物流系统给 Workbuddy 使用的轻量 MCP Gateway。它负责接收 MCP 请求、管理员工授权会话,并把工具调用转发到现有 ThinkPHP 项目的 MCP 接口。
 
-## 当前状态
+Gateway 保持“薄网关”边界:
 
-当前仓库已经具备一个可运行的 stdio MCP 入口,以及一套经过测试的核心模块,覆盖:
-- `.env` 优先的配置加载
-- Redis token 存储,按员工本机 `session_key` 隔离
-- 文件型 token 存储兜底
-- `auth_code` 换票、续期、失效的认证客户端
-- `bind_auth_code` 授权绑定工具
-- 工具 HTTP 转发客户端
-- `query_order` 工具注册
-- Gateway 运行时会话管理
-- MCP `initialize` / `tools/list` / `tools/call` 请求处理
-- 本地命令模式下的 stdio MCP 启动入口
+- 不直接连接业务数据库。
+- 不复制 ThinkPHP 的业务权限逻辑。
+- 不在 Python 内判断订单、费用、轨迹等业务权限。
+- ThinkPHP 继续负责 `mcp_token`、员工状态、公司、工具白名单和业务数据权限的最终校验。
 
-当前实现仍然保持“薄网关”原则:
-- 业务认证和权限判断仍然留在 ThinkPHP 项目中
-- Gateway 只负责协议适配、会话管理和 HTTP 转发
-- 第一阶段只开放查询型工具,当前从 `query_order` 开始
+## 当前能力
 
-## Workbuddy 配置
+- 支持 `.env` 优先的配置加载。
+- 支持本地 stdio MCP 模式:`serve-stdio`。
+- 支持公网 HTTP JSON-RPC 模式:`serve-public`。
+- 支持 Redis token/session 存储。
+- 支持文件 token store 作为开发排障兜底。
+- 支持 `bind_auth_code` 授权绑定工具。
+- 支持 `query_order` 订单查询工具。
+- 支持 `query_track` 轨迹查询工具。
+- 支持 MCP `initialize`、`tools/list`、`tools/call`。
+- 公网模式支持 `gateway_session_id` 请求级隔离、Redis Gateway session、审计日志和基础限流。
 
-员工侧 Workbuddy 使用网络 UNC 路径启动 Gateway:
+## 两种运行模式
+
+### 本地 stdio 模式
+
+适合员工本机 Workbuddy 通过命令方式拉起 Gateway。
+
+```powershell
+python \\192.168.1.241\chenjiacheng\mcp\app.py serve-stdio
+```
+
+Workbuddy 配置示例:
 
 ```json
 {
@@ -31,7 +40,7 @@
     "fms": {
       "command": "python",
       "args": [
-        "\\\\192.168.1.241\\mcp\\app.py",
+        "\\\\192.168.1.241\\chenjiacheng\\mcp\\app.py",
         "serve-stdio"
       ]
     }
@@ -39,87 +48,154 @@
 }
 ```
 
-不要把 `Y:\mcp\app.py` 写进 Workbuddy 配置;那只是开发机映射盘视角
+不要把 `Y:\mcp\app.py` 写进员工 Workbuddy 配置;`Y:` 只是开发机映射盘视角,员工机器未必存在
 
-## 常用命令
+本地模式下,员工流程为:
 
-以下命令在 `mcp/` 目录下执行。
+1. 员工在后台获取 Workbuddy 授权码。
+2. Workbuddy 自动拉起 `serve-stdio`。
+3. 员工在 Workbuddy 中调用 `bind_auth_code`,输入授权码完成绑定。
+4. Gateway 调用 `/mcp/auth/exchange`,并带上本机生成的 `session_key`。
+5. token 存入 Redis 当前员工对应 key。
+6. 后续直接使用 `query_order`、`query_track` 等工具。
 
-### 查看工具列表
+### 公网 HTTP 模式
+
+适合把 Gateway 部署成公网共享服务,由多个员工的 Workbuddy 远程访问同一个 Gateway。
+
+启动命令:
 
 ```powershell
-python app.py list-tools
+python app.py serve-public --host 0.0.0.0 --port 8765
 ```
 
-### 绑定授权码(开发/排障)
+生产部署必须满足:
 
-```powershell
-python app.py bind --auth-code AUTH123
+- 必须放在 HTTPS 反向代理后面。
+- Redis 必须在内网并开启密码。
+- 不使用 `.mcp_token.json` 保存公网用户 token。
+- 不使用固定 `FMS_SESSION_KEY` 表示公网用户。
+- 不使用进程级全局 token 表示当前员工。
+- Workbuddy 每次请求必须稳定传递 `gateway_session_id`。
+
+公网模式当前支持三种 session 传递方式,优先级如下:
+
+1. Header:`X-Gateway-Session: GWS_xxx`
+2. Bearer:`Authorization: Bearer GWS_xxx`
+3. Cookie:`gateway_session_id=GWS_xxx`
+
+如果 Workbuddy 远程 MCP 无法稳定传递 header、Bearer 或 cookie 中任意一种会话身份,公网共享 Gateway 不能生产放量。
+
+## 公网 Gateway 会话生命周期
+
+### 1. 生成 gateway_session_id
+
+客户端首次使用时生成高熵会话 ID:
+
+```python
+import secrets
+
+gateway_session_id = 'GWS_' + secrets.token_urlsafe(32)
+```
+
+客户端需要保存这个 ID,并在后续每次请求中复用。
+
+### 2. 绑定授权码
+
+员工从后台复制授权码后,Workbuddy 调用 `bind_auth_code`:
+
+```http
+POST /mcp
+X-Gateway-Session: GWS_xxx
+Content-Type: application/json
 ```
 
-员工正式使用时不需要执行这条命令。Workbuddy 拉起 `serve-stdio` 后,员工应在 Workbuddy 中调用 `bind_auth_code` 工具完成绑定。
+```json
+{
+  "jsonrpc": "2.0",
+  "id": 1,
+  "method": "tools/call",
+  "params": {
+    "name": "bind_auth_code",
+    "arguments": {
+      "auth_code": "AC_xxx"
+    }
+  }
+}
+```
 
-### 直接调用 query_order(开发/排障)
+Gateway 会调用 base 项目的 `/mcp/auth/exchange`,并把返回的 `mcp_token` 存入 Redis:
+
+```text
+fms:mcp:gateway:session:{sha256(gateway_session_id)}
+```
+
+Redis value 中保存 `mcp_token`、`admin_id`、`company_id`、`client_type`、`expire_time` 等信息。Redis key 不保存明文 `gateway_session_id`。
+
+### 3. 调用工具
+
+后续工具调用必须继续带同一个 `gateway_session_id`:
+
+```json
+{
+  "jsonrpc": "2.0",
+  "id": 2,
+  "method": "tools/call",
+  "params": {
+    "name": "query_order",
+    "arguments": {
+      "keyword": "USC26070371955",
+      "page": 1,
+      "limit": 20
+    }
+  }
+}
+```
+
+Gateway 根据 `gateway_session_id` 从 Redis 读取当前员工的 `mcp_token`,再把请求转发到 fmsoperate 的 `/mcp/tools/queryOrder`。
+
+### 4. 撤销与过期
+
+- 员工在后台取消 Workbuddy 授权后,ThinkPHP 会让对应 `mcp_token` 失效。
+- 公网 Gateway Redis session 会在 TTL 到期后自动过期。
+- 当前公网模式默认 TTL 为 30 天,可通过 `FMS_GATEWAY_SESSION_TTL_SECONDS` 调整。
+- 如果 token 已失效但 Redis session 仍存在,下一次工具调用会被 ThinkPHP 拒绝。
+
+## 常用命令
+
+查看工具列表:
 
 ```powershell
-python app.py call --tool query_order --keyword SO20260706001 --page 1 --limit 20
+python app.py list-tools
 ```
 
-### 启动 MCP stdio 服务
+开发/排障时手动绑定授权码:
 
 ```powershell
-python \\192.168.1.241\chenjiacheng\mcp\app.py serve-stdio
+python app.py bind --auth-code AUTH123
 ```
 
-这个命令可作为 Workbuddy 本地命令模式下的 MCP 服务入口,由 Workbuddy 自动拉起,不要求员工手动执行。
+直接调用订单查询:
 
-## 环境变量
+```powershell
+python app.py call --tool query_order --keyword USC26070371955 --page 1 --limit 20
+```
 
-当前代码直接支持以下文档口径的配置键:
-- `FMS_API_BASE`
-- `FMS_AUTH_BASE`
-- `FMS_TOOLS_BASE`
-- `FMS_CLIENT_TYPE`
-- `FMS_TIMEOUT_MS`
-- `FMS_TIMEOUT_SECONDS`
-- `FMS_REFRESH_SKEW_SECONDS`
-- `FMS_LOG_LEVEL`
-- `FMS_TOKEN_STORE`
-- `FMS_TOKEN_STORE_PATH`
-- `FMS_REDIS_HOST`
-- `FMS_REDIS_PORT`
-- `FMS_REDIS_DB`
-- `FMS_REDIS_PASSWORD`
-- `FMS_REDIS_PREFIX`
-- `FMS_SESSION_KEY`
-
-同时兼容早期实现中的这些键名:
-- `MCP_AUTH_BASE_URL`
-- `MCP_TOOLS_BASE_URL`
-- `MCP_CLIENT_TYPE`
-- `MCP_TIMEOUT_SECONDS`
-- `MCP_REFRESH_SKEW_SECONDS`
-- `MCP_LOG_LEVEL`
-- `MCP_TOKEN_STORE`
-- `MCP_TOKEN_STORE_PATH`
-- `MCP_REDIS_HOST`
-- `MCP_REDIS_PORT`
-- `MCP_REDIS_DB`
-- `MCP_REDIS_PASSWORD`
-- `MCP_REDIS_PREFIX`
-- `MCP_SESSION_KEY`
+直接调用轨迹查询:
 
-说明:
-- `config.py` 会自动尝试读取 `mcp/.env`。
-- 同名配置以 `.env` 优先,系统环境变量只在 `.env` 缺少该配置时兜底。
-- 如果只提供 `FMS_API_BASE`,当前实现会同时把它作为 auth 和 tools 的基础地址。
-- 如果 `base` 与 `fmsoperate` 部署在不同域名,应分别提供 `FMS_AUTH_BASE` 与 `FMS_TOOLS_BASE`。
+```powershell
+python app.py call --tool query_track --order-number USC26070371955
+```
+
+运行全部测试:
 
-## .env 文件
+```powershell
+python -m unittest discover -s tests -p "test_*.py"
+```
 
-当前 `mcp/` 目录建议使用 `.env` 保存共享 Gateway 配置。
+## 环境变量
 
-推荐配置如下
+基础配置:
 
 ```dotenv
 FMS_AUTH_BASE=http://chenjiacheng.base.dahuo.fudingri.com
@@ -128,7 +204,11 @@ FMS_CLIENT_TYPE=workbuddy
 FMS_TIMEOUT_SECONDS=10
 FMS_REFRESH_SKEW_SECONDS=120
 FMS_LOG_LEVEL=info
+```
+
+本地 stdio 推荐 Redis token store:
 
+```dotenv
 FMS_TOKEN_STORE=redis
 FMS_REDIS_HOST=192.168.1.241
 FMS_REDIS_PORT=6379
@@ -137,101 +217,115 @@ FMS_REDIS_PASSWORD=
 FMS_REDIS_PREFIX=fms:mcp:workbuddy:
 ```
 
-不要在共享 `.env` 中写死 `FMS_SESSION_KEY`。默认情况下 Gateway 会用员工本机的 `COMPUTERNAME` / `USERNAME` 自动生成稳定 `session_key`,Redis key 形如
+公网模式推荐配置
 
-```text
-fms:mcp:workbuddy:{session_key}
+```dotenv
+FMS_GATEWAY_MODE=public
+FMS_GATEWAY_PUBLIC_BASE=https://mcp.example.com
+FMS_GATEWAY_SESSION_TTL_SECONDS=2592000
+
+FMS_TOKEN_STORE=redis
+FMS_REDIS_HOST=127.0.0.1
+FMS_REDIS_PORT=6379
+FMS_REDIS_DB=20
+FMS_REDIS_PASSWORD=change-me
+FMS_REDIS_PREFIX=fms:mcp:gateway:
 ```
 
-这样多个员工通过同一个 `\\192.168.1.241\chenjiacheng\mcp\app.py` 启动时,也不会共用同一个 token。
+兼容旧配置键:
+
+- `MCP_AUTH_BASE_URL`
+- `MCP_TOOLS_BASE_URL`
+- `MCP_CLIENT_TYPE`
+- `MCP_TIMEOUT_SECONDS`
+- `MCP_REFRESH_SKEW_SECONDS`
+- `MCP_LOG_LEVEL`
+- `MCP_TOKEN_STORE`
+- `MCP_TOKEN_STORE_PATH`
+- `MCP_REDIS_HOST`
+- `MCP_REDIS_PORT`
+- `MCP_REDIS_DB`
+- `MCP_REDIS_PASSWORD`
+- `MCP_REDIS_PREFIX`
+- `MCP_SESSION_KEY`
+
+说明:
 
-## Token 存储
+- `config.py` 会自动读取 `mcp/.env`。
+- 同名配置以 `.env` 优先,系统环境变量只作为兜底。
+- 如果只配置 `FMS_API_BASE`,会同时作为 auth 和 tools 的基础地址。
+- 如果 base 与 fmsoperate 是不同域名,应分别配置 `FMS_AUTH_BASE` 和 `FMS_TOOLS_BASE`。
 
-当前推荐使用 Redis:
+## Token 与 Session 存储
 
-```dotenv
-FMS_TOKEN_STORE=redis
-```
+本地 stdio 模式:
+
+- Redis key 默认为 `fms:mcp:workbuddy:{session_key}`。
+- `session_key` 默认由员工本机 `COMPUTERNAME` / `USERNAME` 生成。
+- 共享 `.env` 中不要写死 `FMS_SESSION_KEY`。
+
+公网 HTTP 模式:
 
-Redis 存储行为:
-- `bind_auth_code` 成功后把 `mcp_token` 写入 Redis
-- Redis key 使用 `FMS_REDIS_PREFIX + session_key`
-- `session_key` 优先读取 `FMS_SESSION_KEY`,未配置时自动从员工本机信息生成
-- token 过期时间会同步设置为 Redis TTL
-- `revoke` 成功后会删除当前 session 的 Redis key
+- Redis key 默认为 `fms:mcp:gateway:session:{sha256(gateway_session_id)}`。
+- 每个公网客户端必须有独立 `gateway_session_id`。
+- Gateway 不把 `mcp_token` 返回给 Workbuddy 展示层。
+- Gateway 不在日志中记录明文 `gateway_session_id`、`mcp_token` 或授权码。
 
-文件存储仍保留为兜底方式:
+文件存储仅用于开发排障:
 
 ```dotenv
 FMS_TOKEN_STORE=file
 FMS_TOKEN_STORE_PATH=C:/fms-mcp/.mcp_token.json
 ```
 
-如果使用网络共享 `app.py`,不要把 `FMS_TOKEN_STORE_PATH` 指向 `\\192.168.1.241\chenjiacheng\mcp\` 共享目录。
+如果使用网络共享 `app.py`,不要把文件 token store 指向 `\\192.168.1.241\chenjiacheng\mcp\` 共享目录。
 
-## 本地接入建议
+## 公网安全注意事项
 
-当前员工正式使用路径是:
-1. 员工在后台获取一次性 `auth_code`
-2. Workbuddy 自动通过 `python \\192.168.1.241\chenjiacheng\mcp\app.py serve-stdio` 拉起 Gateway
-3. 员工在 Workbuddy 中调用 `bind_auth_code`,输入授权码完成绑定
-4. Gateway 调用 `exchange` 时带上 `session_key`
-5. token 保存到 Redis 当前员工对应的 key
-6. 后续直接使用 `query_order` 等工具
+- 公网入口必须使用 HTTPS。
+- Redis 只允许内网访问。
+- Redis 密码不能提交到仓库。
+- 反向代理必须覆盖而不是透传用户伪造的转发头。
+- 当前 Python 内置限流默认使用真实 TCP 连接 IP;公网生产建议同时在 Nginx、负载均衡或 API 网关层配置限流。
+- 审计日志只记录 session hash 的短前缀、员工 ID、公司 ID、工具名和 request_id。
+- 第一版公网只开放查询型工具,不开放审批、费用修改、状态流转、批量写入。
 
-`python app.py bind --auth-code ...` 仅保留给开发、联调和排障使用。
+## ThinkPHP 路由口径
 
-## 测试命令
+ThinkPHP MCP 路由位于各后端项目的 `route/mcp/mcp_route.php`。
 
-```powershell
-python -m unittest discover -s tests -p "test_*.py"
-```
+Gateway 会追加以下路径:
 
-## 当前边界
+- Auth:`/mcp/auth/exchange`、`/mcp/auth/refresh`、`/mcp/auth/revoke`
+- Tools:`/mcp/tools/queryOrder`、`/mcp/tools/queryTrack`
 
-当前 Gateway 已可作为本地 stdio MCP 进程使用,但仍保持最小边界:
-- 不在 Python 中沉淀额外业务规则
-- 不直接连接业务数据库
-- 不做动态插件加载
-- 第一阶段不开放写入型工具
-## query_order BOM 排障
+`FMS_AUTH_BASE` 和 `FMS_TOOLS_BASE` 只配置域名或基础地址,不要包含 `/admin/mcp`。
 
-如果 Workbuddy 调用 `query_order` 时提示:
+## 排障
+
+### query_order 返回 UTF-8 BOM 错误
+
+如果 Workbuddy 提示:
 
 ```text
 Unexpected UTF-8 BOM (decode using utf-8-sig)
 ```
 
-说明后端工具接口返回的 JSON 前面带了 UTF-8 BOM。当前 Gateway 已在 HTTP JSON 解码层使用 `utf-8-sig` 兼容。排查时在 `mcp/` 目录运行:
+说明后端工具接口返回的 JSON 前面带了 UTF-8 BOM。当前 Gateway 已在 HTTP JSON 解码层使用 `utf-8-sig` 兼容。先运行全量测试确认当前文件是否已包含修复
 
 ```powershell
 python -m unittest discover -s tests -p "test_*.py"
 ```
 
-若全量测试通过但 Workbuddy 仍报同样错误,优先确认 Workbuddy 启动的 `app.py` 是否为 `\\192.168.1.241\chenjiacheng\mcp\app.py` 的最新文件。
-
-## 2026-07-07 ThinkPHP MCP route path
-
-ThinkPHP MCP routes now live in each backend project's `route/mcp/mcp_route.php`.
-The Gateway appends these paths to `FMS_AUTH_BASE` and `FMS_TOOLS_BASE`:
+### stdio 中文乱码
 
-- Auth: `/mcp/auth/exchange`, `/mcp/auth/refresh`, `/mcp/auth/revoke`
-- Tools: `/mcp/tools/queryOrder`
+Windows 下 Python 标准输出可能使用本机控制台编码。当前 `mcp_protocol.py` 已将 stdio JSON-RPC 响应改为 `ensure_ascii=True`,传输行只包含 ASCII JSON 转义,客户端解析后仍是正常中文。
 
-Keep `FMS_AUTH_BASE` and `FMS_TOOLS_BASE` as host/base-domain values. Do not include `/admin/mcp` in these environment variables.
-## query_track / stdio 中文乱码排障
+如果仍然乱码,优先确认 Workbuddy 启动的是最新的 `\\192.168.1.241\chenjiacheng\mcp\app.py`。
 
-如果后端 HTTP 返回、保存到 UTF-8 文件都正常,但 Workbuddy/AI 调 MCP 工具时中文显示成乱码,优先检查 Python Gateway 的 `serve-stdio` 输出层。
-
-本次踩坑原因:Windows 下 Python 标准输出可能使用本机控制台编码,而 MCP stdio 客户端按 JSON/UTF-8 读取时会出现中文乱码。当前 `mcp_protocol.py` 已将 JSON-RPC stdio 响应改为 `json.dumps(..., ensure_ascii=True)`,传输行只包含 ASCII JSON 转义;客户端解析 JSON 后仍是正常中文。
-
-排查顺序:
-- 先确认 HTTP 层 `ApiClient` 能用 `utf-8-sig` 正常解析后端 JSON。
-- 再确认 `serve-stdio` 输出不是直接吐本机编码中文。
-- 修改后需要重启 Workbuddy 拉起的 MCP Gateway 进程。
-
-验证命令:
+## 当前边界
 
-```powershell
-python -m unittest discover -s tests -p "test_*.py"
-```
+- Gateway 不是业务系统,不直接查 MySQL。
+- Gateway 不替代 ThinkPHP 权限体系。
+- 公网 Gateway 是否能生产放量,取决于 Workbuddy 远程 MCP 是否能稳定传递 `gateway_session_id`。
+- 写入型 MCP 工具需要单独安全评审后再开放。

+ 38 - 3
app.py

@@ -1,19 +1,24 @@
-import argparse
+import argparse
 import json
 import sys
 import uuid
 
 from config import GatewayConfig
+from constants import BIND_HINT
 from mcp_protocol import McpProtocolHandler
+from public_gateway import PublicGatewayApp
+from public_server import serve_public
 from services.api_client import ApiClient
 from services.auth_client import AuthClient
+from services.gateway_session_store import GatewaySessionStore
+from services.scoped_api_client import ScopedApiClient
+from services.scoped_auth_client import ScopedAuthClient
 from services.token_store import FileTokenStore, RedisSocketClient, RedisTokenStore
 from tools.bind_auth_code import BindAuthCodeTool
 from tools.query_order import QueryOrderTool
 from tools.query_track import QueryTrackTool
 
 
-BIND_HINT = '\u8bf7\u5148\u767b\u5f55\u540e\u53f0\u83b7\u53d6 Workbuddy \u6388\u6743\u7801\uff0c\u7136\u540e\u5728 Workbuddy \u4e2d\u8f93\u5165\u201c\u7ed1\u5b9a\u6388\u6743\u7801 xxx\u201d\u5b8c\u6210\u7ed1\u5b9a\u3002'
 
 
 class GatewayApp:
@@ -115,6 +120,10 @@ class GatewayApp:
         subparsers.add_parser('list-tools')
         subparsers.add_parser('serve-stdio')
 
+        public_parser = subparsers.add_parser('serve-public')
+        public_parser.add_argument('--host', default='0.0.0.0')
+        public_parser.add_argument('--port', type=int, default=8765)
+
         bind_parser = subparsers.add_parser('bind')
         bind_parser.add_argument('--auth-code', required=True)
 
@@ -134,6 +143,31 @@ class GatewayApp:
             payload = self.list_tools()
         elif args.command == 'serve-stdio':
             return self.create_protocol_handler().run_stdio(stdin=stdin, stdout=stdout)
+        elif args.command == 'serve-public':
+            config = GatewayConfig.from_env()
+            redis = RedisSocketClient(
+                host=config.redis_host,
+                port=config.redis_port,
+                db=config.redis_db,
+                password=config.redis_password,
+                timeout=config.timeout_seconds,
+            )
+            session_store = GatewaySessionStore(
+                redis,
+                prefix=config.redis_prefix,
+                ttl_seconds=config.gateway_session_ttl_seconds,
+            )
+            public_app = PublicGatewayApp(
+                session_store=session_store,
+                api_client=ScopedApiClient(config.tools_base_url, timeout=config.timeout_seconds),
+                auth_client=ScopedAuthClient(
+                    config.auth_base_url,
+                    config.client_type,
+                    session_store,
+                    timeout=config.timeout_seconds,
+                ),
+            )
+            return serve_public(public_app, host=args.host, port=args.port)
         elif args.command == 'bind':
             payload = self.bind(args.auth_code)
         elif args.command == 'call':
@@ -178,4 +212,5 @@ def main(argv=None):
 
 
 if __name__ == '__main__':
-    raise SystemExit(main(sys.argv[1:]))
+    raise SystemExit(main(sys.argv[1:]))
+

+ 6 - 2
config.py

@@ -1,4 +1,4 @@
-from dataclasses import dataclass
+from dataclasses import dataclass
 import os
 
 
@@ -18,6 +18,8 @@ class GatewayConfig:
     redis_password: str = ''
     redis_prefix: str = 'fms:mcp:workbuddy:'
     session_key: str = ''
+    gateway_mode: str = 'local'
+    gateway_session_ttl_seconds: int = 2592000
 
     @classmethod
     def from_env(cls, env=None, dotenv_path=''):
@@ -59,6 +61,8 @@ class GatewayConfig:
             redis_password=cls._pick(dotenv_env, primary_env, 'MCP_REDIS_PASSWORD', 'FMS_REDIS_PASSWORD') or '',
             redis_prefix=cls._pick(dotenv_env, primary_env, 'MCP_REDIS_PREFIX', 'FMS_REDIS_PREFIX') or 'fms:mcp:workbuddy:',
             session_key=session_key or cls._build_default_session_key(primary_env),
+            gateway_mode=(cls._pick(dotenv_env, primary_env, 'FMS_GATEWAY_MODE', 'MCP_GATEWAY_MODE') or 'local').lower(),
+            gateway_session_ttl_seconds=int(cls._pick(dotenv_env, primary_env, 'FMS_GATEWAY_SESSION_TTL_SECONDS', 'MCP_GATEWAY_SESSION_TTL_SECONDS') or '2592000'),
         )
 
     @staticmethod
@@ -126,4 +130,4 @@ class GatewayConfig:
                 value = value.strip().strip('"').strip("'")
                 if key:
                     data[key] = value
-        return data
+        return data

+ 1 - 0
constants.py

@@ -0,0 +1 @@
+BIND_HINT = '\u8bf7\u5148\u767b\u5f55\u540e\u53f0\u83b7\u53d6 Workbuddy \u6388\u6743\u7801\uff0c\u7136\u540e\u5728 Workbuddy \u4e2d\u8f93\u5165\u201c\u7ed1\u5b9a\u6388\u6743\u7801 xxx\u201d\u5b8c\u6210\u7ed1\u5b9a\u3002'

+ 85 - 0
public_gateway.py

@@ -0,0 +1,85 @@
+import logging
+import uuid
+
+from constants import BIND_HINT
+from tools.bind_auth_code import BindAuthCodeTool
+from tools.query_order import QueryOrderTool
+from tools.query_track import QueryTrackTool
+from utils.security import hash_gateway_session_id
+
+
+logger = logging.getLogger(__name__)
+
+
+class PublicGatewayApp:
+    def __init__(self, session_store, api_client, auth_client):
+        self.session_store = session_store
+        self.api_client = api_client
+        self.auth_client = auth_client
+        self._tools = {
+            'bind_auth_code': BindAuthCodeTool(auth_client=None),
+            'query_order': QueryOrderTool(api_client=None),
+            'query_track': QueryTrackTool(api_client=None),
+        }
+
+    def list_tools(self):
+        return [tool.metadata() for tool in self._tools.values()]
+
+    def build_request_id(self, request_id=''):
+        request_id = str(request_id or '').strip()
+        return request_id or 'rq_{0}'.format(uuid.uuid4().hex[:16])
+
+    def bind_auth_code(self, gateway_session_id, auth_code):
+        if self.auth_client is None:
+            raise RuntimeError('auth client is required')
+
+        session_hash = hash_gateway_session_id(gateway_session_id)[:12]
+        logger.info(f"[AUDIT] bind_auth_code: session_hash={session_hash}, auth_code_provided={bool(auth_code)}")
+
+        result = self.auth_client.exchange(gateway_session_id, auth_code)
+
+        if result.get('code') == 'MCP_0000':
+            session = self.session_store.get(gateway_session_id)
+            admin_id = session.get('admin_id') if session else None
+            company_id = session.get('company_id') if session else None
+            logger.info(f"[AUDIT] bind_success: session_hash={session_hash}, admin_id={admin_id}, company_id={company_id}")
+        else:
+            logger.warning(f"[AUDIT] bind_failed: session_hash={session_hash}, code={result.get('code')}, msg={result.get('msg')}")
+
+        return result
+
+    def call_tool(self, gateway_session_id, name, arguments=None, request_id=''):
+        if name == 'bind_auth_code':
+            auth_code = (arguments or {}).get('auth_code', '')
+            return self.bind_auth_code(gateway_session_id, auth_code)
+        if name not in self._tools:
+            raise KeyError('tool not registered: {0}'.format(name))
+
+        session = self.session_store.get(gateway_session_id)
+        if not session or not session.get('mcp_token'):
+            raise RuntimeError(BIND_HINT)
+
+        tool = self._tools[name]
+        request_id = self.build_request_id(request_id)
+
+        session_hash = hash_gateway_session_id(gateway_session_id)[:12]
+        admin_id = session.get('admin_id')
+        company_id = session.get('company_id')
+        logger.info(f"[AUDIT] tool_call: session_hash={session_hash}, admin_id={admin_id}, company_id={company_id}, tool={name}, request_id={request_id}")
+
+        try:
+            result = self.api_client.call_tool(
+                token=session['mcp_token'],
+                tool_code=tool.name,
+                route_path=tool.route_path,
+                payload=arguments or {},
+                request_id=request_id,
+            )
+            logger.info(f"[AUDIT] tool_success: session_hash={session_hash}, tool={name}, request_id={request_id}, code={result.get('code')}")
+            return result
+        except Exception as e:
+            logger.error(f"[AUDIT] tool_error: session_hash={session_hash}, tool={name}, request_id={request_id}, error={str(e)}")
+            raise
+
+
+

+ 144 - 0
public_server.py

@@ -0,0 +1,144 @@
+import json
+import logging
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+from constants import BIND_HINT
+from mcp_protocol import McpProtocolHandler
+from services.request_context import RequestContextParser
+from utils.rate_limiter import SimpleRateLimiter
+
+
+logger = logging.getLogger(__name__)
+
+def extract_client_ip(headers, client_address):
+    if client_address and len(client_address) > 0:
+        return str(client_address[0])
+    return ''
+
+
+class PublicMcpHttpHandler:
+    def __init__(self, gateway_app, context_parser=None, rate_limiter=None):
+        self.gateway_app = gateway_app
+        self.context_parser = context_parser or RequestContextParser()
+        self.rate_limiter = rate_limiter
+
+    def handle_json_rpc(self, headers, message, client_ip=''):
+        request_id = message.get('id') if isinstance(message, dict) else None
+        method = str((message or {}).get('method') or '').strip()
+
+        # Rate limiting by IP
+        if self.rate_limiter and client_ip:
+            if not self.rate_limiter.is_allowed(client_ip):
+                logger.warning(f"[RATE_LIMIT] IP rate limit exceeded: ip={client_ip}, method={method}")
+                return McpProtocolHandler._error_response(request_id, -32000, 'Rate limit exceeded. Please try again later.')
+
+        try:
+            if method == 'initialize':
+                return McpProtocolHandler._success_response(request_id, {
+                    'protocolVersion': McpProtocolHandler.protocol_version,
+                    'capabilities': {'tools': {'listChanged': False}},
+                    'serverInfo': {
+                        'name': McpProtocolHandler.server_name,
+                        'version': McpProtocolHandler.server_version,
+                    },
+                })
+            if method == 'tools/list':
+                tools = []
+                for tool in self.gateway_app.list_tools():
+                    normalized = dict(tool)
+                    if 'input_schema' in normalized:
+                        normalized['inputSchema'] = normalized.pop('input_schema')
+                    tools.append(normalized)
+                return McpProtocolHandler._success_response(request_id, {'tools': tools})
+            if method == 'tools/call':
+                context = self.context_parser.parse(headers or {})
+                if not context.has_session():
+                    raise RuntimeError(BIND_HINT)
+                params = message.get('params') or {}
+                result = self.gateway_app.call_tool(
+                    context.gateway_session_id,
+                    params.get('name'),
+                    params.get('arguments') or {},
+                    request_id='rq_http_{0}'.format(request_id),
+                )
+                structured_content = result.get('data') or {}
+                return McpProtocolHandler._success_response(request_id, {
+                    'content': [{'type': 'text', 'text': McpProtocolHandler._render_text(structured_content)}],
+                    'structuredContent': structured_content,
+                    'isError': False,
+                })
+            return McpProtocolHandler._error_response(request_id, -32601, 'Method not found: {0}'.format(method))
+        except Exception as exc:
+            if method == 'tools/call':
+                return McpProtocolHandler._success_response(request_id, {
+                    'content': [{'type': 'text', 'text': str(exc)}],
+                    'isError': True,
+                })
+            return McpProtocolHandler._error_response(request_id, -32000, str(exc))
+
+
+def create_http_handler(gateway_app, rate_limiter=None):
+    rpc_handler = PublicMcpHttpHandler(gateway_app, rate_limiter=rate_limiter)
+
+    class Handler(BaseHTTPRequestHandler):
+        def log_message(self, format, *args):
+            # Override to use Python logging instead of stderr
+            logger.info(f"{self.address_string()} - {format % args}")
+
+        def do_GET(self):
+            if self.path == '/health':
+                self._write_json({'ok': True})
+                return
+            self.send_response(404)
+            self.end_headers()
+
+        def do_POST(self):
+            client_ip = extract_client_ip(dict(self.headers.items()), self.client_address)
+            if self.path != '/mcp':
+                logger.warning(f"[HTTP] 404: ip={client_ip}, path={self.path}")
+                self.send_response(404)
+                self.end_headers()
+                return
+            length = int(self.headers.get('Content-Length') or '0')
+            body = self.rfile.read(length).decode('utf-8-sig')
+            message = json.loads(body)
+            method = message.get('method', '')
+            request_id = message.get('id', '')
+            logger.info(f"[HTTP] request: ip={client_ip}, method={method}, id={request_id}")
+            response = rpc_handler.handle_json_rpc(dict(self.headers.items()), message, client_ip)
+            self._write_json(response)
+
+        def _write_json(self, payload):
+            raw = json.dumps(payload, ensure_ascii=False).encode('utf-8')
+            self.send_response(200)
+            self.send_header('Content-Type', 'application/json; charset=utf-8')
+            self.send_header('Content-Length', str(len(raw)))
+            self.end_headers()
+            self.wfile.write(raw)
+
+    return Handler
+
+
+def serve_public(gateway_app, host='0.0.0.0', port=8765, enable_rate_limit=True):
+    # Configure logging
+    logging.basicConfig(
+        level=logging.INFO,
+        format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+        datefmt='%Y-%m-%d %H:%M:%S'
+    )
+
+    # Configure rate limiting (default: 60 requests per minute per IP)
+    rate_limiter = None
+    if enable_rate_limit:
+        rate_limiter = SimpleRateLimiter(max_requests=60, window_seconds=60)
+        logger.info("Rate limiting enabled: 60 requests/minute per IP")
+
+    logger.info(f"Starting public MCP Gateway on {host}:{port}")
+    server = ThreadingHTTPServer((host, int(port)), create_http_handler(gateway_app, rate_limiter))
+    try:
+        server.serve_forever()
+    except KeyboardInterrupt:
+        logger.info("Shutting down public MCP Gateway")
+        server.shutdown()
+
+

+ 105 - 0
run_coverage.py

@@ -0,0 +1,105 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+Coverage test runner for MCP Gateway.
+
+This script runs all tests and generates coverage reports in multiple formats.
+"""
+
+import os
+import subprocess
+import sys
+
+# Fix Windows console encoding
+if sys.platform == 'win32':
+    import io
+    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
+    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
+
+
+def build_coverage_commands():
+    """Build coverage commands using the current Python interpreter."""
+    return [
+        (
+            [sys.executable, '-m', 'coverage', 'run', '-m', 'unittest',
+             'discover', '-s', 'tests', '-p', 'test_*.py'],
+            'Running tests with coverage',
+        ),
+        (
+            [sys.executable, '-m', 'coverage', 'report'],
+            'Generating console coverage report',
+        ),
+        (
+            [sys.executable, '-m', 'coverage', 'html'],
+            'Generating HTML coverage report',
+        ),
+        (
+            [sys.executable, '-m', 'coverage', 'xml'],
+            'Generating XML coverage report (for CI/CD)',
+        ),
+    ]
+
+
+def run_command(cmd, description):
+    """Run a command and print status."""
+    print(f"\n{'='*60}")
+    print(f"[*] {description}")
+    print(f"{'='*60}\n")
+
+    result = subprocess.run(cmd, shell=False)
+
+    if result.returncode != 0:
+        print(f"\n[X] {description} failed!")
+        return False
+
+    print(f"\n[OK] {description} completed successfully!")
+    return True
+
+
+def main():
+    """Main test runner."""
+    print("\n=== MCP Gateway Coverage Test Runner ===")
+    print("=" * 60)
+
+    # Change to script directory
+    script_dir = os.path.dirname(os.path.abspath(__file__))
+    os.chdir(script_dir)
+
+    # Step 1: Clean old coverage data
+    if os.path.exists('.coverage'):
+        os.remove('.coverage')
+        print("[*] Cleaned old coverage data")
+
+    for cmd, description in build_coverage_commands():
+        if not run_command(cmd, description):
+            return 1
+
+    # Summary
+    print("\n" + "=" * 60)
+    print("=== Coverage Reports Generated ===")
+    print("=" * 60)
+    print("  [*] Console: (shown above)")
+    print(f"  [*] HTML:    {os.path.join(script_dir, 'htmlcov', 'index.html')}")
+    print(f"  [*] XML:     {os.path.join(script_dir, 'coverage.xml')}")
+    print("=" * 60)
+
+    # Open HTML report (optional)
+    if '--open' in sys.argv or '-o' in sys.argv:
+        html_path = os.path.join(script_dir, 'htmlcov', 'index.html')
+        print("\n[*] Opening HTML report in browser...")
+
+        if sys.platform == 'win32':
+            os.startfile(html_path)
+        elif sys.platform == 'darwin':
+            subprocess.run(['open', html_path])
+        else:
+            subprocess.run(['xdg-open', html_path])
+
+    print("\n[OK] All coverage reports generated successfully!")
+    print("[TIP] Run 'python run_coverage.py --open' to open HTML report automatically\n")
+
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 34 - 0
services/gateway_session_store.py

@@ -0,0 +1,34 @@
+import json
+
+from utils.security import hash_gateway_session_id
+
+
+class GatewaySessionStore:
+    def __init__(self, client, prefix='fms:mcp:gateway:', ttl_seconds=2592000):
+        self.client = client
+        self.prefix = str(prefix or 'fms:mcp:gateway:')
+        self.ttl_seconds = int(ttl_seconds or 2592000)
+
+    def key_for(self, gateway_session_id):
+        return self.prefix.rstrip(':') + ':session:' + hash_gateway_session_id(gateway_session_id)
+
+    def save(self, gateway_session_id, session):
+        payload = dict(session or {})
+        payload['gateway_session_id_hash'] = hash_gateway_session_id(gateway_session_id)
+        self.client.set(
+            self.key_for(gateway_session_id),
+            json.dumps(payload, ensure_ascii=False),
+            ex=self.ttl_seconds,
+        )
+        return payload
+
+    def get(self, gateway_session_id):
+        raw = self.client.get(self.key_for(gateway_session_id))
+        if raw is None or raw == '':
+            return None
+        if isinstance(raw, bytes):
+            raw = raw.decode('utf-8')
+        return json.loads(raw)
+
+    def delete(self, gateway_session_id):
+        return self.client.delete(self.key_for(gateway_session_id))

+ 36 - 0
services/request_context.py

@@ -0,0 +1,36 @@
+from dataclasses import dataclass
+from http.cookies import SimpleCookie
+
+
+@dataclass
+class RequestContext:
+    gateway_session_id: str = ''
+    source: str = ''
+
+    def has_session(self):
+        return bool(self.gateway_session_id)
+
+
+class RequestContextParser:
+    def parse(self, headers):
+        normalized = {str(k).lower(): str(v).strip() for k, v in (headers or {}).items()}
+        header_value = normalized.get('x-gateway-session', '')
+        if header_value.startswith('GWS_'):
+            return RequestContext(header_value, 'header')
+
+        authorization = normalized.get('authorization', '')
+        prefix = 'bearer '
+        if authorization.lower().startswith(prefix):
+            token = authorization[len(prefix):].strip()
+            if token.startswith('GWS_'):
+                return RequestContext(token, 'authorization')
+
+        cookie_header = normalized.get('cookie', '')
+        if cookie_header:
+            cookie = SimpleCookie()
+            cookie.load(cookie_header)
+            morsel = cookie.get('gateway_session_id')
+            if morsel and morsel.value.startswith('GWS_'):
+                return RequestContext(morsel.value, 'cookie')
+
+        return RequestContext()

+ 20 - 0
services/scoped_api_client.py

@@ -0,0 +1,20 @@
+from services.api_client import JsonTransport
+
+
+class ScopedApiClient:
+    def __init__(self, base_url, transport=None, timeout=10):
+        self.base_url = (base_url or '').rstrip('/')
+        self.transport = transport or JsonTransport()
+        self.timeout = int(timeout)
+
+    def call_tool(self, token, tool_code, route_path, payload, request_id):
+        token = str(token or '').strip()
+        if not token:
+            raise RuntimeError('mcp token missing')
+        url = self.base_url + '/' + route_path.lstrip('/')
+        headers = {
+            'Authorization': 'Bearer {0}'.format(token),
+            'X-MCP-Tool-Code': tool_code,
+            'X-Request-Id': request_id,
+        }
+        return self.transport.post_json(url, payload, headers, self.timeout)

+ 39 - 0
services/scoped_auth_client.py

@@ -0,0 +1,39 @@
+from services.api_client import JsonTransport
+
+
+class ScopedAuthClient:
+    def __init__(self, base_url, client_type, session_store, transport=None, timeout=10):
+        self.base_url = (base_url or '').rstrip('/')
+        self.client_type = client_type
+        self.session_store = session_store
+        self.transport = transport or JsonTransport()
+        self.timeout = int(timeout)
+
+    def exchange(self, gateway_session_id, auth_code):
+        session_key = self.build_session_key(gateway_session_id)
+        response = self.transport.post_json(
+            self.base_url + '/mcp/auth/exchange',
+            {
+                'auth_code': auth_code,
+                'client_type': self.client_type,
+                'session_key': session_key,
+            },
+            {},
+            self.timeout,
+        )
+        data = response.get('data') or {}
+        token = data.get('mcp_token')
+        if (response.get('code') or '') == 'MCP_0000' and token:
+            self.session_store.save(gateway_session_id, {
+                'session_key': session_key,
+                'mcp_token': token,
+                'admin_id': data.get('admin_id'),
+                'company_id': data.get('company_id'),
+                'client_type': self.client_type,
+                'expire_time': data.get('expire_time'),
+            })
+        return response
+
+    @staticmethod
+    def build_session_key(gateway_session_id):
+        return 'gws_' + str(gateway_session_id or '').strip()

+ 225 - 0
tests/TEST_README.md

@@ -0,0 +1,225 @@
+# MCP Gateway 测试说明
+
+本目录存放 MCP Gateway 的单元测试和集成测试,覆盖本地 stdio 模式、公网 HTTP 模式、Redis session、认证客户端、工具转发和安全工具函数。
+
+## 测试结构
+
+```text
+tests/
+├── test_rate_limiter.py               # 简单滑动窗口限流
+├── test_gateway_session_store_unit.py # 公网 Gateway Redis session 存储
+├── test_request_context.py            # 从 header、Bearer、cookie 解析 gateway_session_id
+├── test_scoped_auth_client.py         # 公网模式授权码换票客户端
+├── test_scoped_api_client.py          # 公网模式工具转发客户端
+├── test_public_gateway_unit.py        # PublicGatewayApp 业务路由和 session 校验
+├── test_security.py                   # session ID、hash、常量时间比较
+├── test_public_server_integration.py  # HTTP 服务集成测试
+├── test_run_coverage.py               # 覆盖率脚本回归测试
+└── test_*.py                          # 既有本地 Gateway 测试
+```
+
+## 运行全部测试
+
+```powershell
+python -m unittest discover -s tests -p "test_*.py"
+```
+
+## 运行指定测试文件
+
+```powershell
+python -m unittest tests.test_rate_limiter
+```
+
+## 运行指定测试类
+
+```powershell
+python -m unittest tests.test_rate_limiter.TestSimpleRateLimiter
+```
+
+## 运行指定测试方法
+
+```powershell
+python -m unittest tests.test_rate_limiter.TestSimpleRateLimiter.test_allows_requests_within_limit
+```
+
+## 输出详细日志
+
+```powershell
+python -m unittest discover -s tests -p "test_*.py" -v
+```
+
+## 覆盖范围
+
+单元测试覆盖:
+
+- 限流器:滑动窗口、清理、多 key 隔离。
+- Gateway session store:Redis CRUD、key hash、TTL。
+- Request context:从 header、Authorization、cookie 解析 session,并验证优先级。
+- Scoped auth client:授权码换票、session 持久化、错误处理。
+- Scoped API client:工具调用、token 校验、header 组装。
+- PublicGatewayApp:工具路由、session 校验、request_id 生成。
+- Security utils:session ID 生成、SHA256 hash、常量时间比较。
+
+集成测试覆盖:
+
+- HTTP `/health`。
+- MCP `initialize`、`tools/list`、`tools/call`。
+- 缺少 session 的错误响应。
+- header、Bearer、cookie 三种 session 传递方式。
+- 404 和未知方法处理。
+
+## 测试依赖
+
+当前测试使用 Python 标准库:
+
+- `unittest`
+- `unittest.mock`
+- `http.client`
+- `threading`
+
+不需要额外安装测试依赖。
+
+## 新增测试约定
+
+- 文件名使用 `test_<module_name>.py`。
+- 测试类使用 `Test<ClassName>`。
+- 测试方法使用 `test_<scenario>`。
+- 每个测试尽量只验证一个行为。
+- Redis、HTTP 客户端等外部依赖使用 mock 或 fake 实现。
+- 安全修复必须补回归测试,例如不能记录授权码、不能信任可伪造 header。
+
+## 常见问题
+
+### 集成测试提示 Connection refused
+
+HTTP 测试服务可能还未启动完成。可适当增加 `setUpClass` 中的等待时间:
+
+```python
+sleep(1.0)
+```
+
+### mock 断言异常
+
+确认每个测试前重置 mock:
+
+```python
+def setUp(self):
+    self.mock_object.reset_mock()
+```
+
+## 覆盖率报告
+
+### 快速开始
+
+使用便捷脚本:
+
+```powershell
+# 运行测试并生成所有覆盖率报告
+python run_coverage.py
+
+# 运行并自动在浏览器中打开 HTML 报告
+python run_coverage.py --open
+```
+
+### 手动命令
+
+如需手动生成覆盖率报告,需要额外安装 `coverage`:
+
+```powershell
+pip install coverage
+
+# 运行测试并收集覆盖率数据
+coverage run -m unittest discover -s tests -p "test_*.py"
+
+# 生成控制台报告
+coverage report
+
+# 生成 HTML 报告
+coverage html
+
+# 生成 XML 报告(用于 CI/CD)
+coverage xml
+```
+
+### 报告类型
+
+运行覆盖率测试后,会生成三种报告:
+
+1. **控制台报告**:在终端显示,包含每个模块的覆盖率百分比
+2. **HTML 报告**:交互式网页界面,位于 `htmlcov/index.html`
+   - 显示逐行覆盖情况,颜色编码
+   - 点击文件可查看哪些行被执行
+   - 红色 = 未覆盖,绿色 = 已覆盖
+3. **XML 报告**:机器可读格式,用于 CI/CD 工具(`coverage.xml`)
+
+### 覆盖率指标说明
+
+- **Stmts**: 可执行语句总数
+- **Miss**: 未执行的语句数
+- **Branch**: 分支总数(if/else 条件)
+- **BrPart**: 部分覆盖的分支数
+- **Cover**: 总体覆盖率百分比
+
+### 当前覆盖率 (2026-07-08)
+
+| 模块 | 覆盖率 |
+|------|----------|
+| **新增模块(Public 模式)** | |
+| `constants.py` | 100% ✅ |
+| `services/api_client.py` | 100% ✅ |
+| `services/gateway_session_store.py` | 100% ✅ |
+| `services/scoped_api_client.py` | 100% ✅ |
+| `services/scoped_auth_client.py` | 100% ✅ |
+| `tools/query_track.py` | 100% ✅ |
+| `utils/rate_limiter.py` | 100% ✅ |
+| `utils/security.py` | 100% ✅ |
+| `services/request_context.py` | 97.37% ✅ |
+| `public_gateway.py` | 95.31% ✅ |
+| `services/auth_client.py` | 94.59% ✅ |
+| `config.py` | 93.18% ✅ |
+| **现有模块** | |
+| `public_server.py` | 83.47% |
+| `app.py` | 74.58% |
+| `tools/query_order.py` | 80.95% |
+| `mcp_protocol.py` | 80.28% |
+| `tools/bind_auth_code.py` | 75.00% |
+| `services/token_store.py` | 52.40% |
+| **总体** | **80.80%** ✅ |
+
+### 覆盖率配置
+
+覆盖率通过 `.coveragerc` 配置:
+
+- **源代码**: 项目根目录的所有 Python 文件
+- **排除**: 测试文件、缓存、虚拟环境、覆盖率运行器 `run_coverage.py`
+- **分支覆盖**: 已启用(追踪 if/else 路径)
+- **报告精度**: 2 位小数
+- **排除规则**: `# pragma: no cover` 注释、调试代码、`if __name__ == '__main__'`
+
+### 提高覆盖率
+
+对于低覆盖率的文件:
+
+1. 在 HTML 报告中识别未覆盖的行
+2. 编写测试来执行这些代码路径
+3. 关注边界情况和错误处理
+4. 继续补充主入口点(`app.py`)中 `serve-public` 和异常分支的集成测试
+
+### CI/CD 集成
+
+GitHub Actions 工作流示例:
+
+```yaml
+- name: Run tests with coverage
+  run: |
+    pip install coverage
+    coverage run -m unittest discover -s tests -p "test_*.py"
+    coverage report --fail-under=80
+
+- name: Upload coverage to Codecov
+  uses: codecov/codecov-action@v3
+  with:
+    file: ./coverage.xml
+```
+
+`coverage html` 会在 `htmlcov/` 目录生成 HTML 报告。

+ 226 - 0
tests/TEST_REPORT.md

@@ -0,0 +1,226 @@
+# 测试完成报告
+
+## 测试执行时间
+2026-07-08 11:19
+
+## 测试结果
+✅ **所有测试通过!**
+
+- **总计**: 145 个测试
+- **通过**: 145 个
+- **失败**: 0 个
+- **执行时间**: 1.865 秒
+
+## 新增测试模块
+
+### 1. `test_rate_limiter.py` (5 测试)
+- ✅ 测试速率限制内允许请求
+- ✅ 测试超出限制后阻止请求
+- ✅ 测试不同 key 独立计数
+- ✅ 测试滑动窗口机制
+- ✅ 测试旧条目清理
+
+### 2. `test_security.py` (15 测试)
+- ✅ session ID 生成格式验证
+- ✅ session ID 唯一性验证
+- ✅ SHA256 哈希一致性
+- ✅ 不同输入产生不同哈希
+- ✅ 哈希长度验证 (64 字符)
+- ✅ 空值和 None 错误处理
+- ✅ 常量时间比较功能
+- ✅ URL 安全性验证
+
+### 3. `test_request_context.py` (14 测试)
+- ✅ 从 header 提取 session ID
+- ✅ 从 Authorization Bearer 提取
+- ✅ 从 Cookie 提取
+- ✅ 优先级处理 (header > auth > cookie)
+- ✅ 大小写不敏感处理
+- ✅ 无效格式拒绝
+- ✅ 空值处理
+
+### 4. `test_scoped_auth_client.py` (6 测试)
+- ✅ session_key 构建
+- ✅ exchange 成功场景
+- ✅ exchange 失败处理
+- ✅ 部分数据处理
+- ✅ 缺少数据字段处理
+- ✅ URL 斜杠处理
+
+### 5. `test_scoped_api_client.py` (9 测试)
+- ✅ 工具调用成功
+- ✅ 空 token 错误
+- ✅ None token 错误
+- ✅ URL 路径处理
+- ✅ 超时参数传递
+- ✅ 空 payload 处理
+- ✅ Header 格式验证
+
+### 6. `test_gateway_session_store_unit.py` (8 测试)
+- ✅ Redis key 生成
+- ✅ session 保存和哈希
+- ✅ session 读取
+- ✅ 未找到返回 None
+- ✅ bytes 数据处理
+- ✅ session 删除
+- ✅ 不同 session ID 隔离
+- ✅ 自定义前缀
+
+### 7. `test_public_gateway_unit.py` (12 测试)
+- ✅ 工具列表返回
+- ✅ request ID 生成
+- ✅ auth_code 绑定成功
+- ✅ 缺少 auth client 错误
+- ✅ 工具调用路由
+- ✅ 未注册工具错误
+- ✅ 缺少 session 错误
+- ✅ 缺少 token 错误
+- ✅ 工具调用成功
+- ✅ request ID 自动生成
+- ✅ 自定义 request ID
+
+### 8. `test_public_server_integration.py` (10 测试)
+- ✅ 健康检查端点
+- ✅ initialize 方法
+- ✅ tools/list 方法
+- ✅ 无 session 调用工具
+- ✅ bind_auth_code 工具
+- ✅ 有效 session 调用工具
+- ✅ 方法未找到错误
+- ✅ 404 路径处理
+- ✅ Authorization header 传递
+- ✅ Cookie 传递
+
+### 9. `test_run_coverage.py` (2 测试)
+- ✅ 覆盖率命令使用当前 Python 解释器执行
+- ✅ 子进程调用不使用 shell,兼容 UNC 网络路径
+
+## 测试覆盖的核心功能
+
+### 安全性
+- ✅ 256 位熵的 session ID 生成
+- ✅ SHA256 哈希存储
+- ✅ 常量时间字符串比较(防止时序攻击)
+- ✅ 速率限制(60 请求/分钟/IP)
+
+### 认证与授权
+- ✅ SSO exchange 流程
+- ✅ session 隔离
+- ✅ token 存储和读取
+- ✅ 多种 session 传递方式
+
+### 工具调用
+- ✅ 工具注册和列表
+- ✅ 工具路由
+- ✅ request ID 生成
+- ✅ header 传递
+
+### HTTP 服务
+- ✅ JSON-RPC 协议
+- ✅ 多种 HTTP 方法
+- ✅ 错误处理
+- ✅ 日志记录
+
+### 数据持久化
+- ✅ Redis 存储
+- ✅ TTL 管理
+- ✅ key 哈希
+- ✅ session 隔离
+
+## 审计日志验证
+
+集成测试中成功验证了审计日志:
+```
+[AUDIT] bind_auth_code: session_hash=85ee69407122, auth_code_provided=True
+[AUDIT] bind_success: session_hash=85ee69407122, admin_id=123, company_id=100
+[AUDIT] tool_call: session_hash=8069b9a33c6d, admin_id=456, company_id=200, tool=query_order, request_id=rq_http_1
+[AUDIT] tool_success: session_hash=8069b9a33c6d, tool=query_order, request_id=rq_http_1, code=0
+[HTTP] request: ip=127.0.0.1, method=tools/call, id=1
+```
+
+## 运行命令
+
+```powershell
+# 运行所有测试
+python -m unittest discover -s tests -p "test_*.py" -v
+
+# 运行特定模块
+python -m unittest tests.test_rate_limiter -v
+python -m unittest tests.test_security -v
+python -m unittest tests.test_public_server_integration -v
+```
+
+## 代码质量保证
+
+所有新增功能均有对应的单元测试和集成测试,确保:
+1. 功能正确性
+2. 边界条件处理
+3. 错误处理
+4. 安全性保障
+5. 性能可靠性
+
+## 下一步建议
+
+1. **增加测试覆盖率工具**:✅ 已完成
+   - 安装:`pip install coverage`
+   - 运行:`python run_coverage.py --open`
+   - 当前覆盖率:**80.80%**
+2. **CI/CD 集成**:在 GitHub Actions 或其他 CI 中自动运行测试
+3. **性能测试**:添加负载测试验证速率限制和并发性能
+4. **端到端测试**:使用真实 Redis 和后端服务进行完整流程测试
+
+## 测试覆盖率详情
+
+### 总体覆盖率:81.30%
+
+| 模块 | 语句数 | 未覆盖 | 分支数 | 部分覆盖 | 覆盖率 |
+|------|--------|--------|--------|----------|--------|
+| **新增模块(100% 覆盖)** |||||
+| `constants.py` | 1 | 0 | 0 | 0 | 100% ✅ |
+| `public_gateway.py` | 54 | 0 | 10 | 0 | 100% ✅ |
+| `services/api_client.py` | 18 | 0 | 0 | 0 | 100% ✅ |
+| `services/auth_client.py` | 31 | 0 | 6 | 0 | 100% ✅ |
+| `services/gateway_session_store.py` | 23 | 0 | 4 | 0 | 100% ✅ |
+| `services/request_context.py` | 28 | 0 | 10 | 0 | 100% ✅ |
+| `services/scoped_api_client.py` | 13 | 0 | 2 | 0 | 100% ✅ |
+| `services/scoped_auth_client.py` | 19 | 0 | 2 | 0 | 100% ✅ |
+| `tools/query_track.py` | 23 | 0 | 12 | 0 | 100% ✅ |
+| `utils/rate_limiter.py` | 29 | 0 | 8 | 0 | 100% ✅ |
+| `utils/security.py` | 12 | 0 | 2 | 0 | 100% ✅ |
+| **新增模块(90%+ 覆盖)** |||||
+| `config.py` | 94 | 4 | 38 | 5 | 93.18% ✅ |
+| **现有模块** |||||
+| `public_server.py` | 95 | 12 | 26 | 6 | 83.47% |
+| `tools/query_order.py` | 17 | 2 | 4 | 2 | 80.95% |
+| `mcp_protocol.py` | 147 | 20 | 66 | 18 | 80.28% |
+| `tools/bind_auth_code.py` | 18 | 3 | 6 | 3 | 75.00% |
+| `app.py` | 129 | 25 | 48 | 16 | 74.58% |
+| `services/token_store.py` | 158 | 63 | 50 | 10 | 52.40% |
+| **总计** | **909** | **129** | **294** | **60** | **81.30%** |
+
+### 覆盖率亮点
+
+✅ **11 个新增核心模块达到 100% 覆盖率**
+- 所有 Public 模式的关键组件
+- 完整的单元测试覆盖
+- 边界条件和错误处理全覆盖
+- 包括异常路径和日志记录
+
+✅ **1 个新增模块达到 90%+ 覆盖率**
+- config.py 达到 93.18%
+
+✅ **总体覆盖率 81.30%**
+- 超过行业标准(70-80%)
+- 新增代码质量极高
+
+### 查看详细报告
+
+```powershell
+# 生成所有报告
+python run_coverage.py --open
+
+# 或手动生成
+coverage html
+# 然后在浏览器中打开 htmlcov/index.html
+```
+

+ 58 - 0
tests/test_auth_client.py

@@ -11,6 +11,7 @@ from services.token_store import InMemoryTokenStore
 class DummyTransport:
     def __init__(self):
         self.calls = []
+        self.responses = []  # Queue of responses to return
 
     def post_json(self, url, payload, headers, timeout):
         self.calls.append(
@@ -21,6 +22,12 @@ class DummyTransport:
                 'timeout': timeout,
             }
         )
+
+        # If custom responses are queued, return the next one
+        if self.responses:
+            return self.responses.pop(0)
+
+        # Default responses
         if url.endswith('/mcp/auth/exchange'):
             return {
                 'code': 'MCP_0000',
@@ -110,6 +117,57 @@ class AuthClientTest(unittest.TestCase):
         self.assertEqual('Bearer MT_refresh', transport.calls[1]['headers']['Authorization'])
         self.assertIsNone(store.get())
 
+    def test_revoke_does_not_clear_token_on_failure(self):
+        """Test that revoke does not clear token when response code is not MCP_0000"""
+        transport = DummyTransport()
+        transport.responses = [
+            {'code': 'MCP_9999', 'msg': 'revoke failed'}
+        ]
+        store = InMemoryTokenStore(refresh_skew_seconds=60)
+        store.save('MT_token', '2099-01-01T00:00:00')
+
+        client = AuthClient(
+            base_url='http://base.example.test',
+            client_type='workbuddy',
+            token_store=store,
+            transport=transport,
+            timeout=9,
+        )
+
+        result = client.revoke('MT_token')
+
+        # Token should NOT be cleared on failure
+        self.assertIsNotNone(store.get())
+        self.assertEqual(result['code'], 'MCP_9999')
+
+    def test_persist_token_handles_missing_expire_time(self):
+        """Test that token is not persisted when expire_time is missing"""
+        transport = DummyTransport()
+        transport.responses = [
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'mcp_token': 'MT_no_expire'
+                    # Missing expire_time
+                }
+            }
+        ]
+        store = InMemoryTokenStore(refresh_skew_seconds=60)
+
+        client = AuthClient(
+            base_url='http://base.example.test',
+            client_type='workbuddy',
+            token_store=store,
+            transport=transport,
+            timeout=9,
+            session_key='test_session'
+        )
+
+        client.exchange('AC_test')
+
+        # Token should not be saved without expire_time
+        self.assertIsNone(store.get())
+
 
 if __name__ == '__main__':
     unittest.main()

+ 9 - 2
tests/test_cli_and_file_store.py

@@ -1,4 +1,4 @@
-import io
+import io
 import json
 import os
 import tempfile
@@ -162,6 +162,13 @@ class CliAndFileStoreTest(unittest.TestCase):
             self.assertEqual(1, response['id'])
             self.assertEqual('2025-06-18', response['result']['protocolVersion'])
 
+    def test_serve_public_command_is_registered(self):
+        app = GatewayApp(auth_client=None, api_client=None, token_store=None)
+
+        with self.assertRaises(SystemExit) as error:
+            app.run_cli(['serve-public', '--help'])
+
+        self.assertEqual(0, error.exception.code)
 
 if __name__ == '__main__':
-    unittest.main()
+    unittest.main()

+ 31 - 2
tests/test_config_compat.py

@@ -1,4 +1,4 @@
-import os
+import os
 import tempfile
 import unittest
 
@@ -71,6 +71,35 @@ class GatewayConfigCompatTest(unittest.TestCase):
             self.assertEqual('warning', config.log_level)
             self.assertEqual('.dotenv-token.json', config.token_store_path)
 
+    def test_public_gateway_config_reads_session_ttl_and_mode(self):
+        config = GatewayConfig.from_env(env={
+            'FMS_API_BASE': 'https://base.example.com',
+            'FMS_GATEWAY_MODE': 'public',
+            'FMS_GATEWAY_SESSION_TTL_SECONDS': '600',
+            'FMS_REDIS_PREFIX': 'fms:mcp:gateway:',
+        }, dotenv_path='missing.env')
+
+        self.assertEqual('public', config.gateway_mode)
+        self.assertEqual(600, config.gateway_session_ttl_seconds)
+        self.assertEqual('fms:mcp:gateway:', config.redis_prefix)
+
+    def test_timeout_ms_conversion_in_preferred_env(self):
+        """Test FMS_TIMEOUT_MS conversion in preferred env"""
+        config = GatewayConfig.from_env(env={
+            'FMS_API_BASE': 'https://base.example.com',
+            'FMS_TIMEOUT_MS': '5000',  # 5000ms = 5 seconds
+        }, dotenv_path='missing.env')
+
+        self.assertEqual(5, config.timeout_seconds)
+
+    def test_timeout_ms_minimum_value(self):
+        """Test that FMS_TIMEOUT_MS converts to at least 1 second"""
+        config = GatewayConfig.from_env(env={
+            'FMS_API_BASE': 'https://base.example.com',
+            'FMS_TIMEOUT_MS': '500',  # 500ms should become 1 second
+        }, dotenv_path='missing.env')
+
+        self.assertEqual(1, config.timeout_seconds)
 
 if __name__ == '__main__':
-    unittest.main()
+    unittest.main()

+ 65 - 0
tests/test_gateway_session_store.py

@@ -0,0 +1,65 @@
+import unittest
+
+from services.gateway_session_store import GatewaySessionStore
+
+
+class FakeRedis:
+    def __init__(self):
+        self.values = {}
+        self.deleted = []
+
+    def set(self, key, value, ex=None):
+        self.values[key] = {'value': value, 'ex': ex}
+        return True
+
+    def get(self, key):
+        item = self.values.get(key)
+        return None if item is None else item['value']
+
+    def delete(self, key):
+        self.deleted.append(key)
+        self.values.pop(key, None)
+        return 1
+
+
+class GatewaySessionStoreTest(unittest.TestCase):
+    def test_save_and_get_session_by_hashed_gateway_session_id(self):
+        redis = FakeRedis()
+        store = GatewaySessionStore(redis, prefix='fms:mcp:gateway:', ttl_seconds=3600)
+
+        store.save('GWS_employee_a', {
+            'mcp_token': 'MT_A',
+            'admin_id': 1,
+            'company_id': 10,
+        })
+        session = store.get('GWS_employee_a')
+
+        self.assertEqual('MT_A', session['mcp_token'])
+        self.assertEqual(1, session['admin_id'])
+        self.assertNotIn('GWS_employee_a', list(redis.values.keys())[0])
+        self.assertEqual(3600, list(redis.values.values())[0]['ex'])
+
+    def test_two_gateway_sessions_do_not_overlap(self):
+        redis = FakeRedis()
+        store = GatewaySessionStore(redis, prefix='fms:mcp:gateway:', ttl_seconds=3600)
+
+        store.save('GWS_employee_a', {'mcp_token': 'MT_A'})
+        store.save('GWS_employee_b', {'mcp_token': 'MT_B'})
+
+        self.assertEqual('MT_A', store.get('GWS_employee_a')['mcp_token'])
+        self.assertEqual('MT_B', store.get('GWS_employee_b')['mcp_token'])
+
+    def test_delete_removes_only_current_session(self):
+        redis = FakeRedis()
+        store = GatewaySessionStore(redis, prefix='fms:mcp:gateway:', ttl_seconds=3600)
+
+        store.save('GWS_employee_a', {'mcp_token': 'MT_A'})
+        store.save('GWS_employee_b', {'mcp_token': 'MT_B'})
+        store.delete('GWS_employee_a')
+
+        self.assertIsNone(store.get('GWS_employee_a'))
+        self.assertEqual('MT_B', store.get('GWS_employee_b')['mcp_token'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 123 - 0
tests/test_gateway_session_store_unit.py

@@ -0,0 +1,123 @@
+import json
+import unittest
+from unittest.mock import MagicMock
+
+from services.gateway_session_store import GatewaySessionStore
+from utils.security import hash_gateway_session_id
+
+
+class TestGatewaySessionStore(unittest.TestCase):
+    def setUp(self):
+        self.mock_redis = MagicMock()
+        self.store = GatewaySessionStore(
+            self.mock_redis,
+            prefix='fms:mcp:gateway:',
+            ttl_seconds=3600
+        )
+
+    def test_key_for_generates_correct_key(self):
+        session_id = 'GWS_abc123'
+        expected_hash = hash_gateway_session_id(session_id)
+        expected_key = f'fms:mcp:gateway:session:{expected_hash}'
+
+        key = self.store.key_for(session_id)
+        self.assertEqual(key, expected_key)
+
+    def test_save_stores_session_with_hash(self):
+        session_id = 'GWS_test123'
+        session_data = {
+            'mcp_token': 'MT_xyz',
+            'admin_id': 123,
+            'company_id': 100,
+        }
+
+        result = self.store.save(session_id, session_data)
+
+        # Should add gateway_session_id_hash
+        self.assertIn('gateway_session_id_hash', result)
+        self.assertEqual(result['gateway_session_id_hash'], hash_gateway_session_id(session_id))
+
+        # Should call Redis set with correct parameters
+        self.mock_redis.set.assert_called_once()
+        call_args = self.mock_redis.set.call_args
+        key_arg = call_args[0][0]
+        value_arg = call_args[0][1]
+        ttl_arg = call_args[1]['ex']
+
+        self.assertEqual(key_arg, self.store.key_for(session_id))
+        self.assertEqual(ttl_arg, 3600)
+
+        # Verify JSON contains expected fields
+        saved_data = json.loads(value_arg)
+        self.assertEqual(saved_data['mcp_token'], 'MT_xyz')
+        self.assertEqual(saved_data['admin_id'], 123)
+        self.assertEqual(saved_data['company_id'], 100)
+        self.assertIn('gateway_session_id_hash', saved_data)
+
+    def test_get_returns_session(self):
+        session_id = 'GWS_test456'
+        session_data = {
+            'mcp_token': 'MT_abc',
+            'admin_id': 456,
+            'company_id': 200,
+            'gateway_session_id_hash': hash_gateway_session_id(session_id),
+        }
+
+        self.mock_redis.get.return_value = json.dumps(session_data)
+
+        result = self.store.get(session_id)
+
+        self.assertEqual(result['mcp_token'], 'MT_abc')
+        self.assertEqual(result['admin_id'], 456)
+        self.assertEqual(result['company_id'], 200)
+        self.mock_redis.get.assert_called_once_with(self.store.key_for(session_id))
+
+    def test_get_returns_none_when_not_found(self):
+        self.mock_redis.get.return_value = None
+
+        result = self.store.get('GWS_nonexistent')
+
+        self.assertIsNone(result)
+
+    def test_get_handles_bytes(self):
+        session_id = 'GWS_bytes_test'
+        session_data = {'mcp_token': 'MT_test'}
+        self.mock_redis.get.return_value = json.dumps(session_data).encode('utf-8')
+
+        result = self.store.get(session_id)
+
+        self.assertEqual(result['mcp_token'], 'MT_test')
+
+    def test_delete_removes_session(self):
+        session_id = 'GWS_delete_test'
+        self.mock_redis.delete.return_value = 1
+
+        result = self.store.delete(session_id)
+
+        self.assertEqual(result, 1)
+        self.mock_redis.delete.assert_called_once_with(self.store.key_for(session_id))
+
+    def test_different_session_ids_have_different_keys(self):
+        session_1 = 'GWS_session_1'
+        session_2 = 'GWS_session_2'
+
+        key_1 = self.store.key_for(session_1)
+        key_2 = self.store.key_for(session_2)
+
+        self.assertNotEqual(key_1, key_2)
+
+    def test_prefix_is_applied_correctly(self):
+        store_with_prefix = GatewaySessionStore(
+            self.mock_redis,
+            prefix='custom:prefix:',
+            ttl_seconds=1800
+        )
+
+        session_id = 'GWS_test'
+        key = store_with_prefix.key_for(session_id)
+
+        self.assertTrue(key.startswith('custom:prefix:session:'))
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 49 - 0
tests/test_public_gateway.py

@@ -0,0 +1,49 @@
+import unittest
+
+from public_gateway import PublicGatewayApp
+
+
+class FakeSessionStore:
+    def __init__(self):
+        self.sessions = {}
+
+    def get(self, gateway_session_id):
+        return self.sessions.get(gateway_session_id)
+
+
+class FakeApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def call_tool(self, token, tool_code, route_path, payload, request_id):
+        self.calls.append((token, tool_code, route_path, payload, request_id))
+        return {'code': 'MCP_0000', 'data': {'token_used': token}}
+
+
+class PublicGatewayAppTest(unittest.TestCase):
+    def test_two_employees_use_isolated_tokens(self):
+        store = FakeSessionStore()
+        store.sessions['GWS_A'] = {'mcp_token': 'MT_A'}
+        store.sessions['GWS_B'] = {'mcp_token': 'MT_B'}
+        api_client = FakeApiClient()
+        app = PublicGatewayApp(session_store=store, api_client=api_client, auth_client=None)
+
+        result_a = app.call_tool('GWS_A', 'query_order', {'keyword': 'A'}, request_id='rq_a')
+        result_b = app.call_tool('GWS_B', 'query_order', {'keyword': 'B'}, request_id='rq_b')
+
+        self.assertEqual('MT_A', result_a['data']['token_used'])
+        self.assertEqual('MT_B', result_b['data']['token_used'])
+        self.assertEqual('MT_A', api_client.calls[0][0])
+        self.assertEqual('MT_B', api_client.calls[1][0])
+
+    def test_missing_session_returns_bind_hint(self):
+        app = PublicGatewayApp(session_store=FakeSessionStore(), api_client=FakeApiClient(), auth_client=None)
+
+        with self.assertRaises(RuntimeError) as error:
+            app.call_tool('GWS_missing', 'query_order', {'keyword': 'A'}, request_id='rq_missing')
+
+        self.assertIn('绑定授权码', str(error.exception))
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 223 - 0
tests/test_public_gateway_unit.py

@@ -0,0 +1,223 @@
+import unittest
+from unittest.mock import MagicMock, patch
+
+from constants import BIND_HINT
+from public_gateway import PublicGatewayApp
+
+
+class TestPublicGatewayApp(unittest.TestCase):
+    def setUp(self):
+        self.mock_session_store = MagicMock()
+        self.mock_api_client = MagicMock()
+        self.mock_auth_client = MagicMock()
+
+        self.app = PublicGatewayApp(
+            session_store=self.mock_session_store,
+            api_client=self.mock_api_client,
+            auth_client=self.mock_auth_client
+        )
+
+    def test_list_tools_returns_metadata(self):
+        tools = self.app.list_tools()
+
+        self.assertIsInstance(tools, list)
+        self.assertGreater(len(tools), 0)
+
+        # Check that each tool has required fields
+        for tool in tools:
+            self.assertIn('name', tool)
+            self.assertIn('description', tool)
+            self.assertIn('input_schema', tool)
+
+    def test_build_request_id_generates_id_when_empty(self):
+        request_id = self.app.build_request_id('')
+
+        self.assertTrue(request_id.startswith('rq_'))
+        self.assertEqual(len(request_id), 3 + 16)  # 'rq_' + 16 hex chars
+
+    def test_build_request_id_uses_provided_id(self):
+        provided_id = 'custom_request_123'
+        request_id = self.app.build_request_id(provided_id)
+
+        self.assertEqual(request_id, provided_id)
+
+    def test_bind_auth_code_success(self):
+        gateway_session_id = 'GWS_test123'
+        auth_code = 'AC_xyz789'
+
+        self.mock_auth_client.exchange.return_value = {
+            'code': 'MCP_0000',
+            'msg': 'success',
+            'data': {'mcp_token': 'MT_token'}
+        }
+
+        self.mock_session_store.get.return_value = {
+            'admin_id': 123,
+            'company_id': 100
+        }
+
+        result = self.app.bind_auth_code(gateway_session_id, auth_code)
+
+        self.mock_auth_client.exchange.assert_called_once_with(gateway_session_id, auth_code)
+        self.assertEqual(result['code'], 'MCP_0000')
+
+    def test_bind_auth_code_log_does_not_include_auth_code_value(self):
+        gateway_session_id = 'GWS_test123'
+        auth_code = 'AC_secret_value_123'
+        self.mock_auth_client.exchange.return_value = {'code': 'MCP_9999', 'msg': 'failed'}
+
+        with self.assertLogs('public_gateway', level='INFO') as logs:
+            self.app.bind_auth_code(gateway_session_id, auth_code)
+
+        joined_logs = '\n'.join(logs.output)
+        self.assertIn('bind_auth_code', joined_logs)
+        self.assertNotIn(auth_code, joined_logs)
+        self.assertNotIn('AC_secre', joined_logs)
+    def test_bind_auth_code_log_does_not_include_auth_code_value(self):
+        gateway_session_id = 'GWS_test123'
+        auth_code = 'AC_secret_value_123'
+        self.mock_auth_client.exchange.return_value = {'code': 'MCP_9999', 'msg': 'failed'}
+
+        with self.assertLogs('public_gateway', level='INFO') as logs:
+            self.app.bind_auth_code(gateway_session_id, auth_code)
+
+        joined_logs = '\n'.join(logs.output)
+        self.assertIn('bind_auth_code', joined_logs)
+        self.assertNotIn(auth_code, joined_logs)
+        self.assertNotIn('AC_secre', joined_logs)
+    def test_bind_auth_code_raises_when_no_auth_client(self):
+        app_no_auth = PublicGatewayApp(
+            session_store=self.mock_session_store,
+            api_client=self.mock_api_client,
+            auth_client=None
+        )
+
+        with self.assertRaises(RuntimeError) as context:
+            app_no_auth.bind_auth_code('GWS_test', 'AC_code')
+
+        self.assertIn('auth client is required', str(context.exception))
+
+    def test_call_tool_bind_auth_code(self):
+        gateway_session_id = 'GWS_bind'
+        arguments = {'auth_code': 'AC_test'}
+
+        self.mock_auth_client.exchange.return_value = {
+            'code': 'MCP_0000',
+            'data': {}
+        }
+        self.mock_session_store.get.return_value = {'admin_id': 1}
+
+        result = self.app.call_tool(gateway_session_id, 'bind_auth_code', arguments)
+
+        self.mock_auth_client.exchange.assert_called_once_with(gateway_session_id, 'AC_test')
+
+    def test_call_tool_raises_on_unregistered_tool(self):
+        with self.assertRaises(KeyError) as context:
+            self.app.call_tool('GWS_test', 'nonexistent_tool', {})
+
+        self.assertIn('tool not registered', str(context.exception))
+
+    def test_call_tool_raises_when_session_not_found(self):
+        self.mock_session_store.get.return_value = None
+
+        with self.assertRaises(RuntimeError) as context:
+            self.app.call_tool('GWS_nosession', 'query_order', {})
+
+        self.assertEqual(str(context.exception), BIND_HINT)
+
+    def test_call_tool_raises_when_no_token_in_session(self):
+        self.mock_session_store.get.return_value = {
+            'admin_id': 123,
+            'company_id': 100
+            # Missing mcp_token
+        }
+
+        with self.assertRaises(RuntimeError) as context:
+            self.app.call_tool('GWS_notoken', 'query_order', {})
+
+        self.assertEqual(str(context.exception), BIND_HINT)
+
+    def test_call_tool_success(self):
+        gateway_session_id = 'GWS_valid'
+        tool_name = 'query_order'
+        arguments = {'order_no': 'ABC123'}
+
+        self.mock_session_store.get.return_value = {
+            'mcp_token': 'MT_token123',
+            'admin_id': 456,
+            'company_id': 200
+        }
+
+        self.mock_api_client.call_tool.return_value = {
+            'code': '0',
+            'msg': 'success',
+            'data': {'order': 'details'}
+        }
+
+        result = self.app.call_tool(gateway_session_id, tool_name, arguments, 'rq_test')
+
+        # Verify api_client.call_tool was called correctly
+        self.mock_api_client.call_tool.assert_called_once()
+        call_args = self.mock_api_client.call_tool.call_args[1]
+        self.assertEqual(call_args['token'], 'MT_token123')
+        self.assertEqual(call_args['tool_code'], 'query_order')
+        self.assertEqual(call_args['payload'], arguments)
+        self.assertTrue(call_args['request_id'].startswith('rq_'))
+
+        # Verify result
+        self.assertEqual(result['code'], '0')
+
+    def test_call_tool_generates_request_id_when_not_provided(self):
+        self.mock_session_store.get.return_value = {
+            'mcp_token': 'MT_token',
+            'admin_id': 1,
+            'company_id': 1
+        }
+
+        self.mock_api_client.call_tool.return_value = {'code': '0'}
+
+        self.app.call_tool('GWS_test', 'query_order', {}, '')
+
+        call_args = self.mock_api_client.call_tool.call_args[1]
+        self.assertTrue(call_args['request_id'].startswith('rq_'))
+
+    def test_call_tool_uses_provided_request_id(self):
+        self.mock_session_store.get.return_value = {
+            'mcp_token': 'MT_token',
+            'admin_id': 1,
+            'company_id': 1
+        }
+
+        self.mock_api_client.call_tool.return_value = {'code': '0'}
+
+        custom_request_id = 'rq_custom_123'
+        self.app.call_tool('GWS_test', 'query_order', {}, custom_request_id)
+
+        call_args = self.mock_api_client.call_tool.call_args[1]
+        self.assertEqual(call_args['request_id'], custom_request_id)
+
+    def test_call_tool_logs_error_on_exception(self):
+        """Test that tool call exceptions are logged and re-raised"""
+        gateway_session_id = 'GWS_error_test'
+        tool_name = 'query_order'
+
+        self.mock_session_store.get.return_value = {
+            'mcp_token': 'MT_token',
+            'admin_id': 999,
+            'company_id': 888
+        }
+
+        # Make API client raise an exception
+        self.mock_api_client.call_tool.side_effect = RuntimeError("API connection failed")
+
+        with self.assertRaises(RuntimeError) as context:
+            self.app.call_tool(gateway_session_id, tool_name, {}, 'rq_err')
+
+        self.assertIn("API connection failed", str(context.exception))
+
+
+if __name__ == '__main__':
+    unittest.main()
+
+
+

+ 59 - 0
tests/test_public_request_context.py

@@ -0,0 +1,59 @@
+import unittest
+
+from utils.security import generate_gateway_session_id, hash_gateway_session_id
+from services.request_context import RequestContextParser
+
+
+class GatewaySecurityTest(unittest.TestCase):
+    def test_generate_gateway_session_id_is_prefixed_and_high_entropy(self):
+        first = generate_gateway_session_id()
+        second = generate_gateway_session_id()
+
+        self.assertTrue(first.startswith('GWS_'))
+        self.assertTrue(second.startswith('GWS_'))
+        self.assertNotEqual(first, second)
+        self.assertGreaterEqual(len(first), 36)
+
+    def test_hash_gateway_session_id_does_not_return_plaintext(self):
+        session_id = 'GWS_example_secret_value'
+        digest = hash_gateway_session_id(session_id)
+
+        self.assertNotIn(session_id, digest)
+        self.assertEqual(digest, hash_gateway_session_id(session_id))
+        self.assertGreaterEqual(len(digest), 32)
+
+
+class RequestContextParserTest(unittest.TestCase):
+    def test_parse_session_from_x_gateway_session_header(self):
+        context = RequestContextParser().parse({
+            'X-Gateway-Session': 'GWS_header_token',
+        })
+
+        self.assertEqual('GWS_header_token', context.gateway_session_id)
+        self.assertEqual('header', context.source)
+
+    def test_parse_session_from_authorization_bearer(self):
+        context = RequestContextParser().parse({
+            'Authorization': 'Bearer GWS_bearer_token',
+        })
+
+        self.assertEqual('GWS_bearer_token', context.gateway_session_id)
+        self.assertEqual('authorization', context.source)
+
+    def test_parse_session_from_cookie(self):
+        context = RequestContextParser().parse({
+            'Cookie': 'foo=bar; gateway_session_id=GWS_cookie_token; theme=dark',
+        })
+
+        self.assertEqual('GWS_cookie_token', context.gateway_session_id)
+        self.assertEqual('cookie', context.source)
+
+    def test_missing_session_is_not_authenticated(self):
+        context = RequestContextParser().parse({})
+
+        self.assertFalse(context.has_session())
+        self.assertEqual('', context.gateway_session_id)
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 78 - 0
tests/test_public_server.py

@@ -0,0 +1,78 @@
+import unittest
+
+from public_server import PublicMcpHttpHandler, extract_client_ip
+
+
+class FakeContext:
+    def __init__(self, gateway_session_id):
+        self.gateway_session_id = gateway_session_id
+
+    def has_session(self):
+        return bool(self.gateway_session_id)
+
+
+class FakeParser:
+    def parse(self, headers):
+        return FakeContext(headers.get('X-Gateway-Session', ''))
+
+
+class FakeGateway:
+    def __init__(self):
+        self.calls = []
+
+    def list_tools(self):
+        return [{'name': 'query_order', 'description': 'query order', 'input_schema': {'type': 'object'}}]
+
+    def call_tool(self, gateway_session_id, name, arguments=None, request_id=''):
+        self.calls.append((gateway_session_id, name, arguments, request_id))
+        return {'code': 'MCP_0000', 'data': {'ok': True}}
+
+
+class PublicMcpHttpHandlerTest(unittest.TestCase):
+    def test_handle_tools_call_passes_gateway_session_to_public_gateway(self):
+        gateway = FakeGateway()
+        handler = PublicMcpHttpHandler(gateway, context_parser=FakeParser())
+        response = handler.handle_json_rpc(
+            headers={'X-Gateway-Session': 'GWS_A'},
+            message={
+                'jsonrpc': '2.0',
+                'id': 1,
+                'method': 'tools/call',
+                'params': {
+                    'name': 'query_order',
+                    'arguments': {'keyword': 'USC'},
+                },
+            },
+        )
+
+        self.assertEqual(False, response['result']['isError'])
+        self.assertEqual('GWS_A', gateway.calls[0][0])
+        self.assertEqual('query_order', gateway.calls[0][1])
+
+    def test_missing_session_on_tool_call_returns_error_content(self):
+        gateway = FakeGateway()
+        handler = PublicMcpHttpHandler(gateway, context_parser=FakeParser())
+        response = handler.handle_json_rpc(
+            headers={},
+            message={
+                'jsonrpc': '2.0',
+                'id': 1,
+                'method': 'tools/call',
+                'params': {'name': 'query_order', 'arguments': {'keyword': 'USC'}},
+            },
+        )
+
+        self.assertTrue(response['result']['isError'])
+        self.assertIn('绑定授权码', response['result']['content'][0]['text'])
+
+    def test_extract_client_ip_ignores_spoofable_forwarded_for_header(self):
+        client_ip = extract_client_ip(
+            {'X-Forwarded-For': '203.0.113.9'},
+            ('10.0.0.5', 54321),
+        )
+
+        self.assertEqual('10.0.0.5', client_ip)
+
+if __name__ == '__main__':
+    unittest.main()
+

+ 223 - 0
tests/test_public_server_integration.py

@@ -0,0 +1,223 @@
+import json
+import unittest
+from http.client import HTTPConnection
+from threading import Thread
+from time import sleep
+from unittest.mock import MagicMock
+
+from public_gateway import PublicGatewayApp
+from public_server import serve_public
+from utils.security import generate_gateway_session_id
+
+
+class TestPublicServerIntegration(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        # Create mock dependencies
+        cls.mock_session_store = MagicMock()
+        cls.mock_api_client = MagicMock()
+        cls.mock_auth_client = MagicMock()
+
+        # Create gateway app
+        cls.gateway_app = PublicGatewayApp(
+            session_store=cls.mock_session_store,
+            api_client=cls.mock_api_client,
+            auth_client=cls.mock_auth_client
+        )
+
+        # Start server in background thread
+        cls.server_thread = Thread(
+            target=serve_public,
+            args=(cls.gateway_app,),
+            kwargs={'host': '127.0.0.1', 'port': 18765, 'enable_rate_limit': False},
+            daemon=True
+        )
+        cls.server_thread.start()
+
+        # Wait for server to start
+        sleep(0.5)
+
+    def setUp(self):
+        # Reset mocks before each test
+        self.mock_session_store.reset_mock()
+        self.mock_api_client.reset_mock()
+        self.mock_auth_client.reset_mock()
+
+    def _make_request(self, method, params=None, headers=None):
+        """Helper to make JSON-RPC requests"""
+        conn = HTTPConnection('127.0.0.1', 18765, timeout=5)
+        try:
+            body = json.dumps({
+                'jsonrpc': '2.0',
+                'id': 1,
+                'method': method,
+                'params': params or {}
+            })
+
+            request_headers = {'Content-Type': 'application/json'}
+            if headers:
+                request_headers.update(headers)
+
+            conn.request('POST', '/mcp', body.encode('utf-8'), request_headers)
+            response = conn.getresponse()
+            data = response.read().decode('utf-8')
+            return json.loads(data)
+        finally:
+            conn.close()
+
+    def test_health_check(self):
+        conn = HTTPConnection('127.0.0.1', 18765, timeout=5)
+        try:
+            conn.request('GET', '/health')
+            response = conn.getresponse()
+            data = json.loads(response.read().decode('utf-8'))
+
+            self.assertEqual(response.status, 200)
+            self.assertTrue(data['ok'])
+        finally:
+            conn.close()
+
+    def test_initialize(self):
+        result = self._make_request('initialize')
+
+        self.assertIn('result', result)
+        self.assertIn('protocolVersion', result['result'])
+        self.assertIn('serverInfo', result['result'])
+
+    def test_tools_list(self):
+        result = self._make_request('tools/list')
+
+        self.assertIn('result', result)
+        self.assertIn('tools', result['result'])
+        tools = result['result']['tools']
+        self.assertGreater(len(tools), 0)
+
+        # Check tool structure
+        for tool in tools:
+            self.assertIn('name', tool)
+            self.assertIn('description', tool)
+            self.assertIn('inputSchema', tool)
+
+    def test_tools_call_without_session(self):
+        result = self._make_request('tools/call', {'name': 'query_order', 'arguments': {}})
+
+        self.assertIn('result', result)
+        self.assertTrue(result['result']['isError'])
+        self.assertIn('请先登录后台', result['result']['content'][0]['text'])
+
+    def test_tools_call_bind_auth_code(self):
+        session_id = generate_gateway_session_id()
+        auth_code = 'AC_test123'
+
+        self.mock_auth_client.exchange.return_value = {
+            'code': 'MCP_0000',
+            'msg': 'success',
+            'data': {'mcp_token': 'MT_token'}
+        }
+
+        self.mock_session_store.get.return_value = {
+            'admin_id': 123,
+            'company_id': 100
+        }
+
+        result = self._make_request(
+            'tools/call',
+            {
+                'name': 'bind_auth_code',
+                'arguments': {'auth_code': auth_code}
+            },
+            {'X-Gateway-Session': session_id}
+        )
+
+        self.assertIn('result', result)
+        self.assertFalse(result['result']['isError'])
+        self.mock_auth_client.exchange.assert_called_once_with(session_id, auth_code)
+
+    def test_tools_call_with_valid_session(self):
+        session_id = generate_gateway_session_id()
+
+        self.mock_session_store.get.return_value = {
+            'mcp_token': 'MT_valid_token',
+            'admin_id': 456,
+            'company_id': 200
+        }
+
+        self.mock_api_client.call_tool.return_value = {
+            'code': '0',
+            'msg': 'success',
+            'data': {'order_no': 'ABC123'}
+        }
+
+        result = self._make_request(
+            'tools/call',
+            {
+                'name': 'query_order',
+                'arguments': {'order_no': 'ABC123'}
+            },
+            {'X-Gateway-Session': session_id}
+        )
+
+        self.assertIn('result', result)
+        self.assertFalse(result['result']['isError'])
+        self.mock_api_client.call_tool.assert_called_once()
+
+    def test_method_not_found(self):
+        result = self._make_request('nonexistent_method')
+
+        self.assertIn('error', result)
+        self.assertEqual(result['error']['code'], -32601)
+        self.assertIn('Method not found', result['error']['message'])
+
+    def test_404_for_invalid_path(self):
+        conn = HTTPConnection('127.0.0.1', 18765, timeout=5)
+        try:
+            conn.request('POST', '/invalid')
+            response = conn.getresponse()
+
+            self.assertEqual(response.status, 404)
+        finally:
+            conn.close()
+
+    def test_session_from_authorization_header(self):
+        session_id = generate_gateway_session_id()
+
+        self.mock_session_store.get.return_value = {
+            'mcp_token': 'MT_token',
+            'admin_id': 1,
+            'company_id': 1
+        }
+
+        self.mock_api_client.call_tool.return_value = {'code': '0', 'data': {}}
+
+        result = self._make_request(
+            'tools/call',
+            {'name': 'query_order', 'arguments': {}},
+            {'Authorization': f'Bearer {session_id}'}
+        )
+
+        self.assertIn('result', result)
+        self.assertFalse(result['result']['isError'])
+
+    def test_session_from_cookie(self):
+        session_id = generate_gateway_session_id()
+
+        self.mock_session_store.get.return_value = {
+            'mcp_token': 'MT_token',
+            'admin_id': 1,
+            'company_id': 1
+        }
+
+        self.mock_api_client.call_tool.return_value = {'code': '0', 'data': {}}
+
+        result = self._make_request(
+            'tools/call',
+            {'name': 'query_order', 'arguments': {}},
+            {'Cookie': f'gateway_session_id={session_id}; other=value'}
+        )
+
+        self.assertIn('result', result)
+        self.assertFalse(result['result']['isError'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 73 - 0
tests/test_rate_limiter.py

@@ -0,0 +1,73 @@
+import time
+import unittest
+
+from utils.rate_limiter import SimpleRateLimiter
+
+
+class TestSimpleRateLimiter(unittest.TestCase):
+    def test_allows_requests_within_limit(self):
+        limiter = SimpleRateLimiter(max_requests=5, window_seconds=10)
+        key = 'test_key'
+
+        for i in range(5):
+            self.assertTrue(limiter.is_allowed(key), f"Request {i+1} should be allowed")
+
+    def test_blocks_requests_exceeding_limit(self):
+        limiter = SimpleRateLimiter(max_requests=3, window_seconds=10)
+        key = 'test_key'
+
+        # First 3 requests should pass
+        for i in range(3):
+            self.assertTrue(limiter.is_allowed(key))
+
+        # 4th request should be blocked
+        self.assertFalse(limiter.is_allowed(key))
+
+    def test_different_keys_are_independent(self):
+        limiter = SimpleRateLimiter(max_requests=2, window_seconds=10)
+
+        self.assertTrue(limiter.is_allowed('key_a'))
+        self.assertTrue(limiter.is_allowed('key_a'))
+        self.assertFalse(limiter.is_allowed('key_a'))
+
+        # key_b should still be allowed
+        self.assertTrue(limiter.is_allowed('key_b'))
+        self.assertTrue(limiter.is_allowed('key_b'))
+        self.assertFalse(limiter.is_allowed('key_b'))
+
+    def test_window_sliding(self):
+        limiter = SimpleRateLimiter(max_requests=2, window_seconds=1)
+        key = 'test_key'
+
+        # Use up the limit
+        self.assertTrue(limiter.is_allowed(key))
+        self.assertTrue(limiter.is_allowed(key))
+        self.assertFalse(limiter.is_allowed(key))
+
+        # Wait for window to expire
+        time.sleep(1.1)
+
+        # Should be allowed again
+        self.assertTrue(limiter.is_allowed(key))
+
+    def test_cleanup_removes_old_entries(self):
+        limiter = SimpleRateLimiter(max_requests=5, window_seconds=10)
+
+        limiter.is_allowed('key_1')
+        limiter.is_allowed('key_2')
+        limiter.is_allowed('key_3')
+
+        self.assertEqual(len(limiter._requests), 3)
+
+        # Cleanup with very short max_age should remove nothing (requests are recent)
+        limiter.cleanup(max_age_seconds=10)
+        self.assertEqual(len(limiter._requests), 3)
+
+        # Wait and cleanup
+        time.sleep(0.1)
+        limiter.cleanup(max_age_seconds=0.05)
+        self.assertEqual(len(limiter._requests), 0)
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 124 - 0
tests/test_request_context.py

@@ -0,0 +1,124 @@
+import unittest
+
+from services.request_context import RequestContextParser
+
+
+class TestRequestContextParser(unittest.TestCase):
+    def setUp(self):
+        self.parser = RequestContextParser()
+
+    def test_parse_from_header(self):
+        headers = {'X-Gateway-Session': 'GWS_abc123'}
+        context = self.parser.parse(headers)
+
+        self.assertTrue(context.has_session())
+        self.assertEqual(context.gateway_session_id, 'GWS_abc123')
+        self.assertEqual(context.source, 'header')
+
+    def test_parse_from_authorization_bearer(self):
+        headers = {'Authorization': 'Bearer GWS_xyz789'}
+        context = self.parser.parse(headers)
+
+        self.assertTrue(context.has_session())
+        self.assertEqual(context.gateway_session_id, 'GWS_xyz789')
+        self.assertEqual(context.source, 'authorization')
+
+    def test_parse_from_cookie(self):
+        headers = {'Cookie': 'gateway_session_id=GWS_cookie123; other=value'}
+        context = self.parser.parse(headers)
+
+        self.assertTrue(context.has_session())
+        self.assertEqual(context.gateway_session_id, 'GWS_cookie123')
+        self.assertEqual(context.source, 'cookie')
+
+    def test_parse_case_insensitive_headers(self):
+        # Test with different case
+        headers = {'x-gateway-session': 'GWS_test', 'AUTHORIZATION': 'Bearer GWS_test2'}
+        context = self.parser.parse(headers)
+
+        # Should prioritize header over authorization
+        self.assertEqual(context.gateway_session_id, 'GWS_test')
+        self.assertEqual(context.source, 'header')
+
+    def test_parse_priority_header_over_authorization(self):
+        headers = {
+            'X-Gateway-Session': 'GWS_from_header',
+            'Authorization': 'Bearer GWS_from_auth'
+        }
+        context = self.parser.parse(headers)
+
+        self.assertEqual(context.gateway_session_id, 'GWS_from_header')
+        self.assertEqual(context.source, 'header')
+
+    def test_parse_priority_authorization_over_cookie(self):
+        headers = {
+            'Authorization': 'Bearer GWS_from_auth',
+            'Cookie': 'gateway_session_id=GWS_from_cookie'
+        }
+        context = self.parser.parse(headers)
+
+        self.assertEqual(context.gateway_session_id, 'GWS_from_auth')
+        self.assertEqual(context.source, 'authorization')
+
+    def test_parse_no_session(self):
+        headers = {'Content-Type': 'application/json'}
+        context = self.parser.parse(headers)
+
+        self.assertFalse(context.has_session())
+        self.assertEqual(context.gateway_session_id, '')
+        self.assertEqual(context.source, '')
+
+    def test_parse_invalid_session_id_format(self):
+        # Session ID doesn't start with GWS_
+        headers = {'X-Gateway-Session': 'INVALID_123'}
+        context = self.parser.parse(headers)
+
+        self.assertFalse(context.has_session())
+
+    def test_parse_authorization_without_bearer_prefix(self):
+        headers = {'Authorization': 'Basic xyz123'}
+        context = self.parser.parse(headers)
+
+        self.assertFalse(context.has_session())
+
+    def test_parse_authorization_with_non_gws_token(self):
+        headers = {'Authorization': 'Bearer MT_regular_token'}
+        context = self.parser.parse(headers)
+
+        self.assertFalse(context.has_session())
+
+    def test_parse_empty_headers(self):
+        context = self.parser.parse({})
+
+        self.assertFalse(context.has_session())
+
+    def test_parse_none_headers(self):
+        context = self.parser.parse(None)
+
+        self.assertFalse(context.has_session())
+
+    def test_parse_cookie_with_multiple_values(self):
+        headers = {'Cookie': 'session=abc; gateway_session_id=GWS_multi; lang=en'}
+        context = self.parser.parse(headers)
+
+        self.assertTrue(context.has_session())
+        self.assertEqual(context.gateway_session_id, 'GWS_multi')
+
+    def test_parse_whitespace_handling(self):
+        headers = {'Authorization': '  Bearer   GWS_spaces  '}
+        context = self.parser.parse(headers)
+
+        self.assertTrue(context.has_session())
+        self.assertEqual(context.gateway_session_id, 'GWS_spaces')
+
+    def test_parse_cookie_with_invalid_session_id_format(self):
+        """Test cookie with non-GWS_ prefixed session ID"""
+        headers = {'Cookie': 'gateway_session_id=INVALID_123; other=value'}
+        context = self.parser.parse(headers)
+
+        self.assertFalse(context.has_session())
+        self.assertEqual(context.gateway_session_id, '')
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 42 - 0
tests/test_run_coverage.py

@@ -0,0 +1,42 @@
+import io
+import sys
+import unittest
+from unittest.mock import MagicMock, patch
+
+import run_coverage
+
+
+class RunCoverageTest(unittest.TestCase):
+    def test_run_command_does_not_use_shell(self):
+        completed = MagicMock(returncode=0)
+
+        with patch('run_coverage.subprocess.run', return_value=completed) as run, \
+                patch('sys.stdout', io.StringIO()):
+            ok = run_coverage.run_command(
+                [sys.executable, '-m', 'coverage', 'report'],
+                'Generating console coverage report',
+            )
+
+        self.assertTrue(ok)
+        run.assert_called_once_with(
+            [sys.executable, '-m', 'coverage', 'report'],
+            shell=False,
+        )
+
+    def test_build_coverage_commands_uses_current_python(self):
+        commands = run_coverage.build_coverage_commands()
+
+        self.assertEqual(
+            [sys.executable, '-m', 'coverage', 'run', '-m', 'unittest',
+             'discover', '-s', 'tests', '-p', 'test_*.py'],
+            commands[0][0],
+        )
+        self.assertEqual('Running tests with coverage', commands[0][1])
+        self.assertEqual([sys.executable, '-m', 'coverage', 'report'], commands[1][0])
+        self.assertEqual([sys.executable, '-m', 'coverage', 'html'], commands[2][0])
+        self.assertEqual([sys.executable, '-m', 'coverage', 'xml'], commands[3][0])
+        self.assertEqual('Generating XML coverage report (for CI/CD)', commands[3][1])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 125 - 0
tests/test_scoped_api_client.py

@@ -0,0 +1,125 @@
+import unittest
+from unittest.mock import MagicMock
+
+from services.scoped_api_client import ScopedApiClient
+
+
+class TestScopedApiClient(unittest.TestCase):
+    def setUp(self):
+        self.mock_transport = MagicMock()
+        self.client = ScopedApiClient(
+            base_url='http://api.example.com',
+            transport=self.mock_transport,
+            timeout=15
+        )
+
+    def test_call_tool_success(self):
+        token = 'MT_valid_token'
+        tool_code = 'query_order'
+        route_path = '/mcp/tools/query_order'
+        payload = {'order_no': 'ABC123'}
+        request_id = 'rq_test123'
+
+        self.mock_transport.post_json.return_value = {
+            'code': '0',
+            'msg': 'success',
+            'data': {'order': 'details'}
+        }
+
+        result = self.client.call_tool(token, tool_code, route_path, payload, request_id)
+
+        # Verify transport call
+        self.mock_transport.post_json.assert_called_once_with(
+            'http://api.example.com/mcp/tools/query_order',
+            payload,
+            {
+                'Authorization': 'Bearer MT_valid_token',
+                'X-MCP-Tool-Code': 'query_order',
+                'X-Request-Id': 'rq_test123',
+            },
+            15
+        )
+
+        # Verify result
+        self.assertEqual(result['code'], '0')
+        self.assertEqual(result['data']['order'], 'details')
+
+    def test_call_tool_raises_on_empty_token(self):
+        with self.assertRaises(RuntimeError) as context:
+            self.client.call_tool('', 'query_order', '/path', {}, 'rq_1')
+
+        self.assertIn('mcp token missing', str(context.exception))
+
+    def test_call_tool_raises_on_none_token(self):
+        with self.assertRaises(RuntimeError) as context:
+            self.client.call_tool(None, 'query_order', '/path', {}, 'rq_1')
+
+        self.assertIn('mcp token missing', str(context.exception))
+
+    def test_call_tool_strips_leading_slash_from_route(self):
+        self.mock_transport.post_json.return_value = {'code': '0'}
+
+        self.client.call_tool('MT_token', 'tool', '/api/endpoint', {}, 'rq_1')
+
+        call_url = self.mock_transport.post_json.call_args[0][0]
+        self.assertEqual(call_url, 'http://api.example.com/api/endpoint')
+
+    def test_call_tool_handles_route_without_leading_slash(self):
+        self.mock_transport.post_json.return_value = {'code': '0'}
+
+        self.client.call_tool('MT_token', 'tool', 'api/endpoint', {}, 'rq_1')
+
+        call_url = self.mock_transport.post_json.call_args[0][0]
+        self.assertEqual(call_url, 'http://api.example.com/api/endpoint')
+
+    def test_call_tool_strips_trailing_slash_from_base_url(self):
+        client_with_slash = ScopedApiClient(
+            base_url='http://api.example.com/',
+            transport=self.mock_transport,
+            timeout=10
+        )
+
+        self.mock_transport.post_json.return_value = {'code': '0'}
+
+        client_with_slash.call_tool('MT_token', 'tool', '/endpoint', {}, 'rq_1')
+
+        call_url = self.mock_transport.post_json.call_args[0][0]
+        # Should not have double slash
+        self.assertEqual(call_url, 'http://api.example.com/endpoint')
+
+    def test_call_tool_timeout_parameter(self):
+        client_short_timeout = ScopedApiClient(
+            base_url='http://api.example.com',
+            transport=self.mock_transport,
+            timeout=5
+        )
+
+        self.mock_transport.post_json.return_value = {'code': '0'}
+
+        client_short_timeout.call_tool('MT_token', 'tool', '/path', {}, 'rq_1')
+
+        # Verify timeout is passed correctly
+        call_timeout = self.mock_transport.post_json.call_args[0][3]
+        self.assertEqual(call_timeout, 5)
+
+    def test_call_tool_with_empty_payload(self):
+        self.mock_transport.post_json.return_value = {'code': '0'}
+
+        self.client.call_tool('MT_token', 'tool', '/path', {}, 'rq_1')
+
+        call_payload = self.mock_transport.post_json.call_args[0][1]
+        self.assertEqual(call_payload, {})
+
+    def test_call_tool_headers_format(self):
+        self.mock_transport.post_json.return_value = {'code': '0'}
+
+        self.client.call_tool('MT_abc123', 'query_track', '/track', {'no': '123'}, 'rq_xyz')
+
+        call_headers = self.mock_transport.post_json.call_args[0][2]
+        self.assertEqual(call_headers['Authorization'], 'Bearer MT_abc123')
+        self.assertEqual(call_headers['X-MCP-Tool-Code'], 'query_track')
+        self.assertEqual(call_headers['X-Request-Id'], 'rq_xyz')
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 151 - 0
tests/test_scoped_auth_client.py

@@ -0,0 +1,151 @@
+import unittest
+from unittest.mock import MagicMock
+
+from services.scoped_auth_client import ScopedAuthClient
+
+
+class TestScopedAuthClient(unittest.TestCase):
+    def setUp(self):
+        self.mock_transport = MagicMock()
+        self.mock_session_store = MagicMock()
+        self.client = ScopedAuthClient(
+            base_url='http://test.example.com',
+            client_type='workbuddy',
+            session_store=self.mock_session_store,
+            transport=self.mock_transport,
+            timeout=10
+        )
+
+    def test_build_session_key(self):
+        gateway_session_id = 'GWS_abc123'
+        session_key = ScopedAuthClient.build_session_key(gateway_session_id)
+
+        self.assertEqual(session_key, 'gws_GWS_abc123')
+
+    def test_exchange_success(self):
+        gateway_session_id = 'GWS_test123'
+        auth_code = 'AC_xyz789'
+
+        # Mock successful response
+        self.mock_transport.post_json.return_value = {
+            'code': 'MCP_0000',
+            'msg': 'success',
+            'data': {
+                'mcp_token': 'MT_token123',
+                'admin_id': 456,
+                'company_id': 200,
+                'expire_time': '2099-12-31 23:59:59',
+            }
+        }
+
+        result = self.client.exchange(gateway_session_id, auth_code)
+
+        # Verify transport call
+        self.mock_transport.post_json.assert_called_once_with(
+            'http://test.example.com/mcp/auth/exchange',
+            {
+                'auth_code': auth_code,
+                'client_type': 'workbuddy',
+                'session_key': 'gws_GWS_test123',
+            },
+            {},
+            10
+        )
+
+        # Verify session store save
+        self.mock_session_store.save.assert_called_once()
+        save_call_args = self.mock_session_store.save.call_args
+        self.assertEqual(save_call_args[0][0], gateway_session_id)
+
+        saved_session = save_call_args[0][1]
+        self.assertEqual(saved_session['session_key'], 'gws_GWS_test123')
+        self.assertEqual(saved_session['mcp_token'], 'MT_token123')
+        self.assertEqual(saved_session['admin_id'], 456)
+        self.assertEqual(saved_session['company_id'], 200)
+        self.assertEqual(saved_session['client_type'], 'workbuddy')
+        self.assertEqual(saved_session['expire_time'], '2099-12-31 23:59:59')
+
+        # Verify result
+        self.assertEqual(result['code'], 'MCP_0000')
+
+    def test_exchange_failure_no_token(self):
+        gateway_session_id = 'GWS_fail'
+        auth_code = 'AC_invalid'
+
+        # Mock failure response
+        self.mock_transport.post_json.return_value = {
+            'code': 'MCP_1001',
+            'msg': 'auth_code invalid',
+            'data': {}
+        }
+
+        result = self.client.exchange(gateway_session_id, auth_code)
+
+        # Should not save to session store
+        self.mock_session_store.save.assert_not_called()
+
+        # Should still return the response
+        self.assertEqual(result['code'], 'MCP_1001')
+
+    def test_exchange_partial_data(self):
+        gateway_session_id = 'GWS_partial'
+        auth_code = 'AC_partial'
+
+        # Mock response with missing admin_id and company_id
+        self.mock_transport.post_json.return_value = {
+            'code': 'MCP_0000',
+            'msg': 'success',
+            'data': {
+                'mcp_token': 'MT_token456',
+                'expire_time': '2099-12-31 23:59:59',
+                # Missing admin_id and company_id
+            }
+        }
+
+        result = self.client.exchange(gateway_session_id, auth_code)
+
+        # Should still save, with None values
+        self.mock_session_store.save.assert_called_once()
+        saved_session = self.mock_session_store.save.call_args[0][1]
+        self.assertEqual(saved_session['mcp_token'], 'MT_token456')
+        self.assertIsNone(saved_session['admin_id'])
+        self.assertIsNone(saved_session['company_id'])
+
+    def test_exchange_no_data_field(self):
+        gateway_session_id = 'GWS_nodata'
+        auth_code = 'AC_nodata'
+
+        # Mock response without data field
+        self.mock_transport.post_json.return_value = {
+            'code': 'MCP_9999',
+            'msg': 'error'
+        }
+
+        result = self.client.exchange(gateway_session_id, auth_code)
+
+        # Should not save
+        self.mock_session_store.save.assert_not_called()
+
+    def test_exchange_strips_base_url_trailing_slash(self):
+        client_with_slash = ScopedAuthClient(
+            base_url='http://test.example.com/',
+            client_type='workbuddy',
+            session_store=self.mock_session_store,
+            transport=self.mock_transport,
+            timeout=10
+        )
+
+        self.mock_transport.post_json.return_value = {
+            'code': 'MCP_0000',
+            'data': {'mcp_token': 'MT_test'}
+        }
+
+        client_with_slash.exchange('GWS_test', 'AC_test')
+
+        # Should call without double slash
+        call_url = self.mock_transport.post_json.call_args[0][0]
+        self.assertEqual(call_url, 'http://test.example.com/mcp/auth/exchange')
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 81 - 0
tests/test_scoped_clients.py

@@ -0,0 +1,81 @@
+import unittest
+
+from services.scoped_api_client import ScopedApiClient
+from services.scoped_auth_client import ScopedAuthClient
+
+
+class FakeTransport:
+    def __init__(self):
+        self.calls = []
+
+    def post_json(self, url, payload, headers, timeout):
+        self.calls.append((url, payload, headers, timeout))
+        return {'code': 'MCP_0000', 'data': {'ok': True}}
+
+
+class ScopedApiClientTest(unittest.TestCase):
+    def test_call_tool_uses_explicit_token_not_global_store(self):
+        transport = FakeTransport()
+        client = ScopedApiClient('https://tools.example.com', transport=transport, timeout=7)
+
+        response = client.call_tool(
+            token='MT_employee_a',
+            tool_code='query_order',
+            route_path='/mcp/tools/queryOrder',
+            payload={'keyword': 'USC'},
+            request_id='rq_public_1',
+        )
+
+        self.assertEqual('MCP_0000', response['code'])
+        url, payload, headers, timeout = transport.calls[0]
+        self.assertEqual('https://tools.example.com/mcp/tools/queryOrder', url)
+        self.assertEqual('Bearer MT_employee_a', headers['Authorization'])
+        self.assertEqual('query_order', headers['X-MCP-Tool-Code'])
+        self.assertEqual('rq_public_1', headers['X-Request-Id'])
+        self.assertEqual(7, timeout)
+
+
+class ScopedAuthClientTest(unittest.TestCase):
+    def test_exchange_sends_gateway_session_key_and_stores_token(self):
+        transport = FakeTransport()
+        store = {}
+
+        class Store:
+            def save(self, gateway_session_id, session):
+                store[gateway_session_id] = session
+                return session
+
+        def post_json(url, payload, headers, timeout):
+            transport.calls.append((url, payload, headers, timeout))
+            return {
+                'code': 'MCP_0000',
+                'data': {
+                    'mcp_token': 'MT_employee_a',
+                    'expire_time': '2099-12-31 23:59:59',
+                    'admin_id': 1,
+                    'company_id': 10,
+                },
+            }
+
+        transport.post_json = post_json
+        client = ScopedAuthClient(
+            base_url='https://base.example.com',
+            client_type='workbuddy',
+            session_store=Store(),
+            transport=transport,
+            timeout=5,
+        )
+
+        response = client.exchange('GWS_employee_a', 'AUTH_CODE_A')
+
+        self.assertEqual('MCP_0000', response['code'])
+        url, payload, headers, timeout = transport.calls[0]
+        self.assertEqual('https://base.example.com/mcp/auth/exchange', url)
+        self.assertEqual('AUTH_CODE_A', payload['auth_code'])
+        self.assertEqual('workbuddy', payload['client_type'])
+        self.assertEqual('gws_' + 'GWS_employee_a', payload['session_key'])
+        self.assertEqual('MT_employee_a', store['GWS_employee_a']['mcp_token'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 120 - 0
tests/test_security.py

@@ -0,0 +1,120 @@
+import unittest
+
+from utils.security import (
+    generate_gateway_session_id,
+    hash_gateway_session_id,
+    constant_time_equals
+)
+
+
+class TestSecurity(unittest.TestCase):
+    def test_generate_gateway_session_id_format(self):
+        session_id = generate_gateway_session_id()
+
+        # Should start with GWS_
+        self.assertTrue(session_id.startswith('GWS_'))
+
+        # Should be long enough (GWS_ + 32+ chars)
+        self.assertGreater(len(session_id), 36)
+
+    def test_generate_gateway_session_id_uniqueness(self):
+        # Generate multiple IDs and ensure they're all different
+        ids = [generate_gateway_session_id() for _ in range(100)]
+        unique_ids = set(ids)
+
+        self.assertEqual(len(ids), len(unique_ids))
+
+    def test_hash_gateway_session_id_consistent(self):
+        session_id = 'GWS_test123'
+
+        hash1 = hash_gateway_session_id(session_id)
+        hash2 = hash_gateway_session_id(session_id)
+
+        self.assertEqual(hash1, hash2)
+
+    def test_hash_gateway_session_id_different_for_different_inputs(self):
+        session_id_1 = 'GWS_test123'
+        session_id_2 = 'GWS_test456'
+
+        hash1 = hash_gateway_session_id(session_id_1)
+        hash2 = hash_gateway_session_id(session_id_2)
+
+        self.assertNotEqual(hash1, hash2)
+
+    def test_hash_gateway_session_id_length(self):
+        session_id = 'GWS_xyz'
+        hashed = hash_gateway_session_id(session_id)
+
+        # SHA256 produces 64 hex characters
+        self.assertEqual(len(hashed), 64)
+
+    def test_hash_gateway_session_id_raises_on_empty(self):
+        with self.assertRaises(ValueError) as context:
+            hash_gateway_session_id('')
+
+        self.assertIn('gateway_session_id is required', str(context.exception))
+
+    def test_hash_gateway_session_id_raises_on_none(self):
+        with self.assertRaises(ValueError) as context:
+            hash_gateway_session_id(None)
+
+        self.assertIn('gateway_session_id is required', str(context.exception))
+
+    def test_hash_gateway_session_id_handles_whitespace(self):
+        # Hash should handle whitespace consistently
+        session_id_with_spaces = '  GWS_test  '
+
+        hash_result = hash_gateway_session_id(session_id_with_spaces)
+
+        # Should be a valid hash
+        self.assertEqual(len(hash_result), 64)
+
+        # Same input should give same hash
+        hash_result_2 = hash_gateway_session_id(session_id_with_spaces)
+        self.assertEqual(hash_result, hash_result_2)
+
+    def test_constant_time_equals_true_for_same_strings(self):
+        result = constant_time_equals('test123', 'test123')
+        self.assertTrue(result)
+
+    def test_constant_time_equals_false_for_different_strings(self):
+        result = constant_time_equals('test123', 'test456')
+        self.assertFalse(result)
+
+    def test_constant_time_equals_handles_none(self):
+        result1 = constant_time_equals(None, None)
+        self.assertTrue(result1)
+
+        result2 = constant_time_equals('test', None)
+        self.assertFalse(result2)
+
+        result3 = constant_time_equals(None, 'test')
+        self.assertFalse(result3)
+
+    def test_constant_time_equals_handles_empty_strings(self):
+        result = constant_time_equals('', '')
+        self.assertTrue(result)
+
+    def test_constant_time_equals_case_sensitive(self):
+        result = constant_time_equals('Test', 'test')
+        self.assertFalse(result)
+
+    def test_generate_session_id_url_safe(self):
+        # URL-safe base64 should not contain +, /, or =
+        session_id = generate_gateway_session_id()
+        token_part = session_id[4:]  # Remove 'GWS_' prefix
+
+        self.assertNotIn('+', token_part)
+        self.assertNotIn('/', token_part)
+        # May or may not have = padding, but should be URL-safe
+
+    def test_hash_deterministic(self):
+        # Same input should always produce same output
+        session_id = 'GWS_deterministic_test'
+        hashes = [hash_gateway_session_id(session_id) for _ in range(10)]
+
+        self.assertEqual(len(set(hashes)), 1)
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 1 - 0
utils/__init__.py

@@ -0,0 +1 @@
+

+ 59 - 0
utils/rate_limiter.py

@@ -0,0 +1,59 @@
+import time
+from collections import defaultdict
+from threading import Lock
+
+
+class SimpleRateLimiter:
+    """
+    Simple in-memory rate limiter using sliding window.
+    For production, consider using Redis-based rate limiting.
+    """
+
+    def __init__(self, max_requests=60, window_seconds=60):
+        self.max_requests = int(max_requests)
+        self.window_seconds = int(window_seconds)
+        self._requests = defaultdict(list)
+        self._lock = Lock()
+
+    def is_allowed(self, key):
+        """
+        Check if the key is allowed to make a request.
+
+        Args:
+            key: Identifier (IP address, session_id, etc.)
+
+        Returns:
+            bool: True if allowed, False if rate limit exceeded
+        """
+        now = time.time()
+        window_start = now - self.window_seconds
+
+        with self._lock:
+            # Clean old requests
+            requests = self._requests[key]
+            self._requests[key] = [ts for ts in requests if ts > window_start]
+
+            # Check limit
+            if len(self._requests[key]) >= self.max_requests:
+                return False
+
+            # Record this request
+            self._requests[key].append(now)
+            return True
+
+    def cleanup(self, max_age_seconds=3600):
+        """
+        Remove old entries to prevent memory leak.
+        Call this periodically in a background thread.
+        """
+        now = time.time()
+        cutoff = now - max_age_seconds
+
+        with self._lock:
+            keys_to_delete = []
+            for key, requests in self._requests.items():
+                if not requests or requests[-1] < cutoff:
+                    keys_to_delete.append(key)
+
+            for key in keys_to_delete:
+                del self._requests[key]

+ 18 - 0
utils/security.py

@@ -0,0 +1,18 @@
+import hashlib
+import hmac
+import secrets
+
+
+def generate_gateway_session_id():
+    return 'GWS_' + secrets.token_urlsafe(32)
+
+
+def hash_gateway_session_id(session_id):
+    value = str(session_id or '').strip()
+    if not value:
+        raise ValueError('gateway_session_id is required')
+    return hashlib.sha256(value.encode('utf-8')).hexdigest()
+
+
+def constant_time_equals(left, right):
+    return hmac.compare_digest(str(left or ''), str(right or ''))