muonroi-cli 1.4.1 → 1.5.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/LICENSE +21 -21
- package/README.md +122 -122
- package/dist/packages/agent-harness-core/src/predicate.d.ts +1 -1
- package/dist/src/agent-harness/__tests__/mock-model.spec.js +48 -1
- package/dist/src/agent-harness/mock-model.d.ts +11 -0
- package/dist/src/agent-harness/mock-model.js +21 -0
- package/dist/src/cli/cost-forensics.js +12 -12
- package/dist/src/council/__tests__/clarification-prompt.test.js +51 -0
- package/dist/src/council/__tests__/clarifier-ready-gate.test.js +32 -0
- package/dist/src/council/__tests__/decisions-lock.test.js +17 -1
- package/dist/src/council/__tests__/oauth-reachable.test.d.ts +1 -0
- package/dist/src/council/__tests__/oauth-reachable.test.js +31 -0
- package/dist/src/council/__tests__/parse-outcome-fallback.test.js +11 -0
- package/dist/src/council/clarifier.js +9 -1
- package/dist/src/council/debate.js +5 -1
- package/dist/src/council/decisions-lock.js +3 -3
- package/dist/src/council/index.js +12 -5
- package/dist/src/council/leader.d.ts +0 -17
- package/dist/src/council/leader.js +22 -15
- package/dist/src/council/planner.js +1 -1
- package/dist/src/council/prompts.js +63 -57
- package/dist/src/council/types.d.ts +7 -0
- package/dist/src/ee/__tests__/ee-onboarding.test.d.ts +1 -0
- package/dist/src/ee/__tests__/ee-onboarding.test.js +32 -0
- package/dist/src/ee/auth.d.ts +9 -0
- package/dist/src/ee/auth.js +19 -0
- package/dist/src/ee/ee-onboarding.d.ts +5 -0
- package/dist/src/ee/ee-onboarding.js +76 -0
- package/dist/src/generated/version.d.ts +1 -1
- package/dist/src/generated/version.js +1 -1
- package/dist/src/headless/output.js +6 -4
- package/dist/src/headless/output.test.js +4 -3
- package/dist/src/index.js +20 -1
- package/dist/src/mcp/__tests__/auto-setup.test.js +74 -0
- package/dist/src/mcp/__tests__/client-pool.spec.d.ts +1 -0
- package/dist/src/mcp/__tests__/client-pool.spec.js +98 -0
- package/dist/src/mcp/__tests__/parallel-build.spec.d.ts +1 -0
- package/dist/src/mcp/__tests__/parallel-build.spec.js +67 -0
- package/dist/src/mcp/__tests__/smart-filter.test.js +56 -0
- package/dist/src/mcp/auto-setup.js +56 -2
- package/dist/src/mcp/client-pool.d.ts +46 -0
- package/dist/src/mcp/client-pool.js +212 -0
- package/dist/src/mcp/oauth-callback.js +2 -2
- package/dist/src/mcp/parse-headers.test.js +14 -14
- package/dist/src/mcp/runtime.d.ts +28 -0
- package/dist/src/mcp/runtime.js +117 -51
- package/dist/src/mcp/self-verify-runner.d.ts +14 -0
- package/dist/src/mcp/self-verify-runner.js +38 -0
- package/dist/src/mcp/setup-guide-text.d.ts +9 -0
- package/dist/src/mcp/setup-guide-text.js +84 -0
- package/dist/src/mcp/smart-filter.js +49 -0
- package/dist/src/mcp/smoke.test.js +43 -43
- package/dist/src/mcp/tools-server.d.ts +7 -0
- package/dist/src/mcp/tools-server.js +19 -22
- package/dist/src/models/catalog.json +349 -349
- package/dist/src/ops/__tests__/doctor-ee-health.test.js +21 -0
- package/dist/src/ops/doctor.d.ts +3 -2
- package/dist/src/ops/doctor.js +47 -11
- package/dist/src/ops/doctor.test.js +4 -3
- package/dist/src/orchestrator/__tests__/mcp-capability-block.test.d.ts +1 -0
- package/dist/src/orchestrator/__tests__/mcp-capability-block.test.js +39 -0
- package/dist/src/orchestrator/__tests__/project-stack.test.d.ts +1 -0
- package/dist/src/orchestrator/__tests__/project-stack.test.js +65 -0
- package/dist/src/orchestrator/batch-turn-runner.js +7 -11
- package/dist/src/orchestrator/message-processor.js +57 -27
- package/dist/src/orchestrator/orchestrator.js +26 -0
- package/dist/src/orchestrator/prompts.d.ts +51 -0
- package/dist/src/orchestrator/prompts.js +257 -134
- package/dist/src/orchestrator/scope-ceiling.js +6 -1
- package/dist/src/orchestrator/stream-runner.js +20 -15
- package/dist/src/orchestrator/text-tool-call-detector.test.js +13 -13
- package/dist/src/pil/__tests__/clarity-gate.test.js +24 -215
- package/dist/src/pil/__tests__/config.test.js +1 -17
- package/dist/src/pil/__tests__/discovery.test.js +144 -11
- package/dist/src/pil/__tests__/layer1-intent-trace.test.js +7 -2
- package/dist/src/pil/__tests__/layer1-intent.test.js +3 -0
- package/dist/src/pil/__tests__/layer16-clarity.test.js +32 -116
- package/dist/src/pil/__tests__/layer4-gsd.test.js +37 -0
- package/dist/src/pil/__tests__/layer6-output.test.js +137 -18
- package/dist/src/pil/__tests__/llm-classify.test.js +49 -2
- package/dist/src/pil/agent-operating-contract.d.ts +1 -1
- package/dist/src/pil/agent-operating-contract.js +2 -0
- package/dist/src/pil/agent-operating-contract.test.js +7 -2
- package/dist/src/pil/cheap-model-playbook.js +35 -35
- package/dist/src/pil/cheap-model-workbooks.js +16 -13
- package/dist/src/pil/clarity-gate.d.ts +21 -19
- package/dist/src/pil/clarity-gate.js +26 -153
- package/dist/src/pil/config.d.ts +9 -1
- package/dist/src/pil/config.js +15 -4
- package/dist/src/pil/discovery.js +211 -136
- package/dist/src/pil/layer1-intent.d.ts +12 -0
- package/dist/src/pil/layer1-intent.js +283 -38
- package/dist/src/pil/layer1-intent.test.js +210 -4
- package/dist/src/pil/layer16-clarity.d.ts +25 -11
- package/dist/src/pil/layer16-clarity.js +19 -306
- package/dist/src/pil/layer4-gsd.js +18 -6
- package/dist/src/pil/layer6-output.d.ts +2 -0
- package/dist/src/pil/layer6-output.js +137 -22
- package/dist/src/pil/llm-classify.d.ts +26 -0
- package/dist/src/pil/llm-classify.js +34 -5
- package/dist/src/pil/native-capabilities-workbook.d.ts +1 -1
- package/dist/src/pil/native-capabilities-workbook.js +82 -76
- package/dist/src/pil/schema.d.ts +8 -0
- package/dist/src/pil/schema.js +12 -1
- package/dist/src/pil/task-tier-map.js +4 -0
- package/dist/src/pil/types.d.ts +11 -1
- package/dist/src/product-loop/done-gate.js +3 -3
- package/dist/src/product-loop/loop-driver.js +18 -18
- package/dist/src/product-loop/progress-snapshot.js +4 -4
- package/dist/src/providers/auth/gemini-oauth.js +6 -15
- package/dist/src/providers/auth/grok-oauth.js +6 -15
- package/dist/src/providers/auth/openai-oauth.js +6 -15
- package/dist/src/providers/mcp-vision-bridge.js +48 -48
- package/dist/src/reporter/index.js +1 -1
- package/dist/src/scaffold/bb-ecosystem-apply.js +47 -47
- package/dist/src/scaffold/bb-quality-gate.js +5 -5
- package/dist/src/scaffold/continuation-prompt.js +60 -60
- package/dist/src/scaffold/init-new.js +453 -453
- package/dist/src/self-qa/__tests__/scenario-planner.test.js +3 -3
- package/dist/src/self-qa/agentic-loop.js +24 -19
- package/dist/src/self-qa/spec-emitter.js +26 -23
- package/dist/src/storage/__tests__/migrations.test.js +2 -2
- package/dist/src/storage/interaction-log.js +5 -5
- package/dist/src/storage/migrations.js +122 -122
- package/dist/src/storage/sessions.js +42 -42
- package/dist/src/storage/transcript.js +91 -84
- package/dist/src/storage/usage.js +14 -14
- package/dist/src/storage/workspaces.js +12 -12
- package/dist/src/tools/__tests__/native-tools.test.d.ts +1 -0
- package/dist/src/tools/__tests__/native-tools.test.js +53 -0
- package/dist/src/tools/git-safety.d.ts +61 -0
- package/dist/src/tools/git-safety.js +141 -0
- package/dist/src/tools/git-safety.test.d.ts +1 -0
- package/dist/src/tools/git-safety.test.js +111 -0
- package/dist/src/tools/native-tools.d.ts +31 -0
- package/dist/src/tools/native-tools.js +273 -0
- package/dist/src/tools/registry-git-safety.test.d.ts +7 -0
- package/dist/src/tools/registry-git-safety.test.js +92 -0
- package/dist/src/tools/registry.js +39 -4
- package/dist/src/ui/__tests__/markdown-render.test.d.ts +1 -0
- package/dist/src/ui/__tests__/markdown-render.test.js +48 -0
- package/dist/src/ui/app.js +0 -0
- package/dist/src/ui/components/message-view.js +4 -1
- package/dist/src/ui/components/structured-response-view.js +7 -3
- package/dist/src/ui/components/tool-group.js +7 -1
- package/dist/src/ui/markdown-render.d.ts +41 -0
- package/dist/src/ui/markdown-render.js +223 -0
- package/dist/src/ui/markdown.d.ts +10 -0
- package/dist/src/ui/markdown.js +12 -35
- package/dist/src/ui/slash/council-inspect.js +4 -4
- package/dist/src/ui/slash/export.js +4 -4
- package/dist/src/ui/utils/text.d.ts +8 -0
- package/dist/src/ui/utils/text.js +16 -0
- package/dist/src/ui/utils/text.test.d.ts +1 -0
- package/dist/src/ui/utils/text.test.js +23 -0
- package/dist/src/usage/ledger.js +48 -15
- package/dist/src/utils/__tests__/footprint-gitignore.test.d.ts +1 -0
- package/dist/src/utils/__tests__/footprint-gitignore.test.js +50 -0
- package/dist/src/utils/clipboard-image.js +23 -23
- package/dist/src/utils/open-url.d.ts +56 -0
- package/dist/src/utils/open-url.js +58 -0
- package/dist/src/utils/open-url.test.d.ts +1 -0
- package/dist/src/utils/open-url.test.js +86 -0
- package/dist/src/utils/settings.d.ts +12 -0
- package/dist/src/utils/settings.js +48 -0
- package/dist/src/utils/side-question.js +2 -2
- package/dist/src/utils/skills.js +3 -3
- package/dist/src/verify/__tests__/coverage-parsers.test.js +30 -30
- package/dist/src/verify/environment.js +2 -1
- package/package.json +1 -1
- package/dist/src/pil/layer16-clarity.test.js +0 -31
- /package/dist/src/{pil/layer16-clarity.test.d.ts → council/__tests__/clarification-prompt.test.d.ts} +0 -0
|
@@ -5,10 +5,12 @@ export function renderHeadlessPrelude(format, sessionId) {
|
|
|
5
5
|
if (format === "json") {
|
|
6
6
|
return {};
|
|
7
7
|
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
// Status indicator + session id are progress UX, not the reply. Keep stdout
|
|
9
|
+
// pure (only the model's answer) so `--format text` pipes cleanly. VERIFY F3.
|
|
10
|
+
const statusLines = ["\x1b[36m⏳ Processing...\x1b[0m"];
|
|
11
|
+
if (sessionId)
|
|
12
|
+
statusLines.push(`\x1b[2mSession: ${sessionId}\x1b[0m`);
|
|
13
|
+
return { stderr: `${statusLines.join("\n")}\n` };
|
|
12
14
|
}
|
|
13
15
|
/**
|
|
14
16
|
* Headless text output only. JSON streaming uses {@link createHeadlessJsonlEmitter} + `Agent.processMessage` observer.
|
|
@@ -25,10 +25,11 @@ describe("headless output helpers", () => {
|
|
|
25
25
|
expect(isHeadlessOutputFormat("json")).toBe(true);
|
|
26
26
|
expect(isHeadlessOutputFormat("xml")).toBe(false);
|
|
27
27
|
});
|
|
28
|
-
it("renders the text prelude with
|
|
28
|
+
it("renders the text prelude with status on stderr (stdout stays pure for piping)", () => {
|
|
29
|
+
// VERIFY F3: spinner + session id are progress UX, not the reply — both go
|
|
30
|
+
// to stderr so `--format text` stdout contains only the model's answer.
|
|
29
31
|
expect(renderHeadlessPrelude("text", "session-123")).toEqual({
|
|
30
|
-
|
|
31
|
-
stderr: "\u001b[2mSession: session-123\u001b[0m\n",
|
|
32
|
+
stderr: "[36m⏳ Processing...[0m\n[2mSession: session-123[0m\n",
|
|
32
33
|
});
|
|
33
34
|
});
|
|
34
35
|
it("suppresses the prelude in json mode", () => {
|
package/dist/src/index.js
CHANGED
|
@@ -982,8 +982,27 @@ program
|
|
|
982
982
|
}
|
|
983
983
|
}
|
|
984
984
|
// Bootstrap EE auth (loads serverBaseUrl + token from ~/.experience/config.json)
|
|
985
|
-
const { loadEEAuthToken } = await import("./ee/auth.js");
|
|
985
|
+
const { loadEEAuthToken, getCachedServerBaseUrl } = await import("./ee/auth.js");
|
|
986
986
|
await loadEEAuthToken().catch(() => { });
|
|
987
|
+
// First-run EE setup (interactive, once per install): if no EE server is
|
|
988
|
+
// configured, offer to connect one + write ~/.experience/config.json so the
|
|
989
|
+
// agent's record/recall/feedback loop has a brain. One-time, flag-gated.
|
|
990
|
+
if (isInteractive) {
|
|
991
|
+
try {
|
|
992
|
+
const { loadUserSettings, saveUserSettings } = await import("./utils/settings.js");
|
|
993
|
+
if (loadUserSettings().eeSetupPrompted !== true && !getCachedServerBaseUrl()) {
|
|
994
|
+
const { firstRunEESetup } = await import("./ee/ee-onboarding.js");
|
|
995
|
+
const wrote = await firstRunEESetup();
|
|
996
|
+
if (wrote)
|
|
997
|
+
await loadEEAuthToken().catch(() => { });
|
|
998
|
+
saveUserSettings({ eeSetupPrompted: true });
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
catch (err) {
|
|
1002
|
+
if (process.env.MUONROI_DEBUG)
|
|
1003
|
+
console.error(`[muonroi-cli] EE first-run setup skipped: ${err?.message}`);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
987
1006
|
// Auto-detect EE client mode (thin / thin-degraded / fat / disabled).
|
|
988
1007
|
// Result is cached for downstream callsites (PIL layers, bridge.searchByText)
|
|
989
1008
|
// so each request doesn't re-probe.
|
|
@@ -33,6 +33,80 @@ describe("ensureDefaultMcpServers — research servers", () => {
|
|
|
33
33
|
expect(ids).toContain("fetch");
|
|
34
34
|
expect(ids).toContain("tavily");
|
|
35
35
|
});
|
|
36
|
+
it("registers muonroi-docs as a default, enabled, http ecosystem source", async () => {
|
|
37
|
+
const { ensureDefaultMcpServers } = await import("../auto-setup.js");
|
|
38
|
+
const merged = ensureDefaultMcpServers();
|
|
39
|
+
const docs = merged.find((s) => s.id === "muonroi-docs");
|
|
40
|
+
expect(docs).toBeDefined();
|
|
41
|
+
expect(docs?.enabled).toBe(true);
|
|
42
|
+
expect(docs?.transport).toBe("http");
|
|
43
|
+
expect(docs?.url).toContain("docs-mcp.muonroi.com");
|
|
44
|
+
});
|
|
45
|
+
it("does NOT register muonroi-tools (its tools are native in-process builtins now)", async () => {
|
|
46
|
+
// The CLI no longer self-spawns itself as an MCP server. ee_query/ee_feedback/
|
|
47
|
+
// ee_health/usage_forensics/lsp_query/setup_guide/selfverify_* are native
|
|
48
|
+
// builtins (src/tools/native-tools.ts) — strictly better than a per-turn
|
|
49
|
+
// subprocess cold-start. So muonroi-tools must NOT be seeded.
|
|
50
|
+
const { ensureDefaultMcpServers } = await import("../auto-setup.js");
|
|
51
|
+
const merged = ensureDefaultMcpServers();
|
|
52
|
+
expect(merged.find((s) => s.id === "muonroi-tools")).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
it("removes an existing self-spawned muonroi-tools entry (incl. an old vitest-worker-poisoned one)", async () => {
|
|
55
|
+
const settingsPath = path.join(tmpHome, ".muonroi-cli", "user-settings.json");
|
|
56
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
57
|
+
fs.writeFileSync(settingsPath, JSON.stringify({
|
|
58
|
+
mcp: {
|
|
59
|
+
servers: [
|
|
60
|
+
{
|
|
61
|
+
id: "muonroi-tools",
|
|
62
|
+
label: "muonroi-tools (Experience + Self-Diagnostics)",
|
|
63
|
+
enabled: true,
|
|
64
|
+
transport: "stdio",
|
|
65
|
+
command: "C:\\Program Files\\nodejs\\node.exe",
|
|
66
|
+
args: [
|
|
67
|
+
"D:\\repo\\node_modules\\.bun\\vitest@4.1.5\\node_modules\\vitest\\dist\\workers\\forks.js",
|
|
68
|
+
"tools-mcp",
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
}));
|
|
74
|
+
const { ensureDefaultMcpServers } = await import("../auto-setup.js");
|
|
75
|
+
const merged = ensureDefaultMcpServers();
|
|
76
|
+
// Deprecated self-spawn stripped; no vitest worker path survives.
|
|
77
|
+
expect(merged.find((s) => s.id === "muonroi-tools")).toBeUndefined();
|
|
78
|
+
expect(JSON.stringify(merged)).not.toMatch(/vitest|forks\.js/);
|
|
79
|
+
});
|
|
80
|
+
it("removes a self-spawned muonroi-tools entry even with a valid bun-source command", async () => {
|
|
81
|
+
const settingsPath = path.join(tmpHome, ".muonroi-cli", "user-settings.json");
|
|
82
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
83
|
+
fs.writeFileSync(settingsPath, JSON.stringify({
|
|
84
|
+
mcp: {
|
|
85
|
+
servers: [
|
|
86
|
+
{
|
|
87
|
+
id: "muonroi-tools",
|
|
88
|
+
label: "muonroi-tools",
|
|
89
|
+
enabled: true,
|
|
90
|
+
transport: "stdio",
|
|
91
|
+
command: "bun",
|
|
92
|
+
args: ["/repo/src/index.ts", "tools-mcp"],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "context7",
|
|
96
|
+
label: "Context7",
|
|
97
|
+
enabled: true,
|
|
98
|
+
transport: "http",
|
|
99
|
+
url: "https://mcp.context7.com/mcp",
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
}));
|
|
104
|
+
const { ensureDefaultMcpServers } = await import("../auto-setup.js");
|
|
105
|
+
const merged = ensureDefaultMcpServers();
|
|
106
|
+
expect(merged.find((s) => s.id === "muonroi-tools")).toBeUndefined();
|
|
107
|
+
// Unrelated user server preserved.
|
|
108
|
+
expect(merged.find((s) => s.id === "context7")).toBeDefined();
|
|
109
|
+
});
|
|
36
110
|
it("context7 and fetch default to enabled", async () => {
|
|
37
111
|
const { ensureDefaultMcpServers } = await import("../auto-setup.js");
|
|
38
112
|
const merged = ensureDefaultMcpServers();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
-
|
|
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;
|