@inobit/pi-instabox 0.0.0-stage → 0.1.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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +214 -2
- package/README.zh-CN.md +215 -0
- package/package.json +64 -3
- package/src/boxes.ts +158 -0
- package/src/cli.ts +440 -0
- package/src/config.ts +310 -0
- package/src/devtools.ts +257 -0
- package/src/entry-render.ts +42 -0
- package/src/index.ts +1172 -0
- package/src/log.ts +203 -0
- package/src/pool.ts +651 -0
- package/src/remote.ts +678 -0
- package/src/resolve.ts +216 -0
- package/src/schema.ts +370 -0
- package/src/session-target.ts +100 -0
- package/src/template.ts +473 -0
- package/src/tools.ts +362 -0
package/src/remote.ts
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @inobit/pi-instabox — 远端 stdio MCP 客户端(方案 §4.7 D7)。
|
|
3
|
+
*
|
|
4
|
+
* 契约:
|
|
5
|
+
* - 只做 **stdio** 双工:调用方给命令(如 `instabox ssh <box> -- instabox-computer-use mcp`),
|
|
6
|
+
* 本模块负责 `spawn`、JSON-RPC 2.0 请求表、超时、abort、dead 标记。它不认识 box,
|
|
7
|
+
* 也不做 JSON-RPC over HTTP。
|
|
8
|
+
* - 分帧:**读出侧同时容忍两种形态**——换行分隔 JSON(MCP stdio 标准)与
|
|
9
|
+
* `Content-Length: N\r\n\r\n{...}` 头(LSP 风格);首个字节决定模式并锁定,未定前只缓冲。
|
|
10
|
+
* 写入侧固定「单行 JSON + `\n`」(MCP stdio 标准)。T10 spike 实测(`instabox ssh general --
|
|
11
|
+
* instabox-computer-use mcp`):首帧是以 `{"id":1,"jsonrpc":"2.0","result":…}` 开头的
|
|
12
|
+
* 单行 JSON + `\n`,**没有** `Content-Length` 头;头帧容忍作为后备保留。
|
|
13
|
+
* - 握手:`initialize`(`protocolVersion`/`capabilities`/`clientInfo`)→ `notifications/initialized`
|
|
14
|
+
* (**无 id**)→ 之后每个请求 `id` 从 1 起单调自增。
|
|
15
|
+
* - 失败:超时 / 子进程 `close`/`error` / `signal` abort → reject **全部** pending 并置 `dead`;
|
|
16
|
+
* 调用方(`pool.ts`)据 `isDead()` 重建。abort 对**建连与握手期**同样生效
|
|
17
|
+
* (`initialize(signal)` 把 signal 透传到 `initialize` 请求),否则 abort 后仍要白等
|
|
18
|
+
* connect + initialize 两段预算。任何异常只以 {@link RemoteError} 出现,
|
|
19
|
+
* 绝不 throw 到会话层(由调用方转成工具结果 `isError: true`)。
|
|
20
|
+
* - 注入面:`spawn` / `clock` / `now` 全部可替,单测用内存假流,**不 spawn 真进程**。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
24
|
+
import { logEvent } from "./log.ts";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 请求的 MCP 协议版本。取 T10 spike 实测的远端版本(`open-computer-use` 0.3.6-instabox.1
|
|
28
|
+
* 回 `2025-03-26`);远端总是回自己的版本,本客户端不校验版本号,只按返回体行事。
|
|
29
|
+
*/
|
|
30
|
+
export const MCP_PROTOCOL_VERSION = "2025-03-26";
|
|
31
|
+
|
|
32
|
+
/** `initialize` 的 `clientInfo`(`version` 仅作排障标识,不参与协议协商)。 */
|
|
33
|
+
export const CLIENT_INFO = { name: "@inobit/pi-instabox", version: "0.1.0" } as const;
|
|
34
|
+
|
|
35
|
+
/** 单次连接的预算表(毫秒),语义见各字段。 */
|
|
36
|
+
export interface RemoteTimeouts {
|
|
37
|
+
/** spawn 到「远端进程吐出第一个字节」(ssh 挂住/认证失败都在这里暴露) */
|
|
38
|
+
connect: number;
|
|
39
|
+
/** `initialize` 请求发出到响应 */
|
|
40
|
+
initialize: number;
|
|
41
|
+
/** `tools/list` 请求发出到响应 */
|
|
42
|
+
listTools: number;
|
|
43
|
+
/** `tools/call` 请求发出到响应(桌面操作本身可能很慢) */
|
|
44
|
+
callTool: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 默认预算(方案 §4.7 ⑤)。
|
|
49
|
+
*
|
|
50
|
+
* 本模块只认这张表与调用方显式传入的 `timeouts` 覆盖;**配置不在这里读**——T11 接入时由
|
|
51
|
+
* `config.timeouts`(`connectMs`/`initializeMs`/`listToolsMs`/`callMs`)单向喂入
|
|
52
|
+
* `createRemoteClient({ timeouts })`,方向只有配置 → 客户端。
|
|
53
|
+
*/
|
|
54
|
+
export const DEFAULT_TIMEOUTS: RemoteTimeouts = {
|
|
55
|
+
connect: 15_000,
|
|
56
|
+
initialize: 20_000,
|
|
57
|
+
listTools: 20_000,
|
|
58
|
+
callTool: 120_000,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** 错误分类:调用方据此决定「重连 / 报取消 / 报远端错误」。 */
|
|
62
|
+
export type RemoteErrorCode = "spawn" | "protocol" | "timeout" | "closed" | "aborted" | "rpc";
|
|
63
|
+
|
|
64
|
+
/** 本模块唯一抛出的错误类型(带 `code`,绝不裸 Error/字符串)。 */
|
|
65
|
+
export class RemoteError extends Error {
|
|
66
|
+
readonly code: RemoteErrorCode;
|
|
67
|
+
|
|
68
|
+
constructor(message: string, code: RemoteErrorCode) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.name = "RemoteError";
|
|
71
|
+
this.code = code;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** spawn 选项(只带本模块用得到的字段)。 */
|
|
76
|
+
export interface SpawnOptions {
|
|
77
|
+
/** 子进程环境变量(整表替换,用于给 `instabox` 注入 PATH) */
|
|
78
|
+
env?: Record<string, string>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 子进程最小接口:只声明本模块用到的成员,便于注入内存假流。 */
|
|
82
|
+
export interface RemoteChild {
|
|
83
|
+
/** 写入端(协议帧一律单行 JSON + `\n`) */
|
|
84
|
+
readonly stdin: { write(chunk: string): unknown } | null;
|
|
85
|
+
/** 读出端(`data` 可能给 Buffer 或 string,分帧器两种都收) */
|
|
86
|
+
readonly stdout: { on(event: "data", listener: (chunk: Buffer | string) => void): unknown } | null;
|
|
87
|
+
/** 诊断端(只用于把失败原因带进错误文本) */
|
|
88
|
+
readonly stderr?: { on(event: "data", listener: (chunk: Buffer | string) => void): unknown } | null;
|
|
89
|
+
on(event: "close", listener: (code: number | null, signal: string | null) => void): unknown;
|
|
90
|
+
on(event: "error", listener: (error: Error) => void): unknown;
|
|
91
|
+
kill?(signal?: string): unknown;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 注入的 spawn:默认 {@link defaultSpawn}(`node:child_process`),单测一律替换。 */
|
|
95
|
+
export type SpawnFn = (command: string, args: readonly string[], options: SpawnOptions) => RemoteChild;
|
|
96
|
+
|
|
97
|
+
/** 注入的定时器:默认全局 `setTimeout`/`clearTimeout`,单测用假时钟。 */
|
|
98
|
+
export interface RemoteClock {
|
|
99
|
+
setTimeout(handler: () => void, ms: number): unknown;
|
|
100
|
+
clearTimeout(handle: unknown): void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 远端工具(`tools/list` 的一项,schema 原样透传,不在本模块转 TypeBox)。 */
|
|
104
|
+
export interface RemoteTool {
|
|
105
|
+
name: string;
|
|
106
|
+
description?: string;
|
|
107
|
+
/** 远端 `inputSchema` 原样;远端缺省时给空 object schema,调用方仍能拿到参数骨架 */
|
|
108
|
+
inputSchema: Record<string, unknown>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `tools/call` 结果原样(`content`/`isError` 由调用方渲染,本模块不改写)。 */
|
|
112
|
+
export interface CallToolResult extends Record<string, unknown> {
|
|
113
|
+
content?: unknown[];
|
|
114
|
+
isError?: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** `initialize` 结果原样(`serverInfo.version` 进 per-box 模板缓存的版本比对)。 */
|
|
118
|
+
export interface InitializeResult extends Record<string, unknown> {
|
|
119
|
+
protocolVersion?: string;
|
|
120
|
+
capabilities?: Record<string, unknown>;
|
|
121
|
+
serverInfo?: { name?: string; version?: string };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 远端 MCP 客户端(单次连接一实例;失效后由 `pool.ts` 丢弃重建)。 */
|
|
125
|
+
export interface RemoteClient {
|
|
126
|
+
/**
|
|
127
|
+
* 握手(幂等:重复调用共享同一次握手);返回远端 `initialize` 结果原样。
|
|
128
|
+
*
|
|
129
|
+
* `signal` 只作用于**真正发起握手的那一次**调用:握手进行中 abort 会立即 reject
|
|
130
|
+
* (`code: "aborted"`)并置 dead,不必等 connect/initialize 预算耗尽。
|
|
131
|
+
*/
|
|
132
|
+
initialize(signal?: AbortSignal): Promise<InitializeResult>;
|
|
133
|
+
/** `tools/list`,返回 `{ name, description?, inputSchema }[]` */
|
|
134
|
+
listTools(signal?: AbortSignal): Promise<RemoteTool[]>;
|
|
135
|
+
/** `tools/call`,返回远端结果原样(含 `content`/`isError`) */
|
|
136
|
+
callTool(name: string, args?: Record<string, unknown>, signal?: AbortSignal): Promise<CallToolResult>;
|
|
137
|
+
/** 主动关停:reject 全部 pending、杀子进程、置 dead */
|
|
138
|
+
close(): void;
|
|
139
|
+
/** 连接是否已失效(失效后任何请求立即 rejected,调用方应重建) */
|
|
140
|
+
isDead(): boolean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** `createRemoteClient` 的入参。 */
|
|
144
|
+
export interface RemoteClientOptions {
|
|
145
|
+
/** 要 spawn 的可执行文件(如 `instabox`) */
|
|
146
|
+
command: string;
|
|
147
|
+
/** argv(如 `["ssh", box, "--", "instabox-computer-use", "mcp"]`) */
|
|
148
|
+
args?: readonly string[];
|
|
149
|
+
/** 子进程环境变量(默认继承当前进程) */
|
|
150
|
+
env?: Record<string, string>;
|
|
151
|
+
/** 注入的 spawn,默认 `node:child_process.spawn` */
|
|
152
|
+
spawn?: SpawnFn;
|
|
153
|
+
/** 注入的定时器,默认全局 */
|
|
154
|
+
clock?: RemoteClock;
|
|
155
|
+
/** 注入的时钟(毫秒),只用于错误文本里的耗时描述 */
|
|
156
|
+
now?: () => number;
|
|
157
|
+
/** 覆盖 {@link DEFAULT_TIMEOUTS} 的部分字段 */
|
|
158
|
+
timeouts?: Partial<RemoteTimeouts>;
|
|
159
|
+
/** 诊断回调(坏帧/无法解析的行只在这里报,不 throw) */
|
|
160
|
+
debug?: (message: string) => void;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 单帧解析完成后的回调。 */
|
|
164
|
+
export type FrameHandler = (message: unknown) => void;
|
|
165
|
+
|
|
166
|
+
/** 无法成帧/无法解析时的回调(`raw` 已截断,仅供排查)。 */
|
|
167
|
+
export type BadFrameHandler = (raw: string, error: unknown) => void;
|
|
168
|
+
|
|
169
|
+
/** `content-length:` 前缀(模式探测用,小写比较)。 */
|
|
170
|
+
const CONTENT_LENGTH_PREFIX = "content-length:";
|
|
171
|
+
|
|
172
|
+
/** 头块无终止符时的容忍上限(超过即判坏帧并重新探测模式)。 */
|
|
173
|
+
const HEADER_BLOCK_LIMIT = 65_536;
|
|
174
|
+
|
|
175
|
+
/** 坏帧/坏行进 debug 文本时的截断长度。 */
|
|
176
|
+
const BAD_FRAME_RAW_LIMIT = 200;
|
|
177
|
+
|
|
178
|
+
/** stderr 尾部保留字符数(失败原因用)。 */
|
|
179
|
+
const STDERR_TAIL_CHARS = 2_000;
|
|
180
|
+
|
|
181
|
+
/** stderr 尾部最多带几行进错误文本。 */
|
|
182
|
+
const STDERR_TAIL_LINES = 10;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* stdio 分帧器:把任意切分的字节流还原成 JSON 帧。
|
|
186
|
+
*
|
|
187
|
+
* - 模式由**首个字节**决定:缓冲以 `content-length:` 开头(大小写不敏感)→ 头帧;
|
|
188
|
+
* 否则出现 `\n` → 行帧。判定前只缓冲,不猜。
|
|
189
|
+
* - 模式锁定后不再回退(同一连接只用一种分帧)。
|
|
190
|
+
* - 半行/半帧留在缓冲里,等下一 chunk 拼接;截断位置可以落在多字节 UTF-8 字符中间
|
|
191
|
+
* (全程按字节处理,只有成帧后才 decode)。
|
|
192
|
+
* - 坏帧(JSON 解析失败、头块缺 `Content-Length`)只回调 `onBadFrame`,**不中断连接**。
|
|
193
|
+
*/
|
|
194
|
+
export class StdioFramer {
|
|
195
|
+
private mode: "unknown" | "lines" | "headers" = "unknown";
|
|
196
|
+
private buffer: Buffer = Buffer.alloc(0);
|
|
197
|
+
private readonly onFrame: FrameHandler;
|
|
198
|
+
private readonly onBadFrame: BadFrameHandler | undefined;
|
|
199
|
+
|
|
200
|
+
constructor(options: { onFrame: FrameHandler; onBadFrame?: BadFrameHandler }) {
|
|
201
|
+
this.onFrame = options.onFrame;
|
|
202
|
+
this.onBadFrame = options.onBadFrame;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 喂一段字节(string 按 UTF-8 编码);能成的帧同步回调出去。 */
|
|
206
|
+
push(chunk: string | Uint8Array): void {
|
|
207
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk);
|
|
208
|
+
if (bytes.length === 0) return;
|
|
209
|
+
this.buffer = this.buffer.length === 0 ? bytes : Buffer.concat([this.buffer, bytes]);
|
|
210
|
+
for (;;) {
|
|
211
|
+
if (this.mode === "unknown") {
|
|
212
|
+
const detected = this.detectMode();
|
|
213
|
+
// 字节还不够判定:留缓冲等下一 chunk("Content-Le" 既可能是头也可能是坏行)
|
|
214
|
+
if (detected === "unknown") return;
|
|
215
|
+
this.mode = detected;
|
|
216
|
+
}
|
|
217
|
+
const frame = this.mode === "headers" ? this.takeHeaderFrame() : this.takeLineFrame();
|
|
218
|
+
if (frame === undefined) return; // 帧不完整
|
|
219
|
+
if (frame === null) continue; // 空行/坏头已丢弃,继续找下一帧
|
|
220
|
+
this.emit(frame);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 连接失效时丢弃在途字节与已探测的模式。 */
|
|
225
|
+
reset(): void {
|
|
226
|
+
this.mode = "unknown";
|
|
227
|
+
this.buffer = Buffer.alloc(0);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private detectMode(): "unknown" | "lines" | "headers" {
|
|
231
|
+
const headLen = Math.min(this.buffer.length, CONTENT_LENGTH_PREFIX.length);
|
|
232
|
+
const head = this.buffer.toString("latin1", 0, headLen).toLowerCase();
|
|
233
|
+
if (CONTENT_LENGTH_PREFIX.startsWith(head)) {
|
|
234
|
+
return headLen < CONTENT_LENGTH_PREFIX.length ? "unknown" : "headers";
|
|
235
|
+
}
|
|
236
|
+
return this.buffer.includes(0x0a) ? "lines" : "unknown";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 取一行(去掉行尾 `\n` 与可能的 `\r`);`undefined` 表示帧未完整。 */
|
|
240
|
+
private takeLineFrame(): Buffer | null | undefined {
|
|
241
|
+
const end = this.buffer.indexOf(0x0a);
|
|
242
|
+
if (end < 0) return undefined;
|
|
243
|
+
let line = this.buffer.subarray(0, end);
|
|
244
|
+
this.buffer = this.buffer.subarray(end + 1);
|
|
245
|
+
if (line.length > 0 && line[line.length - 1] === 0x0d) line = line.subarray(0, line.length - 1);
|
|
246
|
+
return line.length === 0 ? null : line;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** 按 `Content-Length` 取一帧;`undefined` 表示头或体还没收全。 */
|
|
250
|
+
private takeHeaderFrame(): Buffer | null | undefined {
|
|
251
|
+
const block = this.headerBlockEnd();
|
|
252
|
+
if (block === undefined) {
|
|
253
|
+
if (this.buffer.length <= HEADER_BLOCK_LIMIT) return undefined;
|
|
254
|
+
const raw = this.buffer.toString("utf8").slice(0, BAD_FRAME_RAW_LIMIT);
|
|
255
|
+
this.reset();
|
|
256
|
+
this.onBadFrame?.(raw, new Error("content-length header block not terminated"));
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
const headerText = this.buffer.toString("latin1", 0, block.headerEnd);
|
|
260
|
+
const match = /content-length:[ \t]*(\d+)/i.exec(headerText);
|
|
261
|
+
const lengthText = match?.[1];
|
|
262
|
+
if (lengthText === undefined) {
|
|
263
|
+
this.buffer = this.buffer.subarray(block.bodyStart);
|
|
264
|
+
this.onBadFrame?.(headerText.slice(0, BAD_FRAME_RAW_LIMIT), new Error("missing content-length header"));
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
const length = Number(lengthText);
|
|
268
|
+
if (this.buffer.length - block.bodyStart < length) return undefined;
|
|
269
|
+
const body = this.buffer.subarray(block.bodyStart, block.bodyStart + length);
|
|
270
|
+
this.buffer = this.buffer.subarray(block.bodyStart + length);
|
|
271
|
+
return body.length === 0 ? null : body;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** 头块终止位置:`\r\n\r\n` 或 `\n\n` 取更靠前者。 */
|
|
275
|
+
private headerBlockEnd(): { headerEnd: number; bodyStart: number } | undefined {
|
|
276
|
+
const crlf = this.buffer.indexOf("\r\n\r\n");
|
|
277
|
+
const lf = this.buffer.indexOf("\n\n");
|
|
278
|
+
let best: { headerEnd: number; bodyStart: number } | undefined;
|
|
279
|
+
if (crlf >= 0) best = { headerEnd: crlf, bodyStart: crlf + 4 };
|
|
280
|
+
if (lf >= 0 && (best === undefined || lf < best.headerEnd)) best = { headerEnd: lf, bodyStart: lf + 2 };
|
|
281
|
+
return best;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private emit(body: Buffer): void {
|
|
285
|
+
const text = body.toString("utf8").trim();
|
|
286
|
+
if (text === "") return;
|
|
287
|
+
let message: unknown;
|
|
288
|
+
try {
|
|
289
|
+
message = JSON.parse(text);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
this.onBadFrame?.(text.slice(0, BAD_FRAME_RAW_LIMIT), error);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
this.onFrame(message);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** 一次在途请求(`id` 只用于 map 键,`method` 只用于错误文本)。 */
|
|
299
|
+
interface PendingRequest {
|
|
300
|
+
readonly method: string;
|
|
301
|
+
readonly timer: unknown;
|
|
302
|
+
readonly signal: AbortSignal | undefined;
|
|
303
|
+
readonly onAbort: () => void;
|
|
304
|
+
readonly resolve: (result: unknown) => void;
|
|
305
|
+
readonly reject: (error: RemoteError) => void;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** 默认 spawn:`node:child_process`,三根管道全开(唯一需要类型断言的地方)。 */
|
|
309
|
+
const defaultSpawn: SpawnFn = (command, args, options) =>
|
|
310
|
+
nodeSpawn(command, [...args], { stdio: ["pipe", "pipe", "pipe"], env: options.env }) as unknown as RemoteChild;
|
|
311
|
+
|
|
312
|
+
/** 默认定时器:走全局,宿主/单测都可整体替换。 */
|
|
313
|
+
const defaultClock: RemoteClock = {
|
|
314
|
+
setTimeout: (handler, ms) => globalThis.setTimeout(handler, ms),
|
|
315
|
+
clearTimeout: (handle) => {
|
|
316
|
+
globalThis.clearTimeout(handle as ReturnType<typeof globalThis.setTimeout>);
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
/** `unknown` → message 文本(错误文本里不出现 `[object Object]`)。 */
|
|
321
|
+
function messageOf(error: unknown): string {
|
|
322
|
+
return error instanceof Error ? error.message : String(error);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** 宽松对象判定(远端结果一律先过这里再取字段)。 */
|
|
326
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
327
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** 远端缺 `inputSchema` 时的空骨架(每次新建,避免调用方改到共享对象)。 */
|
|
331
|
+
function emptyInputSchema(): Record<string, unknown> {
|
|
332
|
+
return { type: "object", properties: {} };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** `tools/list` 数组 → {@link RemoteTool}[](坏条目静默跳过,缺 schema 给空骨架)。 */
|
|
336
|
+
function toRemoteTools(entries: readonly unknown[]): RemoteTool[] {
|
|
337
|
+
const tools: RemoteTool[] = [];
|
|
338
|
+
for (const entry of entries) {
|
|
339
|
+
if (!isRecord(entry)) continue;
|
|
340
|
+
const name = entry["name"];
|
|
341
|
+
if (typeof name !== "string" || name === "") continue;
|
|
342
|
+
const description = entry["description"];
|
|
343
|
+
const inputSchema = entry["inputSchema"];
|
|
344
|
+
tools.push({
|
|
345
|
+
name,
|
|
346
|
+
description: typeof description === "string" ? description : undefined,
|
|
347
|
+
inputSchema: isRecord(inputSchema) ? inputSchema : emptyInputSchema(),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return tools;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** JSON-RPC 错误响应 → {@link RemoteError}(只影响该请求,不置 dead)。 */
|
|
354
|
+
function rpcError(method: string, error: unknown): RemoteError {
|
|
355
|
+
if (!isRecord(error)) return new RemoteError(`${method} failed: ${String(error)}`, "rpc");
|
|
356
|
+
const code = error["code"];
|
|
357
|
+
const message = error["message"];
|
|
358
|
+
const detail = typeof message === "string" ? message : "unknown error";
|
|
359
|
+
const label = typeof code === "number" ? `code ${code}: ` : "";
|
|
360
|
+
return new RemoteError(`${method} failed: ${label}${detail}`, "rpc");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* 远端 stdio MCP 客户端实现。
|
|
365
|
+
*
|
|
366
|
+
* 状态机只有三态:未 spawn → 已 spawn(握手/可用)→ dead(终态,**不自动重连**,
|
|
367
|
+
* 重连由 `pool.ts` 重建实例完成)。
|
|
368
|
+
*/
|
|
369
|
+
class RemoteStdioClient implements RemoteClient {
|
|
370
|
+
private readonly spawnFn: SpawnFn;
|
|
371
|
+
private readonly command: string;
|
|
372
|
+
private readonly args: readonly string[];
|
|
373
|
+
private readonly env: Record<string, string> | undefined;
|
|
374
|
+
private readonly clock: RemoteClock;
|
|
375
|
+
private readonly now: () => number;
|
|
376
|
+
private readonly timeouts: RemoteTimeouts;
|
|
377
|
+
private readonly debug: ((message: string) => void) | undefined;
|
|
378
|
+
private readonly framer: StdioFramer;
|
|
379
|
+
private readonly pending = new Map<number, PendingRequest>();
|
|
380
|
+
private child: RemoteChild | undefined;
|
|
381
|
+
private exited = false;
|
|
382
|
+
private dead = false;
|
|
383
|
+
private nextId = 1;
|
|
384
|
+
private connectTimer: unknown;
|
|
385
|
+
private stderrTail = "";
|
|
386
|
+
private handshake: Promise<InitializeResult> | undefined;
|
|
387
|
+
|
|
388
|
+
constructor(options: RemoteClientOptions) {
|
|
389
|
+
this.spawnFn = options.spawn ?? defaultSpawn;
|
|
390
|
+
this.command = options.command;
|
|
391
|
+
this.args = options.args ?? [];
|
|
392
|
+
this.env = options.env;
|
|
393
|
+
this.clock = options.clock ?? defaultClock;
|
|
394
|
+
this.now = options.now ?? Date.now;
|
|
395
|
+
this.timeouts = { ...DEFAULT_TIMEOUTS, ...options.timeouts };
|
|
396
|
+
this.debug = options.debug;
|
|
397
|
+
this.framer = new StdioFramer({
|
|
398
|
+
onFrame: (message) => this.onFrame(message),
|
|
399
|
+
onBadFrame: (raw, error) => this.debug?.(`discarding malformed MCP frame (${messageOf(error)}): ${raw}`),
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
initialize(signal?: AbortSignal): Promise<InitializeResult> {
|
|
404
|
+
// 首次调用决定握手的 signal;并发调用共享同一个 Promise(幂等)
|
|
405
|
+
this.handshake ??= this.runHandshake(signal);
|
|
406
|
+
return this.handshake;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async listTools(signal?: AbortSignal): Promise<RemoteTool[]> {
|
|
410
|
+
await this.initialize(signal);
|
|
411
|
+
const startedAt = this.now();
|
|
412
|
+
try {
|
|
413
|
+
const result = await this.request("tools/list", {}, this.timeouts.listTools, signal);
|
|
414
|
+
if (!isRecord(result) || !Array.isArray(result["tools"])) {
|
|
415
|
+
throw this.protocolFailure("tools/list returned no tools array");
|
|
416
|
+
}
|
|
417
|
+
const tools = toRemoteTools(result["tools"]);
|
|
418
|
+
logEvent("remote.listTools", { ok: true, count: tools.length, ms: this.now() - startedAt });
|
|
419
|
+
return tools;
|
|
420
|
+
} catch (error) {
|
|
421
|
+
logEvent("remote.listTools", { ok: false, count: 0, ms: this.now() - startedAt });
|
|
422
|
+
throw error;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async callTool(
|
|
427
|
+
name: string,
|
|
428
|
+
args: Record<string, unknown> = {},
|
|
429
|
+
signal?: AbortSignal,
|
|
430
|
+
): Promise<CallToolResult> {
|
|
431
|
+
await this.initialize(signal);
|
|
432
|
+
const startedAt = this.now();
|
|
433
|
+
try {
|
|
434
|
+
const result = await this.request("tools/call", { name, arguments: args }, this.timeouts.callTool, signal);
|
|
435
|
+
if (!isRecord(result)) throw this.protocolFailure(`tools/call ${name} returned a non-object result`);
|
|
436
|
+
const call = result as CallToolResult;
|
|
437
|
+
// `isError: true` 是远端报的业务失败(协议上仍是成功响应),单独记一笔便于对比成功率
|
|
438
|
+
logEvent("remote.call", {
|
|
439
|
+
tool: name,
|
|
440
|
+
ok: true,
|
|
441
|
+
ms: this.now() - startedAt,
|
|
442
|
+
isError: call.isError === true ? true : undefined,
|
|
443
|
+
});
|
|
444
|
+
return call;
|
|
445
|
+
} catch (error) {
|
|
446
|
+
logEvent("remote.call", { tool: name, ok: false, ms: this.now() - startedAt });
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
close(): void {
|
|
452
|
+
this.failAll(new RemoteError("remote client closed by caller", "closed"));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
isDead(): boolean {
|
|
456
|
+
return this.dead;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* 握手:spawn → `initialize`(带自身预算)→ `notifications/initialized`(无 id)。
|
|
461
|
+
* `signal` 透传给 `initialize` 请求:建连/握手期 abort 也能立即中断。
|
|
462
|
+
*/
|
|
463
|
+
private async runHandshake(signal?: AbortSignal): Promise<InitializeResult> {
|
|
464
|
+
if (this.dead) throw this.refuseDead("initialize");
|
|
465
|
+
const startedAt = this.now();
|
|
466
|
+
try {
|
|
467
|
+
this.startChild();
|
|
468
|
+
const result = await this.request(
|
|
469
|
+
"initialize",
|
|
470
|
+
{ protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, clientInfo: { ...CLIENT_INFO } },
|
|
471
|
+
this.timeouts.initialize,
|
|
472
|
+
signal,
|
|
473
|
+
);
|
|
474
|
+
if (!isRecord(result)) throw this.protocolFailure("initialize returned a non-object result");
|
|
475
|
+
this.notify("notifications/initialized", {});
|
|
476
|
+
const initialized = result as InitializeResult;
|
|
477
|
+
logEvent("remote.initialize", {
|
|
478
|
+
ok: true,
|
|
479
|
+
ms: this.now() - startedAt,
|
|
480
|
+
protocolVersion: typeof initialized.protocolVersion === "string" ? initialized.protocolVersion : undefined,
|
|
481
|
+
serverInfoVersion:
|
|
482
|
+
typeof initialized.serverInfo?.version === "string" ? initialized.serverInfo.version : undefined,
|
|
483
|
+
});
|
|
484
|
+
return initialized;
|
|
485
|
+
} catch (error) {
|
|
486
|
+
const remoteError = error instanceof RemoteError ? error : new RemoteError(messageOf(error), "protocol");
|
|
487
|
+
// 握手失败即整条连接不可用:标记 dead,让 pool 下次重建
|
|
488
|
+
this.failAll(remoteError);
|
|
489
|
+
logEvent("remote.initialize", { ok: false, ms: this.now() - startedAt });
|
|
490
|
+
throw remoteError;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** 建子进程并挂上三个事件源;同时臂化 connect 预算。 */
|
|
495
|
+
private startChild(): void {
|
|
496
|
+
if (this.child !== undefined) return;
|
|
497
|
+
// 只记命令与 argv(不含 env / token);只在 debug 级别落盘
|
|
498
|
+
logEvent("remote.spawn", { command: this.command, args: [...this.args] }, "debug");
|
|
499
|
+
let child: RemoteChild;
|
|
500
|
+
try {
|
|
501
|
+
child = this.spawnFn(this.command, this.args, this.env === undefined ? {} : { env: this.env });
|
|
502
|
+
} catch (error) {
|
|
503
|
+
const spawnError = new RemoteError(`failed to spawn ${this.command}: ${messageOf(error)}`, "spawn");
|
|
504
|
+
this.failAll(spawnError);
|
|
505
|
+
throw spawnError;
|
|
506
|
+
}
|
|
507
|
+
// 先登记再校验管道:无管道的子进程也归 `failAll` → `terminate` 收尾(kill),不泄漏
|
|
508
|
+
this.child = child;
|
|
509
|
+
if (child.stdin === null || child.stdout === null) {
|
|
510
|
+
const pipeError = new RemoteError(`spawned ${this.command} without stdio pipes`, "spawn");
|
|
511
|
+
this.failAll(pipeError);
|
|
512
|
+
throw pipeError;
|
|
513
|
+
}
|
|
514
|
+
child.stdout.on("data", (chunk) => this.onStdout(chunk));
|
|
515
|
+
child.stderr?.on("data", (chunk) => this.appendStderr(chunk));
|
|
516
|
+
child.on("close", (code, signal) => this.onChildClose(code, signal));
|
|
517
|
+
child.on("error", (error) => this.onChildError(error));
|
|
518
|
+
this.connectTimer = this.clock.setTimeout(() => {
|
|
519
|
+
this.failAll(
|
|
520
|
+
new RemoteError(
|
|
521
|
+
`remote process produced no output within ${this.timeouts.connect}ms (command: ${this.command})${this.stderrSummary()}`,
|
|
522
|
+
"timeout",
|
|
523
|
+
),
|
|
524
|
+
);
|
|
525
|
+
}, this.timeouts.connect);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private onStdout(chunk: Buffer | string): void {
|
|
529
|
+
if (this.dead) return;
|
|
530
|
+
this.clearConnectTimer();
|
|
531
|
+
this.framer.push(chunk);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private onChildClose(code: number | null, signal: string | null): void {
|
|
535
|
+
this.exited = true;
|
|
536
|
+
if (this.dead) return;
|
|
537
|
+
const detail = signal === null ? `code=${code === null ? "none" : code}` : `code=${code === null ? "none" : code}, signal=${signal}`;
|
|
538
|
+
this.failAll(new RemoteError(`remote process closed (${detail})${this.stderrSummary()}`, "closed"));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private onChildError(error: Error): void {
|
|
542
|
+
this.exited = true;
|
|
543
|
+
if (this.dead) return;
|
|
544
|
+
this.failAll(new RemoteError(`remote process error: ${error.message}`, "spawn"));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private appendStderr(chunk: Buffer | string): void {
|
|
548
|
+
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
549
|
+
this.stderrTail = (this.stderrTail + text).slice(-STDERR_TAIL_CHARS);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private stderrSummary(): string {
|
|
553
|
+
const tail = this.stderrTail.trim();
|
|
554
|
+
if (tail === "") return "";
|
|
555
|
+
const lines = tail.split("\n").slice(-STDERR_TAIL_LINES);
|
|
556
|
+
return `; stderr: ${lines.join(" | ")}`;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** 入站消息分流:带 number id → 请求表;其余(`notifications/*`、`$/…`、未知方法)静默丢弃。 */
|
|
560
|
+
private onFrame(message: unknown): void {
|
|
561
|
+
if (this.dead) return;
|
|
562
|
+
if (!isRecord(message)) {
|
|
563
|
+
this.debug?.("ignoring non-object MCP message");
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const id = message["id"];
|
|
567
|
+
if (typeof id !== "number") return;
|
|
568
|
+
const pending = this.pending.get(id);
|
|
569
|
+
if (pending === undefined) return; // 已超时/未知 id:丢弃
|
|
570
|
+
const error = message["error"];
|
|
571
|
+
if (error !== undefined && error !== null) {
|
|
572
|
+
this.settle(id, pending);
|
|
573
|
+
pending.reject(rpcError(pending.method, error));
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
this.settle(id, pending);
|
|
577
|
+
pending.resolve(message["result"]);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** 发请求:登记 pending + 预算 + abort 监听,然后写帧。 */
|
|
581
|
+
private request(method: string, params: unknown, timeoutMs: number, signal?: AbortSignal): Promise<unknown> {
|
|
582
|
+
if (this.dead) return Promise.reject(this.refuseDead(method));
|
|
583
|
+
if (signal?.aborted === true) {
|
|
584
|
+
const aborted = new RemoteError(`${method} aborted by caller`, "aborted");
|
|
585
|
+
this.failAll(aborted);
|
|
586
|
+
return Promise.reject(aborted);
|
|
587
|
+
}
|
|
588
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
589
|
+
const id = this.nextId++;
|
|
590
|
+
const startedAt = this.now();
|
|
591
|
+
const onAbort = (): void => {
|
|
592
|
+
this.failAll(new RemoteError(`${method} aborted by caller`, "aborted"));
|
|
593
|
+
};
|
|
594
|
+
const timer = this.clock.setTimeout(() => {
|
|
595
|
+
const elapsed = this.now() - startedAt;
|
|
596
|
+
this.failAll(new RemoteError(`${method} timed out after ${elapsed}ms (limit ${timeoutMs}ms)`, "timeout"));
|
|
597
|
+
}, timeoutMs);
|
|
598
|
+
this.pending.set(id, { method, timer, signal, onAbort, resolve, reject });
|
|
599
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
600
|
+
this.write({ jsonrpc: "2.0", id, method, params });
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** 发通知(无 id,双工流里不等回执)。 */
|
|
605
|
+
private notify(method: string, params: unknown): void {
|
|
606
|
+
this.write({ jsonrpc: "2.0", method, params });
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** 写一帧(单行 JSON + `\n`);写失败即连接不可用。 */
|
|
610
|
+
private write(message: unknown): void {
|
|
611
|
+
const stdin = this.child?.stdin;
|
|
612
|
+
if (stdin === null || stdin === undefined) {
|
|
613
|
+
this.failAll(new RemoteError("remote process stdin is unavailable", "closed"));
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
stdin.write(`${JSON.stringify(message)}\n`);
|
|
618
|
+
} catch (error) {
|
|
619
|
+
this.failAll(new RemoteError(`failed to write to remote process: ${messageOf(error)}`, "closed"));
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** 摘除 pending 的预算与 abort 监听(resolve/reject 前必调)。 */
|
|
624
|
+
private settle(id: number, pending: PendingRequest): void {
|
|
625
|
+
this.pending.delete(id);
|
|
626
|
+
this.clock.clearTimeout(pending.timer);
|
|
627
|
+
pending.signal?.removeEventListener("abort", pending.onAbort);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** 终态迁移:reject 全部 pending、清缓冲、杀子进程(幂等)。 */
|
|
631
|
+
private failAll(error: RemoteError): void {
|
|
632
|
+
if (this.dead) return;
|
|
633
|
+
this.dead = true;
|
|
634
|
+
this.clearConnectTimer();
|
|
635
|
+
this.framer.reset();
|
|
636
|
+
const entries = [...this.pending.values()];
|
|
637
|
+
this.pending.clear();
|
|
638
|
+
for (const pending of entries) {
|
|
639
|
+
this.clock.clearTimeout(pending.timer);
|
|
640
|
+
pending.signal?.removeEventListener("abort", pending.onAbort);
|
|
641
|
+
pending.reject(error);
|
|
642
|
+
}
|
|
643
|
+
this.terminate();
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
private terminate(): void {
|
|
647
|
+
const child = this.child;
|
|
648
|
+
this.child = undefined;
|
|
649
|
+
if (child === undefined || this.exited) return;
|
|
650
|
+
try {
|
|
651
|
+
child.kill?.();
|
|
652
|
+
} catch {
|
|
653
|
+
// kill 是尽力而为:子进程可能已退出
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
private clearConnectTimer(): void {
|
|
658
|
+
if (this.connectTimer === undefined) return;
|
|
659
|
+
this.clock.clearTimeout(this.connectTimer);
|
|
660
|
+
this.connectTimer = undefined;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** 协议违约(结构不符)也意味着连接状态不可信:一并置 dead。 */
|
|
664
|
+
private protocolFailure(message: string): RemoteError {
|
|
665
|
+
const error = new RemoteError(message, "protocol");
|
|
666
|
+
this.failAll(error);
|
|
667
|
+
return error;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
private refuseDead(method: string): RemoteError {
|
|
671
|
+
return new RemoteError(`${method} refused: remote client is dead`, "closed");
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** 建一个远端 stdio MCP 客户端(单次连接一实例,失效后由调用方重建)。 */
|
|
676
|
+
export function createRemoteClient(options: RemoteClientOptions): RemoteClient {
|
|
677
|
+
return new RemoteStdioClient(options);
|
|
678
|
+
}
|