@bachi/pi-coder 1.0.0 → 1.1.0
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/CHANGELOG.md +19 -0
- package/README.md +16 -7
- package/docs/configuration.md +9 -1
- package/docs/development.md +26 -11
- package/docs/extensions.md +36 -1
- package/docs/handbook.zh.md +87 -2
- package/docs/installation.md +3 -1
- package/docs/themes.md +3 -2
- package/extensions/mcp/client.test.ts +518 -0
- package/extensions/mcp/client.ts +796 -0
- package/extensions/mcp/config.test.ts +307 -0
- package/extensions/mcp/config.ts +360 -0
- package/extensions/mcp/fixtures/fake-mcp-server.mjs +155 -0
- package/extensions/mcp/fixtures/token-helper.mjs +51 -0
- package/extensions/mcp/headers-command.test.ts +172 -0
- package/extensions/mcp/headers-command.ts +203 -0
- package/extensions/mcp/index.ts +295 -0
- package/extensions/mcp/protocol.test.ts +179 -0
- package/extensions/mcp/protocol.ts +239 -0
- package/extensions/mcp/tools.test.ts +236 -0
- package/extensions/mcp/tools.ts +339 -0
- package/extensions/statusline/footer-suppress.test.ts +150 -0
- package/extensions/statusline/footer-suppress.ts +135 -0
- package/extensions/statusline/index.ts +17 -0
- package/package.json +4 -3
- package/themes/ayu.json +3 -2
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* protocol.ts — MCP 的线路层:JSON-RPC 2.0 消息编解码 + SSE 事件解析。
|
|
3
|
+
*
|
|
4
|
+
* 纯逻辑,不 import pi / node(`TextDecoder` 之外不碰宿主 API),所以可以直接 `node --test`。
|
|
5
|
+
* 拆出来的理由:stdio 传输的「按行切 JSON」和 HTTP 传输的「按空行切 SSE 事件」是本扩展里
|
|
6
|
+
* 唯一两处「上游给的字节流可能在任何位置断开」的地方 —— 半行 JSON、半截 SSE 事件都必须缓冲到
|
|
7
|
+
* 下一块 chunk 再处理,边界错误在这里最容易埋雷,单独测最省事。
|
|
8
|
+
*
|
|
9
|
+
* 事实来源(实测,非推断):
|
|
10
|
+
* - stdio 帧格式 = 一行一个 JSON-RPC 消息,行内不含裸换行(MCP spec "stdio" transport)
|
|
11
|
+
* - 对真实 wechat-local-mcp 进程握手成功,返回 protocolVersion 2025-06-18,12 个工具
|
|
12
|
+
* - streamable HTTP 的响应既可能是 `application/json`(整包一个消息)也可能是
|
|
13
|
+
* `text/event-stream`(一个或多个 `message` 事件),两条路都要能解析
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** MCP 当前协议版本(客户端在 initialize 里声明的版本;服务端可回一个它支持的版本)。 */
|
|
17
|
+
export const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
18
|
+
|
|
19
|
+
export const JSONRPC_VERSION = "2.0";
|
|
20
|
+
|
|
21
|
+
/** JSON-RPC 错误码。自定义码从 -32000 起(协议保留区间)。 */
|
|
22
|
+
export const JSONRPC_PARSE_ERROR = -32700;
|
|
23
|
+
export const JSONRPC_INVALID_REQUEST = -32600;
|
|
24
|
+
export const JSONRPC_METHOD_NOT_FOUND = -32601;
|
|
25
|
+
export const JSONRPC_INVALID_PARAMS = -32602;
|
|
26
|
+
export const JSONRPC_INTERNAL_ERROR = -32603;
|
|
27
|
+
|
|
28
|
+
export interface JsonRpcRequest {
|
|
29
|
+
jsonrpc: typeof JSONRPC_VERSION;
|
|
30
|
+
id: number;
|
|
31
|
+
method: string;
|
|
32
|
+
params?: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface JsonRpcNotification {
|
|
36
|
+
jsonrpc: typeof JSONRPC_VERSION;
|
|
37
|
+
method: string;
|
|
38
|
+
params?: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface JsonRpcErrorObject {
|
|
42
|
+
code: number;
|
|
43
|
+
message: string;
|
|
44
|
+
data?: unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface JsonRpcSuccessResponse {
|
|
48
|
+
jsonrpc: typeof JSONRPC_VERSION;
|
|
49
|
+
id: number | string;
|
|
50
|
+
result: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface JsonRpcErrorResponse {
|
|
54
|
+
jsonrpc: typeof JSONRPC_VERSION;
|
|
55
|
+
id: number | string | null;
|
|
56
|
+
error: JsonRpcErrorObject;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse;
|
|
60
|
+
|
|
61
|
+
export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse;
|
|
62
|
+
|
|
63
|
+
/** 传输层/服务端返回的错误。`code` 保留 JSON-RPC 错误码,HTTP 传输失败用 `HTTP_ERROR`。 */
|
|
64
|
+
export class McpError extends Error {
|
|
65
|
+
readonly code: number;
|
|
66
|
+
readonly data: unknown;
|
|
67
|
+
|
|
68
|
+
constructor(code: number, message: string, data?: unknown) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.name = "McpError";
|
|
71
|
+
this.code = code;
|
|
72
|
+
this.data = data;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 连接级失败(进程退出、HTTP 断流、握手失败)统一用这个,便于上层区分「调用失败」与「通道没了」。 */
|
|
77
|
+
export class McpConnectionError extends Error {
|
|
78
|
+
constructor(message: string) {
|
|
79
|
+
super(message);
|
|
80
|
+
this.name = "McpConnectionError";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function isJsonRpcResponse(message: JsonRpcMessage): message is JsonRpcResponse {
|
|
85
|
+
return typeof message === "object" && message !== null && "id" in message && !("method" in message);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function isJsonRpcRequestOrNotification(
|
|
89
|
+
message: JsonRpcMessage,
|
|
90
|
+
): message is JsonRpcRequest | JsonRpcNotification {
|
|
91
|
+
return typeof message === "object" && message !== null && "method" in message;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 把消息序列化成 stdio 传输的一帧(单行 JSON + 换行)。 */
|
|
95
|
+
export function encodeStdioFrame(message: JsonRpcMessage): string {
|
|
96
|
+
return `${JSON.stringify(message)}\n`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 按行切分 stdio 帧。
|
|
101
|
+
*
|
|
102
|
+
* 返回一个「喂 chunk、吐整行」的闭包:调用方把每次 `data` 事件原样喂进来,拿到的才是完整行。
|
|
103
|
+
* 不做 JSON.parse —— 解析失败要由调用方决定是「丢弃这行」还是「当成协议错误断开」。
|
|
104
|
+
* 兼容 `\r\n`(Windows 上的 MCP server)。
|
|
105
|
+
*/
|
|
106
|
+
export function createLineDecoder(): (chunk: string) => string[] {
|
|
107
|
+
let buffer = "";
|
|
108
|
+
return (chunk: string): string[] => {
|
|
109
|
+
buffer += chunk;
|
|
110
|
+
const lines: string[] = [];
|
|
111
|
+
let index = buffer.indexOf("\n");
|
|
112
|
+
while (index >= 0) {
|
|
113
|
+
let line = buffer.slice(0, index);
|
|
114
|
+
if (line.endsWith("\r")) line = line.slice(0, -1);
|
|
115
|
+
lines.push(line);
|
|
116
|
+
buffer = buffer.slice(index + 1);
|
|
117
|
+
index = buffer.indexOf("\n");
|
|
118
|
+
}
|
|
119
|
+
return lines;
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface SseEvent {
|
|
124
|
+
event?: string;
|
|
125
|
+
data: string;
|
|
126
|
+
id?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 按 SSE 规范切分事件流。
|
|
131
|
+
*
|
|
132
|
+
* 只实现我们需要的部分:`event:` / `data:` 字段、多行 data 用 `\n` 拼接、空行结束一个事件、
|
|
133
|
+
* 以 `:` 开头的注释行忽略(有些服务端用注释做心跳)。`retry:` 等字段忽略。
|
|
134
|
+
* 关键点是**增量**:半个事件必须留在缓冲里等下一块 chunk,不能提前切出去。
|
|
135
|
+
*/
|
|
136
|
+
export function createSseDecoder(): (chunk: string) => SseEvent[] {
|
|
137
|
+
let buffer = "";
|
|
138
|
+
return (chunk: string): SseEvent[] => {
|
|
139
|
+
buffer += chunk.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
140
|
+
const events: SseEvent[] = [];
|
|
141
|
+
let separator = buffer.indexOf("\n\n");
|
|
142
|
+
while (separator >= 0) {
|
|
143
|
+
const block = buffer.slice(0, separator);
|
|
144
|
+
buffer = buffer.slice(separator + 2);
|
|
145
|
+
const parsed = parseSseBlock(block);
|
|
146
|
+
if (parsed) events.push(parsed);
|
|
147
|
+
separator = buffer.indexOf("\n\n");
|
|
148
|
+
}
|
|
149
|
+
return events;
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 解析一个已经切完整的 SSE 事件块。只有 `data` 的事件也算合法(MCP 的响应就是这种)。 */
|
|
154
|
+
export function parseSseBlock(block: string): SseEvent | undefined {
|
|
155
|
+
const dataLines: string[] = [];
|
|
156
|
+
let event: string | undefined;
|
|
157
|
+
let id: string | undefined;
|
|
158
|
+
for (const line of block.split("\n")) {
|
|
159
|
+
if (!line || line.startsWith(":")) continue;
|
|
160
|
+
const colon = line.indexOf(":");
|
|
161
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
162
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
163
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
164
|
+
switch (field) {
|
|
165
|
+
case "event":
|
|
166
|
+
event = value;
|
|
167
|
+
break;
|
|
168
|
+
case "data":
|
|
169
|
+
dataLines.push(value);
|
|
170
|
+
break;
|
|
171
|
+
case "id":
|
|
172
|
+
id = value;
|
|
173
|
+
break;
|
|
174
|
+
default:
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (dataLines.length === 0) return undefined;
|
|
179
|
+
return { event, data: dataLines.join("\n"), id };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 解析一行 stdio 帧 / 一个 SSE data 载荷里的 JSON-RPC 消息。 */
|
|
183
|
+
export function parseJsonRpcMessage(raw: string): JsonRpcMessage {
|
|
184
|
+
let parsed: unknown;
|
|
185
|
+
try {
|
|
186
|
+
parsed = JSON.parse(raw);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
throw new McpError(
|
|
189
|
+
JSONRPC_PARSE_ERROR,
|
|
190
|
+
`invalid JSON-RPC payload: ${error instanceof Error ? error.message : String(error)}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
194
|
+
throw new McpError(JSONRPC_INVALID_REQUEST, "JSON-RPC payload must be an object");
|
|
195
|
+
}
|
|
196
|
+
return parsed as JsonRpcMessage;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 把 JSON-RPC 错误响应转成 Error。
|
|
201
|
+
*
|
|
202
|
+
* `data` 里往往有服务端给的细节(例如 MCP 的 `{"uri": ...}` 或 python 的 traceback 摘要),
|
|
203
|
+
* 拼进 message 里对模型更有用,但别让 message 长到失控。
|
|
204
|
+
*/
|
|
205
|
+
export function toErrorFromResponse(error: JsonRpcErrorObject): McpError {
|
|
206
|
+
const detail = formatErrorData(error.data);
|
|
207
|
+
const message = detail ? `${error.message} (${detail})` : error.message;
|
|
208
|
+
return new McpError(error.code, message, error.data);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function formatErrorData(data: unknown): string | undefined {
|
|
212
|
+
if (data === undefined || data === null) return undefined;
|
|
213
|
+
const text = typeof data === "string" ? data : safeStringify(data);
|
|
214
|
+
if (!text) return undefined;
|
|
215
|
+
return text.length > 500 ? `${text.slice(0, 500)}…` : text;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 稳定序列化:循环引用、BigInt 等都不能让「拼错误信息」这件事自己再抛异常。 */
|
|
219
|
+
export function safeStringify(value: unknown): string {
|
|
220
|
+
try {
|
|
221
|
+
const json = JSON.stringify(value);
|
|
222
|
+
return json === undefined ? String(value) : json;
|
|
223
|
+
} catch {
|
|
224
|
+
return String(value);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** 纯文本化一个 JSON-RPC 消息(调试日志用,避免把 base64 图片整个打出来)。 */
|
|
229
|
+
export function summarizeMessage(message: JsonRpcMessage): string {
|
|
230
|
+
if (isJsonRpcResponse(message)) {
|
|
231
|
+
return "error" in message ? `error ${message.error.code}` : "result";
|
|
232
|
+
}
|
|
233
|
+
return message.method;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 用于 `notifications/cancelled`(客户端取消在途请求)。 */
|
|
237
|
+
export function createCancelledNotification(requestId: number | string, reason: string): JsonRpcNotification {
|
|
238
|
+
return { jsonrpc: JSONRPC_VERSION, method: "notifications/cancelled", params: { requestId, reason } };
|
|
239
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for tools.ts — 命名、schema 归一化、MCP 内容块到 pi 内容块的映射、截断。
|
|
3
|
+
*
|
|
4
|
+
* Run with: node --test clients/pi/extensions/mcp/tools.test.ts
|
|
5
|
+
*
|
|
6
|
+
* 这里覆盖的是「模型实际看到什么」:名字能不能被原样回传、图片有没有被降级、
|
|
7
|
+
* 超长输出有没有在保住开头的前提下截断。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { describe, it } from "node:test";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
formatBytes,
|
|
15
|
+
mcpContentToPiContent,
|
|
16
|
+
MAX_TOOL_NAME_LENGTH,
|
|
17
|
+
normalizeInputSchema,
|
|
18
|
+
piToolName,
|
|
19
|
+
toolDescription,
|
|
20
|
+
toolPromptSnippet,
|
|
21
|
+
truncateText,
|
|
22
|
+
} from "./tools.ts";
|
|
23
|
+
|
|
24
|
+
describe("piToolName", () => {
|
|
25
|
+
it("用 Claude Code 的 mcp__<server>__<tool> 形状", () => {
|
|
26
|
+
assert.equal(piToolName("wechat-local", "wechat_status"), "mcp__wechat-local__wechat_status");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("非法字符(点、空格、斜杠)替换成下划线", () => {
|
|
30
|
+
assert.equal(piToolName("my server", "a.b/c"), "mcp__my_server__a_b_c");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("超过 64 字符时截断并接哈希后缀", () => {
|
|
34
|
+
const name = piToolName("server", "t".repeat(120));
|
|
35
|
+
assert.ok(name.length <= MAX_TOOL_NAME_LENGTH, `长度 ${name.length} 超过上限`);
|
|
36
|
+
assert.match(name, /^mcp__server__t+_[0-9a-f]{6}$/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("截断后不同工具名仍然可区分(哈希不同)", () => {
|
|
40
|
+
const a = piToolName("server", `${"x".repeat(100)}a`);
|
|
41
|
+
const b = piToolName("server", `${"x".repeat(100)}b`);
|
|
42
|
+
assert.notEqual(a, b);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("名字相同则结果稳定(哈希不是随机的)", () => {
|
|
46
|
+
assert.equal(piToolName("s", "t".repeat(100)), piToolName("s", "t".repeat(100)));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("空名兜底成下划线,不产生非法工具名", () => {
|
|
50
|
+
assert.equal(piToolName("", ""), "mcp______");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("normalizeInputSchema", () => {
|
|
55
|
+
it("标准 schema 原样通过(保留 required 与其它关键字)", () => {
|
|
56
|
+
const schema = normalizeInputSchema({
|
|
57
|
+
type: "object",
|
|
58
|
+
properties: { chat: { type: "string" } },
|
|
59
|
+
required: ["chat"],
|
|
60
|
+
additionalProperties: false,
|
|
61
|
+
});
|
|
62
|
+
assert.equal(schema.type, "object");
|
|
63
|
+
assert.deepEqual(schema.required, ["chat"]);
|
|
64
|
+
assert.equal(schema.additionalProperties, false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("缺 schema / null / 数组 → 空对象 schema", () => {
|
|
68
|
+
for (const input of [undefined, null, [], "x"]) {
|
|
69
|
+
assert.deepEqual(normalizeInputSchema(input), { type: "object", properties: {} });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("没写 type 但有 properties 时补上 type: object", () => {
|
|
74
|
+
const schema = normalizeInputSchema({ properties: { a: { type: "string" } } });
|
|
75
|
+
assert.equal(schema.type, "object");
|
|
76
|
+
assert.deepEqual(Object.keys(schema.properties as object), ["a"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("type 不是 object(例如 string)时退回空对象 schema", () => {
|
|
80
|
+
assert.deepEqual(normalizeInputSchema({ type: "string" }), { type: "object", properties: {} });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("保留 $defs 这类扩展关键字", () => {
|
|
84
|
+
const schema = normalizeInputSchema({ type: "object", properties: {}, $defs: { X: { type: "string" } } });
|
|
85
|
+
assert.deepEqual(schema.$defs, { X: { type: "string" } });
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("toolDescription / toolPromptSnippet", () => {
|
|
90
|
+
it("把服务端描述与注解提示拼在一起", () => {
|
|
91
|
+
const description = toolDescription("wechat-local", {
|
|
92
|
+
name: "wechat_status",
|
|
93
|
+
description: "检查微信状态",
|
|
94
|
+
annotations: { readOnlyHint: true },
|
|
95
|
+
});
|
|
96
|
+
assert.match(description, /检查微信状态/);
|
|
97
|
+
assert.match(description, /server: wechat-local/);
|
|
98
|
+
assert.match(description, /只读/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("没有描述时也能给出 server 归属", () => {
|
|
102
|
+
assert.match(toolDescription("s", { name: "t" }), /MCP 工具(server: s)/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("破坏性注解会提示模型", () => {
|
|
106
|
+
assert.match(toolDescription("s", { name: "t", annotations: { destructiveHint: true } }), /可能破坏数据/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("snippet 是单行且限长", () => {
|
|
110
|
+
const snippet = toolPromptSnippet(`多行\n描述 带 空白 ${"x".repeat(200)}`);
|
|
111
|
+
assert.ok(snippet && !snippet.includes("\n"));
|
|
112
|
+
assert.ok(snippet.length <= 101, `snippet 长度 ${snippet.length}`);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("没有描述时不给 snippet", () => {
|
|
116
|
+
assert.equal(toolPromptSnippet(undefined), undefined);
|
|
117
|
+
assert.equal(toolPromptSnippet(" "), undefined);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("中文长描述硬切(CJK 没有词边界)", () => {
|
|
121
|
+
const snippet = toolPromptSnippet("中".repeat(300));
|
|
122
|
+
assert.ok(snippet && snippet.length <= 101);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("mcpContentToPiContent", () => {
|
|
127
|
+
it("文本块原样保留,顺序不变", () => {
|
|
128
|
+
const result = mcpContentToPiContent([
|
|
129
|
+
{ type: "text", text: "第一段" },
|
|
130
|
+
{ type: "text", text: "第二段" },
|
|
131
|
+
]);
|
|
132
|
+
assert.deepEqual(result.content, [
|
|
133
|
+
{ type: "text", text: "第一段" },
|
|
134
|
+
{ type: "text", text: "第二段" },
|
|
135
|
+
]);
|
|
136
|
+
assert.equal(result.text, "第一段\n第二段");
|
|
137
|
+
assert.deepEqual(result.notes, []);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("图片映射成 pi 的 ImageContent", () => {
|
|
141
|
+
const result = mcpContentToPiContent([{ type: "image", data: "AAAA", mimeType: "image/png" }]);
|
|
142
|
+
assert.deepEqual(result.content[0], { type: "image", data: "AAAA", mimeType: "image/png" });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("缺 data/mimeType 的图片降级成文本说明", () => {
|
|
146
|
+
const result = mcpContentToPiContent([{ type: "image" }]);
|
|
147
|
+
assert.equal(result.content[0]?.type, "text");
|
|
148
|
+
assert.deepEqual(result.notes.length, 1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("audio 降级成文本说明", () => {
|
|
152
|
+
const result = mcpContentToPiContent([{ type: "audio", data: "AA", mimeType: "audio/wav" }]);
|
|
153
|
+
assert.match((result.content[0] as { text: string }).text, /音频/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("resource 带 text 时内容直出", () => {
|
|
157
|
+
const result = mcpContentToPiContent([
|
|
158
|
+
{ type: "resource", resource: { uri: "file:///a.txt", text: "文件内容" } },
|
|
159
|
+
]);
|
|
160
|
+
assert.deepEqual(result.content, [{ type: "text", text: "文件内容" }]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("resource 带图片 blob 时转成图片块", () => {
|
|
164
|
+
const result = mcpContentToPiContent([
|
|
165
|
+
{ type: "resource", resource: { uri: "file:///a.png", mimeType: "image/png", blob: "AAAA" } },
|
|
166
|
+
]);
|
|
167
|
+
assert.deepEqual(result.content[0], { type: "image", data: "AAAA", mimeType: "image/png" });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("二进制 resource 降级成说明并标注体积", () => {
|
|
171
|
+
const result = mcpContentToPiContent([
|
|
172
|
+
{ type: "resource", resource: { uri: "file:///a.bin", mimeType: "application/octet-stream", blob: "AAECAw==" } },
|
|
173
|
+
]);
|
|
174
|
+
assert.match((result.content[0] as { text: string }).text, /a\.bin/);
|
|
175
|
+
assert.equal(result.notes.length, 1);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("resource_link 变成一行文本", () => {
|
|
179
|
+
const result = mcpContentToPiContent([{ type: "resource_link", uri: "https://x/a", name: "名字" }]);
|
|
180
|
+
assert.match((result.content[0] as { text: string }).text, /https:\/\/x\/a/);
|
|
181
|
+
assert.match((result.content[0] as { text: string }).text, /名字/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("未知内容块不静默丢弃", () => {
|
|
185
|
+
const result = mcpContentToPiContent([{ type: "wat", value: 1 }]);
|
|
186
|
+
assert.match((result.content[0] as { text: string }).text, /不支持的 MCP 内容块 wat/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("空数组 → 空内容(由调用方兜底)", () => {
|
|
190
|
+
assert.deepEqual(mcpContentToPiContent([]).content, []);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("truncateText", () => {
|
|
195
|
+
it("短文本原样返回", () => {
|
|
196
|
+
const result = truncateText("hello");
|
|
197
|
+
assert.equal(result.truncated, false);
|
|
198
|
+
assert.equal(result.text, "hello");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("超行数时保留开头并给出说明", () => {
|
|
202
|
+
const text = Array.from({ length: 10 }, (_, index) => `line ${index}`).join("\n");
|
|
203
|
+
const result = truncateText(text, { maxLines: 3 });
|
|
204
|
+
assert.equal(result.truncated, true);
|
|
205
|
+
assert.match(result.text, /line 0/);
|
|
206
|
+
assert.doesNotMatch(result.text, /line 4/);
|
|
207
|
+
assert.match(result.text, /输出已截断/);
|
|
208
|
+
assert.equal(result.totalLines, 10);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("超字节数时也在字符边界截断(不产生半个 UTF-8 字符)", () => {
|
|
212
|
+
const text = "中".repeat(100); // 每个 3 字节
|
|
213
|
+
const result = truncateText(text, { maxBytes: 30 });
|
|
214
|
+
assert.equal(result.truncated, true);
|
|
215
|
+
assert.ok(!result.text.includes("\uFFFD"));
|
|
216
|
+
assert.ok(result.text.startsWith("中"));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("报告原始体积与行数,便于模型判断要不要缩小查询", () => {
|
|
220
|
+
const result = truncateText(`${"x".repeat(100)}\n`, { maxBytes: 10 });
|
|
221
|
+
assert.equal(result.totalBytes, 101);
|
|
222
|
+
assert.equal(result.totalLines, 2);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("恰好等于上限时不截断", () => {
|
|
226
|
+
assert.equal(truncateText("abc", { maxBytes: 3, maxLines: 1 }).truncated, false);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("formatBytes", () => {
|
|
231
|
+
it("按量级给 B / KB / MB", () => {
|
|
232
|
+
assert.equal(formatBytes(512), "512B");
|
|
233
|
+
assert.equal(formatBytes(2048), "2.0KB");
|
|
234
|
+
assert.equal(formatBytes(3 * 1024 * 1024), "3.0MB");
|
|
235
|
+
});
|
|
236
|
+
});
|