mixdog 0.9.104 → 0.9.106
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 +3 -4
- package/package.json +1 -1
- package/src/rules/shared/01-tool.md +3 -1
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +3 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +145 -46
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +7 -14
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +37 -5
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +4 -2
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +16 -3
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +33 -2
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +31 -31
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +36 -4
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -3
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +22 -1
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +40 -10
- package/src/session-runtime/context-status.mjs +11 -5
- package/src/session-runtime/lifecycle-api.mjs +8 -4
- package/src/session-runtime/mcp-glue.mjs +10 -8
- package/src/session-runtime/runtime-core.mjs +6 -0
- package/src/session-runtime/session-lifecycle.mjs +3 -1
- package/src/session-runtime/session-turn-api.mjs +2 -2
- package/src/session-runtime/tool-catalog.mjs +3 -3
- package/src/session-runtime/tool-surface.mjs +3 -2
- package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
- package/src/standalone/agent-tool.mjs +2 -0
- package/src/standalone/explore-tool.mjs +1 -2
- package/src/tui/app/usage-context-panels.mjs +5 -2
- package/src/tui/components/ContextPanel.jsx +2 -0
- package/src/tui/dist/index.mjs +8 -3
package/README.md
CHANGED
|
@@ -103,7 +103,7 @@ every number above live under `benchmarks/terminal-bench-2.1/`.
|
|
|
103
103
|
role mode for scripting.
|
|
104
104
|
- Mixdog Desktop: a full agent workbench for Windows/macOS/Linux (see
|
|
105
105
|
below).
|
|
106
|
-
-
|
|
106
|
+
- Installable web app over relay pairing — scan a QR code to open your
|
|
107
107
|
running sessions in a phone browser and keep going from any network.
|
|
108
108
|
- Optional Discord/Telegram channels, webhook endpoints, and cron schedules
|
|
109
109
|
with quiet hours for remote/event-driven workflows; channel voice messages
|
|
@@ -246,7 +246,7 @@ wizard covers first-run setup. For development run `npm run dev` inside
|
|
|
246
246
|
- **Automation** — visual editors for workflow and agent packs, cron
|
|
247
247
|
schedules, webhooks, and channel integrations.
|
|
248
248
|
- **Settings hub** — provider auth, capability sweep, git identity, and
|
|
249
|
-
QR device pairing for the web
|
|
249
|
+
QR device pairing for the installable web app, preloaded so every
|
|
250
250
|
category opens instantly.
|
|
251
251
|
|
|
252
252
|
## Scripts
|
|
@@ -302,8 +302,7 @@ src/
|
|
|
302
302
|
rules/ # Lead and agent instructions
|
|
303
303
|
apps/
|
|
304
304
|
desktop/ # Mixdog Desktop — Electron workbench (main/preload/renderer)
|
|
305
|
-
|
|
306
|
-
relay/ # relay server for remote/web/mobile access
|
|
305
|
+
relay/ # relay server for remote web-app access
|
|
307
306
|
scripts/
|
|
308
307
|
smoke*.mjs # smoke checks
|
|
309
308
|
*test.mjs # focused node:test checks
|
package/package.json
CHANGED
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
facets in one query array. It
|
|
15
15
|
returns the minimal complete direct `path:line` anchors, not analysis or
|
|
16
16
|
solutions; resume baseline routing from those anchors.
|
|
17
|
-
- Use verified paths (cwd/project/user/tool)
|
|
17
|
+
- Use verified paths (cwd/project/user/tool). Within the current project, pass
|
|
18
|
+
project-relative paths and omit optional scopes equal to its root; explicit
|
|
19
|
+
paths may be outside cwd only for targets outside the project;
|
|
18
20
|
stay focused on the requested outcome. Avoid investigation, implementation,
|
|
19
21
|
or verification not required to satisfy it; once the requirements are met
|
|
20
22
|
and proven, stop.
|
|
@@ -64,6 +64,7 @@ function normalizeAgentCompactionConfig(value = {}, { memoryEnabled = true } = {
|
|
|
64
64
|
* @param {number} [opts.maxLoopIterations]
|
|
65
65
|
* @param {string} [opts.parentSessionId]
|
|
66
66
|
* @param {string|null} [opts.ownerSessionId] - owning Mixdog MCP instance id for statusline isolation
|
|
67
|
+
* @param {string|null} [opts.mcpScopeId] - owning session-runtime MCP registry scope
|
|
67
68
|
* @returns {{ session: object, effectiveCwd: string|null }}
|
|
68
69
|
*/
|
|
69
70
|
export function prepareAgentSession({
|
|
@@ -86,6 +87,7 @@ export function prepareAgentSession({
|
|
|
86
87
|
cacheKeyOverride,
|
|
87
88
|
schemaAllowedTools,
|
|
88
89
|
sessionId,
|
|
90
|
+
mcpScopeId,
|
|
89
91
|
}) {
|
|
90
92
|
const effectivePermission = resolveAgentSessionPermission(agent, permission);
|
|
91
93
|
// No per-agent loop caps: sessions either pin maxLoopIterations explicitly
|
|
@@ -121,6 +123,7 @@ export function prepareAgentSession({
|
|
|
121
123
|
ownerSessionId: effectiveOwnerSessionId || null,
|
|
122
124
|
clientHostPid: clientHostPid || null,
|
|
123
125
|
compaction: compaction || undefined,
|
|
126
|
+
mcpScopeId: mcpScopeId || null,
|
|
124
127
|
};
|
|
125
128
|
if (agentTag) sessionOpts.agentTag = agentTag;
|
|
126
129
|
if (effectivePermission) sessionOpts.permission = effectivePermission;
|
|
@@ -28,6 +28,10 @@ const DEFAULT_MCP_STARTUP_TIMEOUT_MS = 10000;
|
|
|
28
28
|
const servers = new Map();
|
|
29
29
|
const reconnects = createKeyedSingleflight();
|
|
30
30
|
const callAdmissions = new Map();
|
|
31
|
+
const DEFAULT_MCP_SCOPE_ID = 'global';
|
|
32
|
+
const _knownMcpScopes = new Set([DEFAULT_MCP_SCOPE_ID]);
|
|
33
|
+
const _connectAbortGenerations = new Map();
|
|
34
|
+
const _pendingConnects = new Set();
|
|
31
35
|
let mcpSdkPromise = null;
|
|
32
36
|
// Memo for mcpToolHasField(name, field) — keyed by `${toolName}|${field}`.
|
|
33
37
|
// The lookup (regex parse + servers Map get + tools.find + schema property
|
|
@@ -39,6 +43,30 @@ const _mcpToolFieldMemo = new Map();
|
|
|
39
43
|
function _invalidateMcpToolFieldMemo() {
|
|
40
44
|
_mcpToolFieldMemo.clear();
|
|
41
45
|
}
|
|
46
|
+
function normalizeMcpScopeId(value) {
|
|
47
|
+
const raw = value && typeof value === 'object' ? value.scopeId : value;
|
|
48
|
+
const scopeId = String(raw || '').trim();
|
|
49
|
+
return scopeId || DEFAULT_MCP_SCOPE_ID;
|
|
50
|
+
}
|
|
51
|
+
function mcpServerRegistryKey(scopeId, name) {
|
|
52
|
+
return `${normalizeMcpScopeId(scopeId)}\u0000${String(name || '')}`;
|
|
53
|
+
}
|
|
54
|
+
function scopedServer(scopeId, name) {
|
|
55
|
+
return servers.get(mcpServerRegistryKey(scopeId, name));
|
|
56
|
+
}
|
|
57
|
+
function scopedServerEntries(scopeId) {
|
|
58
|
+
const normalized = normalizeMcpScopeId(scopeId);
|
|
59
|
+
return [...servers.entries()].filter(([, server]) => server?.scopeId === normalized);
|
|
60
|
+
}
|
|
61
|
+
function currentConnectAbortGeneration(scopeId) {
|
|
62
|
+
return _connectAbortGenerations.get(normalizeMcpScopeId(scopeId)) || 0;
|
|
63
|
+
}
|
|
64
|
+
function bumpConnectAbortGeneration(scopeId) {
|
|
65
|
+
const normalized = normalizeMcpScopeId(scopeId);
|
|
66
|
+
const next = currentConnectAbortGeneration(normalized) + 1;
|
|
67
|
+
_connectAbortGenerations.set(normalized, next);
|
|
68
|
+
return next;
|
|
69
|
+
}
|
|
42
70
|
function mcpLog(line) {
|
|
43
71
|
if (process.env.MIXDOG_QUIET_MCP_LOG) return;
|
|
44
72
|
process.stderr.write(line);
|
|
@@ -106,12 +134,14 @@ export function resolveMcpTransportKind(cfg) {
|
|
|
106
134
|
* Connect to MCP servers defined in config.
|
|
107
135
|
* Supports stdio (child process) and http (Streamable HTTP) transports.
|
|
108
136
|
*/
|
|
109
|
-
export async function connectMcpServers(config) {
|
|
137
|
+
export async function connectMcpServers(config, options = {}) {
|
|
138
|
+
const scopeId = normalizeMcpScopeId(options);
|
|
139
|
+
_knownMcpScopes.add(scopeId);
|
|
110
140
|
// Capture the abort generation SYNCHRONOUSLY at entry: the boot path fires
|
|
111
141
|
// this un-awaited, so a runtime close can land while connectServer is still
|
|
112
142
|
// loading the SDK. A capture taken any later would already see the bumped
|
|
113
143
|
// generation and register the server anyway (leaking its stdio child).
|
|
114
|
-
const genAtStart =
|
|
144
|
+
const genAtStart = currentConnectAbortGeneration(scopeId);
|
|
115
145
|
const failures = [];
|
|
116
146
|
const entries = Object.entries(config).filter(([name, cfg]) => {
|
|
117
147
|
if (cfg?.enabled === false) {
|
|
@@ -123,7 +153,7 @@ export async function connectMcpServers(config) {
|
|
|
123
153
|
// Connect all servers in PARALLEL: a slow/hung server (bounded by its
|
|
124
154
|
// per-server startup timeout) must never delay the others' handshakes.
|
|
125
155
|
const settled = await Promise.allSettled(
|
|
126
|
-
entries.map(([name, cfg]) => connectServer(name, cfg, genAtStart)),
|
|
156
|
+
entries.map(([name, cfg]) => connectServer(name, cfg, scopeId, genAtStart)),
|
|
127
157
|
);
|
|
128
158
|
settled.forEach((res, i) => {
|
|
129
159
|
if (res.status !== 'rejected') return;
|
|
@@ -143,15 +173,15 @@ export async function connectMcpServers(config) {
|
|
|
143
173
|
* Get all tool definitions from connected MCP servers.
|
|
144
174
|
* Tool names are prefixed: `mcp__{serverName}__{toolName}`
|
|
145
175
|
*/
|
|
146
|
-
export function getMcpTools() {
|
|
176
|
+
export function getMcpTools(scopeId = DEFAULT_MCP_SCOPE_ID) {
|
|
147
177
|
const tools = [];
|
|
148
|
-
for (const server of
|
|
178
|
+
for (const [, server] of scopedServerEntries(scopeId)) {
|
|
149
179
|
tools.push(...server.tools);
|
|
150
180
|
}
|
|
151
181
|
return tools;
|
|
152
182
|
}
|
|
153
|
-
export function getMcpServerStatus() {
|
|
154
|
-
return
|
|
183
|
+
export function getMcpServerStatus(scopeId = DEFAULT_MCP_SCOPE_ID) {
|
|
184
|
+
return scopedServerEntries(scopeId).map(([, server]) => ({
|
|
155
185
|
name: server.name,
|
|
156
186
|
connected: true,
|
|
157
187
|
toolCount: Array.isArray(server.tools) ? server.tools.length : 0,
|
|
@@ -173,7 +203,8 @@ function positiveInt(value, fallback) {
|
|
|
173
203
|
return Number.isFinite(parsed) && parsed >= 1 ? parsed : fallback;
|
|
174
204
|
}
|
|
175
205
|
function callAdmissionFor(server) {
|
|
176
|
-
|
|
206
|
+
const registryKey = server.registryKey || mcpServerRegistryKey(server.scopeId, server.name);
|
|
207
|
+
let gate = callAdmissions.get(registryKey);
|
|
177
208
|
if (gate) return gate;
|
|
178
209
|
const cfg = server?.cfg || {};
|
|
179
210
|
gate = createOwnerFairGate({
|
|
@@ -192,44 +223,53 @@ function callAdmissionFor(server) {
|
|
|
192
223
|
30_000,
|
|
193
224
|
),
|
|
194
225
|
});
|
|
195
|
-
|
|
226
|
+
gate.mcpServerName = server.name;
|
|
227
|
+
gate.mcpScopeId = server.scopeId;
|
|
228
|
+
callAdmissions.set(registryKey, gate);
|
|
196
229
|
return gate;
|
|
197
230
|
}
|
|
198
|
-
function closeCallAdmission(name, reason) {
|
|
199
|
-
const
|
|
231
|
+
function closeCallAdmission(scopeId, name, reason) {
|
|
232
|
+
const registryKey = mcpServerRegistryKey(scopeId, name);
|
|
233
|
+
const gate = callAdmissions.get(registryKey);
|
|
200
234
|
if (!gate) return;
|
|
201
|
-
callAdmissions.delete(
|
|
235
|
+
callAdmissions.delete(registryKey);
|
|
202
236
|
gate.close(reason || `MCP ${name} disconnected`);
|
|
203
237
|
}
|
|
204
|
-
export function getMcpAdmissionSnapshot() {
|
|
205
|
-
|
|
206
|
-
|
|
238
|
+
export function getMcpAdmissionSnapshot(options = undefined) {
|
|
239
|
+
const scoped = options !== undefined;
|
|
240
|
+
const scopeId = scoped ? normalizeMcpScopeId(options) : null;
|
|
241
|
+
return [...callAdmissions.values()]
|
|
242
|
+
.filter((gate) => !scoped || gate.mcpScopeId === scopeId)
|
|
243
|
+
.map((gate) => ({
|
|
244
|
+
name: gate.mcpServerName,
|
|
245
|
+
scopeId: gate.mcpScopeId,
|
|
207
246
|
...gate.snapshot(),
|
|
208
247
|
}));
|
|
209
248
|
}
|
|
210
249
|
|
|
211
250
|
/** Snapshot of MCP initialize `instructions` per connected server (handshake time). */
|
|
212
|
-
export function getMcpServerInstructionsMap() {
|
|
251
|
+
export function getMcpServerInstructionsMap(scopeId = DEFAULT_MCP_SCOPE_ID) {
|
|
213
252
|
const out = {};
|
|
214
|
-
for (const server of
|
|
253
|
+
for (const [, server] of scopedServerEntries(scopeId)) {
|
|
215
254
|
const text = typeof server.instructions === 'string' ? server.instructions.trim() : '';
|
|
216
255
|
if (text) out[server.name] = text;
|
|
217
256
|
}
|
|
218
257
|
return out;
|
|
219
258
|
}
|
|
220
259
|
|
|
221
|
-
async function reconnectMcpServer(serverName, failedServer) {
|
|
222
|
-
|
|
223
|
-
|
|
260
|
+
async function reconnectMcpServer(scopeId, serverName, failedServer) {
|
|
261
|
+
const registryKey = mcpServerRegistryKey(scopeId, serverName);
|
|
262
|
+
return reconnects.run(registryKey, async () => {
|
|
263
|
+
const current = servers.get(registryKey);
|
|
224
264
|
// A peer already completed the replacement while this failed call was
|
|
225
265
|
// unwinding. Reuse it immediately instead of closing a fresh transport.
|
|
226
266
|
if (current && current !== failedServer) return current;
|
|
227
267
|
if (current) {
|
|
228
268
|
await _closeServer(current);
|
|
229
|
-
if (servers.get(
|
|
269
|
+
if (servers.get(registryKey) === current) servers.delete(registryKey);
|
|
230
270
|
}
|
|
231
|
-
await connectServer(serverName, failedServer.cfg);
|
|
232
|
-
const replacement = servers.get(
|
|
271
|
+
await connectServer(serverName, failedServer.cfg, scopeId);
|
|
272
|
+
const replacement = servers.get(registryKey);
|
|
233
273
|
if (!replacement) {
|
|
234
274
|
throw new Error(`reconnect succeeded but server "${serverName}" entry is missing from registry`);
|
|
235
275
|
}
|
|
@@ -247,7 +287,8 @@ export async function executeMcpTool(name, args, options = {}) {
|
|
|
247
287
|
if (!match)
|
|
248
288
|
throw new Error(`Not an MCP tool name: ${name}`);
|
|
249
289
|
const [, serverName, toolName] = match;
|
|
250
|
-
const
|
|
290
|
+
const scopeId = normalizeMcpScopeId(options);
|
|
291
|
+
const server = scopedServer(scopeId, serverName);
|
|
251
292
|
if (!server)
|
|
252
293
|
throw new Error(`MCP server "${serverName}" not connected`);
|
|
253
294
|
const gate = callAdmissionFor(server);
|
|
@@ -264,7 +305,7 @@ export async function executeMcpTool(name, args, options = {}) {
|
|
|
264
305
|
mcpLog(`[mcp-client] Tool call failed, attempting shared reconnect...\n`);
|
|
265
306
|
let retryServer;
|
|
266
307
|
try {
|
|
267
|
-
retryServer = await reconnectMcpServer(serverName, server);
|
|
308
|
+
retryServer = await reconnectMcpServer(scopeId, serverName, server);
|
|
268
309
|
} catch (reconnectErr) {
|
|
269
310
|
const reconnectMsg = reconnectErr instanceof Error ? reconnectErr.message : String(reconnectErr);
|
|
270
311
|
throw new Error(`Tool call failed: ${firstMsg}; reconnect also failed: ${reconnectMsg}`);
|
|
@@ -415,12 +456,12 @@ export function isMcpTool(name) {
|
|
|
415
456
|
return name.startsWith('mcp__');
|
|
416
457
|
}
|
|
417
458
|
/** True when the prefixed name exists on a connected MCP server. */
|
|
418
|
-
export function isRegisteredMcpTool(name) {
|
|
459
|
+
export function isRegisteredMcpTool(name, scopeId = DEFAULT_MCP_SCOPE_ID) {
|
|
419
460
|
if (!isMcpTool(name)) return false;
|
|
420
461
|
const match = name.match(/^mcp__(.+?)__(.+)$/);
|
|
421
462
|
if (!match) return false;
|
|
422
463
|
const [, serverName] = match;
|
|
423
|
-
const server =
|
|
464
|
+
const server = scopedServer(scopeId, serverName);
|
|
424
465
|
if (!server || !Array.isArray(server.tools)) return false;
|
|
425
466
|
return server.tools.some((t) => t?.name === name);
|
|
426
467
|
}
|
|
@@ -430,14 +471,15 @@ export function isRegisteredMcpTool(name) {
|
|
|
430
471
|
* (e.g. cwd) into the args before dispatch — schemas that don't declare the
|
|
431
472
|
* field would reject the unknown argument.
|
|
432
473
|
*/
|
|
433
|
-
export function mcpToolHasField(name, field) {
|
|
434
|
-
const
|
|
474
|
+
export function mcpToolHasField(name, field, scopeId = DEFAULT_MCP_SCOPE_ID) {
|
|
475
|
+
const normalizedScopeId = normalizeMcpScopeId(scopeId);
|
|
476
|
+
const memoKey = `${normalizedScopeId}|${name}|${field}`;
|
|
435
477
|
const memoized = _mcpToolFieldMemo.get(memoKey);
|
|
436
478
|
if (memoized !== undefined) return memoized;
|
|
437
479
|
const match = name.match(/^mcp__(.+?)__(.+)$/);
|
|
438
480
|
if (!match) { _mcpToolFieldMemo.set(memoKey, false); return false; }
|
|
439
481
|
const [, serverName] = match;
|
|
440
|
-
const server =
|
|
482
|
+
const server = scopedServer(normalizedScopeId, serverName);
|
|
441
483
|
if (!server) { _mcpToolFieldMemo.set(memoKey, false); return false; }
|
|
442
484
|
const tool = server.tools.find((t) => t.name === name);
|
|
443
485
|
if (!tool) { _mcpToolFieldMemo.set(memoKey, false); return false; }
|
|
@@ -449,14 +491,24 @@ export function mcpToolHasField(name, field) {
|
|
|
449
491
|
/**
|
|
450
492
|
* Disconnect all MCP servers.
|
|
451
493
|
*/
|
|
452
|
-
export async function disconnectAll() {
|
|
494
|
+
export async function disconnectAll(options = undefined) {
|
|
495
|
+
const hasExplicitScope = options && typeof options === 'object'
|
|
496
|
+
&& Object.prototype.hasOwnProperty.call(options, 'scopeId');
|
|
497
|
+
const scopes = hasExplicitScope
|
|
498
|
+
? new Set([normalizeMcpScopeId(options)])
|
|
499
|
+
: new Set([
|
|
500
|
+
..._knownMcpScopes,
|
|
501
|
+
...[...servers.values()].map((server) => server.scopeId),
|
|
502
|
+
...[..._pendingConnects].map((entry) => entry.scopeId),
|
|
503
|
+
]);
|
|
453
504
|
// Abort handshakes still in flight: bump the generation so a connect that
|
|
454
505
|
// completes after this point tears itself down instead of registering, and
|
|
455
506
|
// reap any already-spawned stdio child now so its ref'd ChildProcess handle
|
|
456
507
|
// can't keep the event loop alive (close-during-connect previously leaked
|
|
457
508
|
// the uvx/npx wrapper tree and hung process exit).
|
|
458
|
-
|
|
509
|
+
for (const scopeId of scopes) bumpConnectAbortGeneration(scopeId);
|
|
459
510
|
for (const entry of [..._pendingConnects]) {
|
|
511
|
+
if (!scopes.has(entry.scopeId)) continue;
|
|
460
512
|
_pendingConnects.delete(entry);
|
|
461
513
|
// Mid-handshake child: nothing to shut down gracefully — hard-kill the
|
|
462
514
|
// tree without holding the event loop (this path runs during process
|
|
@@ -466,13 +518,14 @@ export async function disconnectAll() {
|
|
|
466
518
|
try { void entry.client.close().catch(() => { /* ignore */ }); }
|
|
467
519
|
catch { /* ignore */ }
|
|
468
520
|
}
|
|
469
|
-
for (const [
|
|
521
|
+
for (const [registryKey, server] of [...servers]) {
|
|
522
|
+
if (!scopes.has(server.scopeId)) continue;
|
|
470
523
|
try {
|
|
471
524
|
await _closeServer(server);
|
|
472
525
|
}
|
|
473
526
|
catch { /* ignore */ }
|
|
474
|
-
servers.delete(
|
|
475
|
-
closeCallAdmission(name, `MCP ${name} disconnected`);
|
|
527
|
+
servers.delete(registryKey);
|
|
528
|
+
closeCallAdmission(server.scopeId, server.name, `MCP ${server.name} disconnected`);
|
|
476
529
|
}
|
|
477
530
|
_invalidateMcpToolFieldMemo();
|
|
478
531
|
}
|
|
@@ -482,15 +535,17 @@ export async function disconnectAll() {
|
|
|
482
535
|
* it, and invalidates the tool-field memo. Lets callers toggle one server
|
|
483
536
|
* without a full disconnectAll()/reconnect cycle.
|
|
484
537
|
*/
|
|
485
|
-
export async function disconnectMcpServer(name) {
|
|
486
|
-
const
|
|
538
|
+
export async function disconnectMcpServer(name, options = {}) {
|
|
539
|
+
const scopeId = normalizeMcpScopeId(options);
|
|
540
|
+
const registryKey = mcpServerRegistryKey(scopeId, name);
|
|
541
|
+
const server = servers.get(registryKey);
|
|
487
542
|
if (!server) return false;
|
|
488
543
|
try {
|
|
489
544
|
await _closeServer(server);
|
|
490
545
|
}
|
|
491
546
|
catch { /* ignore */ }
|
|
492
|
-
servers.delete(
|
|
493
|
-
closeCallAdmission(name, `MCP ${name} disconnected`);
|
|
547
|
+
servers.delete(registryKey);
|
|
548
|
+
closeCallAdmission(scopeId, name, `MCP ${name} disconnected`);
|
|
494
549
|
_invalidateMcpToolFieldMemo();
|
|
495
550
|
return true;
|
|
496
551
|
}
|
|
@@ -515,9 +570,9 @@ async function _closeServer(server) {
|
|
|
515
570
|
// to see (and tear down) their transports, because `servers` only lists fully
|
|
516
571
|
// handshaken entries. Generation token aborts a connect that outlives a
|
|
517
572
|
// disconnectAll() issued mid-handshake (runtime close during boot connect).
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
573
|
+
async function connectServer(name, cfg, scopeId = DEFAULT_MCP_SCOPE_ID, genAtStart = currentConnectAbortGeneration(scopeId)) {
|
|
574
|
+
scopeId = normalizeMcpScopeId(scopeId);
|
|
575
|
+
_knownMcpScopes.add(scopeId);
|
|
521
576
|
const {
|
|
522
577
|
Client,
|
|
523
578
|
StdioClientTransport,
|
|
@@ -525,7 +580,7 @@ async function connectServer(name, cfg, genAtStart = _connectAbortGeneration) {
|
|
|
525
580
|
SSEClientTransport,
|
|
526
581
|
WebSocketClientTransport,
|
|
527
582
|
} = await loadMcpSdk();
|
|
528
|
-
if (genAtStart !==
|
|
583
|
+
if (genAtStart !== currentConnectAbortGeneration(scopeId)) {
|
|
529
584
|
// disconnectAll() ran while the SDK was loading: nothing spawned yet —
|
|
530
585
|
// abort before creating a transport/child at all.
|
|
531
586
|
throw new Error(`MCP server "${name}" connect aborted by shutdown`);
|
|
@@ -623,7 +678,7 @@ async function connectServer(name, cfg, genAtStart = _connectAbortGeneration) {
|
|
|
623
678
|
else {
|
|
624
679
|
throw new Error(`Invalid config for "${name}": need autoDetect, type (stdio/http/sse/ws), url (http), or command (stdio)`);
|
|
625
680
|
}
|
|
626
|
-
const pending = { name, client, transport };
|
|
681
|
+
const pending = { scopeId, name, client, transport };
|
|
627
682
|
_pendingConnects.add(pending);
|
|
628
683
|
try {
|
|
629
684
|
// Bound the connect + listTools handshake so a slow/hung server can't
|
|
@@ -682,7 +737,7 @@ async function connectServer(name, cfg, genAtStart = _connectAbortGeneration) {
|
|
|
682
737
|
if (!toolsResult || !Array.isArray(toolsResult.tools)) {
|
|
683
738
|
throw new Error(`[mcp-client] ListTools returned invalid shape for "${name}": missing or non-array tools field`);
|
|
684
739
|
}
|
|
685
|
-
if (genAtStart !==
|
|
740
|
+
if (genAtStart !== currentConnectAbortGeneration(scopeId)) {
|
|
686
741
|
// disconnectAll() ran mid-handshake: never register — tear down.
|
|
687
742
|
try { await _closeServer({ client, transport }); }
|
|
688
743
|
catch { /* ignore */ }
|
|
@@ -695,7 +750,18 @@ async function connectServer(name, cfg, genAtStart = _connectAbortGeneration) {
|
|
|
695
750
|
...(t.annotations && typeof t.annotations === 'object' ? { annotations: t.annotations } : {}),
|
|
696
751
|
}));
|
|
697
752
|
const toolNames = tools.map(t => t.name);
|
|
698
|
-
|
|
753
|
+
const registryKey = mcpServerRegistryKey(scopeId, name);
|
|
754
|
+
servers.set(registryKey, {
|
|
755
|
+
scopeId,
|
|
756
|
+
registryKey,
|
|
757
|
+
name,
|
|
758
|
+
client,
|
|
759
|
+
transport,
|
|
760
|
+
tools,
|
|
761
|
+
cfg,
|
|
762
|
+
instructions,
|
|
763
|
+
generation: genAtStart,
|
|
764
|
+
});
|
|
699
765
|
_invalidateMcpToolFieldMemo();
|
|
700
766
|
mcpLog(`[mcp] connected: ${tools.length} tools — ${toolNames.join(', ')}\n`);
|
|
701
767
|
}
|
|
@@ -703,3 +769,36 @@ async function connectServer(name, cfg, genAtStart = _connectAbortGeneration) {
|
|
|
703
769
|
_pendingConnects.delete(pending);
|
|
704
770
|
}
|
|
705
771
|
}
|
|
772
|
+
|
|
773
|
+
// Test seam for registry scoping without launching an MCP transport.
|
|
774
|
+
export function _registerMcpServerForTest(scopeId, name, rawTools = [], options = {}) {
|
|
775
|
+
const normalizedScopeId = normalizeMcpScopeId(scopeId);
|
|
776
|
+
const registryKey = mcpServerRegistryKey(normalizedScopeId, name);
|
|
777
|
+
const tools = rawTools.map((tool) => ({
|
|
778
|
+
...tool,
|
|
779
|
+
name: String(tool?.name || '').startsWith('mcp__')
|
|
780
|
+
? String(tool.name)
|
|
781
|
+
: `mcp__${name}__${String(tool?.name || '')}`,
|
|
782
|
+
inputSchema: tool?.inputSchema || { type: 'object', properties: {} },
|
|
783
|
+
}));
|
|
784
|
+
const server = {
|
|
785
|
+
scopeId: normalizedScopeId,
|
|
786
|
+
registryKey,
|
|
787
|
+
name,
|
|
788
|
+
tools,
|
|
789
|
+
cfg: options.cfg || {},
|
|
790
|
+
instructions: options.instructions || '',
|
|
791
|
+
transport: null,
|
|
792
|
+
client: {
|
|
793
|
+
callTool: typeof options.callTool === 'function'
|
|
794
|
+
? options.callTool
|
|
795
|
+
: async () => ({ content: [{ type: 'text', text: 'ok' }] }),
|
|
796
|
+
close: async () => {},
|
|
797
|
+
},
|
|
798
|
+
generation: currentConnectAbortGeneration(normalizedScopeId),
|
|
799
|
+
};
|
|
800
|
+
_knownMcpScopes.add(normalizedScopeId);
|
|
801
|
+
servers.set(registryKey, server);
|
|
802
|
+
_invalidateMcpToolFieldMemo();
|
|
803
|
+
return server;
|
|
804
|
+
}
|
|
@@ -292,20 +292,13 @@ export function applyAnthropicEffortToBody(
|
|
|
292
292
|
// Adaptive-thinking models (4.6+) require `thinking:{type:"adaptive"}`
|
|
293
293
|
// rather than the legacy budget_tokens shape — sending
|
|
294
294
|
// `thinking:{type:"enabled"}` here 400s on sonnet-5/opus-4-7/4-8.
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
// replayed into later requests (saves the 1h cache-write + re-read on
|
|
303
|
-
// accumulated thinking) at the cost of losing visible reasoning and
|
|
304
|
-
// cross-iteration thinking continuity. Default stays summarized.
|
|
305
|
-
const display = (process.env.MIXDOG_ANTHROPIC_THINKING_DISPLAY || '').trim() === 'omitted'
|
|
306
|
-
? 'omitted'
|
|
307
|
-
: 'summarized';
|
|
308
|
-
body.thinking = { type: 'adaptive', display };
|
|
295
|
+
// Match Claude Code's default wire shape: omit `display` and let the
|
|
296
|
+
// model/API choose its default. Operators and benchmarks can explicitly
|
|
297
|
+
// request either supported display mode.
|
|
298
|
+
const display = (process.env.MIXDOG_ANTHROPIC_THINKING_DISPLAY || '').trim();
|
|
299
|
+
body.thinking = display === 'summarized' || display === 'omitted'
|
|
300
|
+
? { type: 'adaptive', display }
|
|
301
|
+
: { type: 'adaptive' };
|
|
309
302
|
// Adaptive/4.7+ models reject any non-default sampling param with a 400.
|
|
310
303
|
delete body.temperature;
|
|
311
304
|
delete body.top_p;
|
|
@@ -199,6 +199,22 @@ function compactPressureTokens(messageTokensEst, policy) {
|
|
|
199
199
|
return Math.max(0, Math.round((messageTokensEst + requestReserve) * calibration) + otherReserve);
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
// Provider-visible context estimate without operator-only compaction reserve.
|
|
203
|
+
// Request/schema reserve remains included because those bytes are sent to the
|
|
204
|
+
// model; configured reserve is merely local headroom and must not inflate the
|
|
205
|
+
// user-facing context gauge.
|
|
206
|
+
function currentContextEstimateTokens(messageTokensEst, policy) {
|
|
207
|
+
if (messageTokensEst === null) return 0;
|
|
208
|
+
const calibration = Number(policy?.tokenCalibration) > 0 ? Number(policy.tokenCalibration) : 1;
|
|
209
|
+
const configured = Math.max(0, Number(policy?.configuredReserveTokens) || 0);
|
|
210
|
+
const totalReserve = Math.max(0, Number(policy?.reserveTokens) || 0);
|
|
211
|
+
const requestReserve = Math.min(
|
|
212
|
+
totalReserve,
|
|
213
|
+
Math.max(0, Number(policy?.requestReserveTokens ?? (totalReserve - configured)) || 0),
|
|
214
|
+
);
|
|
215
|
+
return Math.max(0, Math.round((messageTokensEst + requestReserve) * calibration));
|
|
216
|
+
}
|
|
217
|
+
|
|
202
218
|
function providerPressureTokens(sessionRef, usage) {
|
|
203
219
|
if (!usage || typeof usage !== 'object') return 0;
|
|
204
220
|
const input = Math.max(0, Number(usage.mainInputTokens ?? usage.inputTokens) || 0);
|
|
@@ -268,7 +284,9 @@ export function invalidateProviderContextBaseline(sessionRef) {
|
|
|
268
284
|
// transcript did NOT grow keeps its baseline regardless of age.
|
|
269
285
|
const BASELINE_MAX_STALE_GROWTH_MS = 30 * 60 * 1000;
|
|
270
286
|
|
|
271
|
-
function providerBaselinePressureTokens(messages, sessionRef, policy
|
|
287
|
+
function providerBaselinePressureTokens(messages, sessionRef, policy, {
|
|
288
|
+
includeConfiguredReserve = true,
|
|
289
|
+
} = {}) {
|
|
272
290
|
if (!Array.isArray(messages) || !sessionRef
|
|
273
291
|
|| sessionRef.lastContextTokensStaleAfterCompact === true) return null;
|
|
274
292
|
let tokens = positiveTokenInt(sessionRef.contextPressureBaselineTokens);
|
|
@@ -303,16 +321,31 @@ function providerBaselinePressureTokens(messages, sessionRef, policy) {
|
|
|
303
321
|
const growth = count < messages.length
|
|
304
322
|
? Math.round(estimateMessagesTokens(messages.slice(count)) * calibration)
|
|
305
323
|
: 0;
|
|
306
|
-
|
|
324
|
+
const configuredReserve = includeConfiguredReserve
|
|
325
|
+
? Math.max(0, Number(policy?.configuredReserveTokens) || 0)
|
|
326
|
+
: 0;
|
|
327
|
+
return Math.max(0, tokens + growth + configuredReserve);
|
|
307
328
|
} catch {
|
|
308
329
|
return null;
|
|
309
330
|
}
|
|
310
331
|
}
|
|
311
332
|
|
|
333
|
+
function preferAlignedBaseline(baseline, estimate) {
|
|
334
|
+
if (baseline == null) return estimate;
|
|
335
|
+
if (Number.isFinite(estimate) && estimate > 0 && baseline * 2 < estimate) return estimate;
|
|
336
|
+
return baseline;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function resolveCurrentContextTokens(messageTokensEst, policy, { messages, sessionRef } = {}) {
|
|
340
|
+
const baseline = providerBaselinePressureTokens(messages, sessionRef, policy, {
|
|
341
|
+
includeConfiguredReserve: false,
|
|
342
|
+
});
|
|
343
|
+
return preferAlignedBaseline(baseline, currentContextEstimateTokens(messageTokensEst, policy));
|
|
344
|
+
}
|
|
345
|
+
|
|
312
346
|
export function resolveCompactionPressureTokens(messageTokensEst, policy, { messages, sessionRef } = {}) {
|
|
313
347
|
const baseline = providerBaselinePressureTokens(messages, sessionRef, policy);
|
|
314
348
|
const estimate = compactPressureTokens(messageTokensEst, policy);
|
|
315
|
-
if (baseline == null) return estimate;
|
|
316
349
|
// Sanity band: the baseline exists to correct OVER-counting estimates
|
|
317
350
|
// (dense-data floors can inflate the estimate up to ~2x real usage), so a
|
|
318
351
|
// lower baseline is normally preferred. But a corrupt/stale baseline below
|
|
@@ -322,8 +355,7 @@ export function resolveCompactionPressureTokens(messageTokensEst, policy, { mess
|
|
|
322
355
|
// both the gauge and the compaction decision. Erring toward the estimate
|
|
323
356
|
// may compact somewhat early; erring toward a rotten baseline blows past
|
|
324
357
|
// the context window at full token cost.
|
|
325
|
-
|
|
326
|
-
return baseline;
|
|
358
|
+
return preferAlignedBaseline(baseline, estimate);
|
|
327
359
|
}
|
|
328
360
|
|
|
329
361
|
/** Telemetry pressure when a reactive overflow retry forces the next compact. */
|
|
@@ -178,7 +178,8 @@ async function executeToolOwned(name, args, cwd, callerSessionId, sessionRef, ex
|
|
|
178
178
|
return viewSkill(cwd, args?.name);
|
|
179
179
|
}
|
|
180
180
|
if (isMcpTool(name)) {
|
|
181
|
-
|
|
181
|
+
const mcpScopeId = sessionRef?.mcpScopeId || null;
|
|
182
|
+
if (!isOnDeferredToolSurface(sessionRef, name) && !isRegisteredMcpTool(name, mcpScopeId)) {
|
|
182
183
|
return formatUnknownBuiltinToolMessage(name, args, 'tool');
|
|
183
184
|
}
|
|
184
185
|
// 24h trace data shows ~24% of external MCP calls are cwd-sensitive
|
|
@@ -187,12 +188,13 @@ async function executeToolOwned(name, args, cwd, callerSessionId, sessionRef, ex
|
|
|
187
188
|
// inputSchema declares the field — schemas without it would reject
|
|
188
189
|
// an unknown argument.
|
|
189
190
|
const needsCwdInjection = cwd
|
|
190
|
-
&& mcpToolHasField(name, 'cwd')
|
|
191
|
+
&& mcpToolHasField(name, 'cwd', mcpScopeId)
|
|
191
192
|
&& (args == null || args.cwd == null);
|
|
192
193
|
const finalArgs = needsCwdInjection ? { ...(args || {}), cwd } : args;
|
|
193
194
|
return executeMcpTool(name, finalArgs, {
|
|
194
195
|
signal: executeOpts.signal || null,
|
|
195
196
|
ownerKey: callerSessionId,
|
|
197
|
+
scopeId: mcpScopeId,
|
|
196
198
|
});
|
|
197
199
|
}
|
|
198
200
|
if (name === 'code_graph') {
|
|
@@ -249,7 +249,10 @@ export function createSession(opts) {
|
|
|
249
249
|
const toolSpec = ownerIsAgent
|
|
250
250
|
? (isReadOnlyAgentBundle ? 'readonly' : 'full')
|
|
251
251
|
: (Array.isArray(profile?.tools) ? profile.tools : toolPreset);
|
|
252
|
-
let toolsForRouting = resolveSessionTools(toolSpec, skills, {
|
|
252
|
+
let toolsForRouting = resolveSessionTools(toolSpec, skills, {
|
|
253
|
+
ownerIsAgentSession: ownerIsAgent,
|
|
254
|
+
mcpScopeId: opts.mcpScopeId || null,
|
|
255
|
+
});
|
|
253
256
|
// Fail-closed permission intersection: when a session declares an explicit
|
|
254
257
|
// object-form permission, intersect the
|
|
255
258
|
// resolved tool list with the permission's allow/deny lists. If the
|
|
@@ -371,6 +374,7 @@ export function createSession(opts) {
|
|
|
371
374
|
agent: opts.agent,
|
|
372
375
|
owner: opts.owner || 'user',
|
|
373
376
|
mcpPid: process.pid,
|
|
377
|
+
mcpScopeId: opts.mcpScopeId || null,
|
|
374
378
|
scopeKey: opts.scopeKey || null,
|
|
375
379
|
lane: opts.lane || 'agent',
|
|
376
380
|
cwd: opts.cwd,
|
|
@@ -530,7 +534,10 @@ function _prepareResumeTools(session, preset) {
|
|
|
530
534
|
if (!ownerIsAgent && Array.isArray(profile?.tools)) toolSpec = profile.tools;
|
|
531
535
|
} catch { /* ignore lookup failures, keep preset fallback */ }
|
|
532
536
|
}
|
|
533
|
-
let toolsForRouting = resolveSessionTools(toolSpec, skills, {
|
|
537
|
+
let toolsForRouting = resolveSessionTools(toolSpec, skills, {
|
|
538
|
+
ownerIsAgentSession: ownerIsAgent,
|
|
539
|
+
mcpScopeId: session.mcpScopeId || null,
|
|
540
|
+
});
|
|
534
541
|
if (ownerIsAgent) {
|
|
535
542
|
toolsForRouting = applyToolPermissionNarrowing(toolsForRouting, session.toolPermission, session.agent || null);
|
|
536
543
|
}
|
|
@@ -563,8 +570,10 @@ function _rememberPreparedResume(sessionId, prepared) {
|
|
|
563
570
|
|
|
564
571
|
function _preparedResumeForSession(session, preset) {
|
|
565
572
|
const cached = _preparedResumes.get(session.id);
|
|
566
|
-
if (cached?.session === session && cached?.preset === preset
|
|
573
|
+
if (cached?.session === session && cached?.preset === preset
|
|
574
|
+
&& cached?.mcpScopeId === (session.mcpScopeId || null)) return cached;
|
|
567
575
|
const prepared = _prepareResumeTools(session, preset);
|
|
576
|
+
prepared.mcpScopeId = session.mcpScopeId || null;
|
|
568
577
|
_rememberPreparedResume(session.id, prepared);
|
|
569
578
|
return prepared;
|
|
570
579
|
}
|
|
@@ -745,6 +754,9 @@ export async function resumeSession(sessionId, preset, options = {}) {
|
|
|
745
754
|
session.desktopSession = expectedDesktop;
|
|
746
755
|
}
|
|
747
756
|
if (!session.owner) session.owner = 'user';
|
|
757
|
+
if (Object.prototype.hasOwnProperty.call(options, 'mcpScopeId')) {
|
|
758
|
+
session.mcpScopeId = String(options.mcpScopeId || '').trim() || null;
|
|
759
|
+
}
|
|
748
760
|
if (_isActivelyOwnedElsewhere(session, sessionId)) {
|
|
749
761
|
// ATTACH (viewer mode, zero ownership): hand back the live transcript
|
|
750
762
|
// under the SAME id, flagged remoteAttached. No tool refresh, no save,
|
|
@@ -764,6 +776,7 @@ export async function resumeSession(sessionId, preset, options = {}) {
|
|
|
764
776
|
const cached = _preparedResumes.get(sessionId);
|
|
765
777
|
_preparedResumes.delete(sessionId);
|
|
766
778
|
const prepared = cached?.session === session && cached?.preset === preset
|
|
779
|
+
&& cached?.mcpScopeId === (session.mcpScopeId || null)
|
|
767
780
|
? cached
|
|
768
781
|
: _prepareResumeTools(session, preset);
|
|
769
782
|
// Keep the persisted tool mode in sync on resume (see createSession note).
|