muonroi-cli 1.4.1 → 1.6.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.
Files changed (194) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +122 -122
  3. package/dist/packages/agent-harness-core/src/predicate.d.ts +1 -1
  4. package/dist/src/agent-harness/__tests__/mock-model.spec.js +48 -1
  5. package/dist/src/agent-harness/mock-model.d.ts +11 -0
  6. package/dist/src/agent-harness/mock-model.js +21 -0
  7. package/dist/src/cli/cost-forensics.js +12 -12
  8. package/dist/src/council/__tests__/clarification-prompt.test.js +51 -0
  9. package/dist/src/council/__tests__/clarifier-ready-gate.test.js +32 -0
  10. package/dist/src/council/__tests__/decisions-lock.test.js +17 -1
  11. package/dist/src/council/__tests__/oauth-reachable.test.d.ts +1 -0
  12. package/dist/src/council/__tests__/oauth-reachable.test.js +31 -0
  13. package/dist/src/council/__tests__/parse-outcome-fallback.test.js +11 -0
  14. package/dist/src/council/clarifier.js +9 -1
  15. package/dist/src/council/debate.js +5 -1
  16. package/dist/src/council/decisions-lock.js +3 -3
  17. package/dist/src/council/index.js +12 -5
  18. package/dist/src/council/leader.d.ts +0 -17
  19. package/dist/src/council/leader.js +22 -15
  20. package/dist/src/council/planner.js +1 -1
  21. package/dist/src/council/prompts.js +63 -57
  22. package/dist/src/council/types.d.ts +7 -0
  23. package/dist/src/ee/__tests__/ee-onboarding.test.d.ts +1 -0
  24. package/dist/src/ee/__tests__/ee-onboarding.test.js +32 -0
  25. package/dist/src/ee/artifact-cache.d.ts +56 -0
  26. package/dist/src/ee/artifact-cache.js +155 -0
  27. package/dist/src/ee/artifact-cache.test.d.ts +1 -0
  28. package/dist/src/ee/artifact-cache.test.js +69 -0
  29. package/dist/src/ee/auth.d.ts +9 -0
  30. package/dist/src/ee/auth.js +19 -0
  31. package/dist/src/ee/ee-onboarding.d.ts +5 -0
  32. package/dist/src/ee/ee-onboarding.js +76 -0
  33. package/dist/src/ee/search.js +7 -5
  34. package/dist/src/ee/search.test.d.ts +1 -0
  35. package/dist/src/ee/search.test.js +23 -0
  36. package/dist/src/generated/version.d.ts +1 -1
  37. package/dist/src/generated/version.js +1 -1
  38. package/dist/src/headless/output.js +6 -4
  39. package/dist/src/headless/output.test.js +4 -3
  40. package/dist/src/index.js +20 -1
  41. package/dist/src/mcp/__tests__/auto-setup.test.js +74 -0
  42. package/dist/src/mcp/__tests__/client-pool.spec.d.ts +1 -0
  43. package/dist/src/mcp/__tests__/client-pool.spec.js +98 -0
  44. package/dist/src/mcp/__tests__/parallel-build.spec.d.ts +1 -0
  45. package/dist/src/mcp/__tests__/parallel-build.spec.js +67 -0
  46. package/dist/src/mcp/__tests__/smart-filter.test.js +56 -0
  47. package/dist/src/mcp/auto-setup.js +56 -2
  48. package/dist/src/mcp/client-pool.d.ts +46 -0
  49. package/dist/src/mcp/client-pool.js +212 -0
  50. package/dist/src/mcp/oauth-callback.js +2 -2
  51. package/dist/src/mcp/parse-headers.test.js +14 -14
  52. package/dist/src/mcp/runtime.d.ts +28 -0
  53. package/dist/src/mcp/runtime.js +117 -51
  54. package/dist/src/mcp/self-verify-runner.d.ts +14 -0
  55. package/dist/src/mcp/self-verify-runner.js +38 -0
  56. package/dist/src/mcp/setup-guide-text.d.ts +9 -0
  57. package/dist/src/mcp/setup-guide-text.js +84 -0
  58. package/dist/src/mcp/smart-filter.js +49 -0
  59. package/dist/src/mcp/smoke.test.js +43 -43
  60. package/dist/src/mcp/tools-server.d.ts +7 -0
  61. package/dist/src/mcp/tools-server.js +19 -22
  62. package/dist/src/models/catalog.json +349 -349
  63. package/dist/src/ops/__tests__/doctor-ee-health.test.js +21 -0
  64. package/dist/src/ops/doctor.d.ts +3 -2
  65. package/dist/src/ops/doctor.js +47 -11
  66. package/dist/src/ops/doctor.test.js +4 -3
  67. package/dist/src/orchestrator/__tests__/mcp-capability-block.test.d.ts +1 -0
  68. package/dist/src/orchestrator/__tests__/mcp-capability-block.test.js +39 -0
  69. package/dist/src/orchestrator/__tests__/project-stack.test.d.ts +1 -0
  70. package/dist/src/orchestrator/__tests__/project-stack.test.js +65 -0
  71. package/dist/src/orchestrator/batch-turn-runner.js +7 -11
  72. package/dist/src/orchestrator/compaction.d.ts +2 -0
  73. package/dist/src/orchestrator/compaction.js +14 -1
  74. package/dist/src/orchestrator/compaction.test.js +25 -1
  75. package/dist/src/orchestrator/message-processor.js +72 -32
  76. package/dist/src/orchestrator/orchestrator.js +26 -0
  77. package/dist/src/orchestrator/prompts.d.ts +51 -0
  78. package/dist/src/orchestrator/prompts.js +257 -134
  79. package/dist/src/orchestrator/scope-ceiling.js +6 -1
  80. package/dist/src/orchestrator/scope-reminder.d.ts +12 -0
  81. package/dist/src/orchestrator/scope-reminder.js +16 -0
  82. package/dist/src/orchestrator/scope-reminder.test.js +22 -1
  83. package/dist/src/orchestrator/stream-runner.js +23 -15
  84. package/dist/src/orchestrator/subagent-compactor.d.ts +14 -5
  85. package/dist/src/orchestrator/subagent-compactor.js +30 -8
  86. package/dist/src/orchestrator/subagent-compactor.spec.js +18 -0
  87. package/dist/src/orchestrator/text-tool-call-detector.test.js +13 -13
  88. package/dist/src/pil/__tests__/clarity-gate.test.js +24 -215
  89. package/dist/src/pil/__tests__/config.test.js +1 -17
  90. package/dist/src/pil/__tests__/discovery.test.js +144 -11
  91. package/dist/src/pil/__tests__/layer1-intent-trace.test.js +7 -2
  92. package/dist/src/pil/__tests__/layer1-intent.test.js +3 -0
  93. package/dist/src/pil/__tests__/layer16-clarity.test.js +32 -116
  94. package/dist/src/pil/__tests__/layer4-gsd.test.js +37 -0
  95. package/dist/src/pil/__tests__/layer6-output.test.js +158 -18
  96. package/dist/src/pil/__tests__/llm-classify.test.js +49 -2
  97. package/dist/src/pil/__tests__/surface-compaction-artifacts.test.d.ts +1 -0
  98. package/dist/src/pil/__tests__/surface-compaction-artifacts.test.js +112 -0
  99. package/dist/src/pil/agent-operating-contract.d.ts +1 -1
  100. package/dist/src/pil/agent-operating-contract.js +2 -0
  101. package/dist/src/pil/agent-operating-contract.test.js +7 -2
  102. package/dist/src/pil/cheap-model-playbook.js +35 -35
  103. package/dist/src/pil/cheap-model-workbooks.js +16 -13
  104. package/dist/src/pil/clarity-gate.d.ts +21 -19
  105. package/dist/src/pil/clarity-gate.js +26 -153
  106. package/dist/src/pil/config.d.ts +9 -1
  107. package/dist/src/pil/config.js +15 -4
  108. package/dist/src/pil/discovery.js +211 -136
  109. package/dist/src/pil/layer1-intent.d.ts +12 -0
  110. package/dist/src/pil/layer1-intent.js +283 -38
  111. package/dist/src/pil/layer1-intent.test.js +210 -4
  112. package/dist/src/pil/layer16-clarity.d.ts +25 -11
  113. package/dist/src/pil/layer16-clarity.js +19 -306
  114. package/dist/src/pil/layer3-ee-injection.d.ts +19 -0
  115. package/dist/src/pil/layer3-ee-injection.js +96 -4
  116. package/dist/src/pil/layer4-gsd.js +18 -6
  117. package/dist/src/pil/layer6-output.d.ts +2 -0
  118. package/dist/src/pil/layer6-output.js +151 -25
  119. package/dist/src/pil/llm-classify.d.ts +26 -0
  120. package/dist/src/pil/llm-classify.js +34 -5
  121. package/dist/src/pil/native-capabilities-workbook.d.ts +1 -1
  122. package/dist/src/pil/native-capabilities-workbook.js +82 -76
  123. package/dist/src/pil/pipeline.js +15 -9
  124. package/dist/src/pil/schema.d.ts +8 -0
  125. package/dist/src/pil/schema.js +12 -1
  126. package/dist/src/pil/task-tier-map.js +4 -0
  127. package/dist/src/pil/types.d.ts +11 -1
  128. package/dist/src/product-loop/done-gate.js +3 -3
  129. package/dist/src/product-loop/loop-driver.js +18 -18
  130. package/dist/src/product-loop/progress-snapshot.js +4 -4
  131. package/dist/src/providers/auth/gemini-oauth.js +6 -15
  132. package/dist/src/providers/auth/grok-oauth.js +6 -15
  133. package/dist/src/providers/auth/openai-oauth.js +6 -15
  134. package/dist/src/providers/mcp-vision-bridge.js +48 -48
  135. package/dist/src/reporter/index.js +1 -1
  136. package/dist/src/scaffold/bb-ecosystem-apply.js +47 -47
  137. package/dist/src/scaffold/bb-quality-gate.js +5 -5
  138. package/dist/src/scaffold/continuation-prompt.js +60 -60
  139. package/dist/src/scaffold/init-new.js +453 -453
  140. package/dist/src/self-qa/__tests__/scenario-planner.test.js +3 -3
  141. package/dist/src/self-qa/agentic-loop.js +24 -19
  142. package/dist/src/self-qa/spec-emitter.js +26 -23
  143. package/dist/src/storage/__tests__/migrations.test.js +2 -2
  144. package/dist/src/storage/interaction-log.js +5 -5
  145. package/dist/src/storage/migrations.js +122 -122
  146. package/dist/src/storage/sessions.js +42 -42
  147. package/dist/src/storage/transcript.js +91 -84
  148. package/dist/src/storage/usage.js +14 -14
  149. package/dist/src/storage/workspaces.js +12 -12
  150. package/dist/src/tools/__tests__/native-tools.test.d.ts +1 -0
  151. package/dist/src/tools/__tests__/native-tools.test.js +53 -0
  152. package/dist/src/tools/git-safety.d.ts +61 -0
  153. package/dist/src/tools/git-safety.js +141 -0
  154. package/dist/src/tools/git-safety.test.d.ts +1 -0
  155. package/dist/src/tools/git-safety.test.js +111 -0
  156. package/dist/src/tools/native-tools.d.ts +31 -0
  157. package/dist/src/tools/native-tools.js +273 -0
  158. package/dist/src/tools/registry-ee-query.test.js +18 -1
  159. package/dist/src/tools/registry-git-safety.test.d.ts +7 -0
  160. package/dist/src/tools/registry-git-safety.test.js +92 -0
  161. package/dist/src/tools/registry.js +52 -6
  162. package/dist/src/ui/__tests__/markdown-render.test.d.ts +1 -0
  163. package/dist/src/ui/__tests__/markdown-render.test.js +48 -0
  164. package/dist/src/ui/app.js +0 -0
  165. package/dist/src/ui/components/message-view.js +4 -1
  166. package/dist/src/ui/components/structured-response-view.js +7 -3
  167. package/dist/src/ui/components/tool-group.js +7 -1
  168. package/dist/src/ui/markdown-render.d.ts +41 -0
  169. package/dist/src/ui/markdown-render.js +223 -0
  170. package/dist/src/ui/markdown.d.ts +10 -0
  171. package/dist/src/ui/markdown.js +12 -35
  172. package/dist/src/ui/slash/council-inspect.js +4 -4
  173. package/dist/src/ui/slash/export.js +4 -4
  174. package/dist/src/ui/utils/text.d.ts +8 -0
  175. package/dist/src/ui/utils/text.js +16 -0
  176. package/dist/src/ui/utils/text.test.d.ts +1 -0
  177. package/dist/src/ui/utils/text.test.js +23 -0
  178. package/dist/src/usage/ledger.js +48 -15
  179. package/dist/src/utils/__tests__/footprint-gitignore.test.d.ts +1 -0
  180. package/dist/src/utils/__tests__/footprint-gitignore.test.js +50 -0
  181. package/dist/src/utils/clipboard-image.js +23 -23
  182. package/dist/src/utils/open-url.d.ts +56 -0
  183. package/dist/src/utils/open-url.js +58 -0
  184. package/dist/src/utils/open-url.test.d.ts +1 -0
  185. package/dist/src/utils/open-url.test.js +86 -0
  186. package/dist/src/utils/settings.d.ts +12 -0
  187. package/dist/src/utils/settings.js +48 -0
  188. package/dist/src/utils/side-question.js +2 -2
  189. package/dist/src/utils/skills.js +3 -3
  190. package/dist/src/verify/__tests__/coverage-parsers.test.js +30 -30
  191. package/dist/src/verify/environment.js +2 -1
  192. package/package.json +1 -1
  193. package/dist/src/pil/layer16-clarity.test.js +0 -31
  194. /package/dist/src/{pil/layer16-clarity.test.d.ts → council/__tests__/clarification-prompt.test.d.ts} +0 -0
