modelfunction-cli 0.5.0 → 0.5.2
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 +12 -10
- package/SCHEMA.md +83 -5
- package/lib/audit.js +149 -0
- package/lib/budget.js +30 -0
- package/lib/check.d.ts +31 -0
- package/lib/check.js +117 -22
- package/lib/mcp-probe.js +59 -9
- package/lib/mcp-server.js +105 -9
- package/lib/scan.js +20 -2
- package/lib/score.js +14 -7
- package/lib/usage.js +21 -1
- package/mf.js +187 -45
- package/package.json +1 -1
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,
|
package/lib/mcp-server.js
CHANGED
|
@@ -8,8 +8,13 @@ import { z } from "zod";
|
|
|
8
8
|
import { loadSuite } from "./suite.js";
|
|
9
9
|
import { scanProject, addTools, buildSuiteDraft, redactReport } from "./scan.js";
|
|
10
10
|
import { writeFileAtomic } from "./fsutil.js";
|
|
11
|
-
import { probeStdioServer } from "./mcp-probe.js";
|
|
11
|
+
import { probeStdioServer, probeHttpServer } from "./mcp-probe.js";
|
|
12
12
|
import { addCases, computeCoverage } from "./cases.js";
|
|
13
|
+
import { auditSuite } from "./audit.js";
|
|
14
|
+
import { check, checkMany } from "./check.js";
|
|
15
|
+
import { lintSuite } from "./lint.js";
|
|
16
|
+
import { clusterTools } from "./cluster.js";
|
|
17
|
+
import { compareScorecards } from "./compare.js";
|
|
13
18
|
|
|
14
19
|
const MF_PATH = fileURLToPath(new URL("../mf.js", import.meta.url));
|
|
15
20
|
const PACKAGE_JSON = new URL("../package.json", import.meta.url);
|
|
@@ -32,7 +37,7 @@ export async function startMcpServer() {
|
|
|
32
37
|
{
|
|
33
38
|
title: "Scan tools",
|
|
34
39
|
description:
|
|
35
|
-
"扫描仓库里的 function.json / OpenAPI / MCP 配置 / SKILL.md / 代码内定义。probe=true 时连接 stdio MCP server 拉取 tools/list
|
|
40
|
+
"扫描仓库里的 function.json / OpenAPI / MCP 配置 / SKILL.md / 代码内定义。probe=true 时连接 stdio 或 streamable-HTTP MCP server 拉取 tools/list(HTTP headers 只用于连接,不写入报告)。不执行工具。默认只读;传 writeSuite 时在该路径写出套件草稿(cases 为空,需补用例并复核;来源 ≥2 时草稿带按来源分组的 profiles,并列出不参与评测的 skills)。",
|
|
36
41
|
inputSchema: {
|
|
37
42
|
root: z.string().optional().describe("仓库目录,默认进程当前目录"),
|
|
38
43
|
probe: z.boolean().optional().describe("是否连接 MCP server 拉取工具清单"),
|
|
@@ -44,11 +49,10 @@ export async function startMcpServer() {
|
|
|
44
49
|
const report = await scanProject(root ?? process.cwd());
|
|
45
50
|
if (probe) {
|
|
46
51
|
for (const mcpServer of [...report.mcpServers]) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const result = await probeStdioServer(mcpServer, { timeoutMs: timeoutMs ?? 5000 });
|
|
52
|
+
const result =
|
|
53
|
+
mcpServer.transport === "stdio"
|
|
54
|
+
? await probeStdioServer(mcpServer, { timeoutMs: timeoutMs ?? 5000 })
|
|
55
|
+
: await probeHttpServer(mcpServer, { timeoutMs: timeoutMs ?? 5000 });
|
|
52
56
|
if (!result.ok) {
|
|
53
57
|
report.probes.push({ server: mcpServer.name, ok: false, error: result.error });
|
|
54
58
|
continue;
|
|
@@ -109,6 +113,94 @@ export async function startMcpServer() {
|
|
|
109
113
|
async ({ suite }) => json(computeCoverage(await loadSuite(suitePath(suite))))
|
|
110
114
|
);
|
|
111
115
|
|
|
116
|
+
server.registerTool(
|
|
117
|
+
"audit",
|
|
118
|
+
{
|
|
119
|
+
title: "Audit tool set",
|
|
120
|
+
description:
|
|
121
|
+
"一次静态审计整个工具集:lint 发现、混淆族与边界建议、用例覆盖、挂载检查。不调用模型、不执行工具、无网络。返回 JSON。",
|
|
122
|
+
inputSchema: { suite: z.string().optional().describe("套件路径,默认 ./mf.suite.json") }
|
|
123
|
+
},
|
|
124
|
+
async ({ suite }) =>
|
|
125
|
+
json(
|
|
126
|
+
auditSuite(
|
|
127
|
+
await loadSuite(suitePath(suite), { allowEmptyCases: true, allowEmptyTools: true })
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
server.registerTool(
|
|
133
|
+
"lint",
|
|
134
|
+
{
|
|
135
|
+
title: "Lint suite",
|
|
136
|
+
description:
|
|
137
|
+
"静态检查 tools 与 skills 的描述、参数与工具对边界(不调用模型)。返回 counts / findings / profilePairs。",
|
|
138
|
+
inputSchema: { suite: z.string().optional().describe("套件路径,默认 ./mf.suite.json") }
|
|
139
|
+
},
|
|
140
|
+
async ({ suite }) =>
|
|
141
|
+
json(
|
|
142
|
+
lintSuite(
|
|
143
|
+
await loadSuite(suitePath(suite), { allowEmptyCases: true, allowEmptyTools: true })
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
server.registerTool(
|
|
149
|
+
"check",
|
|
150
|
+
{
|
|
151
|
+
title: "Install gate",
|
|
152
|
+
description:
|
|
153
|
+
"安装门禁:把新能力与已装能力做静态比较,返回 pass/warn、findings 与竞争族。传 manifest 检查单个;传 manifests 检查一包(新能力之间也会互比)。不阻断、不调用模型、无网络。",
|
|
154
|
+
inputSchema: {
|
|
155
|
+
manifest: z.any().optional().describe("新能力 {name, description, parameters?} 或 tool schema"),
|
|
156
|
+
manifests: z.array(z.any()).optional().describe("一次安装一包能力时的数组"),
|
|
157
|
+
installed: z.any().optional().describe("已装能力数组,或 {tools: [], skills: []}")
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
async ({ manifest, manifests, installed }) => {
|
|
161
|
+
if (manifests) return json(checkMany({ manifests, installed }));
|
|
162
|
+
return json(check({ manifest, installed }));
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
server.registerTool(
|
|
167
|
+
"cluster",
|
|
168
|
+
{
|
|
169
|
+
title: "Cluster tools",
|
|
170
|
+
description:
|
|
171
|
+
"聚类工具,给出挂载分组、跨组高风险与族内边界清单(每族要补哪句选择条件)。不调用模型。",
|
|
172
|
+
inputSchema: {
|
|
173
|
+
suite: z.string().optional().describe("套件路径,默认 ./mf.suite.json"),
|
|
174
|
+
threshold: z.number().min(0).max(1).optional().describe("聚类阈值,默认 0.5"),
|
|
175
|
+
maxSize: z.number().int().positive().optional().describe("单组最大工具数,默认 10")
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
async ({ suite, threshold, maxSize }) => {
|
|
179
|
+
const loaded = await loadSuite(suitePath(suite), { allowEmptyCases: true });
|
|
180
|
+
return json(clusterTools(loaded.tools, { threshold, maxSize }));
|
|
181
|
+
}
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
server.registerTool(
|
|
185
|
+
"compare",
|
|
186
|
+
{
|
|
187
|
+
title: "Compare scorecards",
|
|
188
|
+
description:
|
|
189
|
+
"比较两份评分卡 JSON:可比性、升降、干扰归因与混淆矩阵。只读文件,不调用模型。",
|
|
190
|
+
inputSchema: {
|
|
191
|
+
before: z.string().describe("旧评分卡 JSON 路径"),
|
|
192
|
+
after: z.string().describe("新评分卡 JSON 路径")
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
async ({ before, after }) => {
|
|
196
|
+
const [oldText, newText] = await Promise.all([
|
|
197
|
+
readFile(resolve(before), "utf8"),
|
|
198
|
+
readFile(resolve(after), "utf8")
|
|
199
|
+
]);
|
|
200
|
+
return json(compareScorecards(JSON.parse(oldText), JSON.parse(newText)));
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
|
|
112
204
|
server.registerTool(
|
|
113
205
|
"add_cases",
|
|
114
206
|
{
|
|
@@ -154,16 +246,20 @@ export async function startMcpServer() {
|
|
|
154
246
|
.max(10)
|
|
155
247
|
.optional()
|
|
156
248
|
.describe("每条用例重复次数,取多数结果;建议正式基线用 3"),
|
|
157
|
-
failUnder: z.number().optional().describe("选择准确率阈值,低于则退出码为 2")
|
|
249
|
+
failUnder: z.number().optional().describe("选择准确率阈值,低于则退出码为 2"),
|
|
250
|
+
maxCost: z.number().positive().optional().describe("累计估算成本上限(CNY),超限退出 2 且不返回部分评分卡"),
|
|
251
|
+
progress: z.boolean().optional().describe("把逐用例进度写到 stderr(stdout 仍为纯 JSON)")
|
|
158
252
|
}
|
|
159
253
|
},
|
|
160
|
-
async ({ suite, models, cases, profile, repeat, failUnder }) => {
|
|
254
|
+
async ({ suite, models, cases, profile, repeat, failUnder, maxCost, progress }) => {
|
|
161
255
|
const args = ["eval", "--json", "--suite", suitePath(suite)];
|
|
162
256
|
if (models) args.push("--models", models);
|
|
163
257
|
if (cases) args.push("--case", cases);
|
|
164
258
|
if (profile) args.push("--profile", profile);
|
|
165
259
|
if (repeat !== undefined) args.push("--repeat", String(repeat));
|
|
166
260
|
if (failUnder !== undefined) args.push("--fail-under", String(failUnder));
|
|
261
|
+
if (maxCost !== undefined) args.push("--max-cost", String(maxCost));
|
|
262
|
+
if (progress) args.push("--progress");
|
|
167
263
|
|
|
168
264
|
const result = await runCli(args);
|
|
169
265
|
let scorecard = null;
|
package/lib/scan.js
CHANGED
|
@@ -337,9 +337,11 @@ export function addTools(
|
|
|
337
337
|
export function redactReport(report) {
|
|
338
338
|
return {
|
|
339
339
|
...report,
|
|
340
|
-
mcpServers: report.mcpServers.map(({ env, ...rest }) => ({
|
|
340
|
+
mcpServers: report.mcpServers.map(({ env, headers, ...rest }) => ({
|
|
341
341
|
...rest,
|
|
342
|
-
|
|
342
|
+
url: rest.url ? sanitizeUrl(rest.url) : null,
|
|
343
|
+
envKeys: env ? Object.keys(env) : [],
|
|
344
|
+
headerKeys: headers ? Object.keys(headers).sort() : []
|
|
343
345
|
}))
|
|
344
346
|
};
|
|
345
347
|
}
|
|
@@ -526,10 +528,26 @@ function mcpServersFrom(config) {
|
|
|
526
528
|
command: value.command ?? null,
|
|
527
529
|
args: Array.isArray(value.args) ? value.args : [],
|
|
528
530
|
env: value.env && typeof value.env === "object" ? value.env : null,
|
|
531
|
+
headers: value.headers && typeof value.headers === "object" ? value.headers : null,
|
|
529
532
|
url: value.url ?? null
|
|
530
533
|
}));
|
|
531
534
|
}
|
|
532
535
|
|
|
536
|
+
// Remote MCP configs often carry tokens in the query string; keep the report
|
|
537
|
+
// safe to share while the live server object still probes the full URL.
|
|
538
|
+
function sanitizeUrl(raw) {
|
|
539
|
+
try {
|
|
540
|
+
const url = new URL(raw);
|
|
541
|
+
url.username = "";
|
|
542
|
+
url.password = "";
|
|
543
|
+
url.search = "";
|
|
544
|
+
url.hash = "";
|
|
545
|
+
return url.toString();
|
|
546
|
+
} catch {
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
533
551
|
function toolsFromOpenApi(spec) {
|
|
534
552
|
const tools = [];
|
|
535
553
|
const paths = spec.paths ?? {};
|
package/lib/score.js
CHANGED
|
@@ -76,6 +76,19 @@ export function pickRepresentative(runs) {
|
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
export function costCnyOf(usage, pricing) {
|
|
80
|
+
if (!pricing) return 0;
|
|
81
|
+
const input = usage?.input ?? 0;
|
|
82
|
+
const cached = Math.min(input, usage?.cached ?? 0);
|
|
83
|
+
const output = usage?.output ?? 0;
|
|
84
|
+
return (
|
|
85
|
+
((input - cached) * (pricing.input ?? 0) +
|
|
86
|
+
cached * (pricing.cachedInput ?? pricing.input ?? 0) +
|
|
87
|
+
output * (pricing.output ?? 0)) /
|
|
88
|
+
1e6
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
79
92
|
export function scoreModel({ model, cases, results, pricing }) {
|
|
80
93
|
const rows = cases.map((testCase, index) => {
|
|
81
94
|
const result = results[index];
|
|
@@ -115,13 +128,7 @@ export function scoreModel({ model, cases, results, pricing }) {
|
|
|
115
128
|
inputTokens,
|
|
116
129
|
rows.reduce((sum, row) => sum + (row.usage.cached ?? 0), 0)
|
|
117
130
|
);
|
|
118
|
-
const
|
|
119
|
-
const costCny = pricing
|
|
120
|
-
? (uncachedTokens * (pricing.input ?? 0) +
|
|
121
|
-
cachedTokens * (pricing.cachedInput ?? pricing.input ?? 0) +
|
|
122
|
-
outputTokens * (pricing.output ?? 0)) /
|
|
123
|
-
1e6
|
|
124
|
-
: 0;
|
|
131
|
+
const costCny = rows.reduce((sum, row) => sum + costCnyOf(row.usage, pricing), 0);
|
|
125
132
|
const reviewedTotal = reviewedRows.length;
|
|
126
133
|
|
|
127
134
|
return {
|
package/lib/usage.js
CHANGED
|
@@ -6,6 +6,16 @@ import { configDir } from "./credentials.js";
|
|
|
6
6
|
// Events are redacted by construction: only counts, ids and ratios leave the
|
|
7
7
|
// process. Never add free-form text (inputs, descriptions, paths, errors).
|
|
8
8
|
const OPT_OUT_ENV = "MF_NO_USAGE";
|
|
9
|
+
const CI_ENV_KEYS = [
|
|
10
|
+
"GITHUB_ACTIONS",
|
|
11
|
+
"GITLAB_CI",
|
|
12
|
+
"CIRCLECI",
|
|
13
|
+
"TRAVIS",
|
|
14
|
+
"BUILDKITE",
|
|
15
|
+
"TF_BUILD",
|
|
16
|
+
"JENKINS_URL",
|
|
17
|
+
"CNB_BUILD_ID"
|
|
18
|
+
];
|
|
9
19
|
|
|
10
20
|
let pendingDetails = null;
|
|
11
21
|
|
|
@@ -75,10 +85,16 @@ export function buildRecord({ command, exitCode, durationMs, version }) {
|
|
|
75
85
|
platform: process.platform,
|
|
76
86
|
arch: process.arch,
|
|
77
87
|
node: process.versions.node,
|
|
88
|
+
ci: detectCi(),
|
|
78
89
|
...(pendingDetails ?? {})
|
|
79
90
|
};
|
|
80
91
|
}
|
|
81
92
|
|
|
93
|
+
function detectCi() {
|
|
94
|
+
if (process.env.CI === "true" || process.env.CI === "1") return true;
|
|
95
|
+
return CI_ENV_KEYS.some((key) => Boolean(process.env[key]));
|
|
96
|
+
}
|
|
97
|
+
|
|
82
98
|
export async function readUsageEvents() {
|
|
83
99
|
let files = [];
|
|
84
100
|
try {
|
|
@@ -112,6 +128,8 @@ export function summarizeUsage(events) {
|
|
|
112
128
|
firstRun: events[0]?.ts ?? null,
|
|
113
129
|
lastRun: events.at(-1)?.ts ?? null,
|
|
114
130
|
activeDays: new Set(events.map((event) => event.ts.slice(0, 10))).size,
|
|
131
|
+
ciRuns: 0,
|
|
132
|
+
localRuns: 0,
|
|
115
133
|
exitCodes: {},
|
|
116
134
|
commands: {},
|
|
117
135
|
platforms: {},
|
|
@@ -128,6 +146,8 @@ export function summarizeUsage(events) {
|
|
|
128
146
|
const args = [];
|
|
129
147
|
|
|
130
148
|
for (const event of events) {
|
|
149
|
+
if (event.ci) summary.ciRuns += 1;
|
|
150
|
+
else summary.localRuns += 1;
|
|
131
151
|
bump(summary.exitCodes, String(event.exitCode ?? 0));
|
|
132
152
|
bump(summary.commands, event.command ?? "unknown");
|
|
133
153
|
if (event.platform) bump(summary.platforms, event.platform);
|
|
@@ -198,7 +218,7 @@ export function formatStats(summary, { events = 0, files = 0, skipped = 0, path
|
|
|
198
218
|
}
|
|
199
219
|
const lines = [
|
|
200
220
|
`local usage: ${path} (${events} events, ${files} file(s)${skipped > 0 ? `, ${skipped} unreadable line(s)` : ""})`,
|
|
201
|
-
`runs: ${summary.runs} first: ${summary.firstRun} last: ${summary.lastRun} active days: ${summary.activeDays}`,
|
|
221
|
+
`runs: ${summary.runs} (local ${summary.localRuns}, ci ${summary.ciRuns}) first: ${summary.firstRun} last: ${summary.lastRun} active days: ${summary.activeDays}`,
|
|
202
222
|
`exit codes: ${pairs(summary.exitCodes)}`,
|
|
203
223
|
`commands: ${pairs(summary.commands)}`
|
|
204
224
|
];
|