mixdog 0.9.151 → 0.9.152
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/README.md +110 -68
- package/package.json +3 -2
- package/scripts/tool-stress.mjs +0 -2
- package/src/defaults/skills/setup/SKILL.md +18 -27
- package/src/headless-exec.mjs +3 -10
- package/src/headless-exec.test.mjs +2 -7
- package/src/runtime/agent/orchestrator/config.mjs +6 -66
- package/src/runtime/agent/orchestrator/context/collect-skills.test.mjs +153 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +49 -45
- package/src/runtime/agent/orchestrator/mcp/client.mjs +242 -15
- package/src/runtime/agent/orchestrator/mcp/features.test.mjs +66 -0
- package/src/runtime/agent/orchestrator/mcp/security.test.mjs +36 -20
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +54 -33
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +3 -8
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +10 -2
- package/src/runtime/agent/orchestrator/providers/lib/provider-replay.mjs +50 -0
- package/src/runtime/agent/orchestrator/providers/lib/sse-framing.mjs +93 -0
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +3 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +8 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +25 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -2
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +13 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +4 -7
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/provider-replay.test.mjs +235 -0
- package/src/runtime/agent/orchestrator/providers/sse-framing.test.mjs +719 -0
- package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +399 -39
- package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +14 -4
- package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +24 -1
- package/src/runtime/agent/orchestrator/session/compaction-read-reset.test.mjs +161 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +14 -4
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +31 -18
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +6 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +1 -3
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +556 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.test.mjs +457 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +105 -186
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +115 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +23 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool-cwd-env.test.mjs +14 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +19 -10
- package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +30 -14
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +15 -4
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +73 -15
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +76 -2
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +24 -683
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +64 -72
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +2 -5
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.mjs +75 -0
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.test.mjs +24 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +12 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +24 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.test.mjs +44 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +12 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +35 -21
- package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +28 -13
- package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.test.mjs +54 -0
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +8 -0
- package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
- package/src/runtime/browser-bridge/client.mjs +103 -0
- package/src/runtime/browser-bridge/tool-defs.mjs +42 -0
- package/src/runtime/computer-bridge/client.mjs +101 -0
- package/src/runtime/computer-bridge/tool-defs.mjs +53 -0
- package/src/runtime/media/store.mjs +18 -8
- package/src/runtime/media/store.test.mjs +14 -0
- package/src/runtime/memory/index.mjs +1 -1
- package/src/runtime/memory/lib/transcript-ingest.mjs +8 -6
- package/src/runtime/memory/lib/transcript-ingest.test.mjs +52 -0
- package/src/runtime/shared/child-spawn-gate.mjs +17 -0
- package/src/runtime/shared/child-spawn-remote.mjs +69 -7
- package/src/runtime/shared/child-spawn-remote.test.mjs +124 -0
- package/src/runtime/shared/pristine-execution-contract.json +2 -1
- package/src/runtime/shared/provider-api-key.mjs +1 -0
- package/src/runtime/shared/session-runtime-health.mjs +91 -0
- package/src/runtime/shared/session-runtime-health.test.mjs +53 -0
- package/src/runtime/shared/skill-document.mjs +86 -0
- package/src/runtime/shared/skill-document.test.mjs +81 -0
- package/src/runtime/shared/tool-surface.mjs +18 -0
- package/src/runtime/shared/turn-snapshot-store.mjs +1 -0
- package/src/runtime/shared/turn-snapshot.mjs +32 -9
- package/src/runtime/shared/user-cwd.mjs +33 -14
- package/src/runtime/shared/user-cwd.test.mjs +43 -0
- package/src/session-runtime/cwd-plugins.mjs +26 -26
- package/src/session-runtime/cwd-plugins.test.mjs +75 -0
- package/src/session-runtime/cwd-tool-routing.test.mjs +5 -0
- package/src/session-runtime/global-extensions.mjs +26 -0
- package/src/session-runtime/global-extensions.test.mjs +28 -0
- package/src/session-runtime/goal-runtime.mjs +721 -0
- package/src/session-runtime/goal-runtime.test.mjs +108 -0
- package/src/session-runtime/lifecycle-api.mjs +18 -4
- package/src/session-runtime/lifecycle-api.test.mjs +21 -0
- package/src/session-runtime/mcp-glue.mjs +49 -47
- package/src/session-runtime/plugin-mcp-standard.test.mjs +177 -0
- package/src/session-runtime/plugin-mcp.mjs +96 -22
- package/src/session-runtime/prewarm.mjs +1 -10
- package/src/session-runtime/resource-api-global.test.mjs +69 -0
- package/src/session-runtime/resource-api.mjs +173 -58
- package/src/session-runtime/runtime-core.mjs +148 -20
- package/src/session-runtime/runtime-tunables.mjs +6 -8
- package/src/session-runtime/session-turn-api.mjs +22 -11
- package/src/session-runtime/skills-api.mjs +79 -25
- package/src/session-runtime/skills-api.test.mjs +99 -0
- package/src/session-runtime/tool-catalog-schema.mjs +0 -5
- package/src/session-runtime/tool-catalog.mjs +4 -13
- package/src/session-runtime/tool-policy-surface.test.mjs +33 -11
- package/src/session-runtime/tool-surface.mjs +12 -0
- package/src/standalone/agent-dispatch-broker.mjs +5 -9
- package/src/standalone/agent-tool/notify.mjs +6 -8
- package/src/standalone/agent-tool/notify.test.mjs +37 -0
- package/src/standalone/daemon.mjs +31 -15
- package/src/standalone/hook-bus/config.mjs +1 -0
- package/src/standalone/plugin-admin.mjs +17 -0
- package/src/standalone/plugin-admin.test.mjs +7 -0
- package/src/standalone/provider-admin.mjs +1 -0
- package/src/standalone/session-protocol.mjs +5 -0
- package/src/standalone/session-runtime-agent-control-client.mjs +133 -0
- package/src/standalone/session-runtime-agent-control-client.test.mjs +71 -0
- package/src/standalone/session-runtime-dispatch-cancel.test.mjs +139 -0
- package/src/standalone/session-runtime-host-factory.mjs +17 -0
- package/src/standalone/session-runtime-host-factory.test.mjs +31 -0
- package/src/standalone/session-runtime-host-health.test.mjs +5 -0
- package/src/standalone/session-runtime-host.mjs +696 -58
- package/src/standalone/session-runtime-inline-host.mjs +351 -0
- package/src/standalone/session-runtime-inline-host.test.mjs +83 -0
- package/src/standalone/session-runtime-provider-cooldown.mjs +78 -0
- package/src/standalone/session-runtime-provider-cooldown.test.mjs +80 -0
- package/src/standalone/session-runtime-shard-host.test.mjs +569 -0
- package/src/standalone/session-runtime-shard-router.mjs +149 -0
- package/src/standalone/session-runtime-shard-router.test.mjs +101 -0
- package/src/standalone/session-runtime-worker.mjs +476 -18
- package/src/standalone/session-service.mjs +81 -6
- package/src/tui/app/app-view.jsx +1 -0
- package/src/tui/app/core-memory-picker.mjs +2 -0
- package/src/tui/app/extension-pickers.mjs +1 -1
- package/src/tui/app/maintenance-pickers.mjs +3 -3
- package/src/tui/app/model-options.mjs +2 -0
- package/src/tui/app/model-picker.mjs +14 -4
- package/src/tui/app/panel-handoff.test.mjs +311 -0
- package/src/tui/app/project-picker.mjs +1 -0
- package/src/tui/app/route-pickers.mjs +21 -6
- package/src/tui/app/settings-picker.mjs +26 -3
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +73 -22
- package/src/tui/app/theme-effort-pickers.mjs +7 -2
- package/src/tui/components/Picker.jsx +10 -1
- package/src/tui/dist/index.mjs +163 -44
- package/src/tui/session/context-state.mjs +1 -0
- package/src/tui/session/goal-continuation.mjs +110 -0
- package/src/tui/session/goal-continuation.test.mjs +84 -0
- package/src/tui/session/queue-helpers.mjs +5 -2
- package/src/tui/session/session-action-surface.test.mjs +108 -0
- package/src/tui/session/session-api-ext.mjs +39 -8
- package/src/tui/session/session-api.mjs +58 -0
- package/src/tui/session/session-flow.mjs +6 -0
- package/src/tui/session/turn.mjs +1 -0
- package/src/tui/session-local.mjs +15 -1
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +0 -301
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createGoalRuntime,
|
|
9
|
+
DEFAULT_GOAL_TIME_LIMIT_MS,
|
|
10
|
+
parseGoalDuration,
|
|
11
|
+
} from './goal-runtime.mjs';
|
|
12
|
+
|
|
13
|
+
test('Goal runtime persists criteria, enforces completion evidence, and archives after user input', async () => {
|
|
14
|
+
const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-goal-'));
|
|
15
|
+
let clock = 1_800_000_000_000;
|
|
16
|
+
const runtime = createGoalRuntime({ dataDir, now: () => clock });
|
|
17
|
+
try {
|
|
18
|
+
const created = await runtime.control('sess_goal_main', {
|
|
19
|
+
command: 'Ship Goal mode --time 2h',
|
|
20
|
+
});
|
|
21
|
+
assert.equal(created.goal.status, 'active');
|
|
22
|
+
assert.equal(created.goal.timeLimitMs, 2 * 60 * 60 * 1000);
|
|
23
|
+
assert.equal(created.goal.criteriaTotal, 0);
|
|
24
|
+
|
|
25
|
+
const criteria = [
|
|
26
|
+
{ text: 'State is durable', satisfied: true, evidence: 'persistence test passed' },
|
|
27
|
+
{ text: 'Idle continuation works', satisfied: false, evidence: '' },
|
|
28
|
+
];
|
|
29
|
+
await runtime.executeTool('update_goal', {
|
|
30
|
+
success_criteria: criteria,
|
|
31
|
+
progress_summary: 'State complete; continuation remains.',
|
|
32
|
+
}, { callerSessionId: 'sess_goal_main' });
|
|
33
|
+
|
|
34
|
+
await assert.rejects(
|
|
35
|
+
runtime.executeTool('update_goal', {
|
|
36
|
+
status: 'complete',
|
|
37
|
+
completion_evidence: 'partial evidence',
|
|
38
|
+
}, { callerSessionId: 'sess_goal_main' }),
|
|
39
|
+
/success criteria remain unsatisfied/,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
clock += 12 * 60 * 1000;
|
|
43
|
+
const completedCriteria = criteria.map((criterion) => ({
|
|
44
|
+
...criterion,
|
|
45
|
+
satisfied: true,
|
|
46
|
+
evidence: criterion.evidence || 'continuation test passed',
|
|
47
|
+
}));
|
|
48
|
+
const completedText = await runtime.executeTool('update_goal', {
|
|
49
|
+
success_criteria: completedCriteria,
|
|
50
|
+
status: 'complete',
|
|
51
|
+
completion_evidence: 'All required checks passed.',
|
|
52
|
+
}, { callerSessionId: 'sess_goal_main' });
|
|
53
|
+
const completed = JSON.parse(completedText).goal;
|
|
54
|
+
assert.equal(completed.status, 'complete');
|
|
55
|
+
assert.equal(completed.criteriaCompleted, 2);
|
|
56
|
+
assert.equal(completed.timeUsedMs, 12 * 60 * 1000);
|
|
57
|
+
|
|
58
|
+
await runtime.archiveCompletedOnUserInput('sess_goal_main');
|
|
59
|
+
assert.equal(runtime.snapshot('sess_goal_main'), null);
|
|
60
|
+
assert.equal(runtime.storedSnapshot('sess_goal_main').status, 'complete');
|
|
61
|
+
|
|
62
|
+
const reloaded = createGoalRuntime({ dataDir, now: () => clock });
|
|
63
|
+
try {
|
|
64
|
+
assert.equal(reloaded.snapshot('sess_goal_main'), null);
|
|
65
|
+
assert.equal(reloaded.storedSnapshot('sess_goal_main').completionEvidence, 'All required checks passed.');
|
|
66
|
+
} finally {
|
|
67
|
+
reloaded.close();
|
|
68
|
+
}
|
|
69
|
+
} finally {
|
|
70
|
+
runtime.close();
|
|
71
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('Goal continuation parks only for active agent work and the deadline limits new turns', async () => {
|
|
76
|
+
const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-goal-limit-'));
|
|
77
|
+
let clock = 1_900_000_000_000;
|
|
78
|
+
const runtime = createGoalRuntime({ dataDir, now: () => clock });
|
|
79
|
+
try {
|
|
80
|
+
await runtime.control('sess_goal_limit', { action: 'create', objective: 'Finish the objective' });
|
|
81
|
+
const parked = runtime.continuation('sess_goal_limit', {
|
|
82
|
+
agentStatus: { agentJobs: [{ status: 'running' }] },
|
|
83
|
+
});
|
|
84
|
+
assert.equal(parked.run, false);
|
|
85
|
+
assert.equal(parked.reason, 'agent-running');
|
|
86
|
+
|
|
87
|
+
const shellIgnored = runtime.continuation('sess_goal_limit', {
|
|
88
|
+
agentStatus: { agentJobs: [], shellJobs: [{ status: 'running' }] },
|
|
89
|
+
});
|
|
90
|
+
assert.equal(shellIgnored.run, true);
|
|
91
|
+
assert.match(shellIgnored.prompt, /Active Goal/);
|
|
92
|
+
|
|
93
|
+
clock += DEFAULT_GOAL_TIME_LIMIT_MS + 1;
|
|
94
|
+
const limited = runtime.snapshot('sess_goal_limit');
|
|
95
|
+
assert.equal(limited.status, 'budget_limited');
|
|
96
|
+
assert.equal(runtime.continuation('sess_goal_limit').run, false);
|
|
97
|
+
} finally {
|
|
98
|
+
runtime.close();
|
|
99
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('Goal duration parser accepts compound limits and rejects unsafe values', () => {
|
|
104
|
+
assert.equal(parseGoalDuration('1h30m'), 90 * 60 * 1000);
|
|
105
|
+
assert.equal(parseGoalDuration('2d 4h'), (2 * 24 + 4) * 60 * 60 * 1000);
|
|
106
|
+
assert.throws(() => parseGoalDuration('30s'), /at least 1 minute/);
|
|
107
|
+
assert.throws(() => parseGoalDuration('8d'), /exceeds 7 days/);
|
|
108
|
+
});
|
|
@@ -18,10 +18,13 @@ import {
|
|
|
18
18
|
import { SessionClosedError } from '../runtime/agent/orchestrator/session/manager/session-errors.mjs';
|
|
19
19
|
import { saveSession } from '../runtime/agent/orchestrator/session/store.mjs';
|
|
20
20
|
|
|
21
|
-
function resolveResumeCwd(session, currentCwd) {
|
|
21
|
+
export function resolveResumeCwd(session, currentCwd) {
|
|
22
22
|
const desktop = session?.desktopSession;
|
|
23
23
|
if (desktop?.classification === 'project') {
|
|
24
|
-
|
|
24
|
+
// `session.cwd` is the execution source of truth. Older builds updated it
|
|
25
|
+
// on `cwd set` without updating desktopSession.projectPath, so preferring
|
|
26
|
+
// the duplicate UI metadata reverted the Project after a restart.
|
|
27
|
+
return clean(session?.cwd) || clean(desktop.projectPath) || currentCwd;
|
|
25
28
|
}
|
|
26
29
|
if (desktop?.classification === 'task') {
|
|
27
30
|
// Desktop task sessions deliberately stay in the app-managed unclassified
|
|
@@ -80,8 +83,8 @@ export function createLifecycleApi(deps) {
|
|
|
80
83
|
computeContextStatus, invalidateContextStatusCache, invalidatePreSessionToolSurface,
|
|
81
84
|
applyResolvedCwd, resolveRoute, applyDeferredToolSurface, getStandaloneTools,
|
|
82
85
|
beginRoutePreparation, clearRoutePreparation,
|
|
83
|
-
notificationListeners, clearRuntimeNotifications,
|
|
84
|
-
disposeSessionTitles, abortActiveTurns, getReservedSessionId,
|
|
86
|
+
notificationListeners, clearRuntimeNotifications, goalRuntime,
|
|
87
|
+
disposeSessionTitles, disposeGlobalExtensionSubscription, abortActiveTurns, getReservedSessionId,
|
|
85
88
|
} = deps;
|
|
86
89
|
const closeSurfaceSession = (session, reason, options) => {
|
|
87
90
|
if (!session?.id) return false;
|
|
@@ -198,6 +201,7 @@ export function createLifecycleApi(deps) {
|
|
|
198
201
|
// of reaping every session's jobs. CLI exit paths never set this.
|
|
199
202
|
const keepBackgroundWork = options?.keepBackgroundWork === true;
|
|
200
203
|
setCloseRequested(true);
|
|
204
|
+
try { disposeGlobalExtensionSubscription?.(); } catch {}
|
|
201
205
|
const closingTurnId = getSession()?.id || getReservedSessionId?.() || 'pending';
|
|
202
206
|
try {
|
|
203
207
|
abortActiveTurns?.(new SessionClosedError(
|
|
@@ -348,6 +352,7 @@ export function createLifecycleApi(deps) {
|
|
|
348
352
|
invalidateContextStatusCache();
|
|
349
353
|
if (typeof clearRuntimeNotifications === 'function') clearRuntimeNotifications();
|
|
350
354
|
else notificationListeners?.clear?.();
|
|
355
|
+
try { goalRuntime?.close?.(); } catch {}
|
|
351
356
|
const shellJobsStop = teardownReapsWork && globalThis.__mixdogShellJobsRuntimeLoaded === true
|
|
352
357
|
? import('../runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs')
|
|
353
358
|
.then((mod) => mod?.shutdownShellJobs?.(reason, {
|
|
@@ -616,6 +621,15 @@ export function createLifecycleApi(deps) {
|
|
|
616
621
|
async inheritFrom(sourceSessionId) {
|
|
617
622
|
const id = clean(sourceSessionId);
|
|
618
623
|
if (!id) throw new TypeError('inheritFrom: source session id is required');
|
|
624
|
+
// A daemon-created heir is still only RESERVED when the desktop calls
|
|
625
|
+
// this: reserveSessionId starts the materializing create in the
|
|
626
|
+
// background. Join that single-flight instead of rejecting a session
|
|
627
|
+
// that is about to exist. (The TUI path already ran newSession().)
|
|
628
|
+
if (!getSession()?.id
|
|
629
|
+
&& clean(getReservedSessionId?.())
|
|
630
|
+
&& typeof createCurrentSession === 'function') {
|
|
631
|
+
await createCurrentSession('inherit');
|
|
632
|
+
}
|
|
619
633
|
const target = getSession();
|
|
620
634
|
if (!target?.id) throw new Error('inheritFrom: no session is open');
|
|
621
635
|
if (target.id === id) throw new Error('inheritFrom: a session cannot inherit from itself');
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { resolveResumeCwd } from './lifecycle-api.mjs';
|
|
5
|
+
|
|
6
|
+
test('project resume prefers canonical session cwd over stale desktop metadata', () => {
|
|
7
|
+
assert.equal(resolveResumeCwd({
|
|
8
|
+
cwd: 'C:\\Project\\mixdog',
|
|
9
|
+
desktopSession: {
|
|
10
|
+
classification: 'project',
|
|
11
|
+
projectPath: 'C:\\Project\\GamerScroll',
|
|
12
|
+
},
|
|
13
|
+
}, 'C:\\fallback'), 'C:\\Project\\mixdog');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('unclassified desktop task resume stays in its host-managed workspace', () => {
|
|
17
|
+
assert.equal(resolveResumeCwd({
|
|
18
|
+
cwd: 'C:\\old-transient',
|
|
19
|
+
desktopSession: { classification: 'task', projectPath: null },
|
|
20
|
+
}, 'C:\\task-workspace'), 'C:\\task-workspace');
|
|
21
|
+
});
|
|
@@ -1,34 +1,10 @@
|
|
|
1
1
|
// MCP config/status/connect glue. Mutable runtime state is dependency-injected
|
|
2
2
|
// through accessors and the caller-owned `state` object.
|
|
3
|
+
import { homedir } from 'node:os';
|
|
3
4
|
import { resolve } from 'node:path';
|
|
4
|
-
import { statSync } from 'node:fs';
|
|
5
5
|
import { clean } from './session-text.mjs';
|
|
6
|
-
import { normalizeMcpProjectPathKey, readProjectMcpServers } from './plugin-mcp.mjs';
|
|
7
6
|
import { envFlag } from './env.mjs';
|
|
8
7
|
|
|
9
|
-
// Cache project-local `.mcp.json` reads by path + mtime so repeated mcpStatus()
|
|
10
|
-
// calls skip existsSync+readFileSync+JSON.parse when the file is unchanged.
|
|
11
|
-
// Invalidated automatically on any mtime change (or create/delete via mtime=0).
|
|
12
|
-
const projectMcpCache = new Map();
|
|
13
|
-
const PROJECT_MCP_CACHE_MAX = 32;
|
|
14
|
-
export function invalidateProjectMcpCache(cwd) {
|
|
15
|
-
projectMcpCache.delete(resolve(cwd || '.', '.mcp.json'));
|
|
16
|
-
}
|
|
17
|
-
function cachedProjectMcpServers(cwd) {
|
|
18
|
-
const path = resolve(cwd || '.', '.mcp.json');
|
|
19
|
-
let mtimeMs = 0;
|
|
20
|
-
try { mtimeMs = statSync(path).mtimeMs; } catch { mtimeMs = 0; }
|
|
21
|
-
const hit = projectMcpCache.get(path);
|
|
22
|
-
if (hit && hit.mtimeMs === mtimeMs) return hit.value;
|
|
23
|
-
const value = readProjectMcpServers(cwd);
|
|
24
|
-
// Bound the cache: Map preserves insertion order, so drop the oldest entry.
|
|
25
|
-
if (!projectMcpCache.has(path) && projectMcpCache.size >= PROJECT_MCP_CACHE_MAX) {
|
|
26
|
-
projectMcpCache.delete(projectMcpCache.keys().next().value);
|
|
27
|
-
}
|
|
28
|
-
projectMcpCache.set(path, { mtimeMs, value });
|
|
29
|
-
return value;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
8
|
export function createMcpGlue({
|
|
33
9
|
mcpClient,
|
|
34
10
|
getConfig,
|
|
@@ -51,29 +27,43 @@ export function createMcpGlue({
|
|
|
51
27
|
// (precedence: project > user config). `sources[name]` records each server's
|
|
52
28
|
// origin ('config' | 'project') for status reporting.
|
|
53
29
|
function resolveEffectiveMcpServers() {
|
|
54
|
-
//
|
|
30
|
+
// MCP is machine-global. Project `.mcp.json` files and per-project
|
|
31
|
+
// overrides are intentionally outside the runtime resolution chain.
|
|
55
32
|
if (envFlag('MIXDOG_DISABLE_MCP')) return { servers: {}, sources: {} };
|
|
56
33
|
const config = getConfig();
|
|
57
34
|
const configured = config?.mcpServers && typeof config.mcpServers === 'object'
|
|
58
35
|
? config.mcpServers
|
|
59
36
|
: {};
|
|
60
|
-
const
|
|
61
|
-
const overrides = config?.mcpProjectOverrides?.[projectKey];
|
|
62
|
-
const foldedConfigured = {};
|
|
37
|
+
const servers = {};
|
|
63
38
|
for (const [name, cfg] of Object.entries(configured)) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
? {
|
|
67
|
-
|
|
39
|
+
servers[name] = {
|
|
40
|
+
...cfg,
|
|
41
|
+
...(cfg?._mixdogPluginDisabled === true ? { enabled: false } : {}),
|
|
42
|
+
};
|
|
68
43
|
}
|
|
69
|
-
const project = cachedProjectMcpServers(getCurrentCwd());
|
|
70
|
-
const servers = { ...foldedConfigured, ...project };
|
|
71
44
|
const sources = {};
|
|
72
45
|
for (const name of Object.keys(configured)) sources[name] = 'config';
|
|
73
|
-
for (const name of Object.keys(project)) sources[name] = 'project';
|
|
74
46
|
return { servers, sources };
|
|
75
47
|
}
|
|
76
48
|
|
|
49
|
+
function getMcpServerConfig(name) {
|
|
50
|
+
const serverName = clean(name);
|
|
51
|
+
if (!serverName) throw new Error('MCP server name is required');
|
|
52
|
+
const { servers } = resolveEffectiveMcpServers();
|
|
53
|
+
const effective = servers[serverName];
|
|
54
|
+
if (!effective) throw new Error(`MCP server not configured: ${serverName}`);
|
|
55
|
+
const raw = getConfig()?.mcpServers?.[serverName];
|
|
56
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
57
|
+
throw new Error(`MCP server config is unavailable: ${serverName}`);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
name: serverName,
|
|
61
|
+
source: 'config',
|
|
62
|
+
enabled: effective.enabled !== false,
|
|
63
|
+
config: { ...raw },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
77
67
|
function mcpStatus() {
|
|
78
68
|
if (envFlag('MIXDOG_DISABLE_MCP')) {
|
|
79
69
|
return { servers: [], configuredCount: 0, connectedCount: 0, failedCount: 0 };
|
|
@@ -94,6 +84,7 @@ export function createMcpGlue({
|
|
|
94
84
|
transport: mcpTransportLabel(cfg),
|
|
95
85
|
toolCount: live?.toolCount || 0,
|
|
96
86
|
tools: live?.tools || [],
|
|
87
|
+
capabilities: live?.capabilities || { tools: false, prompts: false, resources: false },
|
|
97
88
|
error: fail?.msg || null,
|
|
98
89
|
source: sources[name] || 'config',
|
|
99
90
|
});
|
|
@@ -118,10 +109,10 @@ export function createMcpGlue({
|
|
|
118
109
|
const target = clean(name);
|
|
119
110
|
if (!target) return;
|
|
120
111
|
const { servers } = resolveEffectiveMcpServers();
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
112
|
+
// Definitions stay in their original source. Enabled state is folded from
|
|
113
|
+
// Mixdog's per-project override for both global and `.mcp.json` entries, so
|
|
114
|
+
// acting on the effective entry keeps live state aligned without rewriting
|
|
115
|
+
// a shared project file.
|
|
125
116
|
// Changing this server's state clears any stale failure record for it.
|
|
126
117
|
if (Array.isArray(state.mcpFailures)) {
|
|
127
118
|
state.mcpFailures = state.mcpFailures.filter((row) => row.name !== target);
|
|
@@ -228,9 +219,16 @@ export function createMcpGlue({
|
|
|
228
219
|
}
|
|
229
220
|
return Object.keys(out).length > 0 ? out : null;
|
|
230
221
|
};
|
|
222
|
+
const coerceStringArray = (value) => Array.isArray(value)
|
|
223
|
+
? value.map((entry) => clean(entry)).filter(Boolean)
|
|
224
|
+
: [];
|
|
231
225
|
const withOptionalHeaders = (config) => {
|
|
232
226
|
const headers = coerceStringRecord(input.headers);
|
|
233
227
|
if (headers) config.headers = headers;
|
|
228
|
+
const bearerTokenEnvVar = clean(input.bearer_token_env_var || input.bearerTokenEnvVar);
|
|
229
|
+
if (bearerTokenEnvVar) config.bearer_token_env_var = bearerTokenEnvVar;
|
|
230
|
+
const envHeaders = coerceStringRecord(input.env_http_headers || input.envHttpHeaders);
|
|
231
|
+
if (envHeaders) config.env_http_headers = envHeaders;
|
|
234
232
|
return config;
|
|
235
233
|
};
|
|
236
234
|
const url = clean(input.url);
|
|
@@ -259,15 +257,18 @@ export function createMcpGlue({
|
|
|
259
257
|
? input.args.map((v) => String(v)).filter(Boolean)
|
|
260
258
|
: clean(input.args).split(/\s+/).filter(Boolean);
|
|
261
259
|
const requestedCwd = clean(input.cwd);
|
|
262
|
-
const
|
|
263
|
-
const
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
260
|
+
const expandedCwd = requestedCwd.replace(/^~(?=$|[\\/])/, homedir());
|
|
261
|
+
const resolvedCwd = expandedCwd ? resolve(currentCwd, expandedCwd) : '';
|
|
262
|
+
const config = {
|
|
263
|
+
type: 'stdio',
|
|
264
|
+
command,
|
|
265
|
+
args,
|
|
266
|
+
...(resolvedCwd ? { cwd: resolvedCwd } : {}),
|
|
267
|
+
};
|
|
269
268
|
const env = coerceStringRecord(input.env);
|
|
270
269
|
if (env) config.env = env;
|
|
270
|
+
const envVars = coerceStringArray(input.env_vars || input.envVars);
|
|
271
|
+
if (envVars.length > 0) config.env_vars = envVars;
|
|
271
272
|
return { name, config };
|
|
272
273
|
}
|
|
273
274
|
|
|
@@ -309,6 +310,7 @@ export function createMcpGlue({
|
|
|
309
310
|
mcpTransportLabel,
|
|
310
311
|
resolveEffectiveMcpServers,
|
|
311
312
|
mcpStatus,
|
|
313
|
+
getMcpServerConfig,
|
|
312
314
|
connectConfiguredMcp,
|
|
313
315
|
awaitInitialMcpConnect,
|
|
314
316
|
normalizeMcpServerInput,
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
|
|
7
|
+
import { createMcpGlue } from './mcp-glue.mjs';
|
|
8
|
+
import { createResourceApi } from './resource-api.mjs';
|
|
9
|
+
import {
|
|
10
|
+
mergeMcpServerConfig,
|
|
11
|
+
readProjectMcpServerConfig,
|
|
12
|
+
saveProjectMcpServer,
|
|
13
|
+
} from './plugin-mcp.mjs';
|
|
14
|
+
|
|
15
|
+
test('project MCP edits preserve document shape and unknown fields', () => {
|
|
16
|
+
const cwd = mkdtempSync(join(tmpdir(), 'mixdog-mcp-standard-'));
|
|
17
|
+
try {
|
|
18
|
+
const path = join(cwd, '.mcp.json');
|
|
19
|
+
writeFileSync(path, `${JSON.stringify({
|
|
20
|
+
projectMetadata: { owner: 'test' },
|
|
21
|
+
mcpServers: {
|
|
22
|
+
demo: {
|
|
23
|
+
type: 'stdio',
|
|
24
|
+
command: 'old-command',
|
|
25
|
+
args: ['--old'],
|
|
26
|
+
vendorExtension: { keep: true },
|
|
27
|
+
},
|
|
28
|
+
sibling: { url: 'https://example.com/mcp', custom: 7 },
|
|
29
|
+
},
|
|
30
|
+
}, null, 2)}\n`);
|
|
31
|
+
|
|
32
|
+
saveProjectMcpServer(cwd, {
|
|
33
|
+
originalName: 'demo',
|
|
34
|
+
name: 'renamed',
|
|
35
|
+
config: {
|
|
36
|
+
type: 'http',
|
|
37
|
+
url: 'https://example.com/new-mcp',
|
|
38
|
+
headers: { Authorization: '${MCP_TOKEN}' },
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const saved = JSON.parse(readFileSync(path, 'utf8'));
|
|
43
|
+
assert.deepEqual(saved.projectMetadata, { owner: 'test' });
|
|
44
|
+
assert.deepEqual(saved.mcpServers.sibling, {
|
|
45
|
+
url: 'https://example.com/mcp',
|
|
46
|
+
custom: 7,
|
|
47
|
+
});
|
|
48
|
+
assert.equal(saved.mcpServers.demo, undefined);
|
|
49
|
+
assert.deepEqual(saved.mcpServers.renamed, {
|
|
50
|
+
vendorExtension: { keep: true },
|
|
51
|
+
type: 'http',
|
|
52
|
+
url: 'https://example.com/new-mcp',
|
|
53
|
+
headers: { Authorization: '${MCP_TOKEN}' },
|
|
54
|
+
});
|
|
55
|
+
assert.deepEqual(readProjectMcpServerConfig(cwd, 'renamed')?.config, saved.mcpServers.renamed);
|
|
56
|
+
} finally {
|
|
57
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('MCP transport edits drop stale transport fields but retain extensions', () => {
|
|
62
|
+
assert.deepEqual(mergeMcpServerConfig({
|
|
63
|
+
type: 'http',
|
|
64
|
+
url: 'https://example.com/mcp',
|
|
65
|
+
headers: { Authorization: 'secret' },
|
|
66
|
+
bearer_token_env_var: 'MCP_TOKEN',
|
|
67
|
+
env_http_headers: { 'X-API-Key': 'MCP_API_KEY' },
|
|
68
|
+
env_vars: ['STALE_STDIO_VALUE'],
|
|
69
|
+
startupTimeoutSec: 20,
|
|
70
|
+
}, {
|
|
71
|
+
type: 'stdio',
|
|
72
|
+
command: 'server',
|
|
73
|
+
args: [],
|
|
74
|
+
}), {
|
|
75
|
+
startupTimeoutSec: 20,
|
|
76
|
+
type: 'stdio',
|
|
77
|
+
command: 'server',
|
|
78
|
+
args: [],
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('plugin-disabled MCP cannot be re-enabled by a stale project override', () => {
|
|
83
|
+
const cwd = mkdtempSync(join(tmpdir(), 'mixdog-mcp-plugin-off-'));
|
|
84
|
+
try {
|
|
85
|
+
const config = {
|
|
86
|
+
mcpServers: {
|
|
87
|
+
plugin: {
|
|
88
|
+
command: 'server',
|
|
89
|
+
_mixdogPluginDisabled: true,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
mcpProjectOverrides: {
|
|
93
|
+
[cwd]: {
|
|
94
|
+
plugin: { enabled: true },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
const glue = createMcpGlue({
|
|
99
|
+
mcpClient: {
|
|
100
|
+
resolveMcpTransportKind: () => 'stdio',
|
|
101
|
+
getMcpServerStatus: () => [],
|
|
102
|
+
},
|
|
103
|
+
getConfig: () => config,
|
|
104
|
+
getCurrentCwd: () => cwd,
|
|
105
|
+
state: {
|
|
106
|
+
mcpFailures: [],
|
|
107
|
+
mcpConnectGeneration: 0,
|
|
108
|
+
mcpConnectInFlight: null,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
assert.equal(glue.mcpStatus().servers[0]?.enabled, false);
|
|
112
|
+
} finally {
|
|
113
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('MCP toggle updates the global server and ignores project config and overrides', async () => {
|
|
118
|
+
const cwd = mkdtempSync(join(tmpdir(), 'mixdog-mcp-override-'));
|
|
119
|
+
try {
|
|
120
|
+
const path = join(cwd, '.mcp.json');
|
|
121
|
+
writeFileSync(path, `${JSON.stringify({
|
|
122
|
+
mcpServers: {
|
|
123
|
+
local: { command: 'server', enabled: false, extension: 'kept' },
|
|
124
|
+
},
|
|
125
|
+
}, null, 2)}\n`);
|
|
126
|
+
const before = readFileSync(path, 'utf8');
|
|
127
|
+
let config = {
|
|
128
|
+
mcpServers: {
|
|
129
|
+
local: { command: 'global-server', enabled: false, extension: 'kept' },
|
|
130
|
+
},
|
|
131
|
+
mcpProjectOverrides: {
|
|
132
|
+
[cwd]: {
|
|
133
|
+
local: { enabled: false },
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
const api = createResourceApi({
|
|
138
|
+
getConfig: () => config,
|
|
139
|
+
getSession: () => null,
|
|
140
|
+
getCurrentCwd: () => cwd,
|
|
141
|
+
cfgMod: {},
|
|
142
|
+
mgr: {},
|
|
143
|
+
saveConfigAndAdopt: (next) => { config = next; },
|
|
144
|
+
connectConfiguredMcp: async () => ({ servers: [] }),
|
|
145
|
+
invalidatePreSessionToolSurface: () => {},
|
|
146
|
+
recreateCurrentSessionIfReady: async () => {},
|
|
147
|
+
mcpStatus: () => ({ servers: [{ name: 'local', source: 'config' }] }),
|
|
148
|
+
getActiveTurnCount: () => 0,
|
|
149
|
+
});
|
|
150
|
+
await api.setMcpServerEnabled('local', true);
|
|
151
|
+
assert.equal(config.mcpServers.local.enabled, true);
|
|
152
|
+
assert.equal(config.mcpServers.local.extension, 'kept');
|
|
153
|
+
assert.equal(config.mcpProjectOverrides, undefined);
|
|
154
|
+
|
|
155
|
+
const glue = createMcpGlue({
|
|
156
|
+
mcpClient: {
|
|
157
|
+
resolveMcpTransportKind: () => 'stdio',
|
|
158
|
+
getMcpServerStatus: () => [],
|
|
159
|
+
},
|
|
160
|
+
getConfig: () => config,
|
|
161
|
+
getCurrentCwd: () => cwd,
|
|
162
|
+
state: {
|
|
163
|
+
mcpFailures: [],
|
|
164
|
+
mcpConnectGeneration: 0,
|
|
165
|
+
mcpConnectInFlight: null,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const row = glue.mcpStatus().servers.find((server) => server.name === 'local');
|
|
170
|
+
assert.equal(row?.source, 'config');
|
|
171
|
+
assert.equal(row?.enabled, true);
|
|
172
|
+
assert.equal(glue.getMcpServerConfig('local').config.command, 'global-server');
|
|
173
|
+
assert.equal(readFileSync(path, 'utf8'), before);
|
|
174
|
+
} finally {
|
|
175
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
176
|
+
}
|
|
177
|
+
});
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
// Plugin/project MCP server discovery + normalization, and skill-file counting.
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
fsyncSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
statSync,
|
|
12
|
+
unlinkSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from 'node:fs';
|
|
3
15
|
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
16
|
import { clean } from './session-text.mjs';
|
|
5
17
|
import { readJsonSafe } from './fs-utils.mjs';
|
|
@@ -52,39 +64,101 @@ export function readProjectMcpServers(cwd) {
|
|
|
52
64
|
return out;
|
|
53
65
|
}
|
|
54
66
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
67
|
+
const MCP_TRANSPORT_FIELDS = new Set([
|
|
68
|
+
'type',
|
|
69
|
+
'transport',
|
|
70
|
+
'command',
|
|
71
|
+
'args',
|
|
72
|
+
'env',
|
|
73
|
+
'cwd',
|
|
74
|
+
'url',
|
|
75
|
+
'headers',
|
|
76
|
+
'bearer_token_env_var',
|
|
77
|
+
'env_http_headers',
|
|
78
|
+
'env_vars',
|
|
79
|
+
'autoDetect',
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
export function mergeMcpServerConfig(existing, next) {
|
|
83
|
+
const merged = isPlainObject(existing) ? { ...existing } : {};
|
|
84
|
+
for (const field of MCP_TRANSPORT_FIELDS) delete merged[field];
|
|
85
|
+
return { ...merged, ...next };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readProjectMcpDocument(cwd, allowMissing = false) {
|
|
61
89
|
const path = join(cwd || '.', '.mcp.json');
|
|
62
|
-
|
|
63
|
-
|
|
90
|
+
if (!existsSync(path)) {
|
|
91
|
+
if (allowMissing) return { path, raw: { mcpServers: {} }, usesWrapper: true };
|
|
92
|
+
throw new Error(`MCP config file not found: ${path}`);
|
|
93
|
+
}
|
|
64
94
|
let raw;
|
|
65
95
|
try {
|
|
66
96
|
raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
67
97
|
} catch (error) {
|
|
68
|
-
throw new Error(`cannot
|
|
98
|
+
throw new Error(`cannot read ${path}: ${error?.message || String(error)}`);
|
|
69
99
|
}
|
|
70
|
-
if (!raw
|
|
100
|
+
if (!isPlainObject(raw)) throw new Error(`unexpected .mcp.json shape at ${path}`);
|
|
101
|
+
if (Object.prototype.hasOwnProperty.call(raw, 'mcpServers') && !isPlainObject(raw.mcpServers)) {
|
|
71
102
|
throw new Error(`unexpected .mcp.json shape at ${path}`);
|
|
72
103
|
}
|
|
73
|
-
const usesWrapper =
|
|
104
|
+
const usesWrapper = isPlainObject(raw.mcpServers);
|
|
105
|
+
const map = usesWrapper ? raw.mcpServers : raw;
|
|
106
|
+
if (!isPlainObject(map)) throw new Error(`unexpected .mcp.json shape at ${path}`);
|
|
107
|
+
return { path, raw, usesWrapper };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function writeProjectMcpDocument(path, raw) {
|
|
111
|
+
let mode = 0o644;
|
|
112
|
+
try { mode = statSync(path).mode; } catch { /* new file */ }
|
|
113
|
+
const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
114
|
+
let fd = null;
|
|
115
|
+
try {
|
|
116
|
+
fd = openSync(tempPath, 'w', mode);
|
|
117
|
+
writeFileSync(fd, `${JSON.stringify(raw, null, 2)}\n`, 'utf8');
|
|
118
|
+
fsyncSync(fd);
|
|
119
|
+
closeSync(fd);
|
|
120
|
+
fd = null;
|
|
121
|
+
chmodSync(tempPath, mode);
|
|
122
|
+
renameSync(tempPath, path);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (fd !== null) {
|
|
125
|
+
try { closeSync(fd); } catch { /* best effort */ }
|
|
126
|
+
}
|
|
127
|
+
try { unlinkSync(tempPath); } catch { /* best effort */ }
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function readProjectMcpServerConfig(cwd, name) {
|
|
133
|
+
const { raw, usesWrapper } = readProjectMcpDocument(cwd);
|
|
74
134
|
const map = usesWrapper ? raw.mcpServers : raw;
|
|
135
|
+
const key = String(name || '').trim();
|
|
75
136
|
const entryKey = Object.prototype.hasOwnProperty.call(map, key)
|
|
76
137
|
? key
|
|
77
|
-
: Object.keys(map).
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
138
|
+
: Object.keys(map).find((candidate) => String(candidate || '').trim() === key);
|
|
139
|
+
return entryKey && isPlainObject(map[entryKey]) ? { name: entryKey, config: { ...map[entryKey] } } : null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function saveProjectMcpServer(cwd, { originalName = '', name, config }) {
|
|
143
|
+
const { path, raw, usesWrapper } = readProjectMcpDocument(cwd, !originalName);
|
|
144
|
+
const map = usesWrapper ? raw.mcpServers : raw;
|
|
145
|
+
const target = String(name || '').trim();
|
|
146
|
+
const original = String(originalName || '').trim();
|
|
147
|
+
if (!target) throw new Error('MCP server name is required');
|
|
148
|
+
const entryKey = original
|
|
149
|
+
? (Object.prototype.hasOwnProperty.call(map, original)
|
|
150
|
+
? original
|
|
151
|
+
: Object.keys(map).find((candidate) => String(candidate || '').trim() === original))
|
|
152
|
+
: null;
|
|
153
|
+
if (original && !entryKey) throw new Error(`MCP server not defined in ${path}: ${original}`);
|
|
154
|
+
if (target !== entryKey && Object.prototype.hasOwnProperty.call(map, target)) {
|
|
155
|
+
throw new Error(`MCP server already exists in ${path}: ${target}`);
|
|
84
156
|
}
|
|
85
|
-
map[entryKey]
|
|
86
|
-
|
|
87
|
-
|
|
157
|
+
const existing = entryKey ? map[entryKey] : {};
|
|
158
|
+
if (entryKey && entryKey !== target) delete map[entryKey];
|
|
159
|
+
map[target] = mergeMcpServerConfig(existing, config);
|
|
160
|
+
writeProjectMcpDocument(path, raw);
|
|
161
|
+
return { name: target, source: 'project', path, config: { ...map[target] } };
|
|
88
162
|
}
|
|
89
163
|
|
|
90
164
|
function isPlainObject(value) {
|