s-app-scoder-agent 0.6.0 → 0.7.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/.scoderrc.example.json +97 -0
- package/LICENSE +21 -21
- package/README.md +599 -468
- package/dist/agent.js +825 -175
- package/dist/agents.js +70 -0
- package/dist/compaction.js +114 -0
- package/dist/config.js +178 -12
- package/dist/context.js +119 -14
- package/dist/cost.js +56 -6
- package/dist/guardrails.js +409 -0
- package/dist/index.js +121 -76
- package/dist/json-schema.js +58 -0
- package/dist/llm.js +123 -9
- package/dist/mcp.js +397 -70
- package/dist/model-catalog.js +175 -0
- package/dist/permissions.js +328 -35
- package/dist/prompt-builder.js +47 -0
- package/dist/prompt.js +97 -0
- package/dist/provider-anthropic.js +113 -35
- package/dist/provider-registry.js +116 -0
- package/dist/retry.js +3 -2
- package/dist/sandbox.js +96 -0
- package/dist/server.js +28 -1
- package/dist/session.js +282 -11
- package/dist/shell-scanner.js +112 -0
- package/dist/skills.js +122 -0
- package/dist/snapshot.js +170 -0
- package/dist/steering.js +35 -0
- package/dist/tools.js +513 -19
- package/dist/tui/app.js +498 -198
- package/dist/tui/at-complete.js +93 -0
- package/dist/tui/chat-views.js +145 -0
- package/dist/tui/chrome.js +96 -0
- package/dist/tui/command-registry.js +189 -0
- package/dist/tui/commands.js +200 -2
- package/dist/tui/image.js +149 -0
- package/dist/tui/input.js +214 -0
- package/dist/tui/markdown.js +229 -0
- package/dist/tui/mouse.js +20 -0
- package/dist/tui/permission-modal.js +34 -0
- package/dist/tui/review.js +64 -0
- package/dist/tui/session-picker.js +68 -0
- package/dist/tui/sidebar.js +101 -0
- package/dist/tui/state.js +182 -6
- package/dist/tui/theme.js +90 -0
- package/dist/tui/vim.js +60 -0
- package/dist/watch.js +52 -0
- package/package.json +16 -8
- package/dist/tui-direct.mjs +0 -4
package/dist/mcp.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Minimal MCP (Model Context Protocol) client
|
|
2
|
+
* Minimal MCP (Model Context Protocol) client.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* M3: pluggable transport — `StdioTransport` (JSON-RPC 2.0 over stdin/stdout,
|
|
5
|
+
* line-delimited) và `HttpTransport` (Streamable HTTP: POST JSON-RPC, nhận
|
|
6
|
+
* JSON hoặc SSE stream, echo `Mcp-Session-Id`). Config `url` → http, ngược
|
|
7
|
+
* lại → stdio. Exposes discovered tools as `mcp__<server>__<tool>` prefixed
|
|
8
|
+
* names in the agent's tool registry.
|
|
7
9
|
*/
|
|
8
10
|
import { spawn } from "node:child_process";
|
|
9
11
|
import { CLIENT_NAME, getVersion } from "./version.js";
|
|
12
|
+
import { redactSecrets } from "./llm.js";
|
|
10
13
|
export const MCP_TOOL_PREFIX = "mcp__";
|
|
11
14
|
export function mcpToolKey(serverName, toolName) {
|
|
12
15
|
return `${MCP_TOOL_PREFIX}${serverName}__${toolName}`;
|
|
@@ -19,38 +22,309 @@ function expandEnv(env) {
|
|
|
19
22
|
}
|
|
20
23
|
return out;
|
|
21
24
|
}
|
|
22
|
-
|
|
23
|
-
export class
|
|
24
|
-
name;
|
|
25
|
+
/** stdio transport — spawn process, line-delimited JSON-RPC trên stdin/stdout. */
|
|
26
|
+
export class StdioTransport {
|
|
25
27
|
cfg;
|
|
28
|
+
kind = "stdio";
|
|
26
29
|
proc = null;
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
/** Tail of the server's stderr (last 4KB) — surfaced on connect failures. */
|
|
31
|
+
stderrTail = "";
|
|
29
32
|
buf = "";
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
msgCb = null;
|
|
34
|
+
downCb = null;
|
|
35
|
+
constructor(cfg) {
|
|
32
36
|
this.cfg = cfg;
|
|
33
37
|
}
|
|
34
38
|
get alive() {
|
|
35
39
|
return this.proc !== null && this.proc.exitCode === null;
|
|
36
40
|
}
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
onMessage(cb) {
|
|
42
|
+
this.msgCb = cb;
|
|
43
|
+
}
|
|
44
|
+
onDown(cb) {
|
|
45
|
+
this.downCb = cb;
|
|
46
|
+
}
|
|
47
|
+
async start() {
|
|
48
|
+
const command = this.cfg.command;
|
|
49
|
+
if (!command)
|
|
50
|
+
throw new Error("MCP stdio transport requires \"command\" in the server config");
|
|
51
|
+
const p = spawn(command, this.cfg.args ?? [], {
|
|
39
52
|
stdio: ["pipe", "pipe", "pipe"],
|
|
40
53
|
env: { ...process.env, ...expandEnv(this.cfg.env) },
|
|
54
|
+
// Windows: npx/foo.cmd chỉ spawn được qua shell (libuv không tự tìm .cmd).
|
|
55
|
+
shell: process.platform === "win32",
|
|
41
56
|
});
|
|
42
57
|
this.proc = p;
|
|
43
|
-
p.stderr?.on("data", () => {
|
|
44
|
-
|
|
45
|
-
|
|
58
|
+
p.stderr?.on("data", (c) => {
|
|
59
|
+
// Keep the tail for diagnostics (server logs/help output on crash);
|
|
60
|
+
// otherwise stderr is intentionally ignored.
|
|
61
|
+
this.stderrTail = (this.stderrTail + c.toString("utf8")).slice(-4096);
|
|
62
|
+
});
|
|
63
|
+
p.on("error", (e) => this.downCb?.(new Error(`spawn: ${e.message}`)));
|
|
64
|
+
p.on("exit", (code) => this.downCb?.(new Error(`process exited (code ${code ?? "?"})`)));
|
|
46
65
|
p.stdout?.on("data", (c) => this.onChunk(c));
|
|
66
|
+
}
|
|
67
|
+
send(msg) {
|
|
68
|
+
if (!this.proc?.stdin?.writable)
|
|
69
|
+
return Promise.resolve();
|
|
70
|
+
try {
|
|
71
|
+
this.proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
72
|
+
}
|
|
73
|
+
catch { /* ignore */ }
|
|
74
|
+
return Promise.resolve();
|
|
75
|
+
}
|
|
76
|
+
close() {
|
|
77
|
+
if (this.proc) {
|
|
78
|
+
try {
|
|
79
|
+
this.proc.kill();
|
|
80
|
+
}
|
|
81
|
+
catch { /* already dead */ }
|
|
82
|
+
this.proc = null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
onChunk(chunk) {
|
|
86
|
+
this.buf += chunk.toString("utf8");
|
|
87
|
+
let i;
|
|
88
|
+
while ((i = this.buf.indexOf("\n")) !== -1) {
|
|
89
|
+
const line = this.buf.slice(0, i).trim();
|
|
90
|
+
this.buf = this.buf.slice(i + 1);
|
|
91
|
+
if (!line)
|
|
92
|
+
continue;
|
|
93
|
+
try {
|
|
94
|
+
this.msgCb?.(JSON.parse(line));
|
|
95
|
+
}
|
|
96
|
+
catch { /* skip malformed */ }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* M3: Streamable HTTP transport — POST một JSON-RPC message, server trả về
|
|
102
|
+
* `application/json` (single response) hoặc `text/event-stream` (SSE; parse
|
|
103
|
+
* các `data:` events). Echo `Mcp-Session-Id` từ initialize trên các request
|
|
104
|
+
* sau. Stateless: lỗi request → reject call hiện tại; call sau tự retry
|
|
105
|
+
* (không cần background reconnect như stdio).
|
|
106
|
+
*/
|
|
107
|
+
export class HttpTransport {
|
|
108
|
+
cfg;
|
|
109
|
+
kind = "http";
|
|
110
|
+
sessionId = null;
|
|
111
|
+
msgCb = null;
|
|
112
|
+
closed = false;
|
|
113
|
+
constructor(cfg) {
|
|
114
|
+
this.cfg = cfg;
|
|
115
|
+
}
|
|
116
|
+
get alive() {
|
|
117
|
+
return !this.closed;
|
|
118
|
+
}
|
|
119
|
+
onMessage(cb) {
|
|
120
|
+
this.msgCb = cb;
|
|
121
|
+
}
|
|
122
|
+
onDown(_cb) {
|
|
123
|
+
// HTTP là stateless — không có channel chết để báo.
|
|
124
|
+
}
|
|
125
|
+
async start() {
|
|
126
|
+
this.closed = false;
|
|
127
|
+
}
|
|
128
|
+
async send(msg) {
|
|
129
|
+
if (this.closed)
|
|
130
|
+
throw new Error(`MCP HTTP transport "${this.cfg.url}" closed`);
|
|
131
|
+
const res = await this.post(msg, this.sessionId);
|
|
132
|
+
// Session expired (server restart) → thử lại một lần không session.
|
|
133
|
+
if ((res.status === 404 || res.status === 400) && this.sessionId) {
|
|
134
|
+
this.sessionId = null;
|
|
135
|
+
const retry = await this.post(msg, null);
|
|
136
|
+
return this.consume(retry);
|
|
137
|
+
}
|
|
138
|
+
return this.consume(res);
|
|
139
|
+
}
|
|
140
|
+
close() {
|
|
141
|
+
this.closed = true;
|
|
142
|
+
this.sessionId = null;
|
|
143
|
+
}
|
|
144
|
+
async post(msg, sessionId) {
|
|
145
|
+
const url = this.cfg.url;
|
|
146
|
+
if (!url)
|
|
147
|
+
throw new Error("MCP HTTP transport requires \"url\" in the server config");
|
|
148
|
+
// M3: headers hỗ trợ {env:VAR} substitution như env — không hardcode secrets.
|
|
149
|
+
const headers = {
|
|
150
|
+
"content-type": "application/json",
|
|
151
|
+
accept: "application/json, text/event-stream",
|
|
152
|
+
...expandEnv(this.cfg.headers),
|
|
153
|
+
};
|
|
154
|
+
if (sessionId)
|
|
155
|
+
headers["mcp-session-id"] = sessionId;
|
|
156
|
+
try {
|
|
157
|
+
return await fetch(url, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers,
|
|
160
|
+
body: JSON.stringify(msg),
|
|
161
|
+
signal: AbortSignal.timeout(120_000),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
throw new Error(`MCP HTTP "${url}": ${e.message}`, { cause: e });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async consume(res) {
|
|
169
|
+
const sid = res.headers.get("mcp-session-id");
|
|
170
|
+
if (sid)
|
|
171
|
+
this.sessionId = sid;
|
|
172
|
+
if (!res.ok) {
|
|
173
|
+
res.body?.cancel().catch(() => { });
|
|
174
|
+
throw new Error(`MCP HTTP ${res.status} ${res.statusText}`);
|
|
175
|
+
}
|
|
176
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
177
|
+
if (ct.includes("text/event-stream")) {
|
|
178
|
+
const text = await res.text();
|
|
179
|
+
for (const m of parseSseMessages(text))
|
|
180
|
+
this.msgCb?.(m);
|
|
181
|
+
}
|
|
182
|
+
else if (ct.includes("application/json")) {
|
|
183
|
+
const body = (await res.text()).trim();
|
|
184
|
+
if (body) {
|
|
185
|
+
try {
|
|
186
|
+
this.msgCb?.(JSON.parse(body));
|
|
187
|
+
}
|
|
188
|
+
catch { /* skip malformed */ }
|
|
189
|
+
}
|
|
190
|
+
} // 202/no-content (notifications) — nothing to parse
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Parse SSE text thành JSON-RPC messages (concat các dòng `data:` liên tiếp). */
|
|
194
|
+
export function parseSseMessages(text) {
|
|
195
|
+
const out = [];
|
|
196
|
+
for (const block of text.split(/\r?\n\r?\n/)) {
|
|
197
|
+
const data = block
|
|
198
|
+
.split(/\r?\n/)
|
|
199
|
+
.filter((l) => l.startsWith("data:"))
|
|
200
|
+
.map((l) => l.slice(5).trim())
|
|
201
|
+
.join("\n");
|
|
202
|
+
if (!data)
|
|
203
|
+
continue;
|
|
204
|
+
try {
|
|
205
|
+
out.push(JSON.parse(data));
|
|
206
|
+
}
|
|
207
|
+
catch { /* skip malformed */ }
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
const RECONNECT_MAX = 5;
|
|
212
|
+
const RECONNECT_BASE_MS = 500; // backoff: 0.5s, 1s, 2s, 4s, 8s
|
|
213
|
+
export class McpConnection {
|
|
214
|
+
name;
|
|
215
|
+
cfg;
|
|
216
|
+
transport;
|
|
217
|
+
nextId = 1;
|
|
218
|
+
pending = new Map();
|
|
219
|
+
/** M1: current state machine position. */
|
|
220
|
+
state = "connecting";
|
|
221
|
+
/** M1: status/state sink (AgentEvent mcp_status). */
|
|
222
|
+
onState = null;
|
|
223
|
+
manuallyClosed = false;
|
|
224
|
+
reconnectAttempt = 0;
|
|
225
|
+
constructor(name, cfg) {
|
|
226
|
+
this.name = name;
|
|
227
|
+
this.cfg = cfg;
|
|
228
|
+
// M3: url → HTTP transport; ngược lại stdio (default).
|
|
229
|
+
const kind = cfg.type ?? (cfg.url ? "http" : "stdio");
|
|
230
|
+
if (kind === "http" && !cfg.url)
|
|
231
|
+
throw new Error(`MCP server "${name}": type "http" nhưng thiếu url`);
|
|
232
|
+
if (kind === "stdio" && !cfg.command)
|
|
233
|
+
throw new Error(`MCP server "${name}": thiếu command`);
|
|
234
|
+
this.transport = kind === "http" ? new HttpTransport(cfg) : new StdioTransport(cfg);
|
|
235
|
+
}
|
|
236
|
+
/** stdio process (null với HTTP) — dùng trong tests. */
|
|
237
|
+
get proc() {
|
|
238
|
+
return this.transport.kind === "stdio" ? this.transport.proc : null;
|
|
239
|
+
}
|
|
240
|
+
get stderrTail() {
|
|
241
|
+
return this.transport.kind === "stdio" ? this.transport.stderrTail : "";
|
|
242
|
+
}
|
|
243
|
+
get alive() {
|
|
244
|
+
return this.transport.alive;
|
|
245
|
+
}
|
|
246
|
+
setState(s, detail) {
|
|
247
|
+
this.state = s;
|
|
248
|
+
this.onState?.(s, detail);
|
|
249
|
+
}
|
|
250
|
+
async connect(timeoutMs = 15_000) {
|
|
251
|
+
this.manuallyClosed = false;
|
|
252
|
+
await this.startAndHandshake(timeoutMs);
|
|
253
|
+
}
|
|
254
|
+
async startAndHandshake(timeoutMs) {
|
|
255
|
+
await this.transport.start();
|
|
256
|
+
this.wireTransport();
|
|
47
257
|
// MCP handshake: initialize + notifications/initialized
|
|
48
258
|
await this.rpc("initialize", {
|
|
49
259
|
protocolVersion: "2024-11-05",
|
|
50
260
|
capabilities: {},
|
|
51
261
|
clientInfo: { name: CLIENT_NAME, version: getVersion() },
|
|
52
262
|
}, timeoutMs);
|
|
53
|
-
this.send({ jsonrpc: "2.0", method: "notifications/initialized" })
|
|
263
|
+
void this.transport.send({ jsonrpc: "2.0", method: "notifications/initialized" }).catch(() => {
|
|
264
|
+
// notification — không có pending để reject; lỗi sẽ lộ ở call sau.
|
|
265
|
+
});
|
|
266
|
+
this.setState("ready", "connected");
|
|
267
|
+
}
|
|
268
|
+
/** Wire inbound messages + channel-death → reconnect (stdio only). */
|
|
269
|
+
wireTransport() {
|
|
270
|
+
this.transport.onMessage((msg) => {
|
|
271
|
+
if (msg.id === undefined)
|
|
272
|
+
return; // notification — nothing pending
|
|
273
|
+
const pending = this.pending.get(msg.id);
|
|
274
|
+
if (!pending)
|
|
275
|
+
return;
|
|
276
|
+
this.pending.delete(msg.id);
|
|
277
|
+
const err = msg.error;
|
|
278
|
+
if (err)
|
|
279
|
+
pending.reject(new Error(`${err.code}: ${err.message}`));
|
|
280
|
+
else
|
|
281
|
+
pending.resolve((msg.result ?? {}));
|
|
282
|
+
});
|
|
283
|
+
if (this.transport.kind === "stdio") {
|
|
284
|
+
this.transport.onDown((err) => {
|
|
285
|
+
// M1: exit → drain pending (reject rõ ràng) → reconnect, trừ khi
|
|
286
|
+
// kill() chủ đích.
|
|
287
|
+
this.drain(new Error(`MCP server "${this.name}" ${err.message}`));
|
|
288
|
+
if (this.manuallyClosed)
|
|
289
|
+
return;
|
|
290
|
+
void this.scheduleReconnect();
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/** M1: exponential backoff reconnect — tools/list refresh do Manager lo. */
|
|
295
|
+
async scheduleReconnect() {
|
|
296
|
+
if (this.reconnectAttempt >= RECONNECT_MAX) {
|
|
297
|
+
this.setState("dead", `reconnect failed after ${RECONNECT_MAX} attempts`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
this.reconnectAttempt++;
|
|
301
|
+
const delay = RECONNECT_BASE_MS * 2 ** (this.reconnectAttempt - 1);
|
|
302
|
+
this.setState("reconnecting", `attempt ${this.reconnectAttempt}/${RECONNECT_MAX} in ${delay}ms`);
|
|
303
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
304
|
+
if (this.manuallyClosed)
|
|
305
|
+
return;
|
|
306
|
+
try {
|
|
307
|
+
await this.startAndHandshake(15_000);
|
|
308
|
+
this.reconnectAttempt = 0;
|
|
309
|
+
this.setState("ready", "reconnected");
|
|
310
|
+
}
|
|
311
|
+
catch (e) {
|
|
312
|
+
this.drain(e);
|
|
313
|
+
await this.scheduleReconnect();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** M1: health check — ping không có response trong MCP spec stdio → dùng
|
|
317
|
+
* tools/list với timeout ngắn làm liveness probe. */
|
|
318
|
+
async healthy(timeoutMs = 5_000) {
|
|
319
|
+
if (!this.alive)
|
|
320
|
+
return false;
|
|
321
|
+
try {
|
|
322
|
+
await this.rpc("tools/list", {}, timeoutMs);
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
54
328
|
}
|
|
55
329
|
async listTools(timeoutMs = 120_000) {
|
|
56
330
|
const res = await this.rpc("tools/list", {}, timeoutMs);
|
|
@@ -60,27 +334,17 @@ export class McpConnection {
|
|
|
60
334
|
const res = await this.rpc("tools/call", { name, arguments: args }, timeoutMs);
|
|
61
335
|
const isErr = res.isError === true;
|
|
62
336
|
const parts = (res.content ?? []).map((c) => c.text ?? "").filter(Boolean);
|
|
63
|
-
|
|
337
|
+
// H-13: MCP servers are third-party — redact secret shapes from output
|
|
338
|
+
// before it enters the conversation or logs.
|
|
339
|
+
return { ok: !isErr, output: redactSecrets(parts.join("\n")) || "(empty result)", structured: res.structuredContent };
|
|
64
340
|
}
|
|
65
341
|
kill() {
|
|
342
|
+
this.manuallyClosed = true;
|
|
343
|
+
this.setState("dead", "closed by client");
|
|
66
344
|
this.drain(new Error("disconnected"));
|
|
67
|
-
|
|
68
|
-
try {
|
|
69
|
-
this.proc.kill();
|
|
70
|
-
}
|
|
71
|
-
catch { /* already dead */ }
|
|
72
|
-
this.proc = null;
|
|
73
|
-
}
|
|
345
|
+
this.transport.close();
|
|
74
346
|
}
|
|
75
347
|
// ── internals ─────────────────────────────────────────────────────────────
|
|
76
|
-
send(msg) {
|
|
77
|
-
if (!this.proc?.stdin?.writable)
|
|
78
|
-
return;
|
|
79
|
-
try {
|
|
80
|
-
this.proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
81
|
-
}
|
|
82
|
-
catch { /* ignore */ }
|
|
83
|
-
}
|
|
84
348
|
async rpc(method, params, timeoutMs) {
|
|
85
349
|
if (!this.alive)
|
|
86
350
|
throw new Error(`MCP server "${this.name}" not connected`);
|
|
@@ -94,36 +358,15 @@ export class McpConnection {
|
|
|
94
358
|
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
|
95
359
|
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
96
360
|
});
|
|
97
|
-
this.send({ jsonrpc: "2.0", id, method, params })
|
|
361
|
+
void Promise.resolve(this.transport.send({ jsonrpc: "2.0", id, method, params })).catch((e) => {
|
|
362
|
+
const p = this.pending.get(id);
|
|
363
|
+
if (p) {
|
|
364
|
+
this.pending.delete(id);
|
|
365
|
+
p.reject(e);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
98
368
|
});
|
|
99
369
|
}
|
|
100
|
-
onChunk(chunk) {
|
|
101
|
-
this.buf += chunk.toString("utf8");
|
|
102
|
-
let i;
|
|
103
|
-
while ((i = this.buf.indexOf("\n")) !== -1) {
|
|
104
|
-
const line = this.buf.slice(0, i).trim();
|
|
105
|
-
this.buf = this.buf.slice(i + 1);
|
|
106
|
-
if (!line)
|
|
107
|
-
continue;
|
|
108
|
-
let msg;
|
|
109
|
-
try {
|
|
110
|
-
msg = JSON.parse(line);
|
|
111
|
-
}
|
|
112
|
-
catch {
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
if (msg.id === undefined)
|
|
116
|
-
continue;
|
|
117
|
-
const pending = this.pending.get(msg.id);
|
|
118
|
-
if (!pending)
|
|
119
|
-
continue;
|
|
120
|
-
this.pending.delete(msg.id);
|
|
121
|
-
if (msg.error)
|
|
122
|
-
pending.reject(new Error(`${msg.error.code}: ${msg.error.message}`));
|
|
123
|
-
else
|
|
124
|
-
pending.resolve(msg.result ?? {});
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
370
|
drain(err) {
|
|
128
371
|
for (const [, p] of this.pending)
|
|
129
372
|
p.reject(err);
|
|
@@ -134,6 +377,10 @@ export class McpManager {
|
|
|
134
377
|
configs;
|
|
135
378
|
conns = new Map();
|
|
136
379
|
tools = new Map();
|
|
380
|
+
/** Status sink (default console.error; the TUI routes it to a notice). */
|
|
381
|
+
onStatus = null;
|
|
382
|
+
/** Sidebar TUI: máy trạng thái per-server (connecting/ready/reconnecting/dead). */
|
|
383
|
+
onServerState = null;
|
|
137
384
|
constructor(configs) {
|
|
138
385
|
this.configs = configs;
|
|
139
386
|
}
|
|
@@ -141,32 +388,75 @@ export class McpManager {
|
|
|
141
388
|
async connectAll() {
|
|
142
389
|
await Promise.allSettled(Object.entries(this.configs).map(async ([name, cfg]) => {
|
|
143
390
|
const conn = new McpConnection(name, cfg);
|
|
391
|
+
this.wireReconnect(name, conn);
|
|
144
392
|
await conn.connect();
|
|
145
|
-
|
|
146
|
-
for (const def of defs) {
|
|
147
|
-
const key = mcpToolKey(name, def.name);
|
|
148
|
-
if (this.tools.has(key))
|
|
149
|
-
throw new Error(`Duplicate MCP tool: ${key}`);
|
|
150
|
-
this.tools.set(key, { conn, serverName: name, toolName: def.name, def });
|
|
151
|
-
}
|
|
393
|
+
await this.registerTools(name, conn);
|
|
152
394
|
this.conns.set(name, conn);
|
|
153
395
|
})).then((results) => {
|
|
396
|
+
const report = this.onStatus ?? ((line) => console.error(line));
|
|
154
397
|
results.forEach((r, i) => {
|
|
155
398
|
const name = Object.keys(this.configs)[i];
|
|
156
399
|
if (r.status === "rejected") {
|
|
157
|
-
|
|
400
|
+
this.onServerState?.(name, "dead", "connect failed");
|
|
401
|
+
report(`⚠ MCP server "${name}" unavailable: ${String(r.reason?.message).slice(0, 160)}`);
|
|
158
402
|
}
|
|
159
403
|
else {
|
|
160
404
|
const n = [...this.tools.values()].filter((t) => t.serverName === name).length;
|
|
161
|
-
|
|
405
|
+
report(`✓ MCP "${name}" connected — ${n} tool${n === 1 ? "" : "s"}`);
|
|
162
406
|
}
|
|
163
407
|
});
|
|
164
408
|
});
|
|
165
409
|
return this.tools.size;
|
|
166
410
|
}
|
|
411
|
+
/** M1: surface connection state machine + refresh tools sau reconnect. */
|
|
412
|
+
wireReconnect(name, conn) {
|
|
413
|
+
conn.onState = (state, detail) => {
|
|
414
|
+
// Sidebar TUI: structured state trước — mọi transition đều thú vị với
|
|
415
|
+
// map mcpServers, còn chat notice chỉ với reconnect/dead/ready (dưới).
|
|
416
|
+
this.onServerState?.(name, state, detail);
|
|
417
|
+
const report = this.onStatus ?? ((line) => console.error(line));
|
|
418
|
+
if (state === "reconnecting")
|
|
419
|
+
report(`⚠ MCP "${name}" ${detail}`);
|
|
420
|
+
if (state === "dead")
|
|
421
|
+
report(`✘ MCP "${name}" dead: ${detail}`);
|
|
422
|
+
if (state === "ready" && detail === "reconnected") {
|
|
423
|
+
report(`✓ MCP "${name}" reconnected`);
|
|
424
|
+
// Refresh tool list — server có thể đăng ký bộ tool mới.
|
|
425
|
+
conn
|
|
426
|
+
.listTools()
|
|
427
|
+
.then((defs) => this.reregisterTools(name, conn, defs))
|
|
428
|
+
.catch(() => { });
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
async registerTools(name, conn) {
|
|
433
|
+
const defs = await conn.listTools();
|
|
434
|
+
this.reregisterTools(name, conn, defs);
|
|
435
|
+
}
|
|
436
|
+
/** Replace một server's tool registrations (reconnect refresh). */
|
|
437
|
+
reregisterTools(name, conn, defs) {
|
|
438
|
+
for (const [key, t] of [...this.tools]) {
|
|
439
|
+
if (t.serverName === name)
|
|
440
|
+
this.tools.delete(key);
|
|
441
|
+
}
|
|
442
|
+
for (const def of defs) {
|
|
443
|
+
const key = mcpToolKey(name, def.name);
|
|
444
|
+
this.tools.set(key, { conn, serverName: name, toolName: def.name, def });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
167
447
|
hasTool(name) {
|
|
168
448
|
return this.tools.has(name);
|
|
169
449
|
}
|
|
450
|
+
/** Per-server tool listing (for /mcp status display). */
|
|
451
|
+
listToolsByServer() {
|
|
452
|
+
const map = new Map();
|
|
453
|
+
for (const t of this.tools.values()) {
|
|
454
|
+
const arr = map.get(t.serverName) ?? [];
|
|
455
|
+
arr.push({ name: t.toolName, description: t.def.description, inputSchema: t.def.inputSchema });
|
|
456
|
+
map.set(t.serverName, arr);
|
|
457
|
+
}
|
|
458
|
+
return [...map.entries()].map(([server, tools]) => ({ server, tools }));
|
|
459
|
+
}
|
|
170
460
|
getTool(name) {
|
|
171
461
|
return this.tools.get(name);
|
|
172
462
|
}
|
|
@@ -185,5 +475,42 @@ export class McpManager {
|
|
|
185
475
|
for (const c of this.conns.values())
|
|
186
476
|
c.kill();
|
|
187
477
|
this.conns.clear();
|
|
478
|
+
this.tools.clear();
|
|
479
|
+
}
|
|
480
|
+
/** M4: config hot-reload — connect server mới, kill server đã bị xóa khỏi config. */
|
|
481
|
+
async applyConfig(configs) {
|
|
482
|
+
this.configs = configs;
|
|
483
|
+
// Server bị xóa → kill + unregister.
|
|
484
|
+
for (const [name, conn] of [...this.conns]) {
|
|
485
|
+
if (!configs[name]) {
|
|
486
|
+
conn.kill();
|
|
487
|
+
this.conns.delete(name);
|
|
488
|
+
for (const [key, t] of [...this.tools]) {
|
|
489
|
+
if (t.serverName === name)
|
|
490
|
+
this.tools.delete(key);
|
|
491
|
+
}
|
|
492
|
+
const report = this.onStatus ?? ((line) => console.error(line));
|
|
493
|
+
report(`✘ MCP "${name}" removed (config changed)`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// Server mới → connect.
|
|
497
|
+
for (const [name, cfg] of Object.entries(configs)) {
|
|
498
|
+
if (this.conns.has(name))
|
|
499
|
+
continue;
|
|
500
|
+
const conn = new McpConnection(name, cfg);
|
|
501
|
+
this.wireReconnect(name, conn);
|
|
502
|
+
try {
|
|
503
|
+
await conn.connect();
|
|
504
|
+
await this.registerTools(name, conn);
|
|
505
|
+
this.conns.set(name, conn);
|
|
506
|
+
const report = this.onStatus ?? ((line) => console.error(line));
|
|
507
|
+
const n = [...this.tools.values()].filter((t) => t.serverName === name).length;
|
|
508
|
+
report(`✓ MCP "${name}" connected — ${n} tool${n === 1 ? "" : "s"} (hot-reload)`);
|
|
509
|
+
}
|
|
510
|
+
catch (e) {
|
|
511
|
+
const report = this.onStatus ?? ((line) => console.error(line));
|
|
512
|
+
report(`⚠ MCP server "${name}" unavailable: ${String(e.message).slice(0, 160)}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
188
515
|
}
|
|
189
516
|
}
|