s-app-scoder-agent 0.9.3 → 0.9.6

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.
Files changed (112) hide show
  1. package/README.md +615 -638
  2. package/dist/{guardrails.js → agent/guardrails.js} +1 -1
  3. package/dist/agent/interaction.js +70 -0
  4. package/dist/agent/mcp-bridge.js +136 -0
  5. package/dist/agent/prompt-context.js +83 -0
  6. package/dist/agent/run-loop.js +318 -0
  7. package/dist/agent/session-persistence.js +61 -0
  8. package/dist/agent/tool-executor.js +158 -0
  9. package/dist/agent/types.js +88 -0
  10. package/dist/agent/verification.js +99 -0
  11. package/dist/agent.js +239 -1002
  12. package/dist/cli/args.js +230 -0
  13. package/dist/cli/init.js +79 -0
  14. package/dist/cli/main.js +211 -0
  15. package/dist/cli/mcp-command.js +185 -0
  16. package/dist/cli/repl.js +77 -0
  17. package/dist/cli/run-once.js +26 -0
  18. package/dist/{tui/commands.js → commands/definitions.js} +6 -16
  19. package/dist/commands/registry.js +260 -0
  20. package/dist/{config.js → config/config.js} +1 -1
  21. package/dist/{prompt-builder.js → context/prompt-builder.js} +12 -1
  22. package/dist/{prompt.js → context/prompt.js} +111 -9
  23. package/dist/{skills.js → context/skills.js} +41 -2
  24. package/dist/index.js +5 -798
  25. package/dist/io/console-reporter.js +24 -0
  26. package/dist/io/reporter.js +1 -0
  27. package/dist/mcp/connection.js +227 -0
  28. package/dist/mcp/env.js +8 -0
  29. package/dist/{mcp.js → mcp/manager.js} +3 -515
  30. package/dist/mcp/sse.js +28 -0
  31. package/dist/mcp/transport-http.js +140 -0
  32. package/dist/mcp/transport-stdio.js +98 -0
  33. package/dist/mcp/types.js +11 -0
  34. package/dist/media/image.js +149 -0
  35. package/dist/{server.js → server/server.js} +1 -1
  36. package/dist/{cost.js → session/cost.js} +1 -1
  37. package/dist/{session.js → session/session.js} +1 -1
  38. package/dist/{version.js → shared/version.js} +1 -1
  39. package/dist/tools/apply-patch.js +62 -0
  40. package/dist/tools/edit-file.js +29 -0
  41. package/dist/tools/file-edit-common.js +124 -0
  42. package/dist/tools/list-files.js +64 -0
  43. package/dist/tools/path-safety.js +75 -0
  44. package/dist/tools/read-file.js +64 -0
  45. package/dist/tools/registry.js +297 -0
  46. package/dist/tools/run-command.js +192 -0
  47. package/dist/tools/schemas.js +8 -0
  48. package/dist/tools/search.js +237 -0
  49. package/dist/tools/task-tool.js +48 -0
  50. package/dist/tools/types.js +1 -0
  51. package/dist/tools/write-file.js +34 -0
  52. package/dist/tui/app.js +59 -532
  53. package/dist/tui/ask-user-modal.js +2 -1
  54. package/dist/tui/at-complete.js +2 -93
  55. package/dist/tui/charset.js +41 -0
  56. package/dist/tui/chat-views.js +72 -16
  57. package/dist/tui/chrome.js +11 -5
  58. package/dist/tui/hooks/use-command-host.js +26 -0
  59. package/dist/tui/hooks/use-event-queue.js +69 -0
  60. package/dist/tui/hooks/use-keybinds.js +87 -0
  61. package/dist/tui/hooks/use-scroll.js +38 -0
  62. package/dist/tui/hooks/use-session-resume.js +47 -0
  63. package/dist/tui/hooks/use-submit.js +106 -0
  64. package/dist/tui/image.js +2 -149
  65. package/dist/tui/input.js +1 -1
  66. package/dist/tui/markdown/ast.js +5 -0
  67. package/dist/tui/markdown/diagram.js +239 -0
  68. package/dist/tui/markdown/inline-render.js +24 -0
  69. package/dist/tui/markdown/inline.js +93 -0
  70. package/dist/tui/markdown/parse.js +139 -0
  71. package/dist/tui/markdown/render.js +146 -0
  72. package/dist/tui/markdown/table.js +106 -0
  73. package/dist/tui/markdown.js +32 -379
  74. package/dist/tui/permission-modal.js +2 -1
  75. package/dist/tui/selectors.js +157 -0
  76. package/dist/tui/session-picker.js +4 -3
  77. package/dist/tui/sidebar.js +130 -15
  78. package/dist/tui/state.js +47 -118
  79. package/dist/tui/text-width.js +139 -0
  80. package/dist/tui/theme.js +7 -2
  81. package/dist/{build.js → workspace/build.js} +1 -1
  82. package/dist/workspace/file-index.js +93 -0
  83. package/package.json +5 -1
  84. package/dist/tools.js +0 -1223
  85. package/dist/tui/command-registry.js +0 -477
  86. package/dist/tui/review.js +0 -64
  87. package/dist/tui/transcript.js +0 -145
  88. /package/dist/{agents-guide.js → context/agents-guide.js} +0 -0
  89. /package/dist/{agents.js → context/agents.js} +0 -0
  90. /package/dist/{compaction.js → context/compaction.js} +0 -0
  91. /package/dist/{context.js → context/context.js} +0 -0
  92. /package/dist/{memory.js → context/memory.js} +0 -0
  93. /package/dist/{repo-map.js → context/repo-map.js} +0 -0
  94. /package/dist/{ui.js → io/terminal.js} +0 -0
  95. /package/dist/{llm.js → llm/llm.js} +0 -0
  96. /package/dist/{model-catalog.js → llm/model-catalog.js} +0 -0
  97. /package/dist/{provider-anthropic.js → llm/provider-anthropic.js} +0 -0
  98. /package/dist/{provider-registry.js → llm/provider-registry.js} +0 -0
  99. /package/dist/{retry.js → llm/retry.js} +0 -0
  100. /package/dist/{mcp-import.js → mcp/mcp-import.js} +0 -0
  101. /package/dist/{permissions.js → security/permissions.js} +0 -0
  102. /package/dist/{sandbox.js → security/sandbox.js} +0 -0
  103. /package/dist/{shell-scanner.js → security/shell-scanner.js} +0 -0
  104. /package/dist/{logger.js → session/logger.js} +0 -0
  105. /package/dist/{steering.js → session/steering.js} +0 -0
  106. /package/dist/{constants.js → shared/constants.js} +0 -0
  107. /package/dist/{json-schema.js → shared/json-schema.js} +0 -0
  108. /package/dist/{webfetch.js → tools/webfetch.js} +0 -0
  109. /package/dist/{diagnostics.js → workspace/diagnostics.js} +0 -0
  110. /package/dist/{diff.js → workspace/diff.js} +0 -0
  111. /package/dist/{snapshot.js → workspace/snapshot.js} +0 -0
  112. /package/dist/{watch.js → workspace/watch.js} +0 -0
