ht32-code-mcp 0.5.0__tar.gz → 0.5.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.5.0 → ht32_code_mcp-0.5.2}/DEPLOY.md +8 -9
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/PKG-INFO +1 -1
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/README.md +2 -2
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/pyproject.toml +1 -1
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/src/ht32_code_mcp/server.py +3 -1
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/src/ht32_code_mcp/usage.py +98 -5
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/tests/test_tools.py +87 -1
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/uv.lock +1 -1
- ht32_code_mcp-0.5.0/.pytest_base_final_run.log +0 -4
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/.gitignore +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/.gitkeep +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/components.example.json +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/e2e_scenario_a.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/e2e_scenario_b.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/smoke_http.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/smoke_stdio.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/src/ht32_code_mcp/__init__.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/src/ht32_code_mcp/__main__.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/src/ht32_code_mcp/component_exporter.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/src/ht32_code_mcp/component_source.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/src/ht32_code_mcp/components.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/src/ht32_code_mcp/exporter.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/src/ht32_code_mcp/index.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/src/ht32_code_mcp/knowledge.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/src/ht32_code_mcp/materializer.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/src/ht32_code_mcp/release.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/tests/conftest.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/tests/test_component_export.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/tests/test_component_redact.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/tests/test_component_source.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/tests/test_component_tools.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/tests/test_components.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/tests/test_export_cloud_mode.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/tests/test_knowledge_grounding.py +0 -0
- {ht32_code_mcp-0.5.0 → ht32_code_mcp-0.5.2}/tests/test_recipe_claims.py +0 -0
|
@@ -25,7 +25,7 @@ MCP 与 bundle 都跑在应用机(Linux 本地机),云端 OpenResty 只做
|
|
|
25
25
|
ht32-bundles-web 宿主 18082 → 容器 80 /ht32-bundles
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
反代配置原文在**工作区独立仓库** `ht32-mcp-maint/openresty/`(`ht32-code.conf` / `ht32-bundles.conf` / `default.conf`),
|
|
29
29
|
手工贴进 1Panel 站点 `mcp.s621.work` 的同一个 server 块。`/ht32-bundles` **必须反代**
|
|
30
30
|
到 `100.66.1.6:18082`,且 `proxy_pass` 不能带尾斜杠。
|
|
31
31
|
|
|
@@ -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+)
|
|
@@ -847,7 +847,9 @@ def check_skill_update(current: str = "") -> str:
|
|
|
847
847
|
Args:
|
|
848
848
|
current: 本机已安装的 ht32-skills 版本,取
|
|
849
849
|
`python "<global-skill-root>\\_ht32-runtime\\ht32.py" version` 的
|
|
850
|
-
`skill_version`(如 "v0.1.0"
|
|
850
|
+
`skill_version`(如 "v0.1.0");拿不到就留空,报告会说明如何补。
|
|
851
|
+
传入的版本会记到本 MCP 会话,本次会话后续的 usage 记录均带上
|
|
852
|
+
`skill_version`(服务端拿不到客户端版本,只能靠这里带入)
|
|
851
853
|
"""
|
|
852
854
|
return update_report(CODE_ROOT, current)
|
|
853
855
|
|
|
@@ -7,10 +7,14 @@
|
|
|
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 缺陷版本。
|
|
16
|
+
同一版本还会按 MCP 会话记住(见 `SKILL_VERSION_ARGS`),该会话后续的 tool_call
|
|
17
|
+
记录也会带上 `skill_version`;上报之前或无 session 的调用仍为空串。
|
|
14
18
|
|
|
15
19
|
目录布局(同根 + 服务子目录):
|
|
16
20
|
|
|
@@ -39,7 +43,7 @@ from typing import Any
|
|
|
39
43
|
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
|
40
44
|
|
|
41
45
|
# 实现版本号:两仓库副本必须一致(各仓测试会校验,防止只改一边造成漂移)
|
|
42
|
-
_IMPL_VERSION = "1.
|
|
46
|
+
_IMPL_VERSION = "1.4.0"
|
|
43
47
|
|
|
44
48
|
# 服务名 → 子目录名。新增服务在此登记。
|
|
45
49
|
SERVICE_WIKI = "wiki"
|
|
@@ -124,12 +128,35 @@ def write_record(log_dir: str, prefix: str, record: dict[str, Any]) -> Path | No
|
|
|
124
128
|
return None
|
|
125
129
|
|
|
126
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
|
+
|
|
127
150
|
def _client_info(context: MiddlewareContext) -> dict[str, str]:
|
|
128
|
-
"""取客户端标识(内存调用等无 session 的场景返回空值)。
|
|
151
|
+
"""取客户端标识(内存调用等无 session 的场景返回空值)。
|
|
152
|
+
|
|
153
|
+
`client_name` / `client_version` 恒定存在:老客户端未上报 clientInfo 时为空串,
|
|
154
|
+
保证日志行字段结构不变形。
|
|
155
|
+
"""
|
|
156
|
+
out: dict[str, str] = {"client_name": "", "client_version": ""}
|
|
129
157
|
ctx = getattr(context, "fastmcp_context", None)
|
|
130
158
|
if ctx is None:
|
|
131
|
-
return
|
|
132
|
-
out: dict[str, str] = {}
|
|
159
|
+
return out
|
|
133
160
|
for key in ("client_id", "session_id"):
|
|
134
161
|
val = getattr(ctx, key, None)
|
|
135
162
|
if val:
|
|
@@ -137,9 +164,68 @@ def _client_info(context: MiddlewareContext) -> dict[str, str]:
|
|
|
137
164
|
transport = getattr(ctx, "transport", None)
|
|
138
165
|
if transport:
|
|
139
166
|
out["transport"] = str(transport)
|
|
167
|
+
out["client_name"], out["client_version"] = _initialize_client(ctx)
|
|
140
168
|
return out
|
|
141
169
|
|
|
142
170
|
|
|
171
|
+
# ---- 客户端 skill 版本(会话级)--------------------------------------------
|
|
172
|
+
# usage 在服务端采集,跨进程拿不到客户端 ht32-skills 版本,只能由 Agent 在上报型
|
|
173
|
+
# 工具里带入。这些工具的参数名登记如下:中间件记录调用时顺手把版本记到当前 MCP
|
|
174
|
+
# 会话,该会话**后续所有** tool_call 都补 skill_version 字段。
|
|
175
|
+
SKILL_VERSION_ARGS = {
|
|
176
|
+
"check_skill_update": "current", # code:会话开始查更新(顺带上报版本)
|
|
177
|
+
"submit_feedback": "skill_version", # code / wiki:提交反馈
|
|
178
|
+
"report_skill_version": "version", # wiki:会话开始主动上报
|
|
179
|
+
}
|
|
180
|
+
# 常驻进程会持续接新会话,会话表设上限,超出按插入顺序淘汰最老的
|
|
181
|
+
_MAX_SKILL_SESSIONS = 512
|
|
182
|
+
_SESSION_SKILL_VERSION: dict[str, str] = {}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _session_key(context: Any) -> str:
|
|
186
|
+
"""会话标识(MiddlewareContext 或工具 Context 皆可);取不到返回空串。
|
|
187
|
+
|
|
188
|
+
stdio / in-memory 下 FastMCP 的 session_id 是每请求新生成的 UUID,直接拿来
|
|
189
|
+
做键会永远 miss;这两种传输一个进程只服务一个客户端,故统一用固定键。
|
|
190
|
+
HTTP 才有稳定的 mcp-session-id,按它隔离多用户会话。
|
|
191
|
+
"""
|
|
192
|
+
ctx = getattr(context, "fastmcp_context", None)
|
|
193
|
+
if ctx is None:
|
|
194
|
+
ctx = context
|
|
195
|
+
try:
|
|
196
|
+
transport = str(getattr(ctx, "transport", "") or "")
|
|
197
|
+
except Exception:
|
|
198
|
+
transport = ""
|
|
199
|
+
if transport in ("", "memory", "stdio"):
|
|
200
|
+
return "local"
|
|
201
|
+
for key in ("session_id", "client_id"):
|
|
202
|
+
try:
|
|
203
|
+
val = getattr(ctx, key, None)
|
|
204
|
+
except Exception:
|
|
205
|
+
continue
|
|
206
|
+
if val:
|
|
207
|
+
return f"{key}:{val}"
|
|
208
|
+
return ""
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def remember_skill_version(context: Any, version: str) -> None:
|
|
212
|
+
"""把客户端上报的 ht32-skills 版本记到当前会话(供 usage 打标)。"""
|
|
213
|
+
ver = (version or "").strip()
|
|
214
|
+
key = _session_key(context)
|
|
215
|
+
if not ver or not key:
|
|
216
|
+
return
|
|
217
|
+
_SESSION_SKILL_VERSION.pop(key, None)
|
|
218
|
+
while len(_SESSION_SKILL_VERSION) >= _MAX_SKILL_SESSIONS:
|
|
219
|
+
_SESSION_SKILL_VERSION.pop(next(iter(_SESSION_SKILL_VERSION)), None)
|
|
220
|
+
_SESSION_SKILL_VERSION[key] = ver
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def session_skill_version(context: Any) -> str:
|
|
224
|
+
"""当前会话已上报的 ht32-skills 版本;没有则空串。"""
|
|
225
|
+
key = _session_key(context)
|
|
226
|
+
return _SESSION_SKILL_VERSION.get(key, "") if key else ""
|
|
227
|
+
|
|
228
|
+
|
|
143
229
|
class UsageMiddleware(Middleware):
|
|
144
230
|
"""把所有工具调用落成 JSONL(服务端自动采集)。"""
|
|
145
231
|
|
|
@@ -156,6 +242,12 @@ class UsageMiddleware(Middleware):
|
|
|
156
242
|
|
|
157
243
|
args = dict(context.message.arguments or {})
|
|
158
244
|
recorded = {k: v for k, v in args.items() if k not in ARG_NOISE}
|
|
245
|
+
# 上报型工具:先把版本记到当前会话,本次记录也会带上它
|
|
246
|
+
ver_arg = SKILL_VERSION_ARGS.get(context.message.name)
|
|
247
|
+
if ver_arg:
|
|
248
|
+
val = args.get(ver_arg)
|
|
249
|
+
if isinstance(val, str):
|
|
250
|
+
remember_skill_version(context, val)
|
|
159
251
|
started = time.perf_counter()
|
|
160
252
|
error = ""
|
|
161
253
|
ok = True
|
|
@@ -186,6 +278,7 @@ class UsageMiddleware(Middleware):
|
|
|
186
278
|
"error": error,
|
|
187
279
|
"mcp_version": self.mcp_version,
|
|
188
280
|
"kb_version": self.kb_version,
|
|
281
|
+
"skill_version": session_skill_version(context),
|
|
189
282
|
**_client_info(context),
|
|
190
283
|
}
|
|
191
284
|
write_record(self.log_dir, USAGE_PREFIX, record)
|
|
@@ -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,10 +868,66 @@ 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
|
|
|
844
877
|
|
|
878
|
+
def test_usage_middleware_tags_session_skill_version(tmp_path):
|
|
879
|
+
"""上报型工具带来客户端 skill 版本:按会话记住,后续调用都带上;
|
|
880
|
+
没上报过的会话保持空串(不跨会话串号)。"""
|
|
881
|
+
import asyncio
|
|
882
|
+
|
|
883
|
+
from fastmcp import Client, FastMCP
|
|
884
|
+
|
|
885
|
+
app = FastMCP("mw-skill")
|
|
886
|
+
app.add_middleware(UsageMiddleware(str(tmp_path / "logs"), "code",
|
|
887
|
+
mcp_version="0.5.1", kb_version="v0.4.0"))
|
|
888
|
+
|
|
889
|
+
@app.tool()
|
|
890
|
+
def check_skill_update(current: str = "") -> str:
|
|
891
|
+
return f"current={current}"
|
|
892
|
+
|
|
893
|
+
@app.tool()
|
|
894
|
+
def probe(chip: str) -> str:
|
|
895
|
+
return "ok:" + chip
|
|
896
|
+
|
|
897
|
+
async def run():
|
|
898
|
+
# in-memory 传输按"本进程唯一客户端"归键:上报前 → 上报 → 上报后
|
|
899
|
+
async with Client(app) as c:
|
|
900
|
+
await c.call_tool("probe", {"chip": "before"})
|
|
901
|
+
await c.call_tool("check_skill_update", {"current": "v0.1.34"})
|
|
902
|
+
await c.call_tool("probe", {"chip": "after"})
|
|
903
|
+
|
|
904
|
+
asyncio.run(run())
|
|
905
|
+
recs = [json.loads(l) for l in
|
|
906
|
+
next((tmp_path / "logs").glob("usage_*.jsonl"))
|
|
907
|
+
.read_text(encoding="utf-8").splitlines()]
|
|
908
|
+
assert [r.get("skill_version") for r in recs] == ["", "v0.1.34", "v0.1.34"]
|
|
909
|
+
# 上报调用本身仍保留原始参数
|
|
910
|
+
assert recs[1]["args"] == {"current": "v0.1.34"}
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def test_skill_version_isolated_per_http_session():
|
|
914
|
+
"""HTTP 传输按 session_id 隔离:A 上报的版本不会串到 B 的会话。"""
|
|
915
|
+
from types import SimpleNamespace
|
|
916
|
+
|
|
917
|
+
from ht32_code_mcp.usage import remember_skill_version, session_skill_version
|
|
918
|
+
|
|
919
|
+
def fake_ctx(sid: str):
|
|
920
|
+
return SimpleNamespace(fastmcp_context=SimpleNamespace(
|
|
921
|
+
transport="streamable-http", session_id=sid, client_id=None))
|
|
922
|
+
|
|
923
|
+
remember_skill_version(fake_ctx("sess-a"), "v0.1.34")
|
|
924
|
+
assert session_skill_version(fake_ctx("sess-a")) == "v0.1.34"
|
|
925
|
+
assert session_skill_version(fake_ctx("sess-b")) == ""
|
|
926
|
+
# 空版本 / 无会话不落表
|
|
927
|
+
remember_skill_version(fake_ctx("sess-b"), " ")
|
|
928
|
+
assert session_skill_version(fake_ctx("sess-b")) == ""
|
|
929
|
+
|
|
930
|
+
|
|
845
931
|
def test_usage_middleware_records_soft_failure(tmp_path):
|
|
846
932
|
"""工具返回错误字符串(不抛异常)时,middleware 应记 ok=false 并留错误原文。"""
|
|
847
933
|
import asyncio
|
|
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
|