@uwa4d/openapi-mcp 0.2.0-beta.0 → 0.2.0-beta.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 +9 -1
- package/dist/tools.d.ts +8 -0
- package/dist/tools.js +163 -10
- package/package.json +1 -1
- package/spec/overrides.json +26 -0
- package/spec/uwa-openapi.json +6 -6
package/README.md
CHANGED
|
@@ -128,7 +128,15 @@ npx -y @uwa4d/openapi-mcp list-tools -t overview -e unity
|
|
|
128
128
|
- `_totalItems` — 主数据列表的条目总数
|
|
129
129
|
- `_returnedChars` — 实际返回的字符数
|
|
130
130
|
|
|
131
|
-
只在明确只要前 N 条时才传 `maxRows
|
|
131
|
+
只在明确只要前 N 条时才传 `maxRows`。
|
|
132
|
+
|
|
133
|
+
## 返回体积控制
|
|
134
|
+
|
|
135
|
+
`--max-chars`(默认 50 万字符)对**所有**工具生效,不只是上面这类下载型接口。批量统计、报告列表这类接口一次查十几份报告就可能产生二十万字符以上的返回,容易撑爆模型上下文。
|
|
136
|
+
|
|
137
|
+
超限时不会粗暴截断字符串——那样会切出无法解析的 JSON——而是按条目缩减,保证返回始终是完整合法的结构,并在 `_truncated` 里说明缩减到了多少条、该怎么拿到剩下的(分页或缩小批量)。
|
|
138
|
+
|
|
139
|
+
如果你的客户端对单次工具输出有更严格的限制,把 `--max-chars` 调小即可。
|
|
132
140
|
|
|
133
141
|
## 曲线接口的双横轴
|
|
134
142
|
|
package/dist/tools.d.ts
CHANGED
|
@@ -28,6 +28,14 @@ export interface ToolResult {
|
|
|
28
28
|
* 帧号范围和步长,并在点数对不上时给出警告,从结构上消除歧义。
|
|
29
29
|
*/
|
|
30
30
|
export declare function annotateCurveAxes(data: unknown): unknown;
|
|
31
|
+
/**
|
|
32
|
+
* 报告列表接口一次返回、按项目组分组。但上游会把同一份报告挂到每个项目组下
|
|
33
|
+
* (沙箱实测多组两两交集 100%),直接按组统计会成倍放大。
|
|
34
|
+
*
|
|
35
|
+
* 这里就地合并:按 link 里的 project= 判定真实归属,每份报告只保留在正确的组里。
|
|
36
|
+
* 不额外打接口。上游修好后检测不到重复,本函数原样返回。
|
|
37
|
+
*/
|
|
38
|
+
export declare function annotateProjectGroups(data: unknown): unknown;
|
|
31
39
|
export declare function makeHandler(op: Operation, client: UwaClient, defaultMaxRows: number, maxChars: number): (args: Record<string, unknown>) => Promise<ToolResult>;
|
|
32
40
|
export declare function toolConfig(op: Operation): {
|
|
33
41
|
title: string;
|
package/dist/tools.js
CHANGED
|
@@ -145,13 +145,58 @@ function summarize(payload, maxRows, maxChars) {
|
|
|
145
145
|
rowTruncated = maxRows > 0 && lines.length > maxRows;
|
|
146
146
|
body = rowTruncated ? lines.slice(0, maxRows).join('\n') : (payload.text ?? '');
|
|
147
147
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
148
|
+
const fitted = fitToChars(body, maxChars);
|
|
149
|
+
return { rowTruncated, total, ...fitted };
|
|
150
|
+
}
|
|
151
|
+
const serialize = (v) => (typeof v === 'string' ? v : JSON.stringify(v, null, 2));
|
|
152
|
+
/**
|
|
153
|
+
* 把返回内容压到字符上限以内。
|
|
154
|
+
*
|
|
155
|
+
* 直接按字符裁剪 JSON 会切出非法结构,模型没法解析。因此优先按条目缩减
|
|
156
|
+
* 主数据数组——二分找出能放下的最大条数,保留完整的 JSON 结构,
|
|
157
|
+
* 并在元信息里说明丢了多少。只有在没有数组可缩减时才退化为字符串截断。
|
|
158
|
+
*/
|
|
159
|
+
function fitToChars(body, maxChars) {
|
|
160
|
+
const full = serialize(body);
|
|
161
|
+
if (maxChars <= 0 || full.length <= maxChars) {
|
|
162
|
+
return { charTruncated: false, chars: full.length, body: full };
|
|
153
163
|
}
|
|
154
|
-
|
|
164
|
+
const rebuild = (n) => {
|
|
165
|
+
if (Array.isArray(body))
|
|
166
|
+
return serialize(body.slice(0, n));
|
|
167
|
+
if (body && typeof body === 'object') {
|
|
168
|
+
const obj = body;
|
|
169
|
+
const key = mainArrayKey(obj);
|
|
170
|
+
if (!key)
|
|
171
|
+
return null;
|
|
172
|
+
return serialize({ ...obj, [key]: obj[key].slice(0, n) });
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
};
|
|
176
|
+
const count = Array.isArray(body)
|
|
177
|
+
? body.length
|
|
178
|
+
: body && typeof body === 'object'
|
|
179
|
+
? (() => {
|
|
180
|
+
const key = mainArrayKey(body);
|
|
181
|
+
return key ? body[key].length : 0;
|
|
182
|
+
})()
|
|
183
|
+
: 0;
|
|
184
|
+
if (count > 0 && rebuild(0) !== null) {
|
|
185
|
+
// 二分出能放进上限的最大条数
|
|
186
|
+
let lo = 0;
|
|
187
|
+
let hi = count;
|
|
188
|
+
while (lo < hi) {
|
|
189
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
190
|
+
const text = rebuild(mid);
|
|
191
|
+
if (text.length <= maxChars)
|
|
192
|
+
lo = mid;
|
|
193
|
+
else
|
|
194
|
+
hi = mid - 1;
|
|
195
|
+
}
|
|
196
|
+
const text = rebuild(lo);
|
|
197
|
+
return { charTruncated: true, kept: lo, chars: text.length, body: text };
|
|
198
|
+
}
|
|
199
|
+
return { charTruncated: true, chars: maxChars, body: full.slice(0, maxChars) };
|
|
155
200
|
}
|
|
156
201
|
function isObj(v) {
|
|
157
202
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
@@ -196,6 +241,93 @@ export function annotateCurveAxes(data) {
|
|
|
196
241
|
_axisHint: '每条曲线按 _axisBinding[曲线名].xAxisSource 指定的横轴取值,不要统一使用外层 x_axis',
|
|
197
242
|
};
|
|
198
243
|
}
|
|
244
|
+
const RECORD_ARRAY_KEYS = /^gotOnline.*Records$/;
|
|
245
|
+
/**
|
|
246
|
+
* 报告列表接口一次返回、按项目组分组。但上游会把同一份报告挂到每个项目组下
|
|
247
|
+
* (沙箱实测多组两两交集 100%),直接按组统计会成倍放大。
|
|
248
|
+
*
|
|
249
|
+
* 这里就地合并:按 link 里的 project= 判定真实归属,每份报告只保留在正确的组里。
|
|
250
|
+
* 不额外打接口。上游修好后检测不到重复,本函数原样返回。
|
|
251
|
+
*/
|
|
252
|
+
export function annotateProjectGroups(data) {
|
|
253
|
+
if (!isObj(data) || !Array.isArray(data['content']))
|
|
254
|
+
return data;
|
|
255
|
+
const groups = data['content'].filter(isObj);
|
|
256
|
+
if (groups.length < 2)
|
|
257
|
+
return data;
|
|
258
|
+
// dataKey → 出现过的项目组;以及「该报告在哪个数组字段里、原文是什么」
|
|
259
|
+
const groupsOf = new Map();
|
|
260
|
+
const firstSeen = new Map();
|
|
261
|
+
for (const g of groups) {
|
|
262
|
+
const gid = String(g['projectGroupId'] ?? '');
|
|
263
|
+
if (!gid)
|
|
264
|
+
continue;
|
|
265
|
+
for (const [k, v] of Object.entries(g)) {
|
|
266
|
+
if (!RECORD_ARRAY_KEYS.test(k) || !Array.isArray(v))
|
|
267
|
+
continue;
|
|
268
|
+
for (const r of v) {
|
|
269
|
+
if (!isObj(r))
|
|
270
|
+
continue;
|
|
271
|
+
const key = typeof r['dataKey'] === 'string' ? r['dataKey'] : null;
|
|
272
|
+
if (!key)
|
|
273
|
+
continue;
|
|
274
|
+
if (!groupsOf.has(key))
|
|
275
|
+
groupsOf.set(key, new Set());
|
|
276
|
+
groupsOf.get(key).add(gid);
|
|
277
|
+
if (!firstSeen.has(key)) {
|
|
278
|
+
const fromLink = /[?&]project=(\d+)/.exec(String(r['link'] ?? ''))?.[1];
|
|
279
|
+
firstSeen.set(key, { arrayKey: k, record: r, realGid: fromLink ?? gid });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const duplicated = [...groupsOf.values()].some((gs) => gs.size > 1);
|
|
285
|
+
if (!duplicated)
|
|
286
|
+
return data;
|
|
287
|
+
// 已知项目组壳子(保留名称、engine 等元信息),报告重新按真实归属灌入
|
|
288
|
+
const shells = new Map();
|
|
289
|
+
for (const g of groups) {
|
|
290
|
+
const gid = String(g['projectGroupId'] ?? '');
|
|
291
|
+
if (!gid || shells.has(gid))
|
|
292
|
+
continue;
|
|
293
|
+
const shell = { ...g };
|
|
294
|
+
for (const k of Object.keys(shell)) {
|
|
295
|
+
if (RECORD_ARRAY_KEYS.test(k))
|
|
296
|
+
shell[k] = [];
|
|
297
|
+
}
|
|
298
|
+
shells.set(gid, shell);
|
|
299
|
+
}
|
|
300
|
+
let placed = 0;
|
|
301
|
+
let orphaned = 0;
|
|
302
|
+
for (const { arrayKey, record, realGid } of firstSeen.values()) {
|
|
303
|
+
let shell = shells.get(realGid);
|
|
304
|
+
if (!shell) {
|
|
305
|
+
// link 指向的组不在本页 content 里——仍给出一份壳,避免报告丢失
|
|
306
|
+
shell = {
|
|
307
|
+
projectGroupId: Number.isFinite(Number(realGid)) ? Number(realGid) : realGid,
|
|
308
|
+
projectGroupName: `(id=${realGid})`,
|
|
309
|
+
engine: null,
|
|
310
|
+
};
|
|
311
|
+
shells.set(realGid, shell);
|
|
312
|
+
orphaned++;
|
|
313
|
+
}
|
|
314
|
+
if (!Array.isArray(shell[arrayKey]))
|
|
315
|
+
shell[arrayKey] = [];
|
|
316
|
+
shell[arrayKey].push(record);
|
|
317
|
+
placed++;
|
|
318
|
+
}
|
|
319
|
+
// 丢掉合并后一个报告都没有的空组(它们原先只有别人的副本)
|
|
320
|
+
const content = [...shells.values()].filter((g) => Object.entries(g).some(([k, v]) => RECORD_ARRAY_KEYS.test(k) && Array.isArray(v) && v.length > 0));
|
|
321
|
+
return {
|
|
322
|
+
...data,
|
|
323
|
+
content,
|
|
324
|
+
_deduplicated: true,
|
|
325
|
+
_uniqueReportCount: placed,
|
|
326
|
+
_dedupeNote: `上游把同一份报告重复挂在多个项目组下,已按 link 中 project= 合并归位,去重后 ${placed} 份` +
|
|
327
|
+
(orphaned ? `(其中 ${orphaned} 份所属项目组不在本页,已单独列出)` : '') +
|
|
328
|
+
'。可直接按项目组统计。',
|
|
329
|
+
};
|
|
330
|
+
}
|
|
199
331
|
/** MCP 的 content.text 必须是字符串,undefined 会让客户端校验失败。 */
|
|
200
332
|
function asText(value) {
|
|
201
333
|
if (typeof value === 'string')
|
|
@@ -221,20 +353,41 @@ export function makeHandler(op, client, defaultMaxRows, maxChars) {
|
|
|
221
353
|
if (args[p.name] !== undefined)
|
|
222
354
|
body[p.name] = args[p.name];
|
|
223
355
|
}
|
|
224
|
-
const
|
|
356
|
+
const raw = await client.call(op.method, op.path, apiVersion, query, body);
|
|
357
|
+
const data = annotateProjectGroups(annotateCurveAxes(raw));
|
|
225
358
|
const presignUrl = op.returnsPresignUrl && data && typeof data === 'object'
|
|
226
359
|
? data['dataPresignUrl']
|
|
227
360
|
: undefined;
|
|
228
361
|
if (!presignUrl || !wantDownload) {
|
|
229
|
-
|
|
362
|
+
// 非预签名接口同样可能很大(如报告列表 50 条约 56K 字符、
|
|
363
|
+
// 统计接口批量查十几份报告可达 20 万字符),这里必须同样设闸门,
|
|
364
|
+
// 否则会直接撑爆模型上下文。
|
|
365
|
+
const fitted = fitToChars(data, maxChars);
|
|
366
|
+
if (!fitted.charTruncated) {
|
|
367
|
+
return { content: [{ type: 'text', text: asText(fitted.body) }] };
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
content: [
|
|
371
|
+
{
|
|
372
|
+
type: 'text',
|
|
373
|
+
text: asText({
|
|
374
|
+
_truncated: `返回内容超过 ${maxChars} 字符上限,已缩减为 ${fitted.kept ?? 0} 条。` +
|
|
375
|
+
`请缩小查询范围后重试${op.query.some((p) => p.name === 'pageSize') ? '(如调小 pageSize 分页获取)' : '(如减少批量查询的条目数)'},` +
|
|
376
|
+
`或在启动参数里调大 --max-chars。`,
|
|
377
|
+
_returnedChars: fitted.chars,
|
|
378
|
+
}),
|
|
379
|
+
},
|
|
380
|
+
{ type: 'text', text: asText(fitted.body) },
|
|
381
|
+
],
|
|
382
|
+
};
|
|
230
383
|
}
|
|
231
384
|
const payload = await client.downloadPresign(presignUrl);
|
|
232
|
-
const { rowTruncated, charTruncated, total, chars, body: content } = summarize(payload, maxRows, maxChars);
|
|
385
|
+
const { rowTruncated, charTruncated, total, kept, chars, body: content } = summarize(payload, maxRows, maxChars);
|
|
233
386
|
const notes = [];
|
|
234
387
|
if (rowTruncated)
|
|
235
388
|
notes.push(`按 maxRows=${maxRows} 截断,去掉 maxRows 参数可取全量`);
|
|
236
389
|
if (charTruncated) {
|
|
237
|
-
notes.push(`内容超过 ${maxChars}
|
|
390
|
+
notes.push(`内容超过 ${maxChars} 字符上限,已缩减为 ${kept ?? 0} 条。` +
|
|
238
391
|
`如需完整数据,用 download=false 拿 dataPresignUrl 自行下载,或启动时调大 --max-chars`);
|
|
239
392
|
}
|
|
240
393
|
const meta = {
|
package/package.json
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": [
|
|
3
|
+
"官方文档与接口实际行为不符时的修正层。",
|
|
4
|
+
"生成 spec 时套用(见 scripts/parse-docs.ts),因此刷新 docs/ 下的文档不会冲掉这里的修正。",
|
|
5
|
+
"每条都必须写明 verifiedAt 与 evidence——没有实测依据不要往这里加,",
|
|
6
|
+
"否则下一个人无法判断该修正是否已经过时。",
|
|
7
|
+
"上游修好文档后应删除对应条目,而不是留着。"
|
|
8
|
+
],
|
|
9
|
+
"operations": {
|
|
10
|
+
"get_ue_overview_statistic_v2": {
|
|
11
|
+
"reason": "文档称单次最多 50 份,实测上限为 30 份",
|
|
12
|
+
"verifiedAt": "2026-08-04",
|
|
13
|
+
"evidence": "30 个长 dataKey(参数串 1140 字符)成功;31 个短 dataKey(参数串 1094 字符,更短)失败并返回 20001,排除 URL 长度因素。49 个 key 逐个单查均正常,故确为批量条数限制。",
|
|
14
|
+
"replaceInText": [
|
|
15
|
+
{
|
|
16
|
+
"from": "最多 50 个",
|
|
17
|
+
"to": "最多 30 个(文档写 50,实测上限 30)"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"from": "单次请求最多查询 50 份报告",
|
|
21
|
+
"to": "单次请求最多查询 30 份报告(文档写 50,实测上限 30,超出会返回 20001)"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
package/spec/uwa-openapi.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedAt": "2026-08-
|
|
2
|
+
"generatedAt": "2026-08-04T09:11:08.206Z",
|
|
3
3
|
"sourceDoc": "uwa-openapi-20260730.md",
|
|
4
4
|
"operationCount": 55,
|
|
5
5
|
"operations": [
|
|
@@ -928,14 +928,14 @@
|
|
|
928
928
|
"type": "string",
|
|
929
929
|
"required": false,
|
|
930
930
|
"example": "1002,1003",
|
|
931
|
-
"description": "报告 recordId 列表,逗号分隔,最多 50
|
|
931
|
+
"description": "报告 recordId 列表,逗号分隔,最多 30 个(文档写 50,实测上限 30)。与 dataKeys 二选一,两者均填时取交集"
|
|
932
932
|
},
|
|
933
933
|
{
|
|
934
934
|
"name": "dataKeys",
|
|
935
935
|
"type": "string",
|
|
936
936
|
"required": false,
|
|
937
937
|
"example": "testDataKey1,testDataKey2",
|
|
938
|
-
"description": "报告 dataKey 列表,逗号分隔,最多 50
|
|
938
|
+
"description": "报告 dataKey 列表,逗号分隔,最多 30 个(文档写 50,实测上限 30)。与 recordIds 二选一,两者均填时取交集"
|
|
939
939
|
}
|
|
940
940
|
],
|
|
941
941
|
"body": [],
|
|
@@ -956,7 +956,7 @@
|
|
|
956
956
|
],
|
|
957
957
|
"notes": [
|
|
958
958
|
"本接口仅支持 2026-03-25 之后提交的 UE Overview 报告;更早版本的报告应使用获取报告统计数据 1.0",
|
|
959
|
-
"单次请求最多查询 50
|
|
959
|
+
"单次请求最多查询 30 份报告(文档写 50,实测上限 30,超出会返回 20001),recordIds 与 dataKeys 至少填一个",
|
|
960
960
|
"LLM 字段为 UE 专属低层内存分析数据,当报告未开启 LLM 采集时该字段可能为空数组 []"
|
|
961
961
|
],
|
|
962
962
|
"returnRules": [],
|
|
@@ -966,14 +966,14 @@
|
|
|
966
966
|
"type": "string",
|
|
967
967
|
"required": false,
|
|
968
968
|
"example": "1002,1003",
|
|
969
|
-
"description": "报告 recordId 列表,逗号分隔,最多 50
|
|
969
|
+
"description": "报告 recordId 列表,逗号分隔,最多 30 个(文档写 50,实测上限 30)。与 dataKeys 二选一,两者均填时取交集"
|
|
970
970
|
},
|
|
971
971
|
{
|
|
972
972
|
"name": "dataKeys",
|
|
973
973
|
"type": "string",
|
|
974
974
|
"required": false,
|
|
975
975
|
"example": "testDataKey1,testDataKey2",
|
|
976
|
-
"description": "报告 dataKey 列表,逗号分隔,最多 50
|
|
976
|
+
"description": "报告 dataKey 列表,逗号分隔,最多 30 个(文档写 50,实测上限 30)。与 recordIds 二选一,两者均填时取交集"
|
|
977
977
|
}
|
|
978
978
|
],
|
|
979
979
|
"body": [],
|