@@ -1,26 +1,6 @@
1
- /**
2
- * Minimal MCP (Model Context Protocol) client.
3
- *
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.
9
- */
10
- import { spawn, spawnSync } from "node:child_process";
11
- import { CLIENT_NAME, getVersion } from "./version.js";
12
- import { redactSecrets } from "./llm.js";
13
- export const MCP_TOOL_PREFIX = "mcp__";
14
- export function mcpToolKey(serverName, toolName) {
15
- return `${MCP_TOOL_PREFIX}${serverName}__${toolName}`;
16
- }
17
- /** Phase 4: generic MCP resource/prompt tools (advertised only when available). */
18
- export const MCP_RESOURCE_TOOL_NAMES = new Set([
19
- "mcp_list_resources",
20
- "mcp_read_resource",
21
- "mcp_list_prompts",
22
- "mcp_get_prompt",
23
- ]);
1
+ import { redactSecrets } from "../llm/llm.js";
2
+ import { MCP_RESOURCE_TOOL_NAMES, mcpToolKey } from "./types.js";
3
+ import { McpConnection } from "./connection.js";
24
4
  const schemaObj = (properties, required) => ({
25
5
  type: "object",
26
6
  properties,
@@ -29,498 +9,6 @@ const schemaObj = (properties, required) => ({
29
9
  });
30
10
  const strProp = (description) => ({ type: "string", description });
31
11
  const boolProp = (description) => ({ type: "boolean", description });
32
- /** Expand {env:VAR} references in config env values. */
33
- export function expandEnv(env) {
34
- const out = {};
35
- for (const [k, v] of Object.entries(env ?? {})) {
36
- out[k] = v.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, n) => process.env[n] ?? "");
37
- }
38
- return out;
39
- }
40
- /** stdio transport — spawn process, line-delimited JSON-RPC trên stdin/stdout. */
41
- export class StdioTransport {
42
- cfg;
43
- kind = "stdio";
44
- proc = null;
45
- /** Tail of the server's stderr (last 4KB) — surfaced on connect failures. */
46
- stderrTail = "";
47
- buf = "";
48
- msgCb = null;
49
- downCb = null;
50
- constructor(cfg) {
51
- this.cfg = cfg;
52
- }
53
- get alive() {
54
- return this.proc !== null && this.proc.exitCode === null;
55
- }
56
- onMessage(cb) {
57
- this.msgCb = cb;
58
- }
59
- onDown(cb) {
60
- this.downCb = cb;
61
- }
62
- async start() {
63
- const command = this.cfg.command;
64
- if (!command)
65
- throw new Error("MCP stdio transport requires \"command\" in the server config");
66
- const p = spawn(command, this.cfg.args ?? [], {
67
- stdio: ["pipe", "pipe", "pipe"],
68
- env: { ...process.env, ...expandEnv(this.cfg.env) },
69
- // Windows: npx/foo.cmd chỉ spawn được qua shell (libuv không tự tìm .cmd).
70
- shell: process.platform === "win32",
71
- });
72
- this.proc = p;
73
- p.stderr?.on("data", (c) => {
74
- // Keep the tail for diagnostics (server logs/help output on crash);
75
- // otherwise stderr is intentionally ignored.
76
- this.stderrTail = (this.stderrTail + c.toString("utf8")).slice(-4096);
77
- });
78
- p.on("error", (e) => this.downCb?.(new Error(`spawn: ${e.message}`)));
79
- p.on("exit", (code) => this.downCb?.(new Error(`process exited (code ${code ?? "?"})`)));
80
- p.stdout?.on("data", (c) => this.onChunk(c));
81
- }
82
- send(msg) {
83
- if (!this.proc?.stdin?.writable)
84
- return Promise.resolve();
85
- try {
86
- this.proc.stdin.write(JSON.stringify(msg) + "\n");
87
- }
88
- catch { /* ignore */ }
89
- return Promise.resolve();
90
- }
91
- close() {
92
- const p = this.proc;
93
- this.proc = null;
94
- if (!p)
95
- return;
96
- // Release pipe handles so the parent process can exit immediately.
97
- // On Windows shell:true (cmd.exe wrapper), p.kill() only kills cmd.exe —
98
- // the real node child is orphaned and keeps stdout open. Use taskkill /T
99
- // to kill the whole process tree; this also works for the plain node case.
100
- try {
101
- p.stdin?.end();
102
- }
103
- catch { /* ignore */ }
104
- try {
105
- p.stdout?.destroy();
106
- }
107
- catch { /* ignore */ }
108
- try {
109
- p.stderr?.destroy();
110
- }
111
- catch { /* ignore */ }
112
- try {
113
- p.kill();
114
- }
115
- catch { /* ignore */ }
116
- if (process.platform === "win32" && p.pid) {
117
- // Fire-and-forget; ignore errors — process may already be gone.
118
- spawnSync("taskkill", ["/pid", String(p.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
119
- }
120
- }
121
- onChunk(chunk) {
122
- this.buf += chunk.toString("utf8");
123
- let i;
124
- while ((i = this.buf.indexOf("\n")) !== -1) {
125
- const line = this.buf.slice(0, i).trim();
126
- this.buf = this.buf.slice(i + 1);
127
- if (!line)
128
- continue;
129
- try {
130
- this.msgCb?.(JSON.parse(line));
131
- }
132
- catch { /* skip malformed */ }
133
- }
134
- }
135
- }
136
- /**
137
- * M3: Streamable HTTP transport — POST một JSON-RPC message, server trả về
138
- * `application/json` (single response) hoặc `text/event-stream` (SSE; parse
139
- * các `data:` events). Echo `Mcp-Session-Id` từ initialize trên các request
140
- * sau. Stateless: lỗi request → reject call hiện tại; call sau tự retry
141
- * (không cần background reconnect như stdio).
142
- */
143
- export class HttpTransport {
144
- cfg;
145
- kind = "http";
146
- sessionId = null;
147
- msgCb = null;
148
- closed = false;
149
- constructor(cfg) {
150
- this.cfg = cfg;
151
- }
152
- get alive() {
153
- return !this.closed;
154
- }
155
- onMessage(cb) {
156
- this.msgCb = cb;
157
- }
158
- onDown(_cb) {
159
- // HTTP là stateless — không có channel chết để báo.
160
- }
161
- async start() {
162
- this.closed = false;
163
- }
164
- async send(msg) {
165
- if (this.closed)
166
- throw new Error(`MCP HTTP transport "${this.cfg.url}" closed`);
167
- const res = await this.post(msg, this.sessionId, undefined);
168
- // Session expired (server restart) → thử lại một lần không session.
169
- if ((res.status === 404 || res.status === 400) && this.sessionId) {
170
- this.sessionId = null;
171
- const retry = await this.post(msg, null, undefined);
172
- return this.consume(retry);
173
- }
174
- return this.consume(res);
175
- }
176
- close() {
177
- this.closed = true;
178
- this.sessionId = null;
179
- }
180
- defaultTimeout() {
181
- return this.cfg.timeoutMs ?? 120_000;
182
- }
183
- async post(msg, sessionId, signal) {
184
- const url = this.cfg.url;
185
- if (!url)
186
- throw new Error("MCP HTTP transport requires \"url\" in the server config");
187
- const headers = {
188
- "content-type": "application/json",
189
- accept: "application/json, text/event-stream",
190
- ...expandEnv(this.cfg.headers),
191
- };
192
- if (sessionId)
193
- headers["mcp-session-id"] = sessionId;
194
- try {
195
- // Combine the caller's abort signal (MCP A cancellation) with an idle
196
- // timeout into one AbortSignal. AbortSignal.any needs ≥1 real signal.
197
- const signals = [AbortSignal.timeout(this.defaultTimeout())];
198
- if (signal)
199
- signals.push(signal);
200
- const combined = signals.length > 1 ? AbortSignal.any(signals) : signals[0];
201
- return await fetch(url, {
202
- method: "POST",
203
- headers,
204
- body: JSON.stringify(msg),
205
- signal: combined,
206
- });
207
- }
208
- catch (e) {
209
- throw new Error(`MCP HTTP "${url}": ${e.message}`, { cause: e });
210
- }
211
- }
212
- async consume(res) {
213
- const sid = res.headers.get("mcp-session-id");
214
- if (sid)
215
- this.sessionId = sid;
216
- if (!res.ok) {
217
- res.body?.cancel().catch(() => { });
218
- throw new Error(`MCP HTTP ${res.status} ${res.statusText}`);
219
- }
220
- const ct = res.headers.get("content-type") ?? "";
221
- if (ct.includes("application/json")) {
222
- const body = (await res.text()).trim();
223
- if (body) {
224
- try {
225
- this.msgCb?.(JSON.parse(body));
226
- }
227
- catch { /* skip malformed */ }
228
- }
229
- return;
230
- }
231
- if (ct.includes("text/event-stream")) {
232
- // MCP A: incremental SSE — stream body, emit each JSON-RPC message as
233
- // its `data:` event completes, instead of buffering the entire response.
234
- await this.consumeSseStream(res.body);
235
- return;
236
- }
237
- // 202/no-content (notifications) — nothing to parse
238
- }
239
- /** Incremental SSE: read the body as a stream and emit each JSON-RPC message
240
- * as its SSE block (blank-line-separated) completes — no full-body buffer. */
241
- async consumeSseStream(body) {
242
- if (!body)
243
- return;
244
- const reader = body.getReader();
245
- const decoder = new TextDecoder();
246
- let buf = "";
247
- try {
248
- for (;;) {
249
- const { done, value } = await reader.read();
250
- if (done)
251
- break;
252
- buf += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
253
- let sep;
254
- while ((sep = buf.indexOf("\n\n")) !== -1) {
255
- const block = buf.slice(0, sep);
256
- buf = buf.slice(sep + 2);
257
- const msg = parseSseEvent(block);
258
- if (msg)
259
- this.msgCb?.(msg);
260
- }
261
- }
262
- }
263
- finally {
264
- reader.releaseLock();
265
- }
266
- // Flush any trailing partial SSE block at EOF (no trailing \n\n).
267
- if (buf.trim()) {
268
- const msg = parseSseEvent(buf);
269
- if (msg)
270
- this.msgCb?.(msg);
271
- }
272
- }
273
- }
274
- /** Parse a single SSE block (blank-line-separated events) into one JSON-RPC message.
275
- * Normalizes CRLF → LF before splitting on the event separator. */
276
- export function parseSseEvent(block) {
277
- const data = block
278
- .replace(/\r\n/g, "\n")
279
- .split(/\n/)
280
- .filter((l) => l.startsWith("data:"))
281
- .map((l) => l.slice(5).trim())
282
- .join("\n");
283
- if (!data)
284
- return null;
285
- try {
286
- return JSON.parse(data);
287
- }
288
- catch {
289
- return null;
290
- }
291
- }
292
- /** Parse SSE text (legacy buffer-based) thành JSON-RPC messages. */
293
- export function parseSseMessages(text) {
294
- const out = [];
295
- for (const block of text.split(/\r?\n\r?\n/)) {
296
- const m = parseSseEvent(block);
297
- if (m)
298
- out.push(m);
299
- }
300
- return out;
301
- }
302
- const RECONNECT_MAX = 5;
303
- const RECONNECT_BASE_MS = 500; // backoff: 0.5s, 1s, 2s, 4s, 8s
304
- export class McpConnection {
305
- name;
306
- cfg;
307
- transport;
308
- nextId = 1;
309
- pending = new Map();
310
- /** M1: current state machine position. */
311
- state = "connecting";
312
- /** M1: status/state sink (AgentEvent mcp_status). */
313
- onState = null;
314
- /** MCP A: inbound server notifications (tools/list_changed, progress…). */
315
- onNotification = null;
316
- /** Server-reported metadata from `initialize` (surfaced on /mcp). */
317
- serverInfo = {};
318
- capabilities = {};
319
- instructions = null;
320
- /** Protocol version negotiated with the server. */
321
- protocolVersion = "2024-11-05";
322
- manuallyClosed = false;
323
- reconnectAttempt = 0;
324
- constructor(name, cfg) {
325
- this.name = name;
326
- this.cfg = cfg;
327
- // M3: url → HTTP transport; ngược lại stdio (default).
328
- const kind = cfg.type ?? (cfg.url ? "http" : "stdio");
329
- if (kind === "http" && !cfg.url)
330
- throw new Error(`MCP server "${name}": type "http" nhưng thiếu url`);
331
- if (kind === "stdio" && !cfg.command)
332
- throw new Error(`MCP server "${name}": thiếu command`);
333
- this.transport = kind === "http" ? new HttpTransport(cfg) : new StdioTransport(cfg);
334
- }
335
- /** stdio process (null với HTTP) — dùng trong tests. */
336
- get proc() {
337
- return this.transport.kind === "stdio" ? this.transport.proc : null;
338
- }
339
- get stderrTail() {
340
- return this.transport.kind === "stdio" ? this.transport.stderrTail : "";
341
- }
342
- get alive() {
343
- return this.transport.alive;
344
- }
345
- setState(s, detail) {
346
- this.state = s;
347
- this.onState?.(s, detail);
348
- }
349
- async connect(timeoutMs = 15_000) {
350
- this.manuallyClosed = false;
351
- await this.startAndHandshake(timeoutMs);
352
- }
353
- async startAndHandshake(timeoutMs) {
354
- await this.transport.start();
355
- this.wireTransport();
356
- // MCP handshake: initialize + notifications/initialized. Negotiate the
357
- // highest protocol version we support that the server accepts.
358
- const initRes = await this.rpc("initialize", {
359
- protocolVersion: "2024-11-05",
360
- capabilities: {},
361
- clientInfo: { name: CLIENT_NAME, version: getVersion() },
362
- }, timeoutMs);
363
- this.protocolVersion = initRes.protocolVersion ?? "2024-11-05";
364
- this.capabilities = initRes.capabilities ?? {};
365
- this.serverInfo = initRes.serverInfo ?? {};
366
- this.instructions = initRes.instructions ?? null;
367
- void this.transport.send({ jsonrpc: "2.0", method: "notifications/initialized" }).catch(() => {
368
- // notification — không có pending để reject; lỗi sẽ lộ ở call sau.
369
- });
370
- this.setState("ready", "connected");
371
- }
372
- /** Wire inbound messages + channel-death → reconnect (stdio only). */
373
- wireTransport() {
374
- this.transport.onMessage((msg) => {
375
- if (msg.id === undefined) {
376
- // Server notifications (tools/list_changed, progress, message) —
377
- // route them to the manager so it can react (re-register tools etc.).
378
- this.onNotification?.(msg);
379
- return;
380
- }
381
- const pending = this.pending.get(msg.id);
382
- if (!pending)
383
- return;
384
- this.pending.delete(msg.id);
385
- const err = msg.error;
386
- if (err)
387
- pending.reject(new Error(`${err.code}: ${err.message}`));
388
- else
389
- pending.resolve((msg.result ?? {}));
390
- });
391
- if (this.transport.kind === "stdio") {
392
- this.transport.onDown((err) => {
393
- // M1: exit → drain pending (reject rõ ràng) → reconnect, trừ khi
394
- // kill() chủ đích.
395
- this.drain(new Error(`MCP server "${this.name}" ${err.message}`));
396
- if (this.manuallyClosed)
397
- return;
398
- void this.scheduleReconnect();
399
- });
400
- }
401
- }
402
- /** M1: exponential backoff reconnect — tools/list refresh do Manager lo. */
403
- async scheduleReconnect() {
404
- if (this.reconnectAttempt >= RECONNECT_MAX) {
405
- this.setState("dead", `reconnect failed after ${RECONNECT_MAX} attempts`);
406
- return;
407
- }
408
- this.reconnectAttempt++;
409
- const delay = RECONNECT_BASE_MS * 2 ** (this.reconnectAttempt - 1);
410
- this.setState("reconnecting", `attempt ${this.reconnectAttempt}/${RECONNECT_MAX} in ${delay}ms`);
411
- await new Promise((r) => setTimeout(r, delay));
412
- if (this.manuallyClosed)
413
- return;
414
- try {
415
- await this.startAndHandshake(15_000);
416
- this.reconnectAttempt = 0;
417
- this.setState("ready", "reconnected");
418
- }
419
- catch (e) {
420
- this.drain(e);
421
- await this.scheduleReconnect();
422
- }
423
- }
424
- /** MCP A: liveness probe — prefer ping (official MCP spec), fallback tools/list. */
425
- async healthy(timeoutMs = 5_000) {
426
- if (!this.alive)
427
- return false;
428
- try {
429
- // ping was added in MCP 2025-03; fall back to tools/list for older servers.
430
- if (this.capabilities.ping === true) {
431
- await this.rpc("ping", {}, timeoutMs);
432
- }
433
- else {
434
- await this.rpc("tools/list", {}, timeoutMs);
435
- }
436
- return true;
437
- }
438
- catch {
439
- return false;
440
- }
441
- }
442
- /** MCP A: explicit ping. */
443
- async ping(timeoutMs = 5_000) {
444
- await this.rpc("ping", {}, timeoutMs);
445
- }
446
- async listTools(timeoutMs = 120_000) {
447
- const res = await this.rpc("tools/list", {}, timeoutMs);
448
- return (res.tools ?? []).filter((t) => typeof t.name === "string");
449
- }
450
- // ── Phase 4: resources ────────────────────────────────────────────────────
451
- async listResources(timeoutMs = 30_000) {
452
- const res = await this.rpc("resources/list", {}, timeoutMs);
453
- return (res.resources ?? []).filter((r) => typeof r.uri === "string");
454
- }
455
- async listResourceTemplates(timeoutMs = 30_000) {
456
- const res = await this.rpc("resources/templates/list", {}, timeoutMs);
457
- return (res.resourceTemplates ?? []).filter((r) => typeof r.uriTemplate === "string");
458
- }
459
- async readResource(uri, timeoutMs = 30_000) {
460
- const res = await this.rpc("resources/read", { uri }, timeoutMs);
461
- return { contents: res.contents ?? [] };
462
- }
463
- async subscribeResource(uri, timeoutMs = 30_000) {
464
- await this.rpc("resources/subscribe", { uri }, timeoutMs);
465
- }
466
- async unsubscribeResource(uri, timeoutMs = 30_000) {
467
- await this.rpc("resources/unsubscribe", { uri }, timeoutMs);
468
- }
469
- // ── Phase 4: prompts ──────────────────────────────────────────────────────
470
- async listPrompts(timeoutMs = 30_000) {
471
- const res = await this.rpc("prompts/list", {}, timeoutMs);
472
- return (res.prompts ?? []).filter((p) => typeof p.name === "string");
473
- }
474
- async getPrompt(name, args, timeoutMs = 30_000) {
475
- const res = await this.rpc("prompts/get", { name, arguments: args ?? {} }, timeoutMs);
476
- return { description: res.description, messages: res.messages ?? [] };
477
- }
478
- async callTool(name, args, timeoutMs = 120_000) {
479
- const res = await this.rpc("tools/call", { name, arguments: args }, timeoutMs);
480
- const isErr = res.isError === true;
481
- const parts = (res.content ?? []).map((c) => c.text ?? "").filter(Boolean);
482
- // H-13: MCP servers are third-party — redact secret shapes from output
483
- // before it enters the conversation or logs.
484
- const structured = res.structuredContent
485
- ? JSON.parse(redactSecrets(JSON.stringify(res.structuredContent)))
486
- : undefined;
487
- return { ok: !isErr, output: redactSecrets(parts.join("\n")) || "(empty result)", structured };
488
- }
489
- kill() {
490
- this.manuallyClosed = true;
491
- this.setState("dead", "closed by client");
492
- this.drain(new Error("disconnected"));
493
- this.transport.close();
494
- }
495
- // ── internals ─────────────────────────────────────────────────────────────
496
- async rpc(method, params, timeoutMs) {
497
- if (!this.alive)
498
- throw new Error(`MCP server "${this.name}" not connected`);
499
- const id = this.nextId++;
500
- return new Promise((resolve, reject) => {
501
- const timer = setTimeout(() => {
502
- this.pending.delete(id);
503
- reject(new Error(`MCP "${method}" timed out after ${(timeoutMs / 1000).toFixed(0)}s`));
504
- }, timeoutMs);
505
- this.pending.set(id, {
506
- resolve: (v) => { clearTimeout(timer); resolve(v); },
507
- reject: (e) => { clearTimeout(timer); reject(e); },
508
- });
509
- void Promise.resolve(this.transport.send({ jsonrpc: "2.0", id, method, params })).catch((e) => {
510
- const p = this.pending.get(id);
511
- if (p) {
512
- this.pending.delete(id);
513
- p.reject(e);
514
- }
515
- });
516
- });
517
- }
518
- drain(err) {
519
- for (const [, p] of this.pending)
520
- p.reject(err);
521
- this.pending.clear();
522
- }
523
- }
524
12
  export class McpManager {
525
13
  configs;
526
14
  conns = new Map();
@@ -0,0 +1,28 @@
1
+ /** Parse a single SSE block (blank-line-separated events) into one JSON-RPC message.
2
+ * Normalizes CRLF → LF before splitting on the event separator. */
3
+ export function parseSseEvent(block) {
4
+ const data = block
5
+ .replace(/\r\n/g, "\n")
6
+ .split(/\n/)
7
+ .filter((l) => l.startsWith("data:"))
8
+ .map((l) => l.slice(5).trim())
9
+ .join("\n");
10
+ if (!data)
11
+ return null;
12
+ try {
13
+ return JSON.parse(data);
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ /** Parse SSE text (legacy buffer-based) thành JSON-RPC messages. */
20
+ export function parseSseMessages(text) {
21
+ const out = [];
22
+ for (const block of text.split(/\r?\n\r?\n/)) {
23
+ const m = parseSseEvent(block);
24
+ if (m)
25
+ out.push(m);
26
+ }
27
+ return out;
28
+ }
@@ -0,0 +1,140 @@
1
+ import { expandEnv } from "./env.js";
2
+ import { parseSseEvent } from "./sse.js";
3
+ /**
4
+ * M3: Streamable HTTP transport — POST một JSON-RPC message, server trả về
5
+ * `application/json` (single response) hoặc `text/event-stream` (SSE; parse
6
+ * các `data:` events). Echo `Mcp-Session-Id` từ initialize trên các request
7
+ * sau. Stateless: lỗi request → reject call hiện tại; call sau tự retry
8
+ * (không cần background reconnect như stdio).
9
+ */
10
+ export class HttpTransport {
11
+ cfg;
12
+ kind = "http";
13
+ sessionId = null;
14
+ msgCb = null;
15
+ closed = false;
16
+ constructor(cfg) {
17
+ this.cfg = cfg;
18
+ }
19
+ get alive() {
20
+ return !this.closed;
21
+ }
22
+ onMessage(cb) {
23
+ this.msgCb = cb;
24
+ }
25
+ onDown(_cb) {
26
+ // HTTP là stateless — không có channel chết để báo.
27
+ }
28
+ async start() {
29
+ this.closed = false;
30
+ }
31
+ async send(msg) {
32
+ if (this.closed)
33
+ throw new Error(`MCP HTTP transport "${this.cfg.url}" closed`);
34
+ const res = await this.post(msg, this.sessionId, undefined);
35
+ // Session expired (server restart) → thử lại một lần không session.
36
+ if ((res.status === 404 || res.status === 400) && this.sessionId) {
37
+ this.sessionId = null;
38
+ const retry = await this.post(msg, null, undefined);
39
+ return this.consume(retry);
40
+ }
41
+ return this.consume(res);
42
+ }
43
+ close() {
44
+ this.closed = true;
45
+ this.sessionId = null;
46
+ }
47
+ defaultTimeout() {
48
+ return this.cfg.timeoutMs ?? 120_000;
49
+ }
50
+ async post(msg, sessionId, signal) {
51
+ const url = this.cfg.url;
52
+ if (!url)
53
+ throw new Error("MCP HTTP transport requires \"url\" in the server config");
54
+ const headers = {
55
+ "content-type": "application/json",
56
+ accept: "application/json, text/event-stream",
57
+ ...expandEnv(this.cfg.headers),
58
+ };
59
+ if (sessionId)
60
+ headers["mcp-session-id"] = sessionId;
61
+ try {
62
+ // Combine the caller's abort signal (MCP A cancellation) with an idle
63
+ // timeout into one AbortSignal. AbortSignal.any needs ≥1 real signal.
64
+ const signals = [AbortSignal.timeout(this.defaultTimeout())];
65
+ if (signal)
66
+ signals.push(signal);
67
+ const combined = signals.length > 1 ? AbortSignal.any(signals) : signals[0];
68
+ return await fetch(url, {
69
+ method: "POST",
70
+ headers,
71
+ body: JSON.stringify(msg),
72
+ signal: combined,
73
+ });
74
+ }
75
+ catch (e) {
76
+ throw new Error(`MCP HTTP "${url}": ${e.message}`, { cause: e });
77
+ }
78
+ }
79
+ async consume(res) {
80
+ const sid = res.headers.get("mcp-session-id");
81
+ if (sid)
82
+ this.sessionId = sid;
83
+ if (!res.ok) {
84
+ res.body?.cancel().catch(() => { });
85
+ throw new Error(`MCP HTTP ${res.status} ${res.statusText}`);
86
+ }
87
+ const ct = res.headers.get("content-type") ?? "";
88
+ if (ct.includes("application/json")) {
89
+ const body = (await res.text()).trim();
90
+ if (body) {
91
+ try {
92
+ this.msgCb?.(JSON.parse(body));
93
+ }
94
+ catch { /* skip malformed */ }
95
+ }
96
+ return;
97
+ }
98
+ if (ct.includes("text/event-stream")) {
99
+ // MCP A: incremental SSE — stream body, emit each JSON-RPC message as
100
+ // its `data:` event completes, instead of buffering the entire response.
101
+ await this.consumeSseStream(res.body);
102
+ return;
103
+ }
104
+ // 202/no-content (notifications) — nothing to parse
105
+ }
106
+ /** Incremental SSE: read the body as a stream and emit each JSON-RPC message
107
+ * as its SSE block (blank-line-separated) completes — no full-body buffer. */
108
+ async consumeSseStream(body) {
109
+ if (!body)
110
+ return;
111
+ const reader = body.getReader();
112
+ const decoder = new TextDecoder();
113
+ let buf = "";
114
+ try {
115
+ for (;;) {
116
+ const { done, value } = await reader.read();
117
+ if (done)
118
+ break;
119
+ buf += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
120
+ let sep;
121
+ while ((sep = buf.indexOf("\n\n")) !== -1) {
122
+ const block = buf.slice(0, sep);
123
+ buf = buf.slice(sep + 2);
124
+ const msg = parseSseEvent(block);
125
+ if (msg)
126
+ this.msgCb?.(msg);
127
+ }
128
+ }
129
+ }
130
+ finally {
131
+ reader.releaseLock();
132
+ }
133
+ // Flush any trailing partial SSE block at EOF (no trailing \n\n).
134
+ if (buf.trim()) {
135
+ const msg = parseSseEvent(buf);
136
+ if (msg)
137
+ this.msgCb?.(msg);
138
+ }
139
+ }
140
+ }