@@ -0,0 +1,98 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ // Pool reuse semantics: connect a server ONCE, reuse the live client across
3
+ // turns, evict on failure (retry) and on a post-connect connection error
4
+ // (reconnect), and tear everything down on closeAllMcpClients().
5
+ vi.mock("../validate.js", () => ({ validateMcpServerConfig: () => ({ ok: true }) }));
6
+ const connectOneServer = vi.fn();
7
+ vi.mock("../runtime.js", () => ({
8
+ connectOneServer: (...args) => connectOneServer(...args),
9
+ getMcpBuildDeadlineMs: () => 500,
10
+ }));
11
+ import { __mcpClientPoolSize, __resetMcpClientPoolForTests, acquireMcpTools, closeAllMcpClients, warmMcpClients, } from "../client-pool.js";
12
+ const srv = (id) => ({ id, label: id, enabled: true, transport: "stdio", command: "x", args: [] });
13
+ const connected = (id, close = async () => { }) => ({
14
+ tools: { [`mcp_${id}__ping`]: { execute: async () => "pong" } },
15
+ client: { close },
16
+ });
17
+ describe("acquireMcpTools — cross-turn client pool", () => {
18
+ beforeEach(() => {
19
+ __resetMcpClientPoolForTests();
20
+ connectOneServer.mockReset();
21
+ });
22
+ afterEach(async () => {
23
+ await closeAllMcpClients();
24
+ });
25
+ it("connects a server once and reuses it across turns (no per-turn cold-spawn)", async () => {
26
+ connectOneServer.mockImplementation(async (s) => connected(s.id));
27
+ const b1 = await acquireMcpTools([srv("fs")]);
28
+ expect(Object.keys(b1.tools)).toContain("mcp_fs__ping");
29
+ await b1.close(); // release — must NOT kill the pooled client
30
+ const b2 = await acquireMcpTools([srv("fs")]);
31
+ expect(Object.keys(b2.tools)).toContain("mcp_fs__ping");
32
+ expect(connectOneServer).toHaveBeenCalledTimes(1); // reused, not re-spawned
33
+ });
34
+ it("evicts a failed connect so a later turn retries", async () => {
35
+ connectOneServer
36
+ .mockRejectedValueOnce(new Error("spawn failed"))
37
+ .mockImplementation(async (s) => connected(s.id));
38
+ const b1 = await acquireMcpTools([srv("fs")]);
39
+ expect(b1.errors.some((e) => e.includes("fs"))).toBe(true);
40
+ expect(Object.keys(b1.tools)).not.toContain("mcp_fs__ping");
41
+ const b2 = await acquireMcpTools([srv("fs")]);
42
+ expect(Object.keys(b2.tools)).toContain("mcp_fs__ping");
43
+ expect(connectOneServer).toHaveBeenCalledTimes(2); // retried after eviction
44
+ });
45
+ it("self-heals: a tool hitting a connection error evicts the client so the next turn reconnects", async () => {
46
+ connectOneServer.mockImplementation(async (s) => ({
47
+ tools: {
48
+ [`mcp_${s.id}__boom`]: {
49
+ execute: async () => {
50
+ throw new Error("MCP transport closed");
51
+ },
52
+ },
53
+ },
54
+ client: { close: async () => { } },
55
+ }));
56
+ const b1 = await acquireMcpTools([srv("fs")]);
57
+ await expect(b1.tools["mcp_fs__boom"].execute({}, {})).rejects.toThrow(/transport closed/);
58
+ const b2 = await acquireMcpTools([srv("fs")]);
59
+ expect(b2).toBeDefined();
60
+ expect(connectOneServer).toHaveBeenCalledTimes(2); // reconnected after the connection error
61
+ });
62
+ it("keys by cwd/config — a different command reconnects rather than reusing", async () => {
63
+ connectOneServer.mockImplementation(async (s) => connected(s.id));
64
+ await acquireMcpTools([
65
+ { id: "fs", label: "fs", enabled: true, transport: "stdio", command: "a", args: [] },
66
+ ]);
67
+ await acquireMcpTools([
68
+ { id: "fs", label: "fs", enabled: true, transport: "stdio", command: "b", args: [] },
69
+ ]);
70
+ expect(connectOneServer).toHaveBeenCalledTimes(2);
71
+ });
72
+ it("warmMcpClients pre-connects so the first real turn reuses (no extra spawn)", async () => {
73
+ let resolveConnect = () => { };
74
+ connectOneServer.mockImplementation((s) => new Promise((res) => {
75
+ resolveConnect = () => res(connected(s.id));
76
+ }));
77
+ // Warm starts the connect in the background.
78
+ warmMcpClients([srv("fs")]);
79
+ expect(connectOneServer).toHaveBeenCalledTimes(1);
80
+ expect(__mcpClientPoolSize()).toBe(1);
81
+ // Let the warm connect finish, then a real turn reuses it.
82
+ resolveConnect();
83
+ await new Promise((r) => setTimeout(r, 0));
84
+ const b = await acquireMcpTools([srv("fs")]);
85
+ expect(Object.keys(b.tools)).toContain("mcp_fs__ping");
86
+ expect(connectOneServer).toHaveBeenCalledTimes(1); // warmed, not re-spawned
87
+ });
88
+ it("closeAllMcpClients tears down every pooled client", async () => {
89
+ const closeSpy = vi.fn(async () => { });
90
+ connectOneServer.mockImplementation(async (s) => connected(s.id, closeSpy));
91
+ await acquireMcpTools([srv("fs"), srv("mem")]);
92
+ expect(__mcpClientPoolSize()).toBe(2);
93
+ await closeAllMcpClients();
94
+ expect(closeSpy).toHaveBeenCalledTimes(2);
95
+ expect(__mcpClientPoolSize()).toBe(0);
96
+ });
97
+ });
98
+ //# sourceMappingURL=client-pool.spec.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,67 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ // Phase 1c regression: buildMcpToolSet must connect servers in PARALLEL and
3
+ // return PARTIAL results at its deadline, so a slow server (e.g. an npx stdio
4
+ // spawn) never starves a fast one. The OLD sequential build under an outer race
5
+ // dropped the WHOLE bundle on timeout — the agent then saw NO MCP tools even
6
+ // when a fast HTTP server was reachable (session f6f7881a5fae).
7
+ vi.mock("../mcp-keychain.js", () => ({
8
+ getMcpKey: vi.fn(async () => null),
9
+ setMcpKey: vi.fn(async () => true),
10
+ deleteMcpKey: vi.fn(async () => true),
11
+ }));
12
+ vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({
13
+ StdioClientTransport: vi.fn(function (opts) {
14
+ Object.assign(this, opts);
15
+ }),
16
+ getDefaultEnvironment: () => ({}),
17
+ }));
18
+ vi.mock("../validate.js", () => ({
19
+ validateMcpServerConfig: () => ({ ok: true }),
20
+ }));
21
+ const fastClient = {
22
+ tools: async () => ({ ping: { description: "ping", execute: async () => ({ ok: true }) } }),
23
+ close: async () => { },
24
+ };
25
+ vi.mock("@ai-sdk/mcp", () => ({
26
+ // A server whose name contains "slow" never finishes connecting (simulates a
27
+ // slow npx spawn). Everything else connects instantly.
28
+ createMCPClient: vi.fn(async ({ name }) => {
29
+ if (name.includes("slow"))
30
+ return new Promise(() => { }); // never resolves
31
+ return fastClient;
32
+ }),
33
+ }));
34
+ describe("buildMcpToolSet — parallel build, partial results at deadline (Phase 1c)", () => {
35
+ beforeEach(() => {
36
+ vi.clearAllMocks();
37
+ delete process.env.MUONROI_MCP_BUILD_DEADLINE_MS;
38
+ });
39
+ it("returns the fast server's tools and reports the slow one — slow does NOT starve fast", async () => {
40
+ process.env.MUONROI_MCP_BUILD_DEADLINE_MS = "500";
41
+ const { buildMcpToolSet } = await import("../runtime.js");
42
+ const start = Date.now();
43
+ const bundle = await buildMcpToolSet([
44
+ { id: "slow-server", label: "slow-server", enabled: true, transport: "stdio", command: "node", args: [] },
45
+ { id: "fast-server", label: "fast-server", enabled: true, transport: "stdio", command: "node", args: [] },
46
+ ]);
47
+ const elapsed = Date.now() - start;
48
+ // Resolved at ~the deadline, NOT blocked behind the slow (never-ending) connect.
49
+ expect(elapsed).toBeLessThan(2000);
50
+ // The fast server's tool is available even though a slower server is pending.
51
+ expect(Object.keys(bundle.tools)).toContain("mcp_fast-server__ping");
52
+ // The slow server is surfaced as an error, never silently dropped.
53
+ expect(bundle.errors.some((e) => e.includes("slow-server") && /not ready within/.test(e))).toBe(true);
54
+ await bundle.close();
55
+ });
56
+ it("orders are independent — the fast server loads regardless of position", async () => {
57
+ process.env.MUONROI_MCP_BUILD_DEADLINE_MS = "500";
58
+ const { buildMcpToolSet } = await import("../runtime.js");
59
+ const bundle = await buildMcpToolSet([
60
+ { id: "fast-server", label: "fast-server", enabled: true, transport: "stdio", command: "node", args: [] },
61
+ { id: "slow-server", label: "slow-server", enabled: true, transport: "stdio", command: "node", args: [] },
62
+ ]);
63
+ expect(Object.keys(bundle.tools)).toContain("mcp_fast-server__ping");
64
+ await bundle.close();
65
+ });
66
+ });
67
+ //# sourceMappingURL=parallel-build.spec.js.map
@@ -6,6 +6,7 @@ const servers = [
6
6
  { id: "filesystem" },
7
7
  { id: "muonroi-tools" },
8
8
  { id: "muonroi-harness" },
9
+ { id: "muonroi-docs" },
9
10
  { id: "context7" },
10
11
  { id: "fetch" },
11
12
  { id: "tavily" },
@@ -62,6 +63,26 @@ describe("filterMcpServersByMessage", () => {
62
63
  expect(ids(out)).toContain("muonroi-tools");
63
64
  expect(ids(out)).toContain("muonroi-harness");
64
65
  });
66
+ it("keeps muonroi-docs for an ecosystem question that has no generic docs keyword", () => {
67
+ // Live miss (session dbe408937a3d turn 1): "bạn hiểu thế nào về hệ sinh thái
68
+ // muonroi" carries no docs/api keyword and doesn't say "muonroi-docs", so the
69
+ // authoritative ecosystem source was dropped and the agent guessed from files.
70
+ for (const msg of [
71
+ "bạn hiểu thế nào về hệ sinh thái muonroi",
72
+ "what is the muonroi ecosystem?",
73
+ "explain the building-block rule engine",
74
+ ]) {
75
+ const out = filterMcpServersByMessage(servers, msg);
76
+ expect(ids(out), msg).toContain("muonroi-docs");
77
+ }
78
+ });
79
+ it("keeping muonroi-docs for an ecosystem question does NOT over-keep other docs/web servers", () => {
80
+ const out = filterMcpServersByMessage(servers, "bạn hiểu thế nào về hệ sinh thái muonroi");
81
+ expect(ids(out)).toContain("muonroi-docs");
82
+ expect(ids(out)).not.toContain("context7");
83
+ expect(ids(out)).not.toContain("fetch");
84
+ expect(ids(out)).not.toContain("tavily");
85
+ });
65
86
  it("returns every server unchanged when disabled (MUONROI_DISABLE_SMART_MCP=1)", () => {
66
87
  const out = filterMcpServersByMessage(servers, "Reply PONG", { disabled: true });
67
88
  expect(ids(out)).toEqual(ids(servers));
@@ -74,6 +95,41 @@ describe("filterMcpServersByMessage", () => {
74
95
  expect(ids(out)).toContain("chrome-devtools");
75
96
  }
76
97
  });
98
+ // Regression: session f6f7881a5fae. The user asked "bạn thử call tool
99
+ // setup_guide ... ( call tool chứ không phải đọc code )". `muonroi-docs` (id
100
+ // matches /docs/) carried no docs-lookup keyword, so the category skip dropped
101
+ // it — the model had no `setup_guide` tool and drove the server by hand over
102
+ // bash JSON-RPC. An explicit tool-invocation intent (or an outright server
103
+ // mention) must keep the owning server.
104
+ const docsServers = [{ id: "filesystem" }, { id: "muonroi-docs" }, { id: "context7" }, { id: "tavily" }];
105
+ it("keeps an optional server when the user explicitly asks to CALL a tool by name", () => {
106
+ const msg = "bạn thử call tool setup_guide xem có được thông tin gì không nhé ( call tool chứ không phải đọc code nhé )";
107
+ expect(ids(filterMcpServersByMessage(docsServers, msg))).toContain("muonroi-docs");
108
+ });
109
+ it("recognises explicit tool-invocation intent (EN + VI)", () => {
110
+ for (const msg of [
111
+ "please call the setup_guide tool and report",
112
+ "use the docs_search tool",
113
+ "invoke the mcp tool",
114
+ "do a tool call to setup_guide",
115
+ "dùng tool docs_search giúp tôi",
116
+ "gọi tool setup_guide",
117
+ "thử mcp tool xem sao",
118
+ ]) {
119
+ expect(ids(filterMcpServersByMessage(docsServers, msg)), msg).toContain("muonroi-docs");
120
+ }
121
+ });
122
+ it("keeps a server named outright in the message even without a category signal", () => {
123
+ // "check the muonroi-docs MCP" — no docs-lookup verb, but the server is named.
124
+ const out = filterMcpServersByMessage(docsServers, "bạn check xem dùng được mcp muonroi-docs không nhé");
125
+ expect(ids(out)).toContain("muonroi-docs");
126
+ });
127
+ it("still drops optional servers on a pure code prompt (token savings preserved)", () => {
128
+ // The fix must NOT defeat the filter: no tool-intent, no server mention, no
129
+ // docs signal → muonroi-docs/context7/tavily still dropped.
130
+ const out = filterMcpServersByMessage(docsServers, "fix the off-by-one in parseRange()");
131
+ expect(ids(out)).toEqual(["filesystem"]);
132
+ });
77
133
  });
78
134
  describe("dropRedundantFsMcpTools", () => {
79
135
  const fn = () => ({});
@@ -1,5 +1,44 @@
1
1
  import { loadMcpServers, saveMcpServers } from "../utils/settings.js";
2
+ /**
3
+ * True when running inside a test runner (vitest). Used to keep seed-time
4
+ * persistence from mutating the user's REAL config — see ensureDefaultMcpServers.
5
+ */
6
+ function isTestRunner() {
7
+ return process.env.VITEST === "true" || process.env.VITEST_WORKER_ID !== undefined || process.env.NODE_ENV === "test";
8
+ }
9
+ /**
10
+ * Remove a deprecated self-spawned `muonroi-tools` stdio server from the config.
11
+ *
12
+ * The CLI's OWN inner agent now exposes ee_query/ee_feedback/ee_health/
13
+ * usage_forensics/lsp_query/setup_guide/selfverify_* as NATIVE in-process
14
+ * builtins (src/tools/native-tools.ts) — strictly better than self-spawning a
15
+ * 137MB CLI as an MCP subprocess (which cold-started 2-3.5s, overran the build
16
+ * deadline, and once had a vitest-worker command persisted that crashed on
17
+ * launch). So the self-spawn is now pure waste: every tool it would expose is
18
+ * dropped as a native twin. Strip it on sight. The muonroi-tools MCP server
19
+ * still exists for EXTERNAL agents via their own config (e.g. ~/.claude.json) —
20
+ * that is a different file and is untouched here. Returns true if it changed.
21
+ */
22
+ function removeDeprecatedToolsMcp(servers) {
23
+ const idx = servers.findIndex((s) => s.id === "muonroi-tools" && s.transport === "stdio");
24
+ if (idx < 0)
25
+ return false;
26
+ servers.splice(idx, 1);
27
+ console.error("[mcp:auto-setup] removed deprecated self-spawned muonroi-tools server — its tools are now native in-process builtins");
28
+ return true;
29
+ }
2
30
  const DEFAULT_CONFIGS = [
31
+ {
32
+ // Authoritative source for the Muonroi ecosystem (BB/.NET template recipes,
33
+ // package docs, setup_guide, docs_search). Shipped enabled by default so any
34
+ // task touching the ecosystem always has a standard source to work from —
35
+ // the CLI behaves like a senior who knows the ecosystem, not one guessing.
36
+ id: "muonroi-docs",
37
+ label: "muonroi-docs (Ecosystem Docs)",
38
+ enabled: true,
39
+ transport: "http",
40
+ url: "https://docs-mcp.muonroi.com/mcp",
41
+ },
3
42
  {
4
43
  id: "filesystem",
5
44
  label: "Filesystem",
@@ -81,6 +120,13 @@ export function ensureDefaultMcpServers() {
81
120
  try {
82
121
  const existing = loadMcpServers();
83
122
  let dirty = migrateServers(existing);
123
+ // muonroi-tools is no longer self-spawned by the CLI — its capabilities
124
+ // (ee_query/ee_feedback/ee_health/usage_forensics/lsp_query/setup_guide/
125
+ // selfverify_*) are NATIVE in-process builtins now (src/tools/native-tools.ts).
126
+ // Strip any deprecated self-spawn entry so it stops cold-starting a redundant
127
+ // subprocess every turn (and removes the old vitest-worker-poisoned ones).
128
+ if (removeDeprecatedToolsMcp(existing))
129
+ dirty = true;
84
130
  const existingIds = new Set(existing.map((s) => s.id));
85
131
  const toAdd = DEFAULT_CONFIGS.filter((s) => !existingIds.has(s.id));
86
132
  if (toAdd.length > 0)
@@ -88,10 +134,18 @@ export function ensureDefaultMcpServers() {
88
134
  if (!dirty)
89
135
  return existing;
90
136
  const merged = toAdd.length > 0 ? [...toAdd, ...existing] : existing;
91
- saveMcpServers(merged);
137
+ // Never let a test runner mutate the user's REAL config file. Tests assert on
138
+ // the returned array; persistence is exercised only on real runs. This closes
139
+ // the leak whereby the seed (run from the Orchestrator constructor, which
140
+ // orchestrator tests trigger) wrote into a live config.
141
+ if (!isTestRunner())
142
+ saveMcpServers(merged);
92
143
  return merged;
93
144
  }
94
- catch {
145
+ catch (err) {
146
+ console.error(`[mcp:auto-setup] ensureDefaultMcpServers failed: ${err?.message}`, {
147
+ stack: err?.stack?.split("\n").slice(0, 3),
148
+ });
95
149
  return [];
96
150
  }
97
151
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * src/mcp/client-pool.ts
3
+ *
4
+ * Cross-turn MCP client pool. The orchestrator rebuilds its tool set every turn
5
+ * (and closes it in a `finally`), which previously cold-spawned EVERY stdio MCP
6
+ * server (npx filesystem/playwright/fetch/tavily/…) on every turn — each spawn
7
+ * costs ~1-3s and raced the build deadline. This pool connects each server ONCE
8
+ * and reuses the live client across turns: only the first turn that needs a
9
+ * server pays the cold-start; later turns select its (already-built) tools
10
+ * instantly. Real teardown happens once on orchestrator/process shutdown.
11
+ *
12
+ * Per-turn smart-filtering is unchanged — the caller still passes only the
13
+ * servers relevant to this message; the pool just avoids re-spawning the ones
14
+ * it has already connected.
15
+ *
16
+ * Self-healing: a server that fails to connect is evicted (not cached as a
17
+ * rejection), so a later turn retries. A live client whose child process dies
18
+ * later is evicted when one of its tool calls hits a transport/connection error,
19
+ * so the next turn reconnects fresh.
20
+ */
21
+ import type { McpServerConfig } from "../utils/settings.js";
22
+ import { type McpBuildOptions, type McpToolBundle } from "./runtime.js";
23
+ /**
24
+ * Acquire the tool set for `servers`, reusing pooled clients where possible.
25
+ * Mirrors buildMcpToolSet's parallel + partial-at-deadline contract, but only
26
+ * FIRST-connects can be slow — already-pooled servers resolve instantly. The
27
+ * returned bundle's `close()` is a no-op RELEASE: pooled clients stay alive for
28
+ * the next turn. Use closeAllMcpClients() for real teardown.
29
+ */
30
+ export declare function acquireMcpTools(servers: McpServerConfig[], opts?: McpBuildOptions): Promise<McpToolBundle>;
31
+ /**
32
+ * Fire-and-forget pre-connect: start connecting `servers` in the background so
33
+ * they are pooled BEFORE the first turn needs them. npx stdio servers
34
+ * (filesystem/memory) cold-start >2.5s and would otherwise miss the first turn's
35
+ * build deadline — warming them at startup means they're usually ready by the
36
+ * first prompt. No deadline, no return; per-turn acquireMcpTools reuses whatever
37
+ * has connected. Idempotent (cached entries are reused); a failed connect is
38
+ * evicted by getOrConnect so a real turn retries.
39
+ */
40
+ export declare function warmMcpClients(servers: McpServerConfig[]): void;
41
+ /** Tear down every pooled client. Call on orchestrator/process shutdown. */
42
+ export declare function closeAllMcpClients(): Promise<void>;
43
+ /** Test-only: reset pool state between cases. */
44
+ export declare function __resetMcpClientPoolForTests(): void;
45
+ /** Test-only: number of pooled (connecting or connected) entries. */
46
+ export declare function __mcpClientPoolSize(): number;
@@ -0,0 +1,212 @@
1
+ /**
2
+ * src/mcp/client-pool.ts
3
+ *
4
+ * Cross-turn MCP client pool. The orchestrator rebuilds its tool set every turn
5
+ * (and closes it in a `finally`), which previously cold-spawned EVERY stdio MCP
6
+ * server (npx filesystem/playwright/fetch/tavily/…) on every turn — each spawn
7
+ * costs ~1-3s and raced the build deadline. This pool connects each server ONCE
8
+ * and reuses the live client across turns: only the first turn that needs a
9
+ * server pays the cold-start; later turns select its (already-built) tools
10
+ * instantly. Real teardown happens once on orchestrator/process shutdown.
11
+ *
12
+ * Per-turn smart-filtering is unchanged — the caller still passes only the
13
+ * servers relevant to this message; the pool just avoids re-spawning the ones
14
+ * it has already connected.
15
+ *
16
+ * Self-healing: a server that fails to connect is evicted (not cached as a
17
+ * rejection), so a later turn retries. A live client whose child process dies
18
+ * later is evicted when one of its tool calls hits a transport/connection error,
19
+ * so the next turn reconnects fresh.
20
+ */
21
+ import { connectOneServer, getMcpBuildDeadlineMs, } from "./runtime.js";
22
+ import { validateMcpServerConfig } from "./validate.js";
23
+ const pool = new Map();
24
+ /**
25
+ * Stable identity for a connected server. Includes cwd (stdio servers like
26
+ * filesystem inherit it) + command/args/url/env so a config or cwd change
27
+ * reconnects instead of reusing a stale client.
28
+ */
29
+ function serverKey(s) {
30
+ return JSON.stringify({
31
+ id: s.id,
32
+ transport: s.transport,
33
+ command: s.command ?? null,
34
+ args: s.args ?? null,
35
+ url: s.url ?? null,
36
+ headers: s.headers ?? null,
37
+ env: s.env ?? null,
38
+ cwd: s.cwd ?? process.cwd(),
39
+ });
40
+ }
41
+ /** Tear down one pooled entry (best-effort) and remove it. */
42
+ function evict(key) {
43
+ const entry = pool.get(key);
44
+ if (!entry)
45
+ return;
46
+ pool.delete(key);
47
+ void entry.promise.then((cs) => {
48
+ cs.cleanup?.();
49
+ void cs.client.close().catch(() => { });
50
+ }, () => { });
51
+ }
52
+ /** Heuristic: does this error mean the MCP transport/child is gone? */
53
+ function isConnectionError(e) {
54
+ const msg = (e instanceof Error ? e.message : String(e)).toLowerCase();
55
+ return (msg.includes("closed") ||
56
+ msg.includes("disconnect") ||
57
+ msg.includes("econnrefused") ||
58
+ msg.includes("epipe") ||
59
+ msg.includes("transport") ||
60
+ msg.includes("not connected") ||
61
+ msg.includes("terminated"));
62
+ }
63
+ /** Connect a server (or reuse the live cached client). Evicts on connect failure. */
64
+ function getOrConnect(server, opts) {
65
+ const key = serverKey(server);
66
+ const existing = pool.get(key);
67
+ if (existing)
68
+ return existing.promise;
69
+ const promise = connectOneServer(server, opts);
70
+ const entry = { key, promise };
71
+ pool.set(key, entry);
72
+ // Cache a rejection only transiently: evict so the next turn retries rather
73
+ // than returning the same failed promise forever.
74
+ promise.catch(() => {
75
+ if (pool.get(key) === entry)
76
+ pool.delete(key);
77
+ });
78
+ return promise;
79
+ }
80
+ /**
81
+ * Wrap each tool's execute so a transport/connection failure evicts the pooled
82
+ * client (next turn reconnects). The MCP child may die after a successful
83
+ * connect; without this the dead client would be reused on every later turn.
84
+ */
85
+ function wrapForSelfHeal(tools, key) {
86
+ const out = {};
87
+ for (const [name, tool] of Object.entries(tools)) {
88
+ const base = tool.execute;
89
+ if (typeof base !== "function") {
90
+ out[name] = tool;
91
+ continue;
92
+ }
93
+ out[name] = {
94
+ ...tool,
95
+ execute: async (args, options) => {
96
+ try {
97
+ return await base(args, options);
98
+ }
99
+ catch (e) {
100
+ if (isConnectionError(e)) {
101
+ console.error(`[mcp:pool] '${name}' hit a connection error — evicting cached client so the next turn reconnects`);
102
+ evict(key);
103
+ }
104
+ throw e;
105
+ }
106
+ },
107
+ };
108
+ }
109
+ return out;
110
+ }
111
+ /**
112
+ * Acquire the tool set for `servers`, reusing pooled clients where possible.
113
+ * Mirrors buildMcpToolSet's parallel + partial-at-deadline contract, but only
114
+ * FIRST-connects can be slow — already-pooled servers resolve instantly. The
115
+ * returned bundle's `close()` is a no-op RELEASE: pooled clients stay alive for
116
+ * the next turn. Use closeAllMcpClients() for real teardown.
117
+ */
118
+ export async function acquireMcpTools(servers, opts) {
119
+ const tools = {};
120
+ const errors = [];
121
+ const enabled = servers.filter((s) => s.enabled);
122
+ const slots = enabled.map((s) => ({ label: s.label, key: serverKey(s), done: false }));
123
+ const attempts = enabled.map((server, i) => {
124
+ const validation = validateMcpServerConfig(server);
125
+ if (!validation.ok) {
126
+ slots[i] = { ...slots[i], done: true, error: validation.error };
127
+ return Promise.resolve();
128
+ }
129
+ return getOrConnect(server, opts).then((result) => {
130
+ slots[i] = { ...slots[i], done: true, result };
131
+ }, (error) => {
132
+ slots[i] = { ...slots[i], done: true, error: error instanceof Error ? error.message : String(error) };
133
+ });
134
+ });
135
+ const deadlineMs = getMcpBuildDeadlineMs();
136
+ let deadlineTimer;
137
+ const deadline = new Promise((resolve) => {
138
+ deadlineTimer = setTimeout(resolve, deadlineMs);
139
+ deadlineTimer.unref?.();
140
+ });
141
+ await Promise.race([Promise.allSettled(attempts), deadline]);
142
+ if (deadlineTimer)
143
+ clearTimeout(deadlineTimer);
144
+ for (const slot of slots) {
145
+ if (slot.done) {
146
+ if (slot.error) {
147
+ errors.push(`${slot.label}: ${slot.error}`);
148
+ }
149
+ else if (slot.result) {
150
+ Object.assign(tools, wrapForSelfHeal(slot.result.tools, slot.key));
151
+ }
152
+ }
153
+ else {
154
+ // Still connecting at the deadline (a cold first-connect). It stays in the
155
+ // pool and will be ready for a later turn — just excluded from THIS turn.
156
+ errors.push(`${slot.label}: not ready within ${deadlineMs}ms (still connecting — available next turn)`);
157
+ }
158
+ }
159
+ if (errors.length > 0) {
160
+ console.error(`[mcp:pool] ${errors.length} server(s) unavailable this turn: ${errors.join(" | ")}`);
161
+ }
162
+ return {
163
+ tools,
164
+ errors,
165
+ // Release, not close: pooled clients persist across turns by design.
166
+ async close() { },
167
+ };
168
+ }
169
+ /**
170
+ * Fire-and-forget pre-connect: start connecting `servers` in the background so
171
+ * they are pooled BEFORE the first turn needs them. npx stdio servers
172
+ * (filesystem/memory) cold-start >2.5s and would otherwise miss the first turn's
173
+ * build deadline — warming them at startup means they're usually ready by the
174
+ * first prompt. No deadline, no return; per-turn acquireMcpTools reuses whatever
175
+ * has connected. Idempotent (cached entries are reused); a failed connect is
176
+ * evicted by getOrConnect so a real turn retries.
177
+ */
178
+ export function warmMcpClients(servers) {
179
+ for (const s of servers) {
180
+ if (!s.enabled)
181
+ continue;
182
+ if (!validateMcpServerConfig(s).ok)
183
+ continue;
184
+ void getOrConnect(s).catch(() => {
185
+ /* warm is best-effort — the eviction in getOrConnect lets a real turn retry */
186
+ });
187
+ }
188
+ }
189
+ /** Tear down every pooled client. Call on orchestrator/process shutdown. */
190
+ export async function closeAllMcpClients() {
191
+ const entries = [...pool.values()];
192
+ pool.clear();
193
+ await Promise.all(entries.map(async (e) => {
194
+ try {
195
+ const cs = await e.promise;
196
+ cs.cleanup?.();
197
+ await cs.client.close().catch(() => { });
198
+ }
199
+ catch {
200
+ /* a never-connected entry has nothing to close */
201
+ }
202
+ }));
203
+ }
204
+ /** Test-only: reset pool state between cases. */
205
+ export function __resetMcpClientPoolForTests() {
206
+ pool.clear();
207
+ }
208
+ /** Test-only: number of pooled (connecting or connected) entries. */
209
+ export function __mcpClientPoolSize() {
210
+ return pool.size;
211
+ }
212
+ //# sourceMappingURL=client-pool.js.map
@@ -1,7 +1,7 @@
1
1
  import http from "node:http";
2
2
  import { URL } from "node:url";
3
- const SUCCESS_HTML = `<!DOCTYPE html><html><body style="font-family:system-ui;text-align:center;padding:60px">
4
- <h2>Authorization successful</h2><p>You can close this tab and return to the terminal.</p>
3
+ const SUCCESS_HTML = `<!DOCTYPE html><html><body style="font-family:system-ui;text-align:center;padding:60px">
4
+ <h2>Authorization successful</h2><p>You can close this tab and return to the terminal.</p>
5
5
  </body></html>`;
6
6
  export function startOAuthCallbackServer(opts) {
7
7
  const callbackPath = opts.path ?? "/callback";
@@ -2,19 +2,19 @@ import { describe, expect, it } from "vitest";
2
2
  import { parseEnvLines, parseHeaderLines } from "./parse-headers.js";
3
3
  describe("parseHeaderLines", () => {
4
4
  it("parses colon-separated headers and trims whitespace", () => {
5
- expect(parseHeaderLines(`
6
- Authorization: Bearer token
7
- X-Trace-Id: abc123
5
+ expect(parseHeaderLines(`
6
+ Authorization: Bearer token
7
+ X-Trace-Id: abc123
8
8
  `)).toEqual({
9
9
  Authorization: "Bearer token",
10
10
  "X-Trace-Id": "abc123",
11
11
  });
12
12
  });
13
13
  it("ignores blank and malformed lines while preserving later colons in values", () => {
14
- expect(parseHeaderLines(`
15
- invalid
16
- : missing-name
17
- Host: example.com:443
14
+ expect(parseHeaderLines(`
15
+ invalid
16
+ : missing-name
17
+ Host: example.com:443
18
18
  `)).toEqual({
19
19
  Host: "example.com:443",
20
20
  });
@@ -22,19 +22,19 @@ describe("parseHeaderLines", () => {
22
22
  });
23
23
  describe("parseEnvLines", () => {
24
24
  it("parses equals-separated env assignments and trims whitespace", () => {
25
- expect(parseEnvLines(`
26
- API_KEY = secret
27
- MODE= production
25
+ expect(parseEnvLines(`
26
+ API_KEY = secret
27
+ MODE= production
28
28
  `)).toEqual({
29
29
  API_KEY: "secret",
30
30
  MODE: "production",
31
31
  });
32
32
  });
33
33
  it("ignores blank and malformed lines while preserving later equals in values", () => {
34
- expect(parseEnvLines(`
35
- missing
36
- = no-name
37
- URL=https://example.com?a=b
34
+ expect(parseEnvLines(`
35
+ missing
36
+ = no-name
37
+ URL=https://example.com?a=b
38
38
  `)).toEqual({
39
39
  URL: "https://example.com?a=b",
40
40
  });