modelfunction-cli 0.4.4 → 0.5.1
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.
- package/README.md +36 -6
- package/SCHEMA.md +112 -4
- package/lib/audit.js +149 -0
- package/lib/budget.js +30 -0
- package/lib/credentials.js +121 -0
- package/lib/fsutil.js +5 -3
- package/lib/mcp-probe.js +59 -9
- package/lib/mcp-server.js +29 -9
- package/lib/sandbox-check.js +3 -1
- package/lib/scan.js +20 -2
- package/lib/score.js +14 -7
- package/lib/usage.js +341 -0
- package/mf.js +441 -59
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
## 安装
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npx -y modelfunction-cli@0.
|
|
8
|
+
npx -y modelfunction-cli@0.5.0 --version
|
|
9
9
|
# 或
|
|
10
10
|
npm i -D modelfunction-cli
|
|
11
11
|
npx mf --version
|
|
@@ -26,6 +26,7 @@ mf scan --root . --probe # 连 stdio MCP server 拉 tools/list
|
|
|
26
26
|
mf coverage --suite mf.suite.json # 看每个工具还缺多少用例
|
|
27
27
|
mf eval --suite mf.suite.json --models deepseek,qwen --show-cases
|
|
28
28
|
mf compare before.json after.json # 跨版本比较(内容指纹判定可比性)
|
|
29
|
+
mf audit --suite mf.suite.json --out audit.md # 一次静态体检:lint + 边界 + 覆盖 + 挂载
|
|
29
30
|
```
|
|
30
31
|
|
|
31
32
|
被测模型用环境变量提供 Key:
|
|
@@ -38,9 +39,19 @@ SILICONFLOW_API_KEY=... mf eval --models dsv4-flash
|
|
|
38
39
|
OPENAI_API_KEY=... mf eval --models openai
|
|
39
40
|
```
|
|
40
41
|
|
|
42
|
+
也可以把 Key 存进用户级凭证文件,输入时隐藏、不进 shell history:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
mf config set-key DEEPSEEK_API_KEY # 粘贴 Key,回车;只显示掩码
|
|
46
|
+
mf config list # DEEPSEEK_API_KEY sk-…abcd
|
|
47
|
+
mf config path # ~/.modelfunction/credentials.json(0600)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
加载优先级:**进程环境变量 > 凭证文件**(只补缺失项,不覆盖)。CI 与企业环境继续用环境变量或密钥管理,不要落盘。
|
|
51
|
+
|
|
41
52
|
### 自建 / 内网网关
|
|
42
53
|
|
|
43
|
-
企业内网基本都是 OpenAI 兼容协议,写一个 models-file
|
|
54
|
+
企业内网基本都是 OpenAI 兼容协议,写一个 models-file 即可(Key 用环境变量,或用 `mf config set-key` 存在本机):
|
|
44
55
|
|
|
45
56
|
```json
|
|
46
57
|
{
|
|
@@ -64,10 +75,11 @@ INTERNAL_LLM_KEY=... mf eval --suite mf.suite.json \
|
|
|
64
75
|
|
|
65
76
|
| 命令 | 作用 |
|
|
66
77
|
| --- | --- |
|
|
67
|
-
| `mf scan` | 扫描工具定义(function.json / OpenAPI / MCP 配置 / Python `@tool` / JS·TS 声明与 TypeBox·zod schema);`--probe` 连 MCP server
|
|
68
|
-
| `mf eval` | 跑评分卡;`--repeat` 多次取多数并报不稳定用例;`--fail-under` 阈值;`--baseline` + `--fail-on-regression` 回归门禁;`--profile`
|
|
78
|
+
| `mf scan` | 扫描工具定义(function.json / OpenAPI / MCP 配置 / Python `@tool` / JS·TS 声明与 TypeBox·zod schema);`--probe` 连 stdio 或 streamable-HTTP MCP server(运行时结果覆盖静态推断;headers/query 不写入报告);`--from` 导入运行时工具数组;草稿按来源分组(`profiles`)并列出未覆盖的 skill |
|
|
79
|
+
| `mf eval` | 跑评分卡;`--repeat` 多次取多数并报不稳定用例;`--fail-under` 阈值;`--baseline` + `--fail-on-regression` 回归门禁;`--profile` 按挂载组分别评分;`--max-cost` 按累计成本停机(退出 2);`--progress` 逐用例进度(stderr) |
|
|
69
80
|
| `mf case add` | 写入用例(默认 `source=agent`、`reviewed=false`) |
|
|
70
81
|
| `mf coverage` | 每工具用例数、参数覆盖、负例比例、疑似混淆对;有 `profiles` 时按组统计 |
|
|
82
|
+
| `mf audit` | 一次静态审计:lint + 混淆族边界 + 用例覆盖 + 挂载检查,输出可转发的 Markdown/JSON;不调用模型 |
|
|
71
83
|
| `mf lint` | 静态检查工具描述与参数 schema,以及 skills 的 name/description(不调用模型);重复规则自动合并成一条;有 `profiles` 时给出「这些组不要合并挂载」 |
|
|
72
84
|
| `mf check` | 安装门禁:一个新能力 × 已装能力(`--manifest` + `--installed`),纯静态、返回 pass/warn、不阻断;也可 `import { check } from "modelfunction-cli"` |
|
|
73
85
|
| `mf compare` | 跨版本比较、干扰归因、混淆矩阵;两份报告都有分组时按组对比 |
|
|
@@ -76,6 +88,9 @@ INTERNAL_LLM_KEY=... mf eval --suite mf.suite.json \
|
|
|
76
88
|
| `mf exec` | 单工具执行 + 指标(Node / Python),支持 `--allow-read/write/net`、超时、sideEffects 准入 |
|
|
77
89
|
| `mf eval --execute` | 一次跑完选择 + 参数 + 运行断言(`toolInput` / `expect`) |
|
|
78
90
|
| `mf sandbox check` | 探测本机可用隔离引擎与降级建议 |
|
|
91
|
+
| `mf config set-key` | 把模型 Key 写入 `~/.modelfunction/credentials.json`(隐藏输入);`remove-key` 删除;`list` 只显示掩码,`path` 打印路径 |
|
|
92
|
+
| `mf stats` | 本地使用摘要(命令次数、本地/CI 区分、模型、通过率区间、lint 规则命中);不联网 |
|
|
93
|
+
| `mf feedback` | 导出脱敏反馈包(仅计数与比率),发不发由你决定 |
|
|
79
94
|
| `mf mcp` | stdio MCP server,供 Claude Code / Cursor / Kimi Code 调用 |
|
|
80
95
|
|
|
81
96
|
## 单次结果会抖:用 `--repeat` 看稳定性
|
|
@@ -111,7 +126,7 @@ mf eval --suite mf.suite.json --models internal-ds \
|
|
|
111
126
|
"mcpServers": {
|
|
112
127
|
"modelfunction": {
|
|
113
128
|
"command": "npx",
|
|
114
|
-
"args": ["-y", "modelfunction-cli@0.
|
|
129
|
+
"args": ["-y", "modelfunction-cli@0.5.0", "mcp"]
|
|
115
130
|
}
|
|
116
131
|
}
|
|
117
132
|
}
|
|
@@ -123,12 +138,27 @@ mf eval --suite mf.suite.json --models internal-ds \
|
|
|
123
138
|
|
|
124
139
|
对外 JSON 字段、`check()` 返回结构、MCP 参数与退出码见 [SCHEMA.md](SCHEMA.md),自 0.4.3 起冻结为 v1:v1 内只做加法,重命名或删除字段需 major 并提前两个 minor 或 90 天弃用。`check()` 的类型声明随包发布。
|
|
125
140
|
|
|
141
|
+
npm 已发布 `0.5.0`(凭证与本地反馈渠道);`0.5.1` 待发布,合并开发改动:usage `ci` 标记与 local/ci 统计、eval `--progress`/`--max-cost`、`mf audit` 组合审计、`scan --probe` 支持 streamable HTTP。示例固定 `@0.5.0`,发布后统一升级。
|
|
142
|
+
|
|
126
143
|
## 数据边界
|
|
127
144
|
|
|
128
145
|
- 本地运行,无遥测、无服务端地址
|
|
129
146
|
- 唯一出站:你配置的模型端点,以及你自己的 MCP server(`--probe` 时)
|
|
130
147
|
- 评测会把用例输入与工具描述发给被测模型供应商——敏感数据请选境内供应商或本地模型
|
|
131
148
|
|
|
149
|
+
## 本地使用记录与反馈(默认不上传)
|
|
150
|
+
|
|
151
|
+
每条命令会在 `~/.modelfunction/usage/YYYY-MM.jsonl` 追加一行**计数记录**:命令名、退出码、耗时、mf/Node/OS 版本、模型 id、用例/工具数量、通过率区间、lint 规则命中数、是否 CI。**不记录**用例原文、工具描述、源码、文件路径或密钥。`mf stats` 会把本地运行与 CI 运行分开统计。
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
mf stats # 看本机记录;--json 给脚本
|
|
155
|
+
mf feedback --out out.json # 导出脱敏反馈包(聚合值,不含原始事件)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
- 默认无网络、无遥测;`MF_NO_USAGE=1` 关闭记录;直接删除 `~/.modelfunction/usage/` 即清空。
|
|
159
|
+
- 反馈包发不发由你决定;我们想知道的是「哪些命令在用、评测通过率大概什么水平、lint 命中哪些规则」,不需要项目细节。
|
|
160
|
+
- `MF_CONFIG_DIR` 可改整个配置目录位置(CI/容器里常用)。
|
|
161
|
+
|
|
132
162
|
## 用例从哪来
|
|
133
163
|
|
|
134
164
|
由你或你的编码 Agent 撰写,规则模板见包内 `skills/mf-cases/SKILL.md`。未复核用例(`reviewed:false`)不计入正式分数。
|
|
@@ -137,7 +167,7 @@ mf eval --suite mf.suite.json --models internal-ds \
|
|
|
137
167
|
|
|
138
168
|
```bash
|
|
139
169
|
# 1. 把 mf 挂成 MCP server
|
|
140
|
-
claude mcp add modelfunction -- npx -y modelfunction-cli@0.
|
|
170
|
+
claude mcp add modelfunction -- npx -y modelfunction-cli@0.5.0 mcp
|
|
141
171
|
```
|
|
142
172
|
|
|
143
173
|
```text
|
package/SCHEMA.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Schemas v1
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
自 `modelfunction-cli@0.4.3` 起冻结为 v1;`0.4.4` 是同一 v1 的补丁;`0.5.0` 增加 §9 本地文件、`0.5.1` 增加 §10 审计 JSON,仍属 v1。这里描述 CLI 与库对外的稳定契约。人类可读文本(表格、提示语)不属于契约;**JSON 字段**属于。
|
|
4
4
|
|
|
5
5
|
## 兼容政策
|
|
6
6
|
|
|
@@ -110,7 +110,7 @@ skills 只做静态检查,不参与模型评测。
|
|
|
110
110
|
|
|
111
111
|
`--json` 输出 `{ "report": {...}, "draft": {...} }`;默认写 `mf.suite.json`(draft)与 `mf.scan.json`(report)。
|
|
112
112
|
|
|
113
|
-
report:`root`、`tools[]`(`name` / `description` / `parameters` / `origin` / `confidence` / `source` / `note?` / `descriptionSource?`)、`skills[]`、`mcpServers[]`(env 只保留 `envKeys
|
|
113
|
+
report:`root`、`tools[]`(`name` / `description` / `parameters` / `origin` / `confidence` / `source` / `note?` / `descriptionSource?`)、`skills[]`、`mcpServers[]`(env 只保留 `envKeys`,headers 只保留 `headerKeys`;URL 去掉 userinfo / query / hash)、`probes[]`(`server` / `ok` / `toolCount?` / `tools?` / `error?`)、`sources[]`、`warnings[]`。`--probe` 支持 stdio 与 streamable HTTP 两种传输。
|
|
114
114
|
|
|
115
115
|
`scan --json` 在未找到任何工具时退出码为 3。
|
|
116
116
|
|
|
@@ -184,8 +184,9 @@ import { check } from "modelfunction-cli";
|
|
|
184
184
|
| `scan_tools` | `root?`、`probe?`、`timeoutMs?`、`writeSuite?` |
|
|
185
185
|
| `list_suite` | `suite?` |
|
|
186
186
|
| `coverage` | `suite?` |
|
|
187
|
+
| `audit` | `suite?` |
|
|
187
188
|
| `add_cases` | `suite?`、`cases[]`、`source?`、`reviewed?`、`dryRun?` |
|
|
188
|
-
| `run_eval` | `suite?`、`models?`、`cases?`、`profile?`、`repeat?`、`failUnder?` |
|
|
189
|
+
| `run_eval` | `suite?`、`models?`、`cases?`、`profile?`、`repeat?`、`failUnder?`、`maxCost?`、`progress?` |
|
|
189
190
|
|
|
190
191
|
返回值均为 JSON 文本。
|
|
191
192
|
|
|
@@ -195,6 +196,113 @@ import { check } from "modelfunction-cli";
|
|
|
195
196
|
| --- | --- |
|
|
196
197
|
| 0 | 成功(`mf check` 的 warn 也是 0) |
|
|
197
198
|
| 1 | 输入/用法错误;`mf compare` 判定不可直接比较 |
|
|
198
|
-
| 2 | `mf eval --fail-under`
|
|
199
|
+
| 2 | `mf eval --fail-under` 未达标、`--fail-on-regression` 发现回退,或 `--max-cost` 超限 |
|
|
199
200
|
| 3 | `mf exec` 被权限策略拒绝;`mf scan --json` 未找到工具 |
|
|
200
201
|
| 4 | `mf exec` 超时 |
|
|
202
|
+
|
|
203
|
+
## 9. 本地文件(0.5.0 起)
|
|
204
|
+
|
|
205
|
+
### `~/.modelfunction/credentials.json`
|
|
206
|
+
|
|
207
|
+
`MF_CONFIG_DIR` 可覆盖目录。扁平 JSON 对象,键为环境变量名、值为字符串:
|
|
208
|
+
|
|
209
|
+
```json
|
|
210
|
+
{ "DEEPSEEK_API_KEY": "sk-…" }
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
权限 0600(Windows 尽力)。加载规则:进程环境变量优先;凭证文件只补模型 registry 中声明的 `apiKeyEnv` 缺失项,不会向进程注入任意变量。不要提交到仓库;CI/企业用环境变量或密钥管理。
|
|
214
|
+
|
|
215
|
+
### `~/.modelfunction/usage/YYYY-MM.jsonl`
|
|
216
|
+
|
|
217
|
+
每行一个 JSON 事件(UTF-8,按 UTC 月份分文件;`ts` 为 ISO 8601):
|
|
218
|
+
|
|
219
|
+
```json
|
|
220
|
+
{
|
|
221
|
+
"schema": 1,
|
|
222
|
+
"ts": "2026-11-01T01:26:33.000Z",
|
|
223
|
+
"command": "eval",
|
|
224
|
+
"exitCode": 0,
|
|
225
|
+
"durationMs": 1200,
|
|
226
|
+
"mf": "0.5.0",
|
|
227
|
+
"platform": "win32",
|
|
228
|
+
"arch": "x64",
|
|
229
|
+
"node": "24.6.0",
|
|
230
|
+
"ci": false,
|
|
231
|
+
"models": ["mock"],
|
|
232
|
+
"caseCount": 13,
|
|
233
|
+
"toolCount": 4,
|
|
234
|
+
"reviewedCount": 13,
|
|
235
|
+
"repeat": 1,
|
|
236
|
+
"profileGroups": 0,
|
|
237
|
+
"selectionAccuracy": 0.92,
|
|
238
|
+
"argAccuracy": 1,
|
|
239
|
+
"passRate": 0.92,
|
|
240
|
+
"unstableCases": 0,
|
|
241
|
+
"failedThreshold": false,
|
|
242
|
+
"regressions": false
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
字段为白名单,只含计数、比率与 id;**不含**用例原文、工具描述、源码、文件路径或密钥。`ci` 表示检测到 CI 环境(`CI=true/1` 或 `GITHUB_ACTIONS` / `GITLAB_CI` / `CIRCLECI` / `TRAVIS` / `BUILDKITE` / `TF_BUILD` / `JENKINS_URL` / `CNB_BUILD_ID`),用于把机器运行与本地运行分开;`mf stats` 分别统计。新增字段属于 v1 加法,消费方可忽略未知字段。`MF_NO_USAGE=1` 关闭写入;删除 `usage/` 即清空。
|
|
247
|
+
|
|
248
|
+
### `mf feedback` 包
|
|
249
|
+
|
|
250
|
+
默认写到 `MF_CONFIG_DIR/feedback/mf-feedback-YYYYMMDD-HHmmss.json`(UTC):
|
|
251
|
+
|
|
252
|
+
```json
|
|
253
|
+
{
|
|
254
|
+
"schema": 1,
|
|
255
|
+
"kind": "modelfunction-feedback",
|
|
256
|
+
"generatedAt": "2026-11-01T01:26:33.000Z",
|
|
257
|
+
"mf": { "version": "0.5.0", "node": "24.6.0", "platform": "win32", "arch": "x64" },
|
|
258
|
+
"usage": { "runs": 1, "commands": { "eval": 1 } },
|
|
259
|
+
"privacy": "…",
|
|
260
|
+
"request": "…"
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
`usage` 是 `mf stats` 的聚合结果,不含原始事件、路径或密钥。是否发送由用户决定。
|
|
265
|
+
|
|
266
|
+
## 10. 工具集审计(`mf audit --json`,0.5.1 起)
|
|
267
|
+
|
|
268
|
+
```json
|
|
269
|
+
{
|
|
270
|
+
"suite": { "name": "…", "path": "…", "tools": 4, "skills": 0, "cases": 13, "reviewed": 13 },
|
|
271
|
+
"lint": {
|
|
272
|
+
"tools": 4,
|
|
273
|
+
"skills": 0,
|
|
274
|
+
"counts": { "error": 0, "warn": 6, "info": 0 },
|
|
275
|
+
"findings": [],
|
|
276
|
+
"profilePairs": []
|
|
277
|
+
},
|
|
278
|
+
"clusters": {
|
|
279
|
+
"threshold": 0.5,
|
|
280
|
+
"total": 4,
|
|
281
|
+
"clusters": [],
|
|
282
|
+
"riskyPairs": [],
|
|
283
|
+
"familyHints": [],
|
|
284
|
+
"boundaries": []
|
|
285
|
+
},
|
|
286
|
+
"coverage": {
|
|
287
|
+
"total": 13,
|
|
288
|
+
"negative": 1,
|
|
289
|
+
"negativeRatio": 0.08,
|
|
290
|
+
"unreviewed": 0,
|
|
291
|
+
"tools": [],
|
|
292
|
+
"pairs": [],
|
|
293
|
+
"profiles": [],
|
|
294
|
+
"warnings": []
|
|
295
|
+
},
|
|
296
|
+
"profiles": { "groups": [], "warnings": [], "unmounted": [] },
|
|
297
|
+
"actions": [
|
|
298
|
+
{
|
|
299
|
+
"kind": "boundary|param-source|cases|args|review|mount",
|
|
300
|
+
"subject": "…",
|
|
301
|
+
"message": "…",
|
|
302
|
+
"detail": "…"
|
|
303
|
+
}
|
|
304
|
+
]
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
`profiles` 为 `null` 表示套件未定义分组。`clusters`/`coverage` 的明细与 §5/§4 同源;`actions` 是给人看的优先级列表。`mf audit` 恒退出 0(advisory),不阻断流程。
|
package/lib/audit.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// One static pass over a whole tool set: lint + cluster boundaries + case
|
|
2
|
+
// coverage + mount checks. No model calls, no execution, no network.
|
|
3
|
+
// The markdown output is meant to be forwarded as a trial deliverable.
|
|
4
|
+
import { lintSuite, formatLint } from "./lint.js";
|
|
5
|
+
import { clusterTools, formatClusters } from "./cluster.js";
|
|
6
|
+
import { computeCoverage, formatCoverage } from "./cases.js";
|
|
7
|
+
import { normalizeProfiles, profileWarnings, profileCaseCoverage } from "./profile.js";
|
|
8
|
+
|
|
9
|
+
export function auditSuite(suite) {
|
|
10
|
+
const lint = lintSuite(suite);
|
|
11
|
+
const clusters = clusterTools(suite.tools);
|
|
12
|
+
const coverage = computeCoverage(suite);
|
|
13
|
+
const profiles = normalizeProfiles(suite.profiles, suite.tools.map((tool) => tool.name));
|
|
14
|
+
const mounted = profiles ? new Set(profiles.flatMap((profile) => profile.tools)) : null;
|
|
15
|
+
const profile = profiles
|
|
16
|
+
? {
|
|
17
|
+
groups: profileCaseCoverage(profiles, suite.cases),
|
|
18
|
+
warnings: profileWarnings(profiles, suite),
|
|
19
|
+
unmounted: suite.tools.map((tool) => tool.name).filter((name) => !mounted.has(name))
|
|
20
|
+
}
|
|
21
|
+
: null;
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
suite: {
|
|
25
|
+
name: suite.name,
|
|
26
|
+
path: suite.path ?? null,
|
|
27
|
+
tools: suite.tools.length,
|
|
28
|
+
skills: suite.skills?.length ?? 0,
|
|
29
|
+
cases: suite.cases.length,
|
|
30
|
+
reviewed: suite.cases.filter((testCase) => testCase.reviewed !== false).length
|
|
31
|
+
},
|
|
32
|
+
lint: {
|
|
33
|
+
tools: lint.tools,
|
|
34
|
+
skills: lint.skills,
|
|
35
|
+
counts: lint.counts,
|
|
36
|
+
findings: lint.findings,
|
|
37
|
+
profilePairs: lint.profilePairs ?? []
|
|
38
|
+
},
|
|
39
|
+
clusters,
|
|
40
|
+
coverage,
|
|
41
|
+
profiles: profile,
|
|
42
|
+
actions: buildActions({ lint, clusters, coverage, profile })
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildActions({ lint, clusters, coverage, profile }) {
|
|
47
|
+
const actions = [];
|
|
48
|
+
|
|
49
|
+
const families = (clusters.boundaries ?? [])
|
|
50
|
+
.filter((family) => family.size > 1)
|
|
51
|
+
.sort((a, b) => b.pairs.length - a.pairs.length)
|
|
52
|
+
.slice(0, 3);
|
|
53
|
+
for (const family of families) {
|
|
54
|
+
actions.push({
|
|
55
|
+
kind: "boundary",
|
|
56
|
+
subject: family.label,
|
|
57
|
+
message: `为「${family.label}」补选择边界(${family.pairs.length} 对互抢)`,
|
|
58
|
+
detail: family.suggestion
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const unsourced = lint.findings.filter((finding) => finding.code === "required-unsourced");
|
|
63
|
+
for (const finding of unsourced.slice(0, 5)) {
|
|
64
|
+
actions.push({
|
|
65
|
+
kind: "param-source",
|
|
66
|
+
subject: `${finding.tool}.${finding.param}`,
|
|
67
|
+
message: `补参数来源:${finding.tool}.${finding.param}`,
|
|
68
|
+
detail: finding.message
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const empty = coverage.tools.filter((tool) => tool.total === 0);
|
|
73
|
+
if (empty.length > 0) {
|
|
74
|
+
actions.push({
|
|
75
|
+
kind: "cases",
|
|
76
|
+
subject: `${empty.length} tools`,
|
|
77
|
+
message: `为 ${empty.length} 个工具补用例`,
|
|
78
|
+
detail: empty.map((tool) => tool.name).join(", ")
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const noArgs = coverage.tools.filter((tool) => tool.total > 0 && tool.withArgs === 0);
|
|
82
|
+
if (noArgs.length > 0) {
|
|
83
|
+
actions.push({
|
|
84
|
+
kind: "args",
|
|
85
|
+
subject: `${noArgs.length} tools`,
|
|
86
|
+
message: `为 ${noArgs.length} 个工具补带参数的用例`,
|
|
87
|
+
detail: noArgs.map((tool) => tool.name).join(", ")
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (coverage.unreviewed > 0) {
|
|
91
|
+
actions.push({
|
|
92
|
+
kind: "review",
|
|
93
|
+
subject: `${coverage.unreviewed} cases`,
|
|
94
|
+
message: `复核 ${coverage.unreviewed} 条未复核用例(复核后才计入正式分数)`,
|
|
95
|
+
detail: "把认可的用例 reviewed 改为 true"
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (profile && profile.unmounted.length > 0) {
|
|
99
|
+
actions.push({
|
|
100
|
+
kind: "mount",
|
|
101
|
+
subject: `${profile.unmounted.length} tools`,
|
|
102
|
+
message: `${profile.unmounted.length} 个工具不在任何挂载组`,
|
|
103
|
+
detail: profile.unmounted.join(", ")
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return actions;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function formatAudit(report) {
|
|
111
|
+
const { suite, lint, clusters, coverage, profiles, actions } = report;
|
|
112
|
+
const lines = [
|
|
113
|
+
`# ${suite.name} — 工具集审计`,
|
|
114
|
+
"",
|
|
115
|
+
`- 工具 ${suite.tools} · skills ${suite.skills} · 用例 ${suite.cases}(已复核 ${suite.reviewed})`,
|
|
116
|
+
`- 文件:${suite.path ?? "(内存)"}`,
|
|
117
|
+
`- 纯静态:不调用模型、不执行工具、无网络`,
|
|
118
|
+
"",
|
|
119
|
+
"## 建议动作"
|
|
120
|
+
];
|
|
121
|
+
if (actions.length === 0) {
|
|
122
|
+
lines.push("暂无必须处理项。");
|
|
123
|
+
} else {
|
|
124
|
+
actions.forEach((action, index) => {
|
|
125
|
+
lines.push(`${index + 1}. ${action.message}`);
|
|
126
|
+
if (action.detail) lines.push(` ${action.detail}`);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
lines.push("", "## 描述与参数(lint)", "", formatLint(lint));
|
|
131
|
+
lines.push("", "## 混淆与分组(cluster)", "", formatClusters(clusters));
|
|
132
|
+
lines.push("", "## 用例覆盖(coverage)", "", formatCoverage(coverage));
|
|
133
|
+
lines.push("", "## 挂载分组(profiles)", "");
|
|
134
|
+
if (!profiles) {
|
|
135
|
+
lines.push("未定义 profiles(平台可先用 `mf cluster --out <suite>` 生成候选分组)。");
|
|
136
|
+
} else {
|
|
137
|
+
lines.push("group tools cases negatives tools-without-cases");
|
|
138
|
+
lines.push("---------------------- ----- ----- --------- -------------------");
|
|
139
|
+
for (const row of profiles.groups) {
|
|
140
|
+
lines.push(
|
|
141
|
+
`${row.name.padEnd(22)} ${String(row.tools).padStart(5)} ${String(row.cases).padStart(5)} ${String(row.negatives).padStart(9)} ${row.missing.length ? row.missing.join(", ") : "-"}`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (profiles.unmounted.length > 0) lines.push("", `未挂载工具:${profiles.unmounted.join(", ")}`);
|
|
145
|
+
for (const warning of profiles.warnings) lines.push(`- ${warning}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return lines.join("\n");
|
|
149
|
+
}
|
package/lib/budget.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Running cost guard for evaluations. Pricing is known per model, but token
|
|
2
|
+
// usage is only known after a call returns, so the limit is enforced after
|
|
3
|
+
// each call: the run stops as soon as the budget is crossed.
|
|
4
|
+
export class CostLimitError extends Error {
|
|
5
|
+
constructor({ limit, spent, calls }) {
|
|
6
|
+
super(`cost limit reached: spent ¥${spent.toFixed(4)} of ¥${limit} after ${calls} call(s)`);
|
|
7
|
+
this.name = "CostLimitError";
|
|
8
|
+
this.limit = limit;
|
|
9
|
+
this.spent = spent;
|
|
10
|
+
this.calls = calls;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createBudget(limit = null) {
|
|
15
|
+
let spent = 0;
|
|
16
|
+
let calls = 0;
|
|
17
|
+
return {
|
|
18
|
+
get spent() {
|
|
19
|
+
return spent;
|
|
20
|
+
},
|
|
21
|
+
get calls() {
|
|
22
|
+
return calls;
|
|
23
|
+
},
|
|
24
|
+
spend(cost) {
|
|
25
|
+
calls += 1;
|
|
26
|
+
if (Number.isFinite(cost) && cost > 0) spent += cost;
|
|
27
|
+
if (limit !== null && spent > limit) throw new CostLimitError({ limit, spent, calls });
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { writeFileAtomic } from "./fsutil.js";
|
|
5
|
+
|
|
6
|
+
const ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
7
|
+
const FILE_MODE = 0o600;
|
|
8
|
+
const DIR_MODE = 0o700;
|
|
9
|
+
const SECRET_PATTERNS = [
|
|
10
|
+
{ pattern: /(Bearer\s+)[A-Za-z0-9._~+/-]+=*/gi, replacement: "$1***" },
|
|
11
|
+
{ pattern: /\bsk-[A-Za-z0-9_-]{8,}/g, replacement: "***" }
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const registeredSecrets = new Set();
|
|
15
|
+
|
|
16
|
+
export function configDir() {
|
|
17
|
+
const override = process.env.MF_CONFIG_DIR;
|
|
18
|
+
return override && override.trim() ? resolve(override.trim()) : join(homedir(), ".modelfunction");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function credentialsPath() {
|
|
22
|
+
return join(configDir(), "credentials.json");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function readCredentials() {
|
|
26
|
+
let raw;
|
|
27
|
+
try {
|
|
28
|
+
raw = await readFile(credentialsPath(), "utf8");
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error.code === "ENOENT") return {};
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(raw);
|
|
36
|
+
} catch {
|
|
37
|
+
// Never echo the file content: it holds secrets.
|
|
38
|
+
throw new Error(`credentials file is not valid JSON: ${credentialsPath()}`);
|
|
39
|
+
}
|
|
40
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
41
|
+
throw new Error(`credentials file must be a JSON object: ${credentialsPath()}`);
|
|
42
|
+
}
|
|
43
|
+
const out = {};
|
|
44
|
+
for (const [name, value] of Object.entries(parsed)) {
|
|
45
|
+
if (typeof value === "string" && value.length > 0) out[name] = value;
|
|
46
|
+
}
|
|
47
|
+
registerSecrets(Object.values(out));
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function setCredential(name, value) {
|
|
52
|
+
if (!ENV_NAME.test(name)) {
|
|
53
|
+
throw new Error(`invalid environment variable name "${name}" (expected e.g. DEEPSEEK_API_KEY)`);
|
|
54
|
+
}
|
|
55
|
+
const trimmed = String(value ?? "").trim();
|
|
56
|
+
if (!trimmed) throw new Error("empty value; nothing written");
|
|
57
|
+
if (/[\r\n]/.test(trimmed)) throw new Error("value must be a single line");
|
|
58
|
+
const existing = await readCredentials();
|
|
59
|
+
existing[name] = trimmed;
|
|
60
|
+
await mkdir(configDir(), { recursive: true, mode: DIR_MODE });
|
|
61
|
+
await writeFileAtomic(credentialsPath(), `${JSON.stringify(existing, null, 2)}\n`, { mode: FILE_MODE });
|
|
62
|
+
registerSecrets([trimmed]);
|
|
63
|
+
return { name, path: credentialsPath() };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function removeCredential(name) {
|
|
67
|
+
if (!ENV_NAME.test(name)) {
|
|
68
|
+
throw new Error(`invalid environment variable name "${name}" (expected e.g. DEEPSEEK_API_KEY)`);
|
|
69
|
+
}
|
|
70
|
+
const existing = await readCredentials();
|
|
71
|
+
if (!(name in existing)) return { name, removed: false, path: credentialsPath() };
|
|
72
|
+
delete existing[name];
|
|
73
|
+
await mkdir(configDir(), { recursive: true, mode: DIR_MODE });
|
|
74
|
+
await writeFileAtomic(credentialsPath(), `${JSON.stringify(existing, null, 2)}\n`, { mode: FILE_MODE });
|
|
75
|
+
return { name, removed: true, path: credentialsPath() };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function listCredentials() {
|
|
79
|
+
const stored = await readCredentials();
|
|
80
|
+
return Object.entries(stored)
|
|
81
|
+
.map(([name, value]) => ({ name, masked: maskSecret(value) }))
|
|
82
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Only fill keys the model registry actually reads; a credentials file must
|
|
86
|
+
// not be able to inject arbitrary environment variables into the process.
|
|
87
|
+
export async function applyModelCredentials(registry) {
|
|
88
|
+
const stored = await readCredentials();
|
|
89
|
+
const names = new Set(
|
|
90
|
+
Object.values(registry)
|
|
91
|
+
.map((config) => config?.apiKeyEnv)
|
|
92
|
+
.filter((name) => typeof name === "string" && name.length > 0)
|
|
93
|
+
);
|
|
94
|
+
const filled = [];
|
|
95
|
+
for (const name of names) {
|
|
96
|
+
if (!process.env[name] && stored[name]) {
|
|
97
|
+
process.env[name] = stored[name];
|
|
98
|
+
filled.push(name);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return filled;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function maskSecret(value) {
|
|
105
|
+
const text = String(value ?? "");
|
|
106
|
+
if (text.length <= 8) return "********";
|
|
107
|
+
return `${text.slice(0, 3)}…${text.slice(-4)}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function registerSecrets(values) {
|
|
111
|
+
for (const value of values ?? []) {
|
|
112
|
+
if (typeof value === "string" && value.length >= 8) registeredSecrets.add(value);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function redactText(text) {
|
|
117
|
+
let out = String(text ?? "");
|
|
118
|
+
for (const secret of registeredSecrets) out = out.split(secret).join("***");
|
|
119
|
+
for (const { pattern, replacement } of SECRET_PATTERNS) out = out.replace(pattern, replacement);
|
|
120
|
+
return out;
|
|
121
|
+
}
|
package/lib/fsutil.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { rename, writeFile } from "node:fs/promises";
|
|
1
|
+
import { chmod, rename, writeFile } from "node:fs/promises";
|
|
2
2
|
|
|
3
|
-
export async function writeFileAtomic(path, data) {
|
|
3
|
+
export async function writeFileAtomic(path, data, { mode } = {}) {
|
|
4
4
|
const temp = `${path}.${process.pid}.tmp`;
|
|
5
|
-
await writeFile(temp, data, "utf8");
|
|
5
|
+
await writeFile(temp, data, { encoding: "utf8", ...(mode ? { mode } : {}) });
|
|
6
6
|
await rename(temp, path);
|
|
7
|
+
// Windows maps chmod to the read-only bit only; this is best-effort there.
|
|
8
|
+
if (mode) await chmod(path, mode).catch(() => {});
|
|
7
9
|
}
|
package/lib/mcp-probe.js
CHANGED
|
@@ -1,10 +1,67 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
2
5
|
|
|
3
6
|
const MAX_BYTES = 1_000_000;
|
|
4
7
|
const MAX_STDERR = 4_000;
|
|
5
|
-
const
|
|
8
|
+
const PACKAGE = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
9
|
+
const CLIENT_INFO = { name: "mf", version: PACKAGE.version };
|
|
6
10
|
const PROTOCOL_VERSION = "2025-06-18";
|
|
7
11
|
|
|
12
|
+
// Streamable HTTP is the current remote MCP transport. Headers come from the
|
|
13
|
+
// user's own config and are never copied into reports or logs.
|
|
14
|
+
export async function probeHttpServer(server, { timeoutMs = 5_000 } = {}) {
|
|
15
|
+
if (!server?.url) return { ok: false, error: "missing url" };
|
|
16
|
+
let url;
|
|
17
|
+
try {
|
|
18
|
+
url = new URL(server.url);
|
|
19
|
+
} catch {
|
|
20
|
+
return { ok: false, error: "invalid url" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const client = new Client(CLIENT_INFO);
|
|
24
|
+
const headers = server.headers && Object.keys(server.headers).length > 0 ? server.headers : null;
|
|
25
|
+
const transport = new StreamableHTTPClientTransport(url, {
|
|
26
|
+
requestInit: headers ? { headers } : undefined
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
await withTimeout(client.connect(transport), timeoutMs, "connect");
|
|
31
|
+
const result = await withTimeout(client.listTools(), timeoutMs, "tools/list");
|
|
32
|
+
return { ok: true, tools: normalizeTools(result?.tools ?? []) };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
return { ok: false, error: `http: ${scrubUrl(error?.message ?? error, url)}` };
|
|
35
|
+
} finally {
|
|
36
|
+
await client.close().catch(() => {});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function withTimeout(promise, ms, label) {
|
|
41
|
+
let id;
|
|
42
|
+
const timeout = new Promise((_, reject) => {
|
|
43
|
+
id = setTimeout(() => reject(new Error(`${label} timeout after ${ms}ms`)), ms);
|
|
44
|
+
});
|
|
45
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(id));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function scrubUrl(message, url) {
|
|
49
|
+
let text = String(message ?? "");
|
|
50
|
+
text = text.split(url.href).join(`${url.origin}${url.pathname}`);
|
|
51
|
+
if (url.search) text = text.split(url.search).join("");
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeTools(tools) {
|
|
56
|
+
return tools
|
|
57
|
+
.filter((tool) => typeof tool?.name === "string" && tool.name.trim() !== "")
|
|
58
|
+
.map((tool) => ({
|
|
59
|
+
name: tool.name.trim(),
|
|
60
|
+
description: tool.description ?? "",
|
|
61
|
+
parameters: tool.inputSchema ?? { type: "object", properties: {} }
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
|
|
8
65
|
export async function probeStdioServer(server, { timeoutMs = 5_000, cwd } = {}) {
|
|
9
66
|
if (!server?.command) {
|
|
10
67
|
return { ok: false, error: "missing command" };
|
|
@@ -129,14 +186,7 @@ export async function probeStdioServer(server, { timeoutMs = 5_000, cwd } = {})
|
|
|
129
186
|
});
|
|
130
187
|
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
131
188
|
const result = await request("tools/list", {});
|
|
132
|
-
|
|
133
|
-
.filter((tool) => typeof tool?.name === "string" && tool.name.trim() !== "")
|
|
134
|
-
.map((tool) => ({
|
|
135
|
-
name: tool.name.trim(),
|
|
136
|
-
description: tool.description ?? "",
|
|
137
|
-
parameters: tool.inputSchema ?? { type: "object", properties: {} }
|
|
138
|
-
}));
|
|
139
|
-
return { ok: true, tools };
|
|
189
|
+
return { ok: true, tools: normalizeTools(result?.tools ?? []) };
|
|
140
190
|
} catch (error) {
|
|
141
191
|
return {
|
|
142
192
|
ok: false,
|