@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,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp — 让 pi 用上本机 / 远程的 MCP 服务器(stdio、streamable HTTP、旧版 SSE)。
|
|
3
|
+
*
|
|
4
|
+
* 设计取舍(对齐用户「最简单够用」的要求):
|
|
5
|
+
* - **每个 MCP 工具直接注册成一个 pi 工具**,名字 `mcp__<server>__<tool>`(Claude Code 同款),
|
|
6
|
+
* 不走「一个 mcp 代理工具 + 参数指定 server/tool」那套(pi-mcp-adapter 的做法)——
|
|
7
|
+
* 工具少时直连对模型友好得多,代价只是 system prompt 变长。
|
|
8
|
+
* - **配置沿用 Claude Code 的 `.mcp.json` 形状**,全局 `~/.pi/agent/mcp.json` + 项目
|
|
9
|
+
* `.mcp.json`(项目同名覆盖全局)。用户已有的 `.mcp.json` 抄一份即可。
|
|
10
|
+
* - **会话开始时连接、会话结束断开**。工具表必须先 `tools/list` 才能注册,所以不能等到
|
|
11
|
+
* 首次调用才连;多个 server 并行握手,单个 server 失败只影响它自己。
|
|
12
|
+
* - 诊断信息(子进程 stderr、协议异常)**只进内存环形缓冲**,不写 stdout/stderr ——
|
|
13
|
+
* interactive pi 里往 stderr 写会糊在输入框上(见仓库里 subagent-log-guard 的存在理由)。
|
|
14
|
+
* 要看就用 `/mcp` 或 `/mcp <server>`。
|
|
15
|
+
*
|
|
16
|
+
* 命令:
|
|
17
|
+
* /mcp 当前 server / 工具 / 配置来源一览
|
|
18
|
+
* /mcp reload 重新读配置、重连、重注册工具(改完 mcp.json 不用重启 pi)
|
|
19
|
+
* /mcp <server> 单个 server 的详情与最近诊断输出
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { Type } from "typebox";
|
|
24
|
+
|
|
25
|
+
import { McpClient, type McpToolInfo } from "./client.ts";
|
|
26
|
+
import { loadMcpConfig, type McpConfigIssue, type McpServerConfig } from "./config.ts";
|
|
27
|
+
import { McpConnectionError, McpError } from "./protocol.ts";
|
|
28
|
+
import {
|
|
29
|
+
mcpContentToPiContent,
|
|
30
|
+
normalizeInputSchema,
|
|
31
|
+
piToolName,
|
|
32
|
+
toolDescription,
|
|
33
|
+
toolPromptSnippet,
|
|
34
|
+
truncateText,
|
|
35
|
+
type PiToolContent,
|
|
36
|
+
} from "./tools.ts";
|
|
37
|
+
|
|
38
|
+
/** 每个 server 保留多少行诊断信息(stderr / 协议异常)。 */
|
|
39
|
+
const DIAGNOSTIC_KEEP_LINES = 20;
|
|
40
|
+
|
|
41
|
+
type ServerStatus = "ready" | "disabled" | "error";
|
|
42
|
+
|
|
43
|
+
interface ServerState {
|
|
44
|
+
config: McpServerConfig;
|
|
45
|
+
status: ServerStatus;
|
|
46
|
+
client?: McpClient;
|
|
47
|
+
tools: McpToolInfo[];
|
|
48
|
+
error?: string;
|
|
49
|
+
diagnostics: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface McpRuntime {
|
|
53
|
+
cwd: string;
|
|
54
|
+
sources: string[];
|
|
55
|
+
issues: McpConfigIssue[];
|
|
56
|
+
servers: ServerState[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let runtime: McpRuntime | undefined;
|
|
60
|
+
|
|
61
|
+
export default function (pi: ExtensionAPI) {
|
|
62
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
63
|
+
await stopRuntime();
|
|
64
|
+
runtime = await startRuntime(pi, ctx);
|
|
65
|
+
const failures = runtime.servers.filter((server) => server.status === "error");
|
|
66
|
+
const problems = failures.length + runtime.issues.length;
|
|
67
|
+
if (problems > 0) {
|
|
68
|
+
const ready = runtime.servers.filter((server) => server.status === "ready").length;
|
|
69
|
+
const detail = [
|
|
70
|
+
`MCP:${runtime.servers.length} 个 server,${ready} 个就绪`,
|
|
71
|
+
...failures.map((server) => `${server.config.name}: ${server.error ?? "连接失败"}`),
|
|
72
|
+
...runtime.issues.map((issue) => `${issue.server ?? issue.source}: ${issue.message}`),
|
|
73
|
+
"用 /mcp 查看详情",
|
|
74
|
+
].join("\n");
|
|
75
|
+
ctx.ui.notify(detail, "warning");
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
pi.on("session_shutdown", async () => {
|
|
80
|
+
await stopRuntime();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
pi.registerCommand("mcp", {
|
|
84
|
+
description: "MCP 服务器状态;/mcp reload 重连;/mcp <server> 看单个 server 详情",
|
|
85
|
+
handler: async (args, ctx) => {
|
|
86
|
+
const argument = args.trim();
|
|
87
|
+
if (argument === "reload") {
|
|
88
|
+
if (runtime) ctx.ui.notify(`正在重连 ${runtime.servers.length} 个 MCP server…`, "info");
|
|
89
|
+
await stopRuntime();
|
|
90
|
+
runtime = await startRuntime(pi, ctx);
|
|
91
|
+
ctx.ui.notify(formatStatus(runtime, ctx), runtimeHasProblems(runtime) ? "warning" : "info");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (!runtime) {
|
|
95
|
+
ctx.ui.notify("MCP 尚未初始化(本会话启动时没有可用配置)", "warning");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (argument) {
|
|
99
|
+
const server = runtime.servers.find((candidate) => candidate.config.name === argument);
|
|
100
|
+
if (!server) {
|
|
101
|
+
ctx.ui.notify(`没有名为 "${argument}" 的 MCP server。用 /mcp 查看已配置的 server。`, "warning");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
ctx.ui.notify(formatServerDetail(server), server.status === "error" ? "error" : "info");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
ctx.ui.notify(formatStatus(runtime, ctx), runtimeHasProblems(runtime) ? "warning" : "info");
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function startRuntime(pi: ExtensionAPI, ctx: ExtensionContext): Promise<McpRuntime> {
|
|
113
|
+
const loaded = loadMcpConfig({ cwd: ctx.cwd });
|
|
114
|
+
const servers: ServerState[] = loaded.servers.map((config) => ({
|
|
115
|
+
config,
|
|
116
|
+
status: config.enabled ? "error" : "disabled",
|
|
117
|
+
tools: [],
|
|
118
|
+
error: config.enabled ? undefined : "配置里已禁用(enabled: false)",
|
|
119
|
+
diagnostics: [],
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
const enabled = servers.filter((server) => server.config.enabled);
|
|
123
|
+
// connectServer 只写状态、不抛异常:一个 server 挂了不能影响其它 server 和 pi 本体。
|
|
124
|
+
await Promise.all(enabled.map((server) => connectServer(pi, server)));
|
|
125
|
+
|
|
126
|
+
return { cwd: ctx.cwd, sources: loaded.sources, issues: loaded.issues, servers };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function connectServer(pi: ExtensionAPI, state: ServerState): Promise<void> {
|
|
130
|
+
const { config } = state;
|
|
131
|
+
try {
|
|
132
|
+
const client = await McpClient.connect(config, {
|
|
133
|
+
onDiagnostic: (line) => {
|
|
134
|
+
state.diagnostics.push(line);
|
|
135
|
+
if (state.diagnostics.length > DIAGNOSTIC_KEEP_LINES) state.diagnostics.shift();
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
state.client = client;
|
|
139
|
+
state.tools = await client.listTools();
|
|
140
|
+
state.status = "ready";
|
|
141
|
+
state.error = undefined;
|
|
142
|
+
registerTools(pi, state);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
state.status = "error";
|
|
145
|
+
state.error = describeError(error);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 把一个 MCP server 的工具全部注册成 pi 工具。
|
|
151
|
+
*
|
|
152
|
+
* 同名覆盖是 pi 的行为(`extension.tools` 是 Map),所以 `/mcp reload` 重复注册不会留下旧定义。
|
|
153
|
+
*/
|
|
154
|
+
function registerTools(pi: ExtensionAPI, state: ServerState): void {
|
|
155
|
+
const { config } = state;
|
|
156
|
+
for (const tool of state.tools) {
|
|
157
|
+
const name = piToolName(config.name, tool.name);
|
|
158
|
+
pi.registerTool({
|
|
159
|
+
name,
|
|
160
|
+
label: `MCP: ${tool.name}`,
|
|
161
|
+
description: toolDescription(config.name, tool),
|
|
162
|
+
promptSnippet: toolPromptSnippet(tool.description),
|
|
163
|
+
// MCP 给的是标准 JSON Schema;Type.Unsafe 让 pi 原样用它做参数校验。
|
|
164
|
+
parameters: Type.Unsafe(normalizeInputSchema(tool.inputSchema)),
|
|
165
|
+
async execute(_toolCallId, params, signal) {
|
|
166
|
+
const client = state.client;
|
|
167
|
+
if (!client || client.isClosed) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`MCP server "${config.name}" 未连接${state.error ? `(${state.error})` : ""}。运行 /mcp reload 重连。`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
const started = Date.now();
|
|
173
|
+
const args = (params ?? {}) as Record<string, unknown>;
|
|
174
|
+
const result = await client.callTool(tool.name, args, { signal });
|
|
175
|
+
const mapped = mcpContentToPiContent(result.content);
|
|
176
|
+
const truncated = truncateText(mapped.text);
|
|
177
|
+
const durationMs = Date.now() - started;
|
|
178
|
+
const content = buildToolContent(mapped.content, truncated.text, truncated.truncated);
|
|
179
|
+
|
|
180
|
+
if (result.isError) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`MCP 工具 ${config.name}/${tool.name} 返回错误:\n${truncated.text || "(无输出)"}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
content,
|
|
188
|
+
details: {
|
|
189
|
+
server: config.name,
|
|
190
|
+
tool: tool.name,
|
|
191
|
+
durationMs,
|
|
192
|
+
truncated: truncated.truncated,
|
|
193
|
+
totalBytes: truncated.totalBytes,
|
|
194
|
+
totalLines: truncated.totalLines,
|
|
195
|
+
notes: mapped.notes,
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 截断只作用于文本:图片块必须原样留下。
|
|
205
|
+
* 未截断时保持 MCP 给的块顺序(文本与图片的相对位置有意义),截断时退化成「一段文本 + 图片」。
|
|
206
|
+
*/
|
|
207
|
+
function buildToolContent(blocks: PiToolContent[], truncatedText: string, truncated: boolean): PiToolContent[] {
|
|
208
|
+
if (blocks.length === 0) return [{ type: "text", text: "(MCP 工具返回空内容)" }];
|
|
209
|
+
if (!truncated) return blocks;
|
|
210
|
+
return [{ type: "text", text: truncatedText }, ...blocks.filter((block) => block.type === "image")];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function stopRuntime(): Promise<void> {
|
|
214
|
+
const current = runtime;
|
|
215
|
+
runtime = undefined;
|
|
216
|
+
if (!current) return;
|
|
217
|
+
await Promise.all(current.servers.map((server) => server.client?.close().catch(() => {})));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function runtimeHasProblems(rt: McpRuntime): boolean {
|
|
221
|
+
return rt.issues.length > 0 || rt.servers.some((server) => server.status === "error");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function formatStatus(rt: McpRuntime, ctx: ExtensionContext): string {
|
|
225
|
+
const ready = rt.servers.filter((server) => server.status === "ready");
|
|
226
|
+
const toolCount = ready.reduce((total, server) => total + server.tools.length, 0);
|
|
227
|
+
const lines: string[] = [`MCP:${ready.length}/${rt.servers.length} 个 server 就绪,共 ${toolCount} 个工具`];
|
|
228
|
+
if (rt.servers.length === 0) {
|
|
229
|
+
lines.push(`(未发现配置。写一个 ~/.pi/agent/mcp.json 或项目根的 .mcp.json,然后 /mcp reload)`);
|
|
230
|
+
}
|
|
231
|
+
for (const server of rt.servers) {
|
|
232
|
+
const mark = server.status === "ready" ? "●" : server.status === "disabled" ? "○" : "✗";
|
|
233
|
+
const parts: string[] = [];
|
|
234
|
+
if (server.client) {
|
|
235
|
+
parts.push(shortTransport(server.config));
|
|
236
|
+
parts.push(`v${server.client.serverInfo.version ?? "?"}`);
|
|
237
|
+
}
|
|
238
|
+
if (server.status === "ready") parts.push(`${server.tools.length} tools`);
|
|
239
|
+
if (server.config.transport !== "stdio" && server.config.headersCommand) parts.push("headersCommand");
|
|
240
|
+
if (server.status === "error" && server.error) parts.push(firstLine(server.error));
|
|
241
|
+
lines.push(`${mark} ${server.config.name}${parts.length > 0 ? ` · ${parts.join(" · ")}` : ""}`);
|
|
242
|
+
}
|
|
243
|
+
for (const issue of rt.issues) {
|
|
244
|
+
lines.push(`⚠ ${issue.server ? `${issue.server}: ` : ""}${issue.message}${issue.server ? "" : ` (${issue.source})`}`);
|
|
245
|
+
}
|
|
246
|
+
if (rt.sources.length > 0) lines.push(`配置:${rt.sources.join(" ")}`);
|
|
247
|
+
lines.push(`工作目录:${rt.cwd}`);
|
|
248
|
+
return lines.join("\n");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function formatServerDetail(server: ServerState): string {
|
|
252
|
+
const lines: string[] = [];
|
|
253
|
+
lines.push(`${server.config.name}(${server.config.transport},${server.status})`);
|
|
254
|
+
const client = server.client;
|
|
255
|
+
if (client) {
|
|
256
|
+
lines.push(`server: ${client.serverInfo.name ?? "?"} ${client.serverInfo.version ?? ""} · 协议 ${client.protocolVersion}`);
|
|
257
|
+
lines.push(`连接: ${client.transportLabel}`);
|
|
258
|
+
lines.push(`能力: ${Object.keys(client.capabilities).join(", ") || "(无)"}`);
|
|
259
|
+
}
|
|
260
|
+
if (server.error) lines.push(`错误: ${server.error}`);
|
|
261
|
+
if (server.config.transport !== "stdio" && server.config.headersCommand) {
|
|
262
|
+
// 只展示命令本身(用户自己写的配置),头的**值**任何情况下都不打印。
|
|
263
|
+
const command = server.config.headersCommand;
|
|
264
|
+
lines.push(`头命令: ${command.length > 100 ? `${command.slice(0, 100)}…` : command}`);
|
|
265
|
+
}
|
|
266
|
+
if (server.tools.length > 0) {
|
|
267
|
+
lines.push(`工具(${server.tools.length}):`);
|
|
268
|
+
for (const tool of server.tools) {
|
|
269
|
+
const short = tool.description?.replace(/\s+/g, " ").slice(0, 60) ?? "";
|
|
270
|
+
lines.push(` mcp__${server.config.name}__${tool.name}${short ? ` — ${short}` : ""}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (server.diagnostics.length > 0) {
|
|
274
|
+
lines.push(`最近诊断(${server.diagnostics.length} 行):`);
|
|
275
|
+
for (const line of server.diagnostics.slice(-8)) lines.push(` ${firstLine(line).slice(0, 160)}`);
|
|
276
|
+
}
|
|
277
|
+
return lines.join("\n");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function describeError(error: unknown): string {
|
|
281
|
+
if (error instanceof McpConnectionError) return error.message;
|
|
282
|
+
if (error instanceof McpError) return `${error.message}(code ${error.code})`;
|
|
283
|
+
if (error instanceof Error) return error.message;
|
|
284
|
+
return String(error);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function firstLine(text: string): string {
|
|
288
|
+
const index = text.indexOf("\n");
|
|
289
|
+
return index === -1 ? text : text.slice(0, index);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** 状态行里的短传输标识:stdio 只写 "stdio",完整命令留给 `/mcp <server>` 与报错信息。 */
|
|
293
|
+
function shortTransport(config: McpServerConfig): string {
|
|
294
|
+
return config.transport === "stdio" ? "stdio" : `${config.transport} ${config.url}`;
|
|
295
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for protocol.ts — JSON-RPC 编解码与 SSE 分帧。
|
|
3
|
+
*
|
|
4
|
+
* Run with: node --test clients/pi/extensions/mcp/protocol.test.ts
|
|
5
|
+
*
|
|
6
|
+
* 这里只测「字节流 → 消息」的边界:半个 JSON、半个 SSE 事件、跨 chunk 的字段、\r\n。
|
|
7
|
+
* 真实服务端的握手成功与否由 client.test.ts 的 stdio 端到端用例负责。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { describe, it } from "node:test";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
createCancelledNotification,
|
|
15
|
+
createLineDecoder,
|
|
16
|
+
createSseDecoder,
|
|
17
|
+
encodeStdioFrame,
|
|
18
|
+
isJsonRpcRequestOrNotification,
|
|
19
|
+
isJsonRpcResponse,
|
|
20
|
+
JSONRPC_PARSE_ERROR,
|
|
21
|
+
McpError,
|
|
22
|
+
parseJsonRpcMessage,
|
|
23
|
+
parseSseBlock,
|
|
24
|
+
toErrorFromResponse,
|
|
25
|
+
} from "./protocol.ts";
|
|
26
|
+
|
|
27
|
+
describe("createLineDecoder", () => {
|
|
28
|
+
it("把一个 chunk 里的多行都切出来", () => {
|
|
29
|
+
const decode = createLineDecoder();
|
|
30
|
+
assert.deepEqual(decode("a\nb\nc\n"), ["a", "b", "c"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("半个 JSON 留在缓冲里,等下一块拼上再吐", () => {
|
|
34
|
+
const decode = createLineDecoder();
|
|
35
|
+
assert.deepEqual(decode('{"jsonrpc":"2.0",'), []);
|
|
36
|
+
assert.deepEqual(decode('"id":1}\n'), ['{"jsonrpc":"2.0","id":1}']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("兼容 \\r\\n", () => {
|
|
40
|
+
const decode = createLineDecoder();
|
|
41
|
+
assert.deepEqual(decode("a\r\nb\r\n"), ["a", "b"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("空行原样返回(调用方负责跳过)", () => {
|
|
45
|
+
const decode = createLineDecoder();
|
|
46
|
+
assert.deepEqual(decode("a\n\nb\n"), ["a", "", "b"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("一行跨三个 chunk 也能拼出来", () => {
|
|
50
|
+
const decode = createLineDecoder();
|
|
51
|
+
decode("hel");
|
|
52
|
+
decode("lo ");
|
|
53
|
+
assert.deepEqual(decode("world\n"), ["hello world"]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("createSseDecoder", () => {
|
|
58
|
+
it("按空行切事件,data 多行用 \\n 拼接", () => {
|
|
59
|
+
const decode = createSseDecoder();
|
|
60
|
+
const events = decode("event: message\ndata: a\ndata: b\n\n");
|
|
61
|
+
assert.equal(events.length, 1);
|
|
62
|
+
assert.equal(events[0]?.event, "message");
|
|
63
|
+
assert.equal(events[0]?.data, "a\nb");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("没有 event 字段的事件也算(MCP 的响应就是纯 data)", () => {
|
|
67
|
+
const decode = createSseDecoder();
|
|
68
|
+
const events = decode('data: {"jsonrpc":"2.0","id":1,"result":{}}\n\n');
|
|
69
|
+
assert.equal(events.length, 1);
|
|
70
|
+
assert.equal(events[0]?.event, undefined);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("半个事件留在缓冲里", () => {
|
|
74
|
+
const decode = createSseDecoder();
|
|
75
|
+
assert.deepEqual(decode('data: {"a":'), []);
|
|
76
|
+
assert.deepEqual(decode('1}\n\n'), [{ event: undefined, data: '{"a":1}', id: undefined }]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("忽略注释行(心跳)", () => {
|
|
80
|
+
const decode = createSseDecoder();
|
|
81
|
+
assert.deepEqual(decode(": ping\n\n"), []);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("\\r\\n 分隔的事件也能切", () => {
|
|
85
|
+
const decode = createSseDecoder();
|
|
86
|
+
const events = decode("data: x\r\n\r\n");
|
|
87
|
+
assert.equal(events[0]?.data, "x");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("一个 chunk 里的多个事件都吐出来", () => {
|
|
91
|
+
const decode = createSseDecoder();
|
|
92
|
+
const events = decode("data: 1\n\ndata: 2\n\n");
|
|
93
|
+
assert.deepEqual(events.map((event) => event.data), ["1", "2"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("id 字段被保留", () => {
|
|
97
|
+
const decode = createSseDecoder();
|
|
98
|
+
assert.equal(decode("id: 42\ndata: x\n\n")[0]?.id, "42");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("parseSseBlock", () => {
|
|
103
|
+
it("只有 data 的块直接可用", () => {
|
|
104
|
+
assert.deepEqual(parseSseBlock("data: hello"), { event: undefined, data: "hello", id: undefined });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("去掉冒号后的单个空格,保留后续空格", () => {
|
|
108
|
+
assert.equal(parseSseBlock("data: two spaces")?.data, " two spaces");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("没有 data 的块返回 undefined", () => {
|
|
112
|
+
assert.equal(parseSseBlock("event: ping"), undefined);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("encodeStdioFrame", () => {
|
|
117
|
+
it("一行 JSON 加换行,消息里不会出现裸换行", () => {
|
|
118
|
+
const frame = encodeStdioFrame({ jsonrpc: "2.0", id: 1, method: "x", params: { text: "a\nb" } });
|
|
119
|
+
assert.ok(frame.endsWith("\n"));
|
|
120
|
+
assert.equal(frame.trimEnd().split("\n").length, 1);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("parseJsonRpcMessage", () => {
|
|
125
|
+
it("解析成功", () => {
|
|
126
|
+
const message = parseJsonRpcMessage('{"jsonrpc":"2.0","id":1,"result":{"ok":true}}');
|
|
127
|
+
assert.ok(isJsonRpcResponse(message));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("坏 JSON 抛带 parse error 码的 McpError", () => {
|
|
131
|
+
assert.throws(
|
|
132
|
+
() => parseJsonRpcMessage("{oops"),
|
|
133
|
+
(error: unknown) => error instanceof McpError && error.code === JSONRPC_PARSE_ERROR,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("数组/字面量不是合法 JSON-RPC 消息", () => {
|
|
138
|
+
assert.throws(() => parseJsonRpcMessage("[1,2]"), McpError);
|
|
139
|
+
assert.throws(() => parseJsonRpcMessage("42"), McpError);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("isJsonRpcResponse / isJsonRpcRequestOrNotification", () => {
|
|
144
|
+
it("按 method 字段区分响应与请求", () => {
|
|
145
|
+
assert.ok(isJsonRpcResponse({ jsonrpc: "2.0", id: 1, result: {} } as never));
|
|
146
|
+
assert.ok(isJsonRpcResponse({ jsonrpc: "2.0", id: 1, error: { code: 1, message: "x" } } as never));
|
|
147
|
+
assert.ok(!isJsonRpcResponse({ jsonrpc: "2.0", id: 1, method: "tools/list" } as never));
|
|
148
|
+
assert.ok(isJsonRpcRequestOrNotification({ jsonrpc: "2.0", method: "notifications/initialized" } as never));
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("toErrorFromResponse", () => {
|
|
153
|
+
it("把 data 拼进 message", () => {
|
|
154
|
+
const error = toErrorFromResponse({ code: -32602, message: "bad params", data: { field: "x" } });
|
|
155
|
+
assert.equal(error.code, -32602);
|
|
156
|
+
assert.match(error.message, /bad params/);
|
|
157
|
+
assert.match(error.message, /field/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("data 过长时截断,别让错误信息本身撑爆日志", () => {
|
|
161
|
+
const error = toErrorFromResponse({ code: 1, message: "x", data: "y".repeat(2000) });
|
|
162
|
+
assert.ok(error.message.length < 600);
|
|
163
|
+
assert.match(error.message, /…/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("循环引用的 data 不会让拼消息本身抛异常", () => {
|
|
167
|
+
const data: Record<string, unknown> = {};
|
|
168
|
+
data.self = data;
|
|
169
|
+
assert.doesNotThrow(() => toErrorFromResponse({ code: 1, message: "x", data }));
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("createCancelledNotification", () => {
|
|
174
|
+
it("形状符合 notifications/cancelled", () => {
|
|
175
|
+
const notification = createCancelledNotification(7, "timeout");
|
|
176
|
+
assert.equal(notification.method, "notifications/cancelled");
|
|
177
|
+
assert.deepEqual(notification.params, { requestId: 7, reason: "timeout" });
|
|
178
|
+
});
|
|
179
|
+
});
|