ht32-code-mcp 0.5.1__tar.gz → 0.5.3__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.5.1 → ht32_code_mcp-0.5.3}/DEPLOY.md +7 -8
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/PKG-INFO +1 -1
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/README.md +2 -2
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/pyproject.toml +1 -1
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/src/ht32_code_mcp/server.py +43 -6
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/src/ht32_code_mcp/usage.py +31 -5
- ht32_code_mcp-0.5.3/tests/test_tool_output_budget.py +78 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/tests/test_tools.py +34 -1
- ht32_code_mcp-0.5.3/tests/test_version_file.py +110 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/uv.lock +1 -1
- ht32_code_mcp-0.5.1/.pytest_base_final_run.log +0 -4
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/.gitignore +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/.gitkeep +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/components.example.json +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/e2e_scenario_a.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/e2e_scenario_b.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/smoke_http.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/smoke_stdio.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/src/ht32_code_mcp/__init__.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/src/ht32_code_mcp/__main__.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/src/ht32_code_mcp/component_exporter.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/src/ht32_code_mcp/component_source.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/src/ht32_code_mcp/components.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/src/ht32_code_mcp/exporter.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/src/ht32_code_mcp/index.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/src/ht32_code_mcp/knowledge.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/src/ht32_code_mcp/materializer.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/src/ht32_code_mcp/release.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/tests/conftest.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/tests/test_component_export.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/tests/test_component_redact.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/tests/test_component_source.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/tests/test_component_tools.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/tests/test_components.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/tests/test_export_cloud_mode.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/tests/test_knowledge_grounding.py +0 -0
- {ht32_code_mcp-0.5.1 → ht32_code_mcp-0.5.3}/tests/test_recipe_claims.py +0 -0
|
@@ -42,15 +42,14 @@ PyPI 包 → FastMCP 直接提供 Streamable HTTP(常驻单进程)
|
|
|
42
42
|
|
|
43
43
|
### 镜像与端点
|
|
44
44
|
|
|
45
|
-
- 镜像 `ht32-code-mcp:0.5.
|
|
46
|
-
`git/curl/ca-certificates`,`uv tool install ht32-code-mcp==0.5.
|
|
45
|
+
- 镜像 `ht32-code-mcp:0.5.1`:`ghcr.io/astral-sh/uv:python3.12-bookworm-slim` +
|
|
46
|
+
`git/curl/ca-certificates`,`uv tool install ht32-code-mcp==0.5.1`,入口直跑
|
|
47
47
|
`ht32-code-mcp`(私有组件能力仍需服务端 git)。由 `.work/docker/Dockerfile` 构建。
|
|
48
48
|
- 传输:生产 `MCP_TRANSPORT=streamable-http`,`path=/ht32-code`(`MCP_PATH` 注入);
|
|
49
49
|
本地调试保持默认 `/mcp`。`stdio` 仅供冒烟测试。
|
|
50
50
|
- `MCP_JSON_RESPONSE=0`:保持 SSE,兼容现有客户端。
|
|
51
51
|
- 健康检查:`GET /health` → `{"status":"ok","version":"<包版本>"}`,不触发检索。
|
|
52
|
-
- 资源:`mem_limit: 1g` / `cpus: 1.0` / `pids_limit: 512
|
|
53
|
-
不再使用 V1 按「一会话一进程」算出的 `4g` / `1024` 熔断值。
|
|
52
|
+
- 资源:`mem_limit: 1g` / `cpus: 1.0` / `pids_limit: 512`(常驻单进程与多会话对象)。
|
|
54
53
|
|
|
55
54
|
## compose 关键配置
|
|
56
55
|
|
|
@@ -66,7 +65,7 @@ PyPI 包 → FastMCP 直接提供 Streamable HTTP(常驻单进程)
|
|
|
66
65
|
| `COMPONENTS_CONFIG` | `/app/code/.work/components/components.json` |
|
|
67
66
|
| `COMPONENTS_STATE_FILE` | `/app/feedback/component-tag-lock.json` |
|
|
68
67
|
| 凭据 | `.work/docker/.env` 的 `COMPONENTS_GIT_AUTH`(base64 用户名:只读 token),已 gitignore |
|
|
69
|
-
| `MCP_TRANSPORT` | `streamable-http
|
|
68
|
+
| `MCP_TRANSPORT` | `streamable-http`(容器直跑,无中间网关) |
|
|
70
69
|
| `MCP_HOST` / `MCP_PORT` | `0.0.0.0` / `8001` |
|
|
71
70
|
| `MCP_PATH` | `/ht32-code`(须与反代 location 一致) |
|
|
72
71
|
| `MCP_STATELESS_HTTP` | `0`(P0 保留进程内有状态会话) |
|
|
@@ -140,7 +139,7 @@ location ^~ /ht32-bundles {
|
|
|
140
139
|
|
|
141
140
|
| 通道 | 文件 | 触发 | 内容 |
|
|
142
141
|
|------|------|------|------|
|
|
143
|
-
| 用量日志 | `usage_*.jsonl` | 服务端自动(middleware) | 工具名、参数原文、耗时、成败、session |
|
|
142
|
+
| 用量日志 | `usage_*.jsonl` | 服务端自动(middleware) | 工具名、参数原文、耗时、成败、session、客户端身份(`client_name`/`client_version`,取自 initialize 的 `clientInfo`) |
|
|
144
143
|
| 反馈 | `feedback_*.jsonl` | Agent 主动(`submit_feedback`) | 语义性问题 |
|
|
145
144
|
|
|
146
145
|
- `FEEDBACK_ROOT` 为空 = 整体关闭(本地开发默认不落盘);必须填**绝对路径**。
|
|
@@ -272,8 +271,8 @@ curl -s -o /dev/null -w "%{http_code}" https://mcp.s621.work/ht32-code # 400 =
|
|
|
272
271
|
```
|
|
273
272
|
|
|
274
273
|
- 启动日志应含 `启动服务: Streamable HTTP 0.0.0.0:8001/ht32-code(stateless=False, json=False)`;
|
|
275
|
-
|
|
276
|
-
- 协议版本被拒 →
|
|
274
|
+
否则检查服务器 compose 与镜像是否已同步
|
|
275
|
+
- 协议版本被拒 → 检查镜像版本与客户端协议版本
|
|
277
276
|
- `/health` 404 → 进程是旧版(`MCP_PATH` 之前的版本没有该路由)
|
|
278
277
|
- 容器 126 退出 → 旧 compose 的 `$$(...)` 命令替换被吃掉;新版无 `command`,重新 `git pull` 即可
|
|
279
278
|
- 首会话超时 → 冷启动(索引加载);容器健康检查 `start_period: 60s` 已留余量
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# .work/mcp-server/
|
|
2
2
|
|
|
3
|
-
MCP server 代码目录(Python + FastMCP),实现
|
|
3
|
+
MCP server 代码目录(Python + FastMCP),实现 **13 个工具**(检索/导出/组件/版本检查/反馈)。
|
|
4
4
|
|
|
5
|
-
传输由 `MCP_TRANSPORT` 决定:`stdio
|
|
5
|
+
传输由 `MCP_TRANSPORT` 决定:`stdio`(本地调试/冒烟)/ `streamable-http`(线上,FastMCP 常驻单进程)/ `sse`(旧客户端兼容)。
|
|
6
6
|
部署与排错见 [DEPLOY.md](DEPLOY.md)。
|
|
7
7
|
|
|
8
8
|
## 私有组件(0.4.5+)
|
|
@@ -138,7 +138,8 @@ M0+(HT32F5 / L5)、M3(HT32F1 / F175x-275x)、M4(F490x1 / F491x3 / F493
|
|
|
138
138
|
|
|
139
139
|
## 按需求选工具
|
|
140
140
|
- 确认版本/覆盖范围 → get_code_version
|
|
141
|
-
- 问"某外设库怎么用/有哪些模块" → list_libraries
|
|
141
|
+
- 问"某外设库怎么用/有哪些模块" → list_libraries(compact=True) 或 list_libraries(module="gpio")
|
|
142
|
+
→ search_code_knowledge(模块名);需要 raw 路径时用 list_libraries(module="<模块名>") 取,不要猜路径
|
|
142
143
|
- 找官方例程(按外设/系列/芯片) → find_example → get_file(例程路径) 看原码
|
|
143
144
|
- 取 raw/ 真实源文件(驱动 .c/.h、例程、devices.json 等) → get_file
|
|
144
145
|
- 检索知识页(初始化套路/易错点/子系列差异) → search_code_knowledge
|
|
@@ -304,32 +305,59 @@ def get_code_version() -> str:
|
|
|
304
305
|
|
|
305
306
|
|
|
306
307
|
@mcp.tool()
|
|
307
|
-
def list_libraries() -> str:
|
|
308
|
+
def list_libraries(series: str = "", module: str = "", compact: bool = False) -> str:
|
|
308
309
|
"""枚举函数库模块与覆盖系列。
|
|
309
310
|
|
|
310
311
|
返回每个库模块的驱动源码/头文件路径(raw/)、变体数、
|
|
311
312
|
对应官方例程数与知识页路径(entities/libraries/)。
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
series: 系列或内核家族过滤(f5 / l5 / F493x5 / M0+ / M3 / M4),默认不过滤
|
|
316
|
+
module: 模块名子串过滤(如 gpio / usart),只列命中模块并保留完整路径
|
|
317
|
+
compact: True 时每包一行只给模块名清单(省 token);需要路径时用
|
|
318
|
+
module= 或 compact=False
|
|
312
319
|
"""
|
|
313
320
|
fwlibs = index.data.get("fwlibs", [])
|
|
314
321
|
families: dict[str, list[str]] = {}
|
|
315
322
|
for p in fwlibs:
|
|
316
323
|
families.setdefault(p.get("family") or "?", []).append(
|
|
317
324
|
"/".join(p.get("series") or ["?"]))
|
|
325
|
+
# 表头始终按全量固件库计算:migrate 靠「包 → 系列」判定同系列,过滤后不能丢
|
|
318
326
|
lines = ["库模块清单(模块名按官方驱动 .c 文件名归一):",
|
|
319
327
|
f"覆盖 {len(fwlibs)} 个固件库包 / {len(families)} 个内核家族:"
|
|
320
328
|
+ ";".join(f"{k}({'、'.join(v)})" for k, v in sorted(families.items()))]
|
|
329
|
+
pool = fwlibs
|
|
330
|
+
if series:
|
|
331
|
+
pool = _filter_by_series(pool, series)
|
|
332
|
+
if not pool:
|
|
333
|
+
return (f"系列 '{series}' 不在覆盖范围。当前覆盖:"
|
|
334
|
+
+ "、".join(
|
|
335
|
+
f"{p['name']}({'/'.join(p.get('series') or ['?'])},"
|
|
336
|
+
f"{p.get('family') or '?'})" for p in fwlibs))
|
|
337
|
+
lines.append(f"(已按系列 '{series}' 过滤,命中 {len(pool)} 个包)")
|
|
321
338
|
page_paths = {p["path"] for p in index.data.get("knowledge_pages", [])}
|
|
322
|
-
|
|
323
|
-
|
|
339
|
+
want = module.strip().lower()
|
|
340
|
+
matched = False
|
|
341
|
+
for pkg in pool:
|
|
342
|
+
series_label = "/".join(pkg.get("series") or ["?"]) + (
|
|
343
|
+
f"({pkg['family']})" if pkg.get("family") else "")
|
|
324
344
|
ex_counts: dict[str, int] = {}
|
|
325
345
|
for ex in pkg.get("examples", []):
|
|
326
346
|
ex_counts[ex["peripheral"].lower()] = ex_counts.get(ex["peripheral"].lower(), 0) + 1
|
|
327
347
|
modules: dict[str, list[dict]] = {}
|
|
328
348
|
for m in pkg.get("modules", []):
|
|
329
349
|
modules.setdefault(m["module"], []).append(m)
|
|
350
|
+
if want:
|
|
351
|
+
modules = {k: v for k, v in modules.items() if want in k.lower()}
|
|
352
|
+
if not modules:
|
|
353
|
+
continue
|
|
354
|
+
matched = True
|
|
330
355
|
lines.append("")
|
|
331
|
-
lines.append(f"== 固件库 {pkg['name']}(系列 {
|
|
356
|
+
lines.append(f"== 固件库 {pkg['name']}(系列 {series_label})== "
|
|
332
357
|
f"共 {len(modules)} 个模块")
|
|
358
|
+
if compact:
|
|
359
|
+
lines.append("、".join(sorted(modules)))
|
|
360
|
+
continue
|
|
333
361
|
for mod in sorted(modules):
|
|
334
362
|
variants = modules[mod]
|
|
335
363
|
srcs = "、".join(v["src"] for v in variants)
|
|
@@ -339,8 +367,17 @@ def list_libraries() -> str:
|
|
|
339
367
|
f"- {mod}:{len(variants)} 个驱动变体 | {srcs}"
|
|
340
368
|
f" | 例程 {ex_counts.get(mod, 0)} 个 | 知识页 {page_note}"
|
|
341
369
|
)
|
|
370
|
+
if want and not matched:
|
|
371
|
+
# 模块名按驱动文件名归一(usart 而非 uart),查不到时回退列全部模块名
|
|
372
|
+
all_mods = sorted({m["module"] for p in fwlibs for m in p.get("modules", [])})
|
|
373
|
+
return (f"未找到模块 '{module}'。可用模块({len(all_mods)} 个):"
|
|
374
|
+
+ "、".join(all_mods))
|
|
342
375
|
lines.append("")
|
|
343
|
-
|
|
376
|
+
if compact:
|
|
377
|
+
lines.append("需要路径/例程数/知识页:list_libraries(module=\"<模块名>\") "
|
|
378
|
+
"或 list_libraries(compact=False)。")
|
|
379
|
+
else:
|
|
380
|
+
lines.append("用法:模块详情查 search_code_knowledge(模块名);原码用 get_file(路径)。")
|
|
344
381
|
lines.append("注:模块名按驱动文件名归一,跨包同名模块(如 gpio)在各包下独立列出,"
|
|
345
382
|
"选型时以目标芯片所属系列为准。")
|
|
346
383
|
return "\n".join(lines)
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
两条采集通道,写入同一目录树:
|
|
8
8
|
|
|
9
9
|
- **tool_call**(`UsageMiddleware`,服务端自动):拦截所有工具调用,
|
|
10
|
-
|
|
10
|
+
记录工具名、参数原文、耗时、成败,以及 MCP `initialize` 请求里的客户端身份
|
|
11
|
+
(`client_info.name` / `client_info.version` → 日志字段 `client_name` /
|
|
12
|
+
`client_version`,老客户端未上报时为空串)。不依赖 Agent 配合,数据完整。
|
|
11
13
|
- **feedback**(`record_feedback`,由各 server 的 `submit_feedback` 工具调用):
|
|
12
14
|
记录 Agent 主动上报的语义性问题,middleware 无法判断这些。其中 `skill_version`
|
|
13
15
|
由 Agent 自报(服务端跨进程拿不到客户端 skill 版本),用于定位 ht32-skills 缺陷版本。
|
|
@@ -41,7 +43,7 @@ from typing import Any
|
|
|
41
43
|
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
|
42
44
|
|
|
43
45
|
# 实现版本号:两仓库副本必须一致(各仓测试会校验,防止只改一边造成漂移)
|
|
44
|
-
_IMPL_VERSION = "1.
|
|
46
|
+
_IMPL_VERSION = "1.4.0"
|
|
45
47
|
|
|
46
48
|
# 服务名 → 子目录名。新增服务在此登记。
|
|
47
49
|
SERVICE_WIKI = "wiki"
|
|
@@ -126,12 +128,35 @@ def write_record(log_dir: str, prefix: str, record: dict[str, Any]) -> Path | No
|
|
|
126
128
|
return None
|
|
127
129
|
|
|
128
130
|
|
|
131
|
+
def _initialize_client(ctx: Any) -> tuple[str, str]:
|
|
132
|
+
"""从 MCP `initialize` 的 `clientInfo` 取客户端名与版本。
|
|
133
|
+
|
|
134
|
+
FastMCP 4 / MCP SDK 2 把 initialize 参数常驻在会话底层 Connection 上
|
|
135
|
+
(`session.client_params.client_info`),initialize 时写一次、整段会话只读,
|
|
136
|
+
因此工具调用阶段可直接读取,无需另建缓存,也就不存在并发写竞态。
|
|
137
|
+
取不到(无会话 / 客户端未上报 / 字段缺失 / 访问抛错)一律返回空串。
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
session = getattr(ctx, "session", None)
|
|
141
|
+
params = getattr(session, "client_params", None) if session is not None else None
|
|
142
|
+
info = getattr(params, "client_info", None) if params is not None else None
|
|
143
|
+
if info is None:
|
|
144
|
+
return "", ""
|
|
145
|
+
return str(getattr(info, "name", "") or ""), str(getattr(info, "version", "") or "")
|
|
146
|
+
except Exception: # noqa: BLE001 - 采集失败绝不能影响工具调用本身
|
|
147
|
+
return "", ""
|
|
148
|
+
|
|
149
|
+
|
|
129
150
|
def _client_info(context: MiddlewareContext) -> dict[str, str]:
|
|
130
|
-
"""取客户端标识(内存调用等无 session 的场景返回空值)。
|
|
151
|
+
"""取客户端标识(内存调用等无 session 的场景返回空值)。
|
|
152
|
+
|
|
153
|
+
`client_name` / `client_version` 恒定存在:老客户端未上报 clientInfo 时为空串,
|
|
154
|
+
保证日志行字段结构不变形。
|
|
155
|
+
"""
|
|
156
|
+
out: dict[str, str] = {"client_name": "", "client_version": ""}
|
|
131
157
|
ctx = getattr(context, "fastmcp_context", None)
|
|
132
158
|
if ctx is None:
|
|
133
|
-
return
|
|
134
|
-
out: dict[str, str] = {}
|
|
159
|
+
return out
|
|
135
160
|
for key in ("client_id", "session_id"):
|
|
136
161
|
val = getattr(ctx, key, None)
|
|
137
162
|
if val:
|
|
@@ -139,6 +164,7 @@ def _client_info(context: MiddlewareContext) -> dict[str, str]:
|
|
|
139
164
|
transport = getattr(ctx, "transport", None)
|
|
140
165
|
if transport:
|
|
141
166
|
out["transport"] = str(transport)
|
|
167
|
+
out["client_name"], out["client_version"] = _initialize_client(ctx)
|
|
142
168
|
return out
|
|
143
169
|
|
|
144
170
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""工具输出体积预算 + list_libraries 过滤行为门禁。
|
|
2
|
+
|
|
3
|
+
目的:`list_libraries` 全量输出曾达 28.5k 字符(~12–14k token),是单次调用最大开销。
|
|
4
|
+
本文件把 compact / module 过滤的收益钉住,同时守住三条准确度约束:
|
|
5
|
+
1. 表头「包 → 系列」始终按全量固件库计算(migrate 靠它判定同系列)
|
|
6
|
+
2. module 无命中时回退列出全部模块名(模块名按驱动文件名归一,uart≠usart)
|
|
7
|
+
3. compact 输出带「怎么取路径」的指引
|
|
8
|
+
|
|
9
|
+
运行(从 .work/mcp-server/):pytest tests/test_tool_output_budget.py
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from ht32_code_mcp import server as srv
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------- 体积预算
|
|
17
|
+
def test_list_libraries_compact_budget():
|
|
18
|
+
out = srv.list_libraries(compact=True)
|
|
19
|
+
assert len(out) <= 4000, f"compact 全量 {len(out)} 字符,超出 4000 预算"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_list_libraries_module_budget():
|
|
23
|
+
out = srv.list_libraries(module="gpio")
|
|
24
|
+
assert len(out) <= 2000, f"module=gpio {len(out)} 字符,超出 2000 预算"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_list_libraries_full_budget():
|
|
28
|
+
out = srv.list_libraries()
|
|
29
|
+
assert len(out) <= 30000, f"全量 {len(out)} 字符,超出 30000 预算(防回涨)"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_get_code_version_budget():
|
|
33
|
+
out = srv.get_code_version()
|
|
34
|
+
assert len(out) <= 1500, f"get_code_version {len(out)} 字符,超出 1500 预算"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------- 准确度约束
|
|
38
|
+
def test_compact_keeps_package_series_header():
|
|
39
|
+
"""约束 1:过滤后表头仍列全部固件库包与内核家族。"""
|
|
40
|
+
full = srv.list_libraries(compact=True)
|
|
41
|
+
filtered = srv.list_libraries(series="f5", compact=True)
|
|
42
|
+
header = full.splitlines()[1]
|
|
43
|
+
assert header in filtered, "按系列过滤后表头丢失了全量包/家族信息"
|
|
44
|
+
assert "覆盖" in header and "内核家族" in header
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_series_filter_narrows_packages():
|
|
48
|
+
out = srv.list_libraries(series="f5", compact=True)
|
|
49
|
+
assert "已按系列 'f5' 过滤" in out
|
|
50
|
+
assert "HT32_STD_5xxxx_FWLib" in out
|
|
51
|
+
assert "HT32F493x5_FWLib" not in out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_unknown_series_lists_coverage():
|
|
55
|
+
out = srv.list_libraries(series="f9")
|
|
56
|
+
assert "不在覆盖范围" in out
|
|
57
|
+
assert "HT32_STD_5xxxx_FWLib" in out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_module_filter_keeps_paths():
|
|
61
|
+
out = srv.list_libraries(module="gpio")
|
|
62
|
+
assert "gpio" in out
|
|
63
|
+
assert "raw/" in out, "module 过滤必须保留 raw 路径,否则 agent 只能猜路径"
|
|
64
|
+
assert "entities/libraries/gpio.md" in out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_unknown_module_falls_back_to_all_names():
|
|
68
|
+
"""约束 2:查不到时回退列全部模块名,避免 agent 误判模块不存在。"""
|
|
69
|
+
out = srv.list_libraries(module="uart")
|
|
70
|
+
assert "未找到模块 'uart'" in out
|
|
71
|
+
assert "可用模块" in out
|
|
72
|
+
assert "usart" in out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_compact_has_path_hint():
|
|
76
|
+
"""约束 3:compact 输出必须告诉 agent 怎么取路径。"""
|
|
77
|
+
out = srv.list_libraries(compact=True)
|
|
78
|
+
assert 'list_libraries(module="<模块名>")' in out
|
|
@@ -679,13 +679,43 @@ def test_version_reports_skill_latest(monkeypatch):
|
|
|
679
679
|
def test_usage_impl_version_pinned():
|
|
680
680
|
"""两仓库的 usage.py 是逐字节相同副本,实现版本号用于防止只改一边。"""
|
|
681
681
|
from ht32_code_mcp import usage
|
|
682
|
-
assert usage._IMPL_VERSION == "1.
|
|
682
|
+
assert usage._IMPL_VERSION == "1.4.0"
|
|
683
683
|
assert usage.SERVICE_CODE == "code"
|
|
684
684
|
assert usage.SERVICE_WIKI == "wiki"
|
|
685
685
|
# 噪声参数含 out_dir(code 特有:可能含用户本地路径)
|
|
686
686
|
assert {"limit", "max_length", "out_dir"} <= usage.ARG_NOISE
|
|
687
687
|
|
|
688
688
|
|
|
689
|
+
def test_usage_client_info_from_initialize():
|
|
690
|
+
"""initialize 的 clientInfo 落到 client_name/client_version;缺失时不报错且为空串。"""
|
|
691
|
+
from types import SimpleNamespace
|
|
692
|
+
|
|
693
|
+
from fastmcp.server.middleware import MiddlewareContext
|
|
694
|
+
|
|
695
|
+
from ht32_code_mcp.usage import _client_info
|
|
696
|
+
|
|
697
|
+
def fake_ctx(**kwargs):
|
|
698
|
+
return MiddlewareContext(message=None, fastmcp_context=SimpleNamespace(**kwargs))
|
|
699
|
+
|
|
700
|
+
with_info = fake_ctx(
|
|
701
|
+
transport="streamable-http", session_id="s1", client_id=None,
|
|
702
|
+
session=SimpleNamespace(client_params=SimpleNamespace(
|
|
703
|
+
client_info=SimpleNamespace(name="Trae", version="3.4.15"))))
|
|
704
|
+
info = _client_info(with_info)
|
|
705
|
+
assert info["client_name"] == "Trae"
|
|
706
|
+
assert info["client_version"] == "3.4.15"
|
|
707
|
+
|
|
708
|
+
# 老客户端 / 现代协议未带 clientInfo:字段仍在,值为空串,且不抛错
|
|
709
|
+
for no_info in (
|
|
710
|
+
fake_ctx(transport="stdio", session_id="s2", client_id=None,
|
|
711
|
+
session=SimpleNamespace(client_params=None)),
|
|
712
|
+
fake_ctx(transport="stdio", session_id="s3", client_id=None,
|
|
713
|
+
session=SimpleNamespace(client_params=SimpleNamespace(client_info=None))),
|
|
714
|
+
):
|
|
715
|
+
out = _client_info(no_info)
|
|
716
|
+
assert out["client_name"] == "" and out["client_version"] == ""
|
|
717
|
+
|
|
718
|
+
|
|
689
719
|
def test_soft_failure_marker_semantics():
|
|
690
720
|
"""软失败判定口径 = 工具是否完成工作。
|
|
691
721
|
|
|
@@ -838,6 +868,9 @@ def test_usage_middleware_records_calls(tmp_path):
|
|
|
838
868
|
assert ok_rec["duration_ms"] >= 0
|
|
839
869
|
assert ok_rec["mcp_version"] == "0.1.4"
|
|
840
870
|
assert ok_rec["kb_version"] == "v0.1.0"
|
|
871
|
+
# 客户端身份字段恒定存在(内存客户端可能未上报 clientInfo → 空串)
|
|
872
|
+
assert isinstance(ok_rec["client_name"], str)
|
|
873
|
+
assert isinstance(ok_rec["client_version"], str)
|
|
841
874
|
assert fail_rec["tool"] == "boom" and fail_rec["ok"] is False
|
|
842
875
|
assert "deliberate" in fail_rec["error"]
|
|
843
876
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""VERSION 文件格式门禁:固定 6 行,禁止回涨成变更日志。
|
|
2
|
+
|
|
3
|
+
为什么需要这道门禁
|
|
4
|
+
------------------
|
|
5
|
+
`get_code_version` 会把 VERSION **全文**回显给 agent,VERSION 每多写一段变更说明,
|
|
6
|
+
每次版本查询就多烧一份 token。历史上这份文件长成了「多版本并列 + run-on 变更史」
|
|
7
|
+
(1.3 KB),靠人自觉守不住,用测试钉死格式。
|
|
8
|
+
|
|
9
|
+
格式(见 AGENTS.md 核心规则 10):
|
|
10
|
+
vX.Y.Z
|
|
11
|
+
Date: YYYY-MM-DD
|
|
12
|
+
Pages: N(可选括号细分)
|
|
13
|
+
Sources: <官方固件库包目录名> / ... ← 与 raw/ 目录名一致
|
|
14
|
+
Components: <组件 id> <默认 tag> / ... ← 与 .work/components/components.json 一致
|
|
15
|
+
MCP Server: X.Y.Z
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import tomllib
|
|
22
|
+
|
|
23
|
+
import pytest
|
|
24
|
+
|
|
25
|
+
from ht32_code_mcp import server as srv
|
|
26
|
+
|
|
27
|
+
MAX_LINES = 6
|
|
28
|
+
MAX_BYTES = 400
|
|
29
|
+
MAX_LINE_CHARS = 120
|
|
30
|
+
MAX_SOURCES_CHARS = 240 # 官方包全名较长,单独放宽
|
|
31
|
+
|
|
32
|
+
_FIRST_LINE = re.compile(r"^v\d+\.\d+\.\d+$")
|
|
33
|
+
_DATE_LINE = re.compile(r"^Date:\s*\d{4}-\d{2}-\d{2}$")
|
|
34
|
+
_PAGES_LINE = re.compile(r"^Pages:\s*\d+")
|
|
35
|
+
_SOURCES_LINE = re.compile(r"^Sources:\s*\S")
|
|
36
|
+
_COMPONENTS_LINE = re.compile(r"^Components:\s*\S")
|
|
37
|
+
_MCP_LINE = re.compile(r"^MCP Server:\s*(\d+\.\d+\.\d+)$")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _lines() -> list[str]:
|
|
41
|
+
text = (srv.CODE_ROOT / "VERSION").read_text(encoding="utf-8")
|
|
42
|
+
return [ln for ln in text.splitlines() if ln.strip()]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _segments(line: str) -> list[str]:
|
|
46
|
+
return [seg.strip() for seg in line.split(":", 1)[1].split(" / ") if seg.strip()]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_version_file_line_count_and_size():
|
|
50
|
+
text = (srv.CODE_ROOT / "VERSION").read_text(encoding="utf-8")
|
|
51
|
+
lines = _lines()
|
|
52
|
+
assert len(lines) <= MAX_LINES, (
|
|
53
|
+
f"VERSION 最多 {MAX_LINES} 行,实际 {len(lines)} 行:{lines}\n"
|
|
54
|
+
"变更说明请写进 log.md,不要写进 VERSION")
|
|
55
|
+
assert len(text.encode("utf-8")) <= MAX_BYTES, (
|
|
56
|
+
f"VERSION 体积 {len(text.encode('utf-8'))} 字节,超过 {MAX_BYTES} 字节上限")
|
|
57
|
+
for line in lines:
|
|
58
|
+
limit = MAX_SOURCES_CHARS if line.startswith("Sources:") else MAX_LINE_CHARS
|
|
59
|
+
assert len(line) <= limit, f"VERSION 单行超过 {limit} 字符:{line}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_version_file_fields():
|
|
63
|
+
lines = _lines()
|
|
64
|
+
assert len(lines) == MAX_LINES, f"VERSION 应为 {MAX_LINES} 行,实际 {len(lines)} 行:{lines}"
|
|
65
|
+
assert _FIRST_LINE.match(lines[0]), f"首行必须是纯版本号(vX.Y.Z):{lines[0]}"
|
|
66
|
+
assert _DATE_LINE.match(lines[1]), f"第 2 行必须是 'Date: YYYY-MM-DD':{lines[1]}"
|
|
67
|
+
assert _PAGES_LINE.match(lines[2]), f"第 3 行必须是 'Pages: N':{lines[2]}"
|
|
68
|
+
assert _SOURCES_LINE.match(lines[3]), (
|
|
69
|
+
f"第 4 行必须是 'Sources: <官方包目录名> / ...':{lines[3]}")
|
|
70
|
+
assert _COMPONENTS_LINE.match(lines[4]), (
|
|
71
|
+
f"第 5 行必须是 'Components: <id> <tag> / ...':{lines[4]}")
|
|
72
|
+
assert _MCP_LINE.match(lines[5]), (
|
|
73
|
+
f"末行必须是 'MCP Server: X.Y.Z'(只写版本号,不写 deployed/日期/变更说明):{lines[5]}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_version_sources_are_real_raw_packages():
|
|
77
|
+
"""Sources 的每一项必须是 raw/ 下真实存在的官方包目录,且包数与索引一致。"""
|
|
78
|
+
segments = _segments(_lines()[3])
|
|
79
|
+
raw = srv.CODE_ROOT / "raw"
|
|
80
|
+
for seg in segments:
|
|
81
|
+
assert (raw / seg).is_dir(), f"Sources 里的 '{seg}' 不是 raw/ 下的真实目录"
|
|
82
|
+
packages = srv.index.data.get("fwlibs", [])
|
|
83
|
+
assert len(segments) == len(packages), (
|
|
84
|
+
f"Sources 登记 {len(segments)} 个官方包,索引里有 {len(packages)} 个:{segments}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_version_components_match_catalog():
|
|
88
|
+
"""Components 的每一项必须与私有组件登记表的 id + 默认 tag 一致。"""
|
|
89
|
+
catalog_path = srv.CODE_ROOT / ".work" / "components" / "components.json"
|
|
90
|
+
if not catalog_path.is_file():
|
|
91
|
+
pytest.skip("未配置私有组件登记表")
|
|
92
|
+
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
|
|
93
|
+
expected = {c["id"]: c.get("default_tag", "") for c in catalog.get("components", [])}
|
|
94
|
+
actual = {}
|
|
95
|
+
for seg in _segments(_lines()[4]):
|
|
96
|
+
parts = seg.split()
|
|
97
|
+
assert len(parts) == 2, f"Components 项格式应为 '<id> <tag>':{seg}"
|
|
98
|
+
actual[parts[0]] = parts[1]
|
|
99
|
+
assert actual == expected, f"Components {actual} 与登记表 {expected} 不一致"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_version_mcp_line_matches_pyproject():
|
|
103
|
+
"""VERSION 末行与 pyproject.toml 的 version 必须一致(发布流程的硬约束)。"""
|
|
104
|
+
pyproject = srv.CODE_ROOT / ".work" / "mcp-server" / "pyproject.toml"
|
|
105
|
+
with pyproject.open("rb") as fh:
|
|
106
|
+
pkg_version = tomllib.load(fh)["project"]["version"]
|
|
107
|
+
match = _MCP_LINE.match(_lines()[-1])
|
|
108
|
+
assert match, "VERSION 末行格式不对,无法比对 pyproject 版本"
|
|
109
|
+
assert match.group(1) == pkg_version, (
|
|
110
|
+
f"VERSION 末行 {match.group(1)} != pyproject.toml {pkg_version}")
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|