ht32-code-mcp 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/DEPLOY.md +112 -40
  2. {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/PKG-INFO +1 -1
  3. ht32_code_mcp-0.2.2/README.md +6 -0
  4. {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/ht32-code-mcp-update.md +7 -5
  5. {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/pyproject.toml +1 -1
  6. ht32_code_mcp-0.2.2/smoke_http.py +193 -0
  7. ht32_code_mcp-0.2.2/src/ht32_code_mcp/__init__.py +4 -0
  8. {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/src/ht32_code_mcp/server.py +19 -5
  9. {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/src/ht32_code_mcp/usage.py +46 -2
  10. {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/tests/test_tools.py +69 -1
  11. {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/uv.lock +1 -1
  12. ht32_code_mcp-0.2.0/README.md +0 -5
  13. ht32_code_mcp-0.2.0/src/ht32_code_mcp/__init__.py +0 -3
  14. {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/.gitignore +0 -0
  15. {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/.gitkeep +0 -0
  16. {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/e2e_scenario_a.py +0 -0
  17. {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/e2e_scenario_b.py +0 -0
  18. {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/smoke_stdio.py +0 -0
  19. {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/src/ht32_code_mcp/__main__.py +0 -0
  20. {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/src/ht32_code_mcp/exporter.py +0 -0
  21. {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/src/ht32_code_mcp/index.py +0 -0
  22. {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/src/ht32_code_mcp/knowledge.py +0 -0
  23. {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/tests/conftest.py +0 -0
@@ -25,6 +25,44 @@ PyPI 发布包 → 1Panel 原生 MCP 管理(类型 uvx,启动命令 uvx ht32
25
25
  代码默认走 `stdio`(`MCP_TRANSPORT` 未设置时),supergateway 负责 stdio→Streamable HTTP 转换。
26
26
  与 ht32-wiki 同构:SSE 无心跳会被 OpenResty 空闲切断,故走 streamableHttp stateful。
27
27
 
28
+ ## 传输模式(0.2.2+ 支持原生 HTTP)
29
+
30
+ `server.py` 按 `MCP_TRANSPORT` 分派三种传输,**与 ht32-wiki 同构**:
31
+
32
+ | `MCP_TRANSPORT` | 行为 | 典型用途 |
33
+ |---|---|---|
34
+ | `stdio`(默认) | 标准输入输出 | 本地开发;或供 supergateway 包装为 Streamable HTTP |
35
+ | `streamable-http` | 单端点 HTTP,**默认有状态会话**(FastMCP `stateless_http=False`) | 直接对外暴露,无需 supergateway 这一层 |
36
+ | `sse` | 旧式 SSE(无心跳,易被反代空闲切断) | 兼容旧客户端,不推荐 |
37
+
38
+ HTTP 模式监听地址由 `MCP_HOST`(默认 `0.0.0.0`)/ `MCP_PORT`(默认 `8101`,
39
+ **避开 wiki 的 8100**)控制;端点路径为 FastMCP 默认的 `/mcp`。
40
+
41
+ ```bash
42
+ # 原生 Streamable HTTP(stateful),供反代直连
43
+ MCP_TRANSPORT=streamable-http MCP_HOST=0.0.0.0 MCP_PORT=8101 uvx ht32-code-mcp
44
+ ```
45
+
46
+ > **stateful 是默认值而非开关**:FastMCP 4.0.5 的 `stateless_http` 默认 `False`,
47
+ > 即同一 `mcp-session-id` 复用同一会话,子进程/进程内状态不每次 POST 重建——
48
+ > 与 supergateway 的 `--stateful --sessionTimeout 3600000` 语义一致。
49
+ > 若将来需要 stateless,须显式改 FastMCP 设置,届时 Wiki/Code 两端要一起对照。
50
+
51
+ ### 生产接线:一律用 supergateway 包装(2026-09-23 定)
52
+
53
+ 两个 MCP(wiki 与 code)**统一由 supergateway 包装**,不接受「服务自身原生 HTTP」形态:
54
+
55
+ - 容器里 `uvx ht32-code-mcp` 保持 stdio,由网关转 Streamable HTTP + stateful
56
+ - 好处:**网关这一层统一管协议版本与会话**(`--protocolVersion 2025-06-18`
57
+ `--stateful --sessionTimeout 3600000`),云端反代只认固定的容器端口/路径,
58
+ 换服务或改内部实现时反代无需跟着动
59
+
60
+ 原生 `streamable-http` / `sse` 保留为**能力**(本地直跑、调试、将来去网关时可用),
61
+ 但**当前生产不启用**;本地 compose 部署方案见本仓库 `.work/docker/docker-compose.yml`(按此约定编写)。
62
+
63
+ 无论走哪种,**监听端口必须绑 `0.0.0.0`**(默认值),否则网关/反代从容器外连不上。
64
+ 验证:`ss -ltnp | grep -E '1808[0-2]'` 应显示 `0.0.0.0`,而非 `127.0.0.1`。
65
+
28
66
  ## 关键配置
29
67
 
30
68
  1Panel MCP 表单:
@@ -174,46 +212,80 @@ docker exec 1Panel-openresty-ih7J nginx -t
174
212
 
175
213
  4. 确认 `ls /opt/1panel/www/sites/mcp.s621.work/index/ht32-bundles` 只有 `*.zip`(无残留临时目录)。
176
214
 
177
- ## 反馈与用量日志(P1-5,0.1.3+)
215
+ ## 反馈与用量日志(P1-5,0.2.0+)
178
216
 
179
- 两条通道写入同一目录、按 UTC 日期分文件(JSON Lines):
217
+ > **与 ht32-wiki-mcp 统一**:两服务使用同一套实现(`usage.py`,两仓库逐字节
218
+ > 相同的副本)、同一套目录约定(同根 + 服务子目录)、同一套字段。
219
+ > 本服务写 `<FEEDBACK_ROOT>/code/`,wiki 写 `<FEEDBACK_ROOT>/wiki/`。
180
220
 
181
- | 通道 | 触发 | 记录内容 |
182
- |------|------|----------|
183
- | `usage_YYYY-MM-DD.jsonl` | **服务端自动**(middleware 拦截所有工具调用) | 工具名、参数原文(剔除 `limit`/`max_length`/`out_dir`)、耗时、成败、错误、客户端 session |
184
- | `feedback_YYYY-MM-DD.jsonl` | **Agent 主动**(`submit_feedback` 工具) | 语义性问题:答错 / 知识库缺口 / 模板缺陷,含 `issue_type`、`model_id`、`tools_called` |
221
+ 两条通道,按 UTC 日期分文件(JSON Lines):
222
+
223
+ | 通道 | 文件 | 触发 | 记录内容 |
224
+ |------|------|------|----------|
225
+ | 用量日志 | `usage_*.jsonl` | **服务端自动**(middleware 拦截所有工具调用) | 工具名、参数原文(剔除 `limit`/`max_length`/`out_dir`)、耗时、成败、错误、客户端 session |
226
+ | 反馈 | `feedback_*.jsonl` | **Agent 主动**(`submit_feedback` 工具) | 语义性问题:API 错 / 缺口 / 路径错 / 模板缺陷 |
227
+
228
+ 自动通道保证调用事实完整(不依赖 Agent 配合),用于追踪用户需求与生成的模板;
229
+ 反馈通道补 middleware 判断不了的语义问题。每条记录都带 `service` 字段
230
+ (`code`),便于两服务日志合并分析。
185
231
 
186
- 自动通道保证调用事实完整(不依赖 Agent 配合);反馈通道补 middleware
187
- 判断不了的语义问题。`issue_type` 枚举:`wrong_api` / `missing_info` /
188
- `wrong_path` / `export_defect` / `unclear` / `other`。
232
+ ### `ok` 字段的口径(0.2.1+)
233
+
234
+ 工具用**字符串**返回错误而不抛异常,middleware 拿不到协议级 `is_error`,
235
+ 故靠返回值文本判断。口径是**工具是否完成了它的工作**:
236
+
237
+ | 返回值 | `ok` | 说明 |
238
+ |---|---|---|
239
+ | `错误:…` / `导出失败…` | `false` | 参数校验、路径越界、IO 失败、无法产出工程 |
240
+ | `未找到…` / `不在支持列表` / `不在覆盖范围` / `暂无模板` | `true` | **"无数据"≠失败** |
241
+
242
+ "无数据"刻意记为成功:`用户搜 BLDC 但知识库没有` 正是指导 P2-1 补内容的
243
+ 最强信号,若标成失败会被分析工具当作噪声剔除。标记表在 `usage.py` 的
244
+ `SOFT_FAILURE_MARKERS`,**改动工具返回值文案时需同步维护**。
245
+
246
+ ### 目录布局
247
+
248
+ ```
249
+ /opt/ht32-mcp-feedback/ ← 宿主机单一入口,两个容器挂同一卷
250
+ ├─ wiki/ ← ht32-wiki 容器写这里
251
+ └─ code/ ← ht32-code 容器写这里(本服务)
252
+ ├─ usage_2026-09-21.jsonl
253
+ └─ feedback_2026-09-21.jsonl
254
+ ```
189
255
 
190
256
  ### 环境变量
191
257
 
192
258
  | 变量 | 默认 | 说明 |
193
259
  |------|------|------|
194
- | `CODE_LOG_DIR` | 空(= **整体关闭**,本地开发默认不落盘) | 日志写入目录,须可写 |
260
+ | `FEEDBACK_ROOT` | 空(= **关闭**,本地开发默认不落盘) | 日志根目录,服务名子目录自动拼接;**必须填绝对路径**(如 `/app/feedback`,不要写 `app/feedback`) |
261
+ | `CODE_LOG_DIR` | — | 已废弃,改用 `FEEDBACK_ROOT` |
195
262
 
196
- > **隐私**:参数按原文记录,`query`/`question` 可能含用户项目信息。
263
+ > ⚠️ **必须用绝对路径**:`FEEDBACK_ROOT=app/feedback` 会相对容器 WORKDIR
264
+ > 解析。实测云端曾因漏写前导 `/`,`get_code_version` 显示为
265
+ > `反馈与用量日志:启用(app/feedback/code)`——靠 WORKDIR 恰为 `/` 才侥幸
266
+ > 落对位置,改镜像 WORKDIR 后会静默写到别处。验证时看输出路径是否以 `/` 开头。
267
+
268
+ > **隐私**:参数按原文记录,`query` / `question` 可能含用户项目信息。
197
269
  > 该目录只应挂在受控服务器上,**不要提交进仓库**(定时脚本是
198
270
  > `git reset --hard` 镜像模式,写进仓库也会被覆盖丢失)。
199
271
 
200
- ### 服务器侧落地(2 步)
272
+ ### 服务器侧落地
201
273
 
202
- **第 1 步:宿主机建日志目录**
274
+ **第 1 步:宿主机建目录**
203
275
 
204
276
  ```bash
205
- mkdir -p /opt/ht32-code-logs
206
- chmod 755 /opt/ht32-code-logs
277
+ mkdir -p /opt/ht32-mcp-feedback
278
+ chmod 755 /opt/ht32-mcp-feedback
207
279
  ```
208
280
 
209
- **第 2 步:1Panel 给 code-mcp 容器补挂载与环境变量**
281
+ **第 2 步:两个 MCP 容器都挂同一卷**
210
282
 
211
- | 配置项 | 值 |
212
- |--------|-----|
213
- | 挂载 | `/opt/ht32-code-logs:/app/logs`(**不要加 `:ro`,需可写**) |
214
- | 环境变量 | `CODE_LOG_DIR=/app/logs` |
283
+ | 服务 | 挂载 | 环境变量 |
284
+ |------|------|----------|
285
+ | ht32-wiki-mcp | `/opt/ht32-mcp-feedback:/app/feedback` | `FEEDBACK_ROOT=/app/feedback` |
286
+ | ht32-code-mcp | `/opt/ht32-mcp-feedback:/app/feedback` | `FEEDBACK_ROOT=/app/feedback` |
215
287
 
216
- 完整配置(含现有项):
288
+ 完整配置(code 侧,含现有项):
217
289
 
218
290
  ```
219
291
  类型: uvx
@@ -226,38 +298,37 @@ chmod 755 /opt/ht32-code-logs
226
298
  环境变量: CODE_ROOT=/app/code
227
299
  BUNDLE_DIR=/app/bundles
228
300
  BUNDLE_BASE_URL=https://mcp.s621.work/ht32-bundles
229
- CODE_LOG_DIR=/app/logs
301
+ FEEDBACK_ROOT=/app/feedback
230
302
  挂载: /opt/ht32-code:/app/code
231
303
  /opt/1panel/www/sites/mcp.s621.work/index/ht32-bundles:/app/bundles
232
- /opt/ht32-code-logs:/app/logs
304
+ /opt/ht32-mcp-feedback:/app/feedback
233
305
  ```
234
306
 
235
- ### 验证生效
307
+ ### 验证
236
308
 
237
- 1. 调 `get_code_version`,应出现 `用量日志:启用(/app/logs)`。
309
+ 1. 调 `get_code_version`,应出现
310
+ `反馈与用量日志:启用(/app/feedback/code)`。
238
311
  仍显示「未配置」→ 环境变量没注入(查变量名 / 容器是否重建)。
239
312
 
240
313
  2. 随便调几个工具,然后看日志:
241
314
 
242
315
  ```bash
243
- tail -3 /opt/ht32-code-logs/usage_$(date -u +%Y-%m-%d).jsonl
316
+ tail -3 /opt/ht32-mcp-feedback/code/usage_$(date -u +%Y-%m-%d).jsonl
244
317
  ```
245
318
 
246
- 3. 按日期分析需求分布(示例:统计被请求的芯片型号,指导 P2-1 排优先级):
319
+ 3. 统计被请求的芯片型号(指导 P2-1 排优先级)——用 ht32-skills 的分析工具:
247
320
 
248
321
  ```bash
249
- grep -h export_project_bundle /opt/ht32-code-logs/usage_*.jsonl \
250
- | python3 -c "
251
- import sys, json, collections
252
- c = collections.Counter()
253
- for l in sys.stdin:
254
- c.update([json.loads(l)['args'].get('chip', '?')])
255
- for k, v in c.most_common():
256
- print(f'{v:5d} {k}')
257
- "
322
+ # 克隆 ht32-skills 后(或用本地副本)
323
+ python ht32-skills/scripts/analyze_feedback.py /opt/ht32-mcp-feedback
258
324
  ```
259
325
 
260
- 4. 清理策略由定时同步脚本第 7 步负责(`LOG_KEEP_DAYS=30`,与同步日志同口径)。
326
+ 该工具(零依赖,仅标准库)合并 wiki + code 两服务日志,输出需求信号、
327
+ 调用分布与失败、反馈类型、耗时 p50/p95、最近失败与反馈明细;支持
328
+ `--days N` / `--service` / `--json`。详见 ht32-skills 的 README。
329
+
330
+ 4. 清理策略由定时同步脚本第 7 步负责(`LOG_KEEP_DAYS=30`,与同步日志同口径,
331
+ 目录变量为 `FEEDBACK_ROOT`)。
261
332
 
262
333
  ## PyPI 包发布流程
263
334
 
@@ -291,10 +362,11 @@ uv lock && uv build && uv publish
291
362
  | # | 任务 | 说明 | 状态 |
292
363
  |---|------|------|------|
293
364
  | 3.1 | MCP 工具实现(7 检索/导出 + 1 反馈) | `.work/mcp-server/src/ht32_code_mcp/`(FastMCP) | ✅ |
294
- | 3.2 | 工具级测试 | `pytest` 42 用例全绿 | ✅ |
295
- | 3.3 | PyPI 发布(hatchling) | `ht32-code-mcp` 0.1.3(2026-09-21,含 bundle 通道 + 反馈/用量日志) | ✅ |
365
+ | 3.2 | 工具级测试 | `pytest` 46 用例全绿 | ✅ |
366
+ | 3.3 | PyPI 发布(hatchling) | `ht32-code-mcp` 0.2.2(2026-09-23,含 bundle 通道 + 统一反馈/用量日志 + 软失败识别 + 原生 Streamable HTTP) | ✅ |
296
367
  | 3.4 | 云端 git clone + 挂载 | `/opt/ht32-code:/app/code` | ✅ |
297
368
  | 3.5 | 1Panel 部署(uvx + supergateway → streamableHttp stateful,镜像 next-uvx) | `https://mcp.s621.work/ht32-code` | ✅ |
298
369
  | 3.6 | OpenResty 超时修复(`proxy_read_timeout 3600s`) | 修复 60s 空闲断连;2026-09-21 复核两端空闲 100s 后调用仍 200,未被 1Panel 回退 | ✅ |
299
370
  | 3.7 | 云端 zip 下载通道(P1-3) | 服务器侧已上线:下载 1608157 字节 / SHA256 一致 / 解压 455 文件 / UV4 0E0W | ✅ |
300
- | 3.8 | 定时同步脚本含 bundle 清理(P1-2) | `ht32-code-mcp-update.md` 第 6 步 `find … -mmin +60 -delete` | ✅ |
371
+ | 3.8 | 定时同步脚本含 bundle 清理(P1-2) | `ht32-code-mcp-update.md` 第 6 步 `find … -mmin +60 -delete` | ✅ |
372
+ | 3.9 | 反馈与用量日志(P1-5,与 wiki 统一) | 0.2.1;云端已启用,但 `FEEDBACK_ROOT` 漏写前导 `/`(显示 `app/feedback/code`),需改绝对路径 | ⚠️ |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ht32-code-mcp
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: HT32 Code MCP Server - 为 AI Agent 提供 HT32 函数库/例程/模板检索与工程导出
5
5
  License-Expression: MIT
6
6
  Keywords: firmware,holtek,ht32,mcp,mcu
@@ -0,0 +1,6 @@
1
+ # .work/mcp-server/
2
+
3
+ MCP server 代码目录(Python + FastMCP),实现 7 个检索/导出工具 + 1 个反馈工具。
4
+
5
+ 传输由 `MCP_TRANSPORT` 决定:`stdio`(默认,供 supergateway 包装)/ `streamable-http` / `sse`。
6
+ 部署与排错见 [DEPLOY.md](DEPLOY.md)。
@@ -52,9 +52,9 @@ INDEX_FILE="$CODE_DIR/.work/mcp-server/index/code_index.json"
52
52
  BUNDLE_DIR="/opt/1panel/www/sites/mcp.s621.work/index/ht32-bundles"
53
53
  BUNDLE_TTL_MIN=60
54
54
 
55
- # 用量日志 / 反馈目录(1Panel 挂载到容器 /app/logs)
55
+ # 用量日志 / 反馈目录(1Panel 挂载到容器 /app/feedback,与 wiki 同一卷)
56
56
 
57
- CODE_LOG_DIR="/opt/ht32-code-logs"
57
+ FEEDBACK_DIR="/opt/ht32-mcp-feedback"
58
58
 
59
59
  # 日志目录(不存在则创建)
60
60
 
@@ -131,11 +131,13 @@ fi
131
131
 
132
132
  # 7. 清理超期的用量日志 / 反馈(同样保留 LOG_KEEP_DAYS 天)
133
133
 
134
- if [ -d "$CODE_LOG_DIR" ]; then
135
- OLD_LOGS=$(find "$CODE_LOG_DIR" -maxdepth 1 \( -name "usage_*.jsonl" -o -name "feedback_*.jsonl" \) -mtime +$LOG_KEEP_DAYS -print -delete 2>/dev/null | wc -l)
134
+ # 目录为同根 + 服务子目录(wiki/ 与 code/),故递归找两层下的 jsonl
135
+
136
+ if [ -d "$FEEDBACK_DIR" ]; then
137
+ OLD_LOGS=$(find "$FEEDBACK_DIR" -mindepth 2 -maxdepth 2 \( -name "usage_*.jsonl" -o -name "feedback_*.jsonl" \) -mtime +$LOG_KEEP_DAYS -print -delete 2>/dev/null | wc -l)
136
138
  log "用量日志清理:删除 $OLD_LOGS 个过期文件"
137
139
  else
138
- log "用量日志目录不存在,跳过清理:$CODE_LOG_DIR"
140
+ log "用量日志目录不存在,跳过清理:$FEEDBACK_DIR"
139
141
  fi
140
142
 
141
143
  log "本次同步完成"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ht32-code-mcp"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "HT32 Code MCP Server - 为 AI Agent 提供 HT32 函数库/例程/模板检索与工程导出"
9
9
  requires-python = ">=3.11"
10
10
  license = "MIT"
@@ -0,0 +1,193 @@
1
+ """Streamable HTTP 冒烟测试:以子进程起 server(MCP_TRANSPORT=streamable-http),
2
+ 用真 HTTP 客户端走 initialize → notifications/initialized → tools/list → tools/call,
3
+ 并验证**会话复用**(stateful:同一 mcp-session-id 持续可用)。
4
+
5
+ 为什么必须真起进程 + 真 HTTP:与 smoke_stdio.py 同理——直接 import 调库测不到
6
+ 握手时序、传输层会话管理、stdout 洁净度;而这两者正是线上故障的高发面
7
+ (wiki 侧曾因 smoke 缺失漏掉协议层两个缺陷到线上)。
8
+
9
+ 用法(从 .work/mcp-server/ 目录,需已安装依赖):
10
+ python smoke_http.py
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import socket
17
+ import subprocess
18
+ import sys
19
+ import time
20
+ import urllib.request
21
+ from pathlib import Path
22
+
23
+ SCRIPT_DIR = Path(__file__).resolve().parent
24
+ SRC = SCRIPT_DIR / "src"
25
+ sys.path.insert(0, str(SRC))
26
+
27
+ from fastmcp import Client # noqa: E402
28
+
29
+ # server.py 里 FastMCP.run("streamable-http") 的默认端点路径
30
+ MCP_PATH = "/mcp"
31
+ EXPECTED_TOOLS = {
32
+ "get_code_version", "list_libraries", "find_example", "get_file",
33
+ "search_code_knowledge", "get_project_manifest", "export_project_bundle",
34
+ }
35
+ START_TIMEOUT = 60.0
36
+ REQUEST_TIMEOUT = 60.0
37
+
38
+
39
+ def _free_port() -> int:
40
+ with socket.socket() as s:
41
+ s.bind(("127.0.0.1", 0))
42
+ return int(s.getsockname()[1])
43
+
44
+
45
+ def _wait_port(port: int, proc: subprocess.Popen, deadline: float) -> None:
46
+ while time.monotonic() < deadline:
47
+ if proc.poll() is not None:
48
+ raise RuntimeError(f"服务进程提前退出,退出码 {proc.returncode}")
49
+ with socket.socket() as s:
50
+ s.settimeout(0.5)
51
+ if s.connect_ex(("127.0.0.1", port)) == 0:
52
+ return
53
+ time.sleep(0.2)
54
+
55
+ raise TimeoutError(f"端口 {port} 在 {START_TIMEOUT}s 内未监听")
56
+
57
+
58
+ def _parse_sse(text: str) -> dict:
59
+ """从 SSE 文本中取第一条 data: 的 JSON(FastMCP 默认 json_response=False)。"""
60
+ for line in text.splitlines():
61
+ if line.startswith("data:"):
62
+ return json.loads(line[len("data:"):].strip())
63
+
64
+ raise AssertionError(f"响应中没有 SSE data 行:{text[:300]!r}")
65
+
66
+
67
+ def _rpc(url: str, payload: dict, session_id: str | None = None):
68
+ """POST 一次 JSON-RPC(标准库实现,避免为测试引入 httpx 依赖)。"""
69
+ headers = {
70
+ "Content-Type": "application/json",
71
+ "Accept": "application/json, text/event-stream",
72
+ }
73
+ if session_id:
74
+ headers["mcp-session-id"] = session_id
75
+
76
+ req = urllib.request.Request(
77
+ url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST")
78
+ with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
79
+ return resp.status, dict(resp.headers), resp.read().decode("utf-8", "replace")
80
+
81
+
82
+ def check_transport_layer(url: str) -> str:
83
+ """协议层:initialize 拿 session → initialized 通知 → 复用同一 session 调 tools/list。"""
84
+ status, headers, body = _rpc(url, {
85
+ "jsonrpc": "2.0", "id": 1, "method": "initialize",
86
+ "params": {
87
+ "protocolVersion": "2025-06-18",
88
+ "capabilities": {},
89
+ "clientInfo": {"name": "smoke-http", "version": "0"},
90
+ },
91
+ })
92
+ assert status == 200, f"initialize 非 200:{status} {body[:300]}"
93
+ session_id = headers.get("mcp-session-id")
94
+ assert session_id, f"initialize 未返回 mcp-session-id(头:{headers})"
95
+ print(f"✅ initialize 成功,会话 id = {session_id[:16]}…")
96
+
97
+ proto = _parse_sse(body).get("result", {}).get("protocolVersion")
98
+ assert proto, f"initialize 响应缺少 protocolVersion:{body[:300]}"
99
+ print(f"✅ 协议版本协商 = {proto}")
100
+
101
+ status, _, _ = _rpc(url, {"jsonrpc": "2.0", "method": "notifications/initialized"},
102
+ session_id=session_id)
103
+ assert status in (200, 202), f"initialized 通知非 2xx:{status}"
104
+
105
+ status, headers, body = _rpc(
106
+ url, {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, session_id=session_id)
107
+ assert status == 200, f"tools/list 非 200:{status}"
108
+ assert headers.get("mcp-session-id") == session_id, "tools/list 换用了新会话(非 stateful)"
109
+
110
+ tools = {t["name"] for t in _parse_sse(body)["result"]["tools"]}
111
+ missing = EXPECTED_TOOLS - tools
112
+ assert not missing, f"缺失工具:{missing}"
113
+ print(f"✅ 同一会话复用成功(stateful),工具 {len(tools)} 个:{sorted(tools)}")
114
+
115
+ return session_id
116
+
117
+
118
+ async def check_client_layer(url: str) -> None:
119
+ """客户端层:用 FastMCP 官方 Client 走完整 tools/call(含结果反序列化)。"""
120
+ async with Client(url) as client:
121
+ tools = {t.name for t in await client.list_tools()}
122
+ missing = EXPECTED_TOOLS - tools
123
+ assert not missing, f"Client 视角缺失工具:{missing}"
124
+
125
+ r = await client.call_tool("get_code_version", {})
126
+ print("✅ get_code_version:", (r.content[0].text or "").splitlines()[0])
127
+
128
+ r = await client.call_tool("find_example", {"peripheral": "GPIO", "limit": 3})
129
+ text = r.content[0].text or ""
130
+ assert "example/GPIO" in text, text
131
+ print(f"✅ find_example(GPIO): {text.splitlines()[0]}")
132
+
133
+
134
+ def main() -> int:
135
+ port = _free_port()
136
+ url = f"http://127.0.0.1:{port}{MCP_PATH}"
137
+
138
+ env = dict(os.environ)
139
+ env["PYTHONPATH"] = str(SRC) + os.pathsep + env.get("PYTHONPATH", "")
140
+ env["MCP_TRANSPORT"] = "streamable-http"
141
+ env["MCP_HOST"] = "127.0.0.1"
142
+ env["MCP_PORT"] = str(port)
143
+ env.pop("FEEDBACK_ROOT", None) # 不带日志落盘,保持测试无副作用
144
+
145
+ # stdout 丢弃(HTTP 模式下 uvicorn 访问日志走 stdout,与协议无关);
146
+ # 启动与异常信息走 stderr,失败时打印出来供排查
147
+ proc = subprocess.Popen(
148
+ [sys.executable, "-m", "ht32_code_mcp"],
149
+ cwd=str(SCRIPT_DIR),
150
+ env=env,
151
+ stdout=subprocess.DEVNULL,
152
+ stderr=subprocess.PIPE,
153
+ text=True,
154
+ encoding="utf-8",
155
+ errors="replace",
156
+ )
157
+
158
+ def stop() -> str:
159
+ """服务是长驻进程,须先 terminate 再取输出,不能直接 communicate。"""
160
+ if proc.poll() is None:
161
+ proc.terminate()
162
+ try:
163
+ _, err = proc.communicate(timeout=10)
164
+ except subprocess.TimeoutExpired:
165
+ proc.kill()
166
+ _, err = proc.communicate()
167
+ return err or ""
168
+
169
+ try:
170
+ _wait_port(port, proc, time.monotonic() + START_TIMEOUT)
171
+ print(f"✅ 服务已监听 http://127.0.0.1:{port}{MCP_PATH}")
172
+
173
+ check_transport_layer(url)
174
+
175
+ import anyio
176
+ anyio.run(check_client_layer, url)
177
+
178
+ stop()
179
+
180
+ print("✅ Streamable HTTP 冒烟全部通过(stateful 会话复用已证实)")
181
+ return 0
182
+
183
+ except Exception:
184
+ print("❌ 冒烟失败,服务 stderr:", file=sys.stderr)
185
+ print(stop().strip()[:3000], file=sys.stderr)
186
+ raise
187
+
188
+ finally:
189
+ stop()
190
+
191
+
192
+ if __name__ == "__main__":
193
+ raise SystemExit(main())
@@ -0,0 +1,4 @@
1
+ """ht32-code-mcp:HT32 代码知识库 MCP Server(7 检索/导出工具 + 1 反馈工具)。
2
+
3
+ 传输:stdio(默认)/ streamable-http / sse,由 MCP_TRANSPORT 决定。
4
+ """
@@ -1,7 +1,7 @@
1
1
  """HT32 Code MCP Server。
2
2
 
3
3
  提供函数库/例程/模板检索与工程导出,供 AI Agent 调用。
4
- 传输协议:stdio(P0 本地阶段;云端阶段见总纲 5.3)。
4
+ 传输协议:stdio(供 supergateway 包装为 Streamable HTTP)或 Streamable HTTP / SSE。
5
5
  """
6
6
  from __future__ import annotations
7
7
 
@@ -49,6 +49,11 @@ CLOUD_BUNDLE = bool(BUNDLE_DIR and BUNDLE_BASE_URL)
49
49
  FEEDBACK_ROOT = os.environ.get("FEEDBACK_ROOT", "").strip()
50
50
  LOG_DIR = resolve_log_dir(FEEDBACK_ROOT, SERVICE_CODE)
51
51
 
52
+ # HTTP 传输模式监听地址(MCP_TRANSPORT 非 stdio 时生效)
53
+ # 默认 8101 而非 8100:wiki 占用 8100,两服务常同机部署,避开默认端口冲突
54
+ HOST = os.environ.get("MCP_HOST", "0.0.0.0")
55
+ PORT = _env_int("MCP_PORT", 8101)
56
+
52
57
  index = CodeIndex(CODE_ROOT).load()
53
58
  knowledge = KnowledgeSearch(CODE_ROOT)
54
59
  exporter = ProjectExporter(CODE_ROOT)
@@ -427,7 +432,12 @@ def submit_feedback(
427
432
 
428
433
 
429
434
  def main() -> None:
430
- """入口:按 MCP_TRANSPORT 环境变量启动(默认 stdio,P0 本地阶段)。"""
435
+ """入口:按 MCP_TRANSPORT 环境变量启动对应传输模式的服务器。
436
+
437
+ - stdio: 供 supergateway 包装为 Streamable HTTP(默认)
438
+ - streamable-http: 直接对外暴露的单端点 HTTP(有状态会话)
439
+ - sse: FastMCP 内置 SSE 服务器(旧式,无心跳易被反代空闲切断)
440
+ """
431
441
  transport = os.environ.get("MCP_TRANSPORT", "stdio").lower()
432
442
  stats = index.data.get("stats", {})
433
443
  print(f"[ht32-code-mcp] 程序版本: ht32-code-mcp {_MCP_VERSION}", file=sys.stderr)
@@ -437,11 +447,15 @@ def main() -> None:
437
447
  print(f"[ht32-code-mcp] 索引就绪: {stats.get('library_modules', 0)} 模块 / "
438
448
  f"{stats.get('examples', 0)} 例程 / {stats.get('raw_files', 0)} raw 文件", file=sys.stderr)
439
449
  if transport == "stdio":
440
- print("[ht32-code-mcp] 启动服务: stdio", file=sys.stderr)
450
+ print("[ht32-code-mcp] 启动服务: stdio(供 supergateway 包装为 Streamable HTTP)",
451
+ file=sys.stderr)
441
452
  mcp.run(transport="stdio")
453
+ elif transport == "sse":
454
+ print(f"[ht32-code-mcp] 启动服务: SSE {HOST}:{PORT}", file=sys.stderr)
455
+ mcp.run(transport="sse", host=HOST, port=PORT)
442
456
  else:
443
- print(f"[ht32-code-mcp] 传输模式 '{transport}' 暂未启用(P0 仅 stdio)", file=sys.stderr)
444
- sys.exit(2)
457
+ print(f"[ht32-code-mcp] 启动服务: Streamable HTTP {HOST}:{PORT}", file=sys.stderr)
458
+ mcp.run(transport="streamable-http", host=HOST, port=PORT)
445
459
 
446
460
 
447
461
  if __name__ == "__main__":
@@ -38,7 +38,7 @@ from typing import Any
38
38
  from fastmcp.server.middleware import Middleware, MiddlewareContext
39
39
 
40
40
  # 实现版本号:两仓库副本必须一致(各仓测试会校验,防止只改一边造成漂移)
41
- _IMPL_VERSION = "1.0.0"
41
+ _IMPL_VERSION = "1.1.0"
42
42
 
43
43
  # 服务名 → 子目录名。新增服务在此登记。
44
44
  SERVICE_WIKI = "wiki"
@@ -47,10 +47,49 @@ SERVICE_CODE = "code"
47
47
  # 不记录的工具参数:分页/长度控制项无分析价值;out_dir 可能含用户本地路径
48
48
  ARG_NOISE = frozenset({"limit", "max_length", "out_dir"})
49
49
 
50
+ # 工具内的"软失败"标记:两服务的工具用**字符串**返回错误而不抛异常,middleware
51
+ # 拿不到协议级 is_error,只能靠返回值文本判断。不识别会让真失败被记成 ok=true,
52
+ # 后果不只是统计失真——分析工具按 ok 过滤"需求信号",型号打错导致的
53
+ # "导出失败:不在模板参数表"会被当成一次真实的芯片需求,污染选型优先级结论。
54
+ #
55
+ # 判定口径 = **工具是否完成了它的工作**,故只收明确执行失败:
56
+ # - 错误: 参数校验/路径越界/IO 失败/反馈写盘失败(两服务)
57
+ # - 导出失败 code: export_project_bundle 无法产出工程
58
+ # - 请至少提供 wiki: compare_chips 参数校验
59
+ #
60
+ # 刻意**不算**失败的是"无数据"类回答(工具正常跑完,只是没结果):
61
+ # - 未找到匹配结果 / 未找到型号 'X' / 未找到外设 'X'
62
+ # - 不在支持列表 / 不在覆盖范围 / 暂无模板
63
+ # 它们记 ok=true 才能进入需求信号——"用户搜 BLDC 但知识库没有"正是
64
+ # 指导 P2-1 补内容的最强信号,标成失败会被分析工具当作噪声剔除。
65
+ SOFT_FAILURE_MARKERS = (
66
+ "错误:",
67
+ "导出失败",
68
+ "请至少提供",
69
+ )
70
+ # 只在返回值开头这段内匹配,避免正文里偶然出现标记词造成误判
71
+ _FAILURE_SCAN_CHARS = 80
72
+
50
73
  USAGE_PREFIX = "usage"
51
74
  FEEDBACK_PREFIX = "feedback"
52
75
 
53
76
 
77
+ def looks_like_failure(text: str) -> bool:
78
+ """判断工具返回值是否为"软失败"(字符串形式的错误提示)。"""
79
+ head = (text or "").lstrip()[:_FAILURE_SCAN_CHARS]
80
+ return any(m in head for m in SOFT_FAILURE_MARKERS)
81
+
82
+
83
+ def _result_text(result: Any) -> str:
84
+ """取工具返回值中的文本(供软失败判断)。"""
85
+ parts: list[str] = []
86
+ for block in getattr(result, "content", None) or []:
87
+ text = getattr(block, "text", None)
88
+ if isinstance(text, str):
89
+ parts.append(text)
90
+ return "\n".join(parts)
91
+
92
+
54
93
  def _now() -> str:
55
94
  return datetime.now(timezone.utc).isoformat()
56
95
 
@@ -121,9 +160,14 @@ class UsageMiddleware(Middleware):
121
160
  ok = True
122
161
  try:
123
162
  result = await call_next(context)
124
- # 协议级错误标记;工具内以字符串返回的软失败不体现在此
125
163
  if getattr(result, "is_error", False):
164
+ # 协议级错误
165
+ ok = False
166
+ error = _result_text(result)[:500]
167
+ elif looks_like_failure(_result_text(result)):
168
+ # 工具以字符串返回的软失败(如"导出失败:…")
126
169
  ok = False
170
+ error = _result_text(result)[:500]
127
171
  return result
128
172
  except Exception as e:
129
173
  error = f"{type(e).__name__}: {e}" # 异常上抛,但不能漏记
@@ -307,13 +307,45 @@ def test_version_reports_bundle_mode(monkeypatch):
307
307
  def test_usage_impl_version_pinned():
308
308
  """两仓库的 usage.py 是逐字节相同副本,实现版本号用于防止只改一边。"""
309
309
  from ht32_code_mcp import usage
310
- assert usage._IMPL_VERSION == "1.0.0"
310
+ assert usage._IMPL_VERSION == "1.1.0"
311
311
  assert usage.SERVICE_CODE == "code"
312
312
  assert usage.SERVICE_WIKI == "wiki"
313
313
  # 噪声参数含 out_dir(code 特有:可能含用户本地路径)
314
314
  assert {"limit", "max_length", "out_dir"} <= usage.ARG_NOISE
315
315
 
316
316
 
317
+ def test_soft_failure_marker_semantics():
318
+ """软失败判定口径 = 工具是否完成工作。
319
+
320
+ 真失败(执行没成功)→ 算失败;"无数据"(正常跑完但没结果)→ 不算失败,
321
+ 否则"用户搜 BLDC 但库里没有"这类最强缺口信号会被当噪声剔除。
322
+ """
323
+ from ht32_code_mcp.usage import looks_like_failure as bad
324
+
325
+ # 真失败:参数/路径/IO/导出
326
+ assert bad("错误:path 不能为空。")
327
+ assert bad("错误:路径越界(只允许 raw/ 下文件):x")
328
+ assert bad("导出失败:芯片 'X' 不在模板参数表")
329
+ assert bad("导出失败(打包或写盘失败,检查 bundle 目录权限/空间):boom")
330
+ assert bad("错误:反馈未能写入。服务端可能未配置 FEEDBACK_ROOT,")
331
+ assert bad("请至少提供两个型号对比。") # wiki: compare_chips 参数校验
332
+
333
+ # 无数据 ≠ 失败:必须记 ok=true 才能进需求信号
334
+ assert not bad("未找到匹配知识页。建议:换模块官方名小写重试…")
335
+ assert not bad("未找到外设 'xxx' 的例程。可用外设目录(44 个):…")
336
+ assert not bad("芯片 'HT32F99999' 不在支持列表(当前 79 个型号,…")
337
+ assert not bad("系列 'F9' 不在覆盖范围。当前覆盖:…")
338
+ assert not bad("raw/templates/ 下暂无模板。")
339
+ assert not bad("未找到型号 'zzz'。可用型号:…")
340
+
341
+ # 正常成功返回值
342
+ assert not bad("找到 6 个 'gpio' 例程:…")
343
+ assert not bad("已导出 455 个文件到 …")
344
+ assert not bad("反馈已记录,感谢!问题类型:other")
345
+ # 正文后段偶然出现标记词不应误判(只扫开头 80 字符)
346
+ assert not bad("找到 3 条结果:\n" + "填充" * 60 + "\n注意:错误:的用法见页尾")
347
+
348
+
317
349
  def test_resolve_log_dir_layout():
318
350
  from ht32_code_mcp.usage import resolve_log_dir
319
351
  # 同根 + 服务子目录
@@ -409,6 +441,42 @@ def test_usage_middleware_records_calls(tmp_path):
409
441
  assert "deliberate" in fail_rec["error"]
410
442
 
411
443
 
444
+ def test_usage_middleware_records_soft_failure(tmp_path):
445
+ """工具返回错误字符串(不抛异常)时,middleware 应记 ok=false 并留错误原文。"""
446
+ import asyncio
447
+
448
+ from fastmcp import Client, FastMCP
449
+
450
+ app = FastMCP("mw-soft")
451
+ app.add_middleware(UsageMiddleware(str(tmp_path / "logs"), "code",
452
+ mcp_version="0.2.1", kb_version="v0.1.0"))
453
+
454
+ @app.tool()
455
+ def soft_fail(chip: str) -> str:
456
+ return f"导出失败:芯片 '{chip}' 不在模板参数表"
457
+
458
+ @app.tool()
459
+ def no_data(peripheral: str) -> str:
460
+ return f"未找到外设 '{peripheral}' 的例程。可用外设目录(44 个):…"
461
+
462
+ async def run():
463
+ async with Client(app) as c:
464
+ await c.call_tool("soft_fail", {"chip": "HT32F99999"})
465
+ await c.call_tool("no_data", {"peripheral": "zzz"})
466
+
467
+ asyncio.run(run())
468
+ recs = [json.loads(l) for l in
469
+ next((tmp_path / "logs").glob("usage_*.jsonl"))
470
+ .read_text(encoding="utf-8").splitlines()]
471
+ fail_rec, nodata_rec = recs
472
+ # 真失败:ok=false 且留下错误原文
473
+ assert fail_rec["tool"] == "soft_fail" and fail_rec["ok"] is False
474
+ assert "不在模板参数表" in fail_rec["error"]
475
+ # 无数据:不算失败,保留在需求信号里
476
+ assert nodata_rec["tool"] == "no_data" and nodata_rec["ok"] is True
477
+ assert nodata_rec["error"] == ""
478
+
479
+
412
480
  def test_usage_log_disabled_and_resilient(tmp_path):
413
481
  """log_dir 为空不落盘;目录不可写时调用仍成功(日志绝不中断服务)。"""
414
482
  import asyncio
@@ -440,7 +440,7 @@ wheels = [
440
440
 
441
441
  [[package]]
442
442
  name = "ht32-code-mcp"
443
- version = "0.2.0"
443
+ version = "0.2.2"
444
444
  source = { editable = "." }
445
445
  dependencies = [
446
446
  { name = "fastmcp" },
@@ -1,5 +0,0 @@
1
- # .work/mcp-server/
2
-
3
- MCP server 代码目录(Python/FastMCP),P0-2 在此实现 7 工具(本地 stdio)。
4
-
5
- 当前为占位:仓库初始化(P0-1)时建立骨架,保证目录结构随 git 入库。
@@ -1,3 +0,0 @@
1
- """ht32-code-mcp:HT32 代码知识库 MCP Server(7 工具,本地 stdio)。"""
2
-
3
- __version__ = "0.1.0"
File without changes
File without changes