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.
- {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/DEPLOY.md +112 -40
- {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/PKG-INFO +1 -1
- ht32_code_mcp-0.2.2/README.md +6 -0
- {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/ht32-code-mcp-update.md +7 -5
- {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/pyproject.toml +1 -1
- ht32_code_mcp-0.2.2/smoke_http.py +193 -0
- ht32_code_mcp-0.2.2/src/ht32_code_mcp/__init__.py +4 -0
- {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/src/ht32_code_mcp/server.py +19 -5
- {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/src/ht32_code_mcp/usage.py +46 -2
- {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/tests/test_tools.py +69 -1
- {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/uv.lock +1 -1
- ht32_code_mcp-0.2.0/README.md +0 -5
- ht32_code_mcp-0.2.0/src/ht32_code_mcp/__init__.py +0 -3
- {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/.gitignore +0 -0
- {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/.gitkeep +0 -0
- {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/e2e_scenario_a.py +0 -0
- {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/e2e_scenario_b.py +0 -0
- {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/smoke_stdio.py +0 -0
- {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/src/ht32_code_mcp/__main__.py +0 -0
- {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/src/ht32_code_mcp/exporter.py +0 -0
- {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/src/ht32_code_mcp/index.py +0 -0
- {ht32_code_mcp-0.2.0 → ht32_code_mcp-0.2.2}/src/ht32_code_mcp/knowledge.py +0 -0
- {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.
|
|
215
|
+
## 反馈与用量日志(P1-5,0.2.0+)
|
|
178
216
|
|
|
179
|
-
|
|
217
|
+
> **与 ht32-wiki-mcp 统一**:两服务使用同一套实现(`usage.py`,两仓库逐字节
|
|
218
|
+
> 相同的副本)、同一套目录约定(同根 + 服务子目录)、同一套字段。
|
|
219
|
+
> 本服务写 `<FEEDBACK_ROOT>/code/`,wiki 写 `<FEEDBACK_ROOT>/wiki/`。
|
|
180
220
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
|
184
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
| `
|
|
260
|
+
| `FEEDBACK_ROOT` | 空(= **关闭**,本地开发默认不落盘) | 日志根目录,服务名子目录自动拼接;**必须填绝对路径**(如 `/app/feedback`,不要写 `app/feedback`) |
|
|
261
|
+
| `CODE_LOG_DIR` | — | 已废弃,改用 `FEEDBACK_ROOT` |
|
|
195
262
|
|
|
196
|
-
>
|
|
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
|
-
###
|
|
272
|
+
### 服务器侧落地
|
|
201
273
|
|
|
202
|
-
**第 1
|
|
274
|
+
**第 1 步:宿主机建目录**
|
|
203
275
|
|
|
204
276
|
```bash
|
|
205
|
-
mkdir -p /opt/ht32-
|
|
206
|
-
chmod 755 /opt/ht32-
|
|
277
|
+
mkdir -p /opt/ht32-mcp-feedback
|
|
278
|
+
chmod 755 /opt/ht32-mcp-feedback
|
|
207
279
|
```
|
|
208
280
|
|
|
209
|
-
**第 2
|
|
281
|
+
**第 2 步:两个 MCP 容器都挂同一卷**
|
|
210
282
|
|
|
211
|
-
|
|
|
212
|
-
|
|
213
|
-
|
|
|
214
|
-
|
|
|
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
|
-
|
|
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-
|
|
304
|
+
/opt/ht32-mcp-feedback:/app/feedback
|
|
233
305
|
```
|
|
234
306
|
|
|
235
|
-
###
|
|
307
|
+
### 验证
|
|
236
308
|
|
|
237
|
-
1. 调 `get_code_version`,应出现
|
|
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-
|
|
316
|
+
tail -3 /opt/ht32-mcp-feedback/code/usage_$(date -u +%Y-%m-%d).jsonl
|
|
244
317
|
```
|
|
245
318
|
|
|
246
|
-
3.
|
|
319
|
+
3. 统计被请求的芯片型号(指导 P2-1 排优先级)——用 ht32-skills 的分析工具:
|
|
247
320
|
|
|
248
321
|
```bash
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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`
|
|
295
|
-
| 3.3 | PyPI 发布(hatchling) | `ht32-code-mcp` 0.
|
|
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`),需改绝对路径 | ⚠️ |
|
|
@@ -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/
|
|
55
|
+
# 用量日志 / 反馈目录(1Panel 挂载到容器 /app/feedback,与 wiki 同一卷)
|
|
56
56
|
|
|
57
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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 "用量日志目录不存在,跳过清理:$
|
|
140
|
+
log "用量日志目录不存在,跳过清理:$FEEDBACK_DIR"
|
|
139
141
|
fi
|
|
140
142
|
|
|
141
143
|
log "本次同步完成"
|
|
@@ -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())
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""HT32 Code MCP Server。
|
|
2
2
|
|
|
3
3
|
提供函数库/例程/模板检索与工程导出,供 AI Agent 调用。
|
|
4
|
-
传输协议:stdio
|
|
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
|
|
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",
|
|
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]
|
|
444
|
-
|
|
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.
|
|
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.
|
|
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
|
ht32_code_mcp-0.2.0/README.md
DELETED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|