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.
Files changed (175) hide show
  1. package/README.md +110 -68
  2. package/package.json +3 -2
  3. package/scripts/tool-stress.mjs +0 -2
  4. package/src/defaults/skills/setup/SKILL.md +18 -27
  5. package/src/headless-exec.mjs +3 -10
  6. package/src/headless-exec.test.mjs +2 -7
  7. package/src/runtime/agent/orchestrator/config.mjs +6 -66
  8. package/src/runtime/agent/orchestrator/context/collect-skills.test.mjs +153 -0
  9. package/src/runtime/agent/orchestrator/context/collect.mjs +49 -45
  10. package/src/runtime/agent/orchestrator/mcp/client.mjs +242 -15
  11. package/src/runtime/agent/orchestrator/mcp/features.test.mjs +66 -0
  12. package/src/runtime/agent/orchestrator/mcp/security.test.mjs +36 -20
  13. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +54 -33
  14. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -0
  15. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
  16. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +7 -0
  17. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +3 -8
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +10 -2
  20. package/src/runtime/agent/orchestrator/providers/lib/provider-replay.mjs +50 -0
  21. package/src/runtime/agent/orchestrator/providers/lib/sse-framing.mjs +93 -0
  22. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +3 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +8 -0
  24. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -0
  25. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +25 -2
  26. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -0
  28. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -2
  29. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +13 -0
  30. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +4 -7
  31. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +9 -0
  32. package/src/runtime/agent/orchestrator/providers/provider-replay.test.mjs +235 -0
  33. package/src/runtime/agent/orchestrator/providers/sse-framing.test.mjs +719 -0
  34. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +399 -39
  35. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +26 -0
  36. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +14 -4
  37. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +13 -0
  38. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +24 -1
  39. package/src/runtime/agent/orchestrator/session/compaction-read-reset.test.mjs +161 -0
  40. package/src/runtime/agent/orchestrator/session/context-utils.mjs +14 -4
  41. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -1
  43. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +31 -18
  44. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -0
  45. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +3 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +6 -1
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +1 -3
  48. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +556 -0
  49. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.test.mjs +457 -0
  50. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +105 -186
  51. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +115 -0
  52. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +2 -0
  53. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -0
  54. package/src/runtime/agent/orchestrator/session/store.mjs +23 -1
  55. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool-cwd-env.test.mjs +14 -0
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +19 -10
  57. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +30 -14
  58. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +15 -4
  59. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +73 -15
  60. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +76 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +24 -683
  62. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +64 -72
  63. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +2 -5
  64. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.mjs +75 -0
  65. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.test.mjs +24 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +12 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +4 -1
  68. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +24 -5
  69. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.test.mjs +44 -0
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +12 -2
  71. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +35 -21
  72. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +28 -13
  73. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.test.mjs +54 -0
  74. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  75. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  76. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +8 -0
  77. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  78. package/src/runtime/browser-bridge/client.mjs +103 -0
  79. package/src/runtime/browser-bridge/tool-defs.mjs +42 -0
  80. package/src/runtime/computer-bridge/client.mjs +101 -0
  81. package/src/runtime/computer-bridge/tool-defs.mjs +53 -0
  82. package/src/runtime/media/store.mjs +18 -8
  83. package/src/runtime/media/store.test.mjs +14 -0
  84. package/src/runtime/memory/index.mjs +1 -1
  85. package/src/runtime/memory/lib/transcript-ingest.mjs +8 -6
  86. package/src/runtime/memory/lib/transcript-ingest.test.mjs +52 -0
  87. package/src/runtime/shared/child-spawn-gate.mjs +17 -0
  88. package/src/runtime/shared/child-spawn-remote.mjs +69 -7
  89. package/src/runtime/shared/child-spawn-remote.test.mjs +124 -0
  90. package/src/runtime/shared/pristine-execution-contract.json +2 -1
  91. package/src/runtime/shared/provider-api-key.mjs +1 -0
  92. package/src/runtime/shared/session-runtime-health.mjs +91 -0
  93. package/src/runtime/shared/session-runtime-health.test.mjs +53 -0
  94. package/src/runtime/shared/skill-document.mjs +86 -0
  95. package/src/runtime/shared/skill-document.test.mjs +81 -0
  96. package/src/runtime/shared/tool-surface.mjs +18 -0
  97. package/src/runtime/shared/turn-snapshot-store.mjs +1 -0
  98. package/src/runtime/shared/turn-snapshot.mjs +32 -9
  99. package/src/runtime/shared/user-cwd.mjs +33 -14
  100. package/src/runtime/shared/user-cwd.test.mjs +43 -0
  101. package/src/session-runtime/cwd-plugins.mjs +26 -26
  102. package/src/session-runtime/cwd-plugins.test.mjs +75 -0
  103. package/src/session-runtime/cwd-tool-routing.test.mjs +5 -0
  104. package/src/session-runtime/global-extensions.mjs +26 -0
  105. package/src/session-runtime/global-extensions.test.mjs +28 -0
  106. package/src/session-runtime/goal-runtime.mjs +721 -0
  107. package/src/session-runtime/goal-runtime.test.mjs +108 -0
  108. package/src/session-runtime/lifecycle-api.mjs +18 -4
  109. package/src/session-runtime/lifecycle-api.test.mjs +21 -0
  110. package/src/session-runtime/mcp-glue.mjs +49 -47
  111. package/src/session-runtime/plugin-mcp-standard.test.mjs +177 -0
  112. package/src/session-runtime/plugin-mcp.mjs +96 -22
  113. package/src/session-runtime/prewarm.mjs +1 -10
  114. package/src/session-runtime/resource-api-global.test.mjs +69 -0
  115. package/src/session-runtime/resource-api.mjs +173 -58
  116. package/src/session-runtime/runtime-core.mjs +148 -20
  117. package/src/session-runtime/runtime-tunables.mjs +6 -8
  118. package/src/session-runtime/session-turn-api.mjs +22 -11
  119. package/src/session-runtime/skills-api.mjs +79 -25
  120. package/src/session-runtime/skills-api.test.mjs +99 -0
  121. package/src/session-runtime/tool-catalog-schema.mjs +0 -5
  122. package/src/session-runtime/tool-catalog.mjs +4 -13
  123. package/src/session-runtime/tool-policy-surface.test.mjs +33 -11
  124. package/src/session-runtime/tool-surface.mjs +12 -0
  125. package/src/standalone/agent-dispatch-broker.mjs +5 -9
  126. package/src/standalone/agent-tool/notify.mjs +6 -8
  127. package/src/standalone/agent-tool/notify.test.mjs +37 -0
  128. package/src/standalone/daemon.mjs +31 -15
  129. package/src/standalone/hook-bus/config.mjs +1 -0
  130. package/src/standalone/plugin-admin.mjs +17 -0
  131. package/src/standalone/plugin-admin.test.mjs +7 -0
  132. package/src/standalone/provider-admin.mjs +1 -0
  133. package/src/standalone/session-protocol.mjs +5 -0
  134. package/src/standalone/session-runtime-agent-control-client.mjs +133 -0
  135. package/src/standalone/session-runtime-agent-control-client.test.mjs +71 -0
  136. package/src/standalone/session-runtime-dispatch-cancel.test.mjs +139 -0
  137. package/src/standalone/session-runtime-host-factory.mjs +17 -0
  138. package/src/standalone/session-runtime-host-factory.test.mjs +31 -0
  139. package/src/standalone/session-runtime-host-health.test.mjs +5 -0
  140. package/src/standalone/session-runtime-host.mjs +696 -58
  141. package/src/standalone/session-runtime-inline-host.mjs +351 -0
  142. package/src/standalone/session-runtime-inline-host.test.mjs +83 -0
  143. package/src/standalone/session-runtime-provider-cooldown.mjs +78 -0
  144. package/src/standalone/session-runtime-provider-cooldown.test.mjs +80 -0
  145. package/src/standalone/session-runtime-shard-host.test.mjs +569 -0
  146. package/src/standalone/session-runtime-shard-router.mjs +149 -0
  147. package/src/standalone/session-runtime-shard-router.test.mjs +101 -0
  148. package/src/standalone/session-runtime-worker.mjs +476 -18
  149. package/src/standalone/session-service.mjs +81 -6
  150. package/src/tui/app/app-view.jsx +1 -0
  151. package/src/tui/app/core-memory-picker.mjs +2 -0
  152. package/src/tui/app/extension-pickers.mjs +1 -1
  153. package/src/tui/app/maintenance-pickers.mjs +3 -3
  154. package/src/tui/app/model-options.mjs +2 -0
  155. package/src/tui/app/model-picker.mjs +14 -4
  156. package/src/tui/app/panel-handoff.test.mjs +311 -0
  157. package/src/tui/app/project-picker.mjs +1 -0
  158. package/src/tui/app/route-pickers.mjs +21 -6
  159. package/src/tui/app/settings-picker.mjs +26 -3
  160. package/src/tui/app/slash-commands.mjs +1 -0
  161. package/src/tui/app/slash-dispatch.mjs +73 -22
  162. package/src/tui/app/theme-effort-pickers.mjs +7 -2
  163. package/src/tui/components/Picker.jsx +10 -1
  164. package/src/tui/dist/index.mjs +163 -44
  165. package/src/tui/session/context-state.mjs +1 -0
  166. package/src/tui/session/goal-continuation.mjs +110 -0
  167. package/src/tui/session/goal-continuation.test.mjs +84 -0
  168. package/src/tui/session/queue-helpers.mjs +5 -2
  169. package/src/tui/session/session-action-surface.test.mjs +108 -0
  170. package/src/tui/session/session-api-ext.mjs +39 -8
  171. package/src/tui/session/session-api.mjs +58 -0
  172. package/src/tui/session/session-flow.mjs +6 -0
  173. package/src/tui/session/turn.mjs +1 -0
  174. package/src/tui/session-local.mjs +15 -1
  175. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +0 -301
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, existsSync, mkdirSync } from 'fs';
2
2
  import { writeFile } from 'fs/promises';
3
- import { join } from 'path';
3
+ import { basename, join, resolve } from 'path';
4
+ import { pathToFileURL } from 'url';
4
5
  import { tmpdir } from 'os';
5
6
  import { randomUUID } from 'crypto';
6
7
  import { smartReadTruncate } from '../tools/builtin/read-formatting.mjs';
@@ -93,7 +94,7 @@ export function scrubMcpConnectionMessage(value, cfg = {}) {
93
94
  }
94
95
  } catch { /* invalid URL is reported without echoing it */ }
95
96
  }
96
- const headers = cfg?.headers && typeof cfg.headers === 'object' ? expandEnvVars(cfg.headers) : {};
97
+ const headers = resolveMcpHttpHeaders(cfg);
97
98
  for (const [name, headerValue] of Object.entries(headers)) {
98
99
  if (/authorization|cookie|token|secret|api[-_]?key|signature/i.test(name)) {
99
100
  text = replaceSecret(text, headerValue);
@@ -158,12 +159,15 @@ async function loadMcpSdk() {
158
159
  import('@modelcontextprotocol/sdk/client/streamableHttp.js'),
159
160
  import('@modelcontextprotocol/sdk/client/sse.js'),
160
161
  import('@modelcontextprotocol/sdk/client/websocket.js'),
161
- ]).then(([clientMod, stdioMod, httpMod, sseMod, wsMod]) => ({
162
+ import('@modelcontextprotocol/sdk/types.js'),
163
+ ]).then(([clientMod, stdioMod, httpMod, sseMod, wsMod, typesMod]) => ({
162
164
  Client: clientMod.Client,
163
165
  StdioClientTransport: stdioMod.StdioClientTransport,
164
166
  StreamableHTTPClientTransport: httpMod.StreamableHTTPClientTransport,
165
167
  SSEClientTransport: sseMod.SSEClientTransport,
166
168
  WebSocketClientTransport: wsMod.WebSocketClientTransport,
169
+ ListRootsRequestSchema: typesMod.ListRootsRequestSchema,
170
+ ToolListChangedNotificationSchema: typesMod.ToolListChangedNotificationSchema,
167
171
  }));
168
172
  return mcpSdkPromise;
169
173
  }
@@ -191,6 +195,46 @@ function expandEnvVars(value, env = process.env) {
191
195
  }
192
196
  return value;
193
197
  }
198
+
199
+ const DEFAULT_STDIO_ENV_KEYS = process.platform === 'win32'
200
+ ? ['APPDATA', 'HOMEDRIVE', 'HOMEPATH', 'LOCALAPPDATA', 'PATH',
201
+ 'PROCESSOR_ARCHITECTURE', 'SYSTEMDRIVE', 'SYSTEMROOT', 'TEMP',
202
+ 'TMP', 'USERDOMAIN', 'USERNAME', 'USERPROFILE']
203
+ : ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER'];
204
+
205
+ export function resolveMcpStdioEnvironment(cfg = {}, env = process.env) {
206
+ const explicit = cfg.env && typeof cfg.env === 'object' && !Array.isArray(cfg.env)
207
+ ? expandEnvVars(cfg.env, env)
208
+ : {};
209
+ if (!Array.isArray(cfg.env_vars)) return { ...env, ...explicit };
210
+ const inherited = {};
211
+ const keys = new Set([...DEFAULT_STDIO_ENV_KEYS, ...cfg.env_vars.map((value) => String(value).trim()).filter(Boolean)]);
212
+ for (const requested of keys) {
213
+ const actual = process.platform === 'win32'
214
+ ? Object.keys(env || {}).find((key) => key.toLowerCase() === requested.toLowerCase())
215
+ : requested;
216
+ if (actual && env?.[actual] != null) inherited[actual] = String(env[actual]);
217
+ }
218
+ return { ...inherited, ...explicit };
219
+ }
220
+
221
+ export function resolveMcpHttpHeaders(cfg = {}, env = process.env) {
222
+ const headers = {};
223
+ const envHeaders = cfg.env_http_headers && typeof cfg.env_http_headers === 'object'
224
+ && !Array.isArray(cfg.env_http_headers) ? cfg.env_http_headers : {};
225
+ for (const [header, envName] of Object.entries(envHeaders)) {
226
+ const value = env?.[String(envName)];
227
+ if (value != null && String(value)) headers[String(header)] = String(value);
228
+ }
229
+ const bearerEnv = String(cfg.bearer_token_env_var || '').trim();
230
+ if (bearerEnv && env?.[bearerEnv] != null && String(env[bearerEnv])) {
231
+ headers.Authorization = `Bearer ${String(env[bearerEnv])}`;
232
+ }
233
+ const explicit = cfg.headers && typeof cfg.headers === 'object' && !Array.isArray(cfg.headers)
234
+ ? expandEnvVars(cfg.headers, env)
235
+ : {};
236
+ return { ...headers, ...explicit };
237
+ }
194
238
  /**
195
239
  * Resolve the canonical transport kind for an MCP server config entry.
196
240
  * Returns one of: 'autoDetect' | 'stdio' | 'http' | 'sse' | 'ws'.
@@ -260,6 +304,54 @@ export function getMcpTools(scopeId = DEFAULT_MCP_SCOPE_ID) {
260
304
  }
261
305
  return tools;
262
306
  }
307
+
308
+ function mcpFeatureTools(serverName, capabilities, protocolTools) {
309
+ const tools = [];
310
+ const used = new Set(protocolTools.map((tool) => tool.name));
311
+ const add = (leaf, description, properties, required, operation) => {
312
+ let name = `mcp__${serverName}__${leaf}`;
313
+ while (used.has(name)) name += '_';
314
+ used.add(name);
315
+ tools.push({
316
+ name,
317
+ description,
318
+ inputSchema: {
319
+ type: 'object',
320
+ properties,
321
+ ...(required?.length ? { required } : {}),
322
+ additionalProperties: false,
323
+ },
324
+ mcpOperation: operation,
325
+ });
326
+ };
327
+ if (capabilities?.resources) {
328
+ add('mixdog_list_resources', 'List resources exposed by this MCP server.',
329
+ { cursor: { type: 'string', description: 'Pagination cursor from a previous result.' } },
330
+ [], 'list-resources');
331
+ add('mixdog_list_resource_templates', 'List resource URI templates exposed by this MCP server.',
332
+ { cursor: { type: 'string', description: 'Pagination cursor from a previous result.' } },
333
+ [], 'list-resource-templates');
334
+ add('mixdog_read_resource', 'Read one resource exposed by this MCP server.',
335
+ { uri: { type: 'string', description: 'Resource URI returned by the server.' } },
336
+ ['uri'], 'read-resource');
337
+ }
338
+ if (capabilities?.prompts) {
339
+ add('mixdog_list_prompts', 'List reusable prompts exposed by this MCP server.',
340
+ { cursor: { type: 'string', description: 'Pagination cursor from a previous result.' } },
341
+ [], 'list-prompts');
342
+ add('mixdog_get_prompt', 'Render one reusable prompt exposed by this MCP server.',
343
+ {
344
+ name: { type: 'string', description: 'Prompt name returned by the server.' },
345
+ arguments: {
346
+ type: 'object',
347
+ description: 'Prompt argument values.',
348
+ additionalProperties: { type: 'string' },
349
+ },
350
+ },
351
+ ['name'], 'get-prompt');
352
+ }
353
+ return tools;
354
+ }
263
355
  export function getMcpServerStatus(scopeId = DEFAULT_MCP_SCOPE_ID) {
264
356
  return scopedServerEntries(scopeId).map(([, server]) => ({
265
357
  name: server.name,
@@ -269,6 +361,11 @@ export function getMcpServerStatus(scopeId = DEFAULT_MCP_SCOPE_ID) {
269
361
  name: tool.name,
270
362
  description: tool.description || '',
271
363
  })),
364
+ capabilities: {
365
+ tools: Boolean(server.capabilities?.tools),
366
+ prompts: Boolean(server.capabilities?.prompts),
367
+ resources: Boolean(server.capabilities?.resources),
368
+ },
272
369
  transport: (() => {
273
370
  try {
274
371
  return resolveMcpTransportKind(server.cfg);
@@ -278,6 +375,36 @@ export function getMcpServerStatus(scopeId = DEFAULT_MCP_SCOPE_ID) {
278
375
  })(),
279
376
  }));
280
377
  }
378
+
379
+ function requireMcpServer(name, options = {}) {
380
+ const serverName = String(name || '').trim();
381
+ const server = scopedServer(normalizeMcpScopeId(options), serverName);
382
+ if (!server) throw new Error(`MCP server "${serverName}" not connected`);
383
+ return server;
384
+ }
385
+
386
+ export async function listMcpResources(name, request = {}, options = {}) {
387
+ return requireMcpServer(name, options).client.listResources(request || {});
388
+ }
389
+
390
+ export async function listMcpResourceTemplates(name, request = {}, options = {}) {
391
+ return requireMcpServer(name, options).client.listResourceTemplates(request || {});
392
+ }
393
+
394
+ export async function readMcpResource(name, uri, options = {}) {
395
+ return requireMcpServer(name, options).client.readResource({ uri: String(uri || '') });
396
+ }
397
+
398
+ export async function listMcpPrompts(name, request = {}, options = {}) {
399
+ return requireMcpServer(name, options).client.listPrompts(request || {});
400
+ }
401
+
402
+ export async function getMcpPrompt(name, promptName, args = {}, options = {}) {
403
+ return requireMcpServer(name, options).client.getPrompt({
404
+ name: String(promptName || ''),
405
+ arguments: args && typeof args === 'object' && !Array.isArray(args) ? args : {},
406
+ });
407
+ }
281
408
  function positiveInt(value, fallback) {
282
409
  const parsed = Math.floor(Number(value));
283
410
  return Number.isFinite(parsed) && parsed >= 1 ? parsed : fallback;
@@ -376,6 +503,10 @@ export async function executeMcpTool(name, args, options = {}) {
376
503
  const server = scopedServer(scopeId, serverName);
377
504
  if (!server)
378
505
  throw new Error(`MCP server "${serverName}" not connected`);
506
+ const definition = (server.tools || []).find((tool) => tool.name === name);
507
+ const dispatch = (target) => definition?.mcpOperation
508
+ ? _callMcpFeatureWithTimeout(target, definition.mcpOperation, args, callSignal)
509
+ : _callToolWithTimeout(target, toolName, args, callSignal);
379
510
  const gate = callAdmissionFor(server);
380
511
  const callSignal = options?.signal || null;
381
512
  return gate.run(options?.ownerKey || currentToolExecutionOwner(), async () => {
@@ -389,7 +520,7 @@ export async function executeMcpTool(name, args, options = {}) {
389
520
  }
390
521
  let result;
391
522
  try {
392
- result = await _callToolWithTimeout(server, toolName, args, callSignal);
523
+ result = await dispatch(server);
393
524
  } catch (firstErr) {
394
525
  const firstMsg = firstErr instanceof Error ? firstErr.message : String(firstErr);
395
526
  if (isMcpCallAbortError(firstErr) || callSignal?.aborted) {
@@ -430,7 +561,7 @@ export async function executeMcpTool(name, args, options = {}) {
430
561
  );
431
562
  }
432
563
  try {
433
- result = await _callToolWithTimeout(retryServer, toolName, args, callSignal);
564
+ result = await dispatch(retryServer);
434
565
  } catch (retryErr) {
435
566
  if (isMcpCallAbortError(retryErr) || callSignal?.aborted) throw retryErr;
436
567
  const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
@@ -447,6 +578,53 @@ export async function executeMcpTool(name, args, options = {}) {
447
578
  });
448
579
  }
449
580
 
581
+ async function _callMcpFeatureWithTimeout(server, operation, args, signal = null) {
582
+ const requestOptions = signal ? { signal } : undefined;
583
+ const cursor = typeof args?.cursor === 'string' && args.cursor ? { cursor: args.cursor } : {};
584
+ let request;
585
+ if (operation === 'list-resources') {
586
+ request = server.client.listResources(cursor, requestOptions);
587
+ } else if (operation === 'list-resource-templates') {
588
+ request = server.client.listResourceTemplates(cursor, requestOptions);
589
+ } else if (operation === 'read-resource') {
590
+ request = server.client.readResource({ uri: String(args?.uri || '') }, requestOptions);
591
+ } else if (operation === 'list-prompts') {
592
+ request = server.client.listPrompts(cursor, requestOptions);
593
+ } else if (operation === 'get-prompt') {
594
+ request = server.client.getPrompt({
595
+ name: String(args?.name || ''),
596
+ arguments: args?.arguments && typeof args.arguments === 'object' && !Array.isArray(args.arguments)
597
+ ? args.arguments
598
+ : {},
599
+ }, requestOptions);
600
+ } else {
601
+ throw new Error(`Unsupported MCP feature operation: ${operation}`);
602
+ }
603
+ let timer;
604
+ const timeoutMs = resolveMcpCallTimeoutMs(server?.cfg);
605
+ const abortMessage = `MCP feature call aborted (server="${server?.name}", operation="${operation}")`;
606
+ const bounded = timeoutMs > 0
607
+ ? Promise.race([request, new Promise((_, reject) => {
608
+ timer = setTimeout(() => {
609
+ try { _closeServer(server).catch(() => {}); } catch { /* ignore */ }
610
+ const error = new Error(`MCP feature call timed out after ${timeoutMs}ms (server="${server.name}", operation="${operation}")`);
611
+ error.code = 'EMCPTOOLTIMEOUT';
612
+ reject(error);
613
+ }, timeoutMs);
614
+ if (timer.unref) timer.unref();
615
+ })])
616
+ : request;
617
+ try {
618
+ const result = await raceMcpAbort(bounded, signal, abortMessage);
619
+ return {
620
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
621
+ isError: false,
622
+ };
623
+ } finally {
624
+ if (timer) clearTimeout(timer);
625
+ }
626
+ }
627
+
450
628
  // Preserve MCP failure metadata across the object→string boundary. The
451
629
  // session loop classifies the canonical Error: prefix as toolKind:error.
452
630
  export function normalizeMcpToolResult(result) {
@@ -746,13 +924,30 @@ async function connectServer(name, cfg, scopeId = DEFAULT_MCP_SCOPE_ID, genAtSta
746
924
  StreamableHTTPClientTransport,
747
925
  SSEClientTransport,
748
926
  WebSocketClientTransport,
927
+ ListRootsRequestSchema,
928
+ ToolListChangedNotificationSchema,
749
929
  } = await loadMcpSdk();
750
930
  if (genAtStart !== currentConnectAbortGeneration(scopeId)) {
751
931
  // disconnectAll() ran while the SDK was loading: nothing spawned yet —
752
932
  // abort before creating a transport/child at all.
753
933
  throw new Error(`MCP server "${name}" connect aborted by shutdown`);
754
934
  }
755
- const client = new Client({ name: `mixdog-agent/${name}`, version: '1.0.0' });
935
+ const projectRoot = String(cfg?._mixdogProjectRoot || cfg?.cwd || '').trim();
936
+ const client = new Client(
937
+ { name: `mixdog-agent/${name}`, version: '1.0.0' },
938
+ { capabilities: projectRoot ? { roots: { listChanged: false } } : {} },
939
+ );
940
+ if (projectRoot && ListRootsRequestSchema) {
941
+ client.setRequestHandler(ListRootsRequestSchema, async () => {
942
+ const rootPath = resolve(projectRoot);
943
+ return {
944
+ roots: [{
945
+ uri: pathToFileURL(rootPath).href,
946
+ name: basename(rootPath) || rootPath,
947
+ }],
948
+ };
949
+ });
950
+ }
756
951
  let transport;
757
952
  const kind = resolveMcpTransportKind(cfg);
758
953
  // When the autoDetect port comes from a discovery advert (not the legacy
@@ -804,7 +999,7 @@ async function connectServer(name, cfg, scopeId = DEFAULT_MCP_SCOPE_ID, genAtSta
804
999
  }
805
1000
  else if (kind === 'http') {
806
1001
  const url = normalizeMcpTransportUrl(expandEnvVars(String(cfg.url ?? '')), 'http');
807
- const headers = expandEnvVars(cfg.headers && typeof cfg.headers === 'object' ? cfg.headers : {});
1002
+ const headers = resolveMcpHttpHeaders(cfg);
808
1003
  const opts = (headers && Object.keys(headers).length > 0)
809
1004
  ? { requestInit: { headers } }
810
1005
  : undefined;
@@ -815,7 +1010,7 @@ async function connectServer(name, cfg, scopeId = DEFAULT_MCP_SCOPE_ID, genAtSta
815
1010
  }
816
1011
  else if (kind === 'sse') {
817
1012
  const url = normalizeMcpTransportUrl(expandEnvVars(String(cfg.url ?? '')), 'sse');
818
- const headers = expandEnvVars(cfg.headers && typeof cfg.headers === 'object' ? cfg.headers : {});
1013
+ const headers = resolveMcpHttpHeaders(cfg);
819
1014
  const opts = (headers && Object.keys(headers).length > 0)
820
1015
  ? { requestInit: { headers } }
821
1016
  : undefined;
@@ -835,7 +1030,7 @@ async function connectServer(name, cfg, scopeId = DEFAULT_MCP_SCOPE_ID, genAtSta
835
1030
  command: expandEnvVars(String(cfg.command ?? '')),
836
1031
  args: Array.isArray(cfg.args) ? expandEnvVars(cfg.args) : cfg.args,
837
1032
  cwd: cfg.cwd,
838
- env: { ...process.env, ...expandEnvVars(cfg.env && typeof cfg.env === 'object' ? cfg.env : {}) },
1033
+ env: resolveMcpStdioEnvironment(cfg),
839
1034
  stderr: cfg.stderr ?? 'pipe',
840
1035
  });
841
1036
  transport.stderr?.on?.('data', (chunk) => {
@@ -862,11 +1057,13 @@ async function connectServer(name, cfg, scopeId = DEFAULT_MCP_SCOPE_ID, genAtSta
862
1057
  ? client.getInstructions()
863
1058
  : undefined;
864
1059
  const instr = typeof instructionsRaw === 'string' ? instructionsRaw.trim() : '';
865
- const result = await client.listTools();
866
- return { instructions: instr, toolsResult: result };
1060
+ const capabilities = client.getServerCapabilities?.() || {};
1061
+ const result = capabilities.tools ? await client.listTools() : { tools: [] };
1062
+ return { instructions: instr, toolsResult: result, capabilities };
867
1063
  })();
868
1064
  let instructions;
869
1065
  let toolsResult;
1066
+ let capabilities;
870
1067
  try {
871
1068
  if (startupTimeoutMs > 0) {
872
1069
  const guard = new Promise((_, rej) => {
@@ -877,9 +1074,9 @@ async function connectServer(name, cfg, scopeId = DEFAULT_MCP_SCOPE_ID, genAtSta
877
1074
  rej(err);
878
1075
  }, startupTimeoutMs);
879
1076
  });
880
- ({ instructions, toolsResult } = await Promise.race([handshake, guard]));
1077
+ ({ instructions, toolsResult, capabilities } = await Promise.race([handshake, guard]));
881
1078
  } else {
882
- ({ instructions, toolsResult } = await handshake);
1079
+ ({ instructions, toolsResult, capabilities } = await handshake);
883
1080
  }
884
1081
  } catch (err) {
885
1082
  // A discovery-advert port that fails to connect is a corpse (recycled
@@ -910,12 +1107,16 @@ async function connectServer(name, cfg, scopeId = DEFAULT_MCP_SCOPE_ID, genAtSta
910
1107
  catch { /* ignore */ }
911
1108
  throw new Error(`MCP server "${name}" connect aborted by shutdown`);
912
1109
  }
913
- const tools = toolsResult.tools.map((t) => ({
1110
+ const protocolTools = toolsResult.tools.map((t) => ({
914
1111
  name: `mcp__${name}__${t.name}`,
915
1112
  description: t.description || '',
916
1113
  inputSchema: (t.inputSchema || { type: 'object', properties: {} }),
917
1114
  ...(t.annotations && typeof t.annotations === 'object' ? { annotations: t.annotations } : {}),
918
1115
  }));
1116
+ const tools = [
1117
+ ...protocolTools,
1118
+ ...mcpFeatureTools(name, capabilities, protocolTools),
1119
+ ];
919
1120
  const toolNames = tools.map(t => t.name);
920
1121
  const registryKey = mcpServerRegistryKey(scopeId, name);
921
1122
  servers.set(registryKey, {
@@ -927,8 +1128,31 @@ async function connectServer(name, cfg, scopeId = DEFAULT_MCP_SCOPE_ID, genAtSta
927
1128
  tools,
928
1129
  cfg,
929
1130
  instructions,
1131
+ capabilities,
930
1132
  generation: genAtStart,
931
1133
  });
1134
+ if (capabilities?.tools?.listChanged && ToolListChangedNotificationSchema) {
1135
+ client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
1136
+ const live = servers.get(registryKey);
1137
+ if (!live || live.client !== client) return;
1138
+ try {
1139
+ const refreshed = await client.listTools();
1140
+ const refreshedProtocolTools = (refreshed.tools || []).map((tool) => ({
1141
+ name: `mcp__${name}__${tool.name}`,
1142
+ description: tool.description || '',
1143
+ inputSchema: tool.inputSchema || { type: 'object', properties: {} },
1144
+ ...(tool.annotations && typeof tool.annotations === 'object' ? { annotations: tool.annotations } : {}),
1145
+ }));
1146
+ live.tools = [
1147
+ ...refreshedProtocolTools,
1148
+ ...mcpFeatureTools(name, capabilities, refreshedProtocolTools),
1149
+ ];
1150
+ _invalidateMcpToolFieldMemo();
1151
+ } catch (error) {
1152
+ mcpLog(`[mcp-client] Failed to refresh tools for "${name}": ${scrubMcpConnectionMessage(error?.message || error, cfg)}\n`);
1153
+ }
1154
+ });
1155
+ }
932
1156
  _invalidateMcpToolFieldMemo();
933
1157
  mcpLog(`[mcp] connected: ${tools.length} tools — ${toolNames.join(', ')}\n`);
934
1158
  }
@@ -948,18 +1172,21 @@ export function _registerMcpServerForTest(scopeId, name, rawTools = [], options
948
1172
  : `mcp__${name}__${String(tool?.name || '')}`,
949
1173
  inputSchema: tool?.inputSchema || { type: 'object', properties: {} },
950
1174
  }));
1175
+ const capabilities = options.capabilities || {};
951
1176
  const server = {
952
1177
  scopeId: normalizedScopeId,
953
1178
  registryKey,
954
1179
  name,
955
- tools,
1180
+ tools: [...tools, ...mcpFeatureTools(name, capabilities, tools)],
956
1181
  cfg: options.cfg || {},
957
1182
  instructions: options.instructions || '',
1183
+ capabilities,
958
1184
  transport: null,
959
1185
  client: {
960
1186
  callTool: typeof options.callTool === 'function'
961
1187
  ? options.callTool
962
1188
  : async () => ({ content: [{ type: 'text', text: 'ok' }] }),
1189
+ ...(options.client && typeof options.client === 'object' ? options.client : {}),
963
1190
  // Injectable so a test can stall teardown (and therefore the shared
964
1191
  // reconnect) without a transport or a child process.
965
1192
  close: typeof options.close === 'function' ? options.close : async () => {},
@@ -0,0 +1,66 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import {
5
+ _registerMcpServerForTest,
6
+ disconnectAll,
7
+ executeMcpTool,
8
+ getMcpServerStatus,
9
+ getMcpTools,
10
+ } from './client.mjs';
11
+
12
+ test('resources and prompts become callable MCP feature tools', async () => {
13
+ const scopeId = 'mcp-features-test';
14
+ const calls = [];
15
+ _registerMcpServerForTest(scopeId, 'catalog', [], {
16
+ capabilities: {
17
+ resources: {},
18
+ prompts: {},
19
+ },
20
+ client: {
21
+ listResources: async (params) => {
22
+ calls.push(['listResources', params]);
23
+ return { resources: [{ uri: 'file:///guide.md', name: 'Guide' }] };
24
+ },
25
+ listResourceTemplates: async () => ({ resourceTemplates: [] }),
26
+ readResource: async ({ uri }) => ({ contents: [{ uri, text: '# Guide' }] }),
27
+ listPrompts: async () => ({ prompts: [{ name: 'review' }] }),
28
+ getPrompt: async ({ name, arguments: args }) => ({
29
+ description: 'Review prompt',
30
+ messages: [{ role: 'user', content: { type: 'text', text: `${name}:${args.depth}` } }],
31
+ }),
32
+ },
33
+ });
34
+ try {
35
+ const names = getMcpTools(scopeId).map((tool) => tool.name);
36
+ assert.deepEqual(names, [
37
+ 'mcp__catalog__mixdog_list_resources',
38
+ 'mcp__catalog__mixdog_list_resource_templates',
39
+ 'mcp__catalog__mixdog_read_resource',
40
+ 'mcp__catalog__mixdog_list_prompts',
41
+ 'mcp__catalog__mixdog_get_prompt',
42
+ ]);
43
+ assert.deepEqual(getMcpServerStatus(scopeId)[0]?.capabilities, {
44
+ tools: false,
45
+ prompts: true,
46
+ resources: true,
47
+ });
48
+
49
+ const resources = await executeMcpTool(
50
+ 'mcp__catalog__mixdog_list_resources',
51
+ { cursor: 'next-page' },
52
+ { scopeId },
53
+ );
54
+ assert.match(resources, /file:\/\/\/guide\.md/);
55
+ assert.deepEqual(calls, [['listResources', { cursor: 'next-page' }]]);
56
+
57
+ const prompt = await executeMcpTool(
58
+ 'mcp__catalog__mixdog_get_prompt',
59
+ { name: 'review', arguments: { depth: 'deep' } },
60
+ { scopeId },
61
+ );
62
+ assert.match(prompt, /review:deep/);
63
+ } finally {
64
+ await disconnectAll({ scopeId });
65
+ }
66
+ });
@@ -1,31 +1,13 @@
1
1
  import assert from 'node:assert/strict';
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
2
  import test from 'node:test';
6
3
 
7
4
  import {
8
5
  mcpUrlForLog,
9
6
  normalizeMcpTransportUrl,
7
+ resolveMcpHttpHeaders,
8
+ resolveMcpStdioEnvironment,
10
9
  scrubMcpConnectionMessage,
11
10
  } from './client.mjs';
12
- import { readProjectMcpServers } from '../../../../session-runtime/plugin-mcp.mjs';
13
-
14
- test('configured project MCP remains enabled by default', () => {
15
- const root = mkdtempSync(join(tmpdir(), 'mixdog-mcp-on-'));
16
- try {
17
- mkdirSync(root, { recursive: true });
18
- writeFileSync(join(root, '.mcp.json'), JSON.stringify({
19
- mcpServers: { demo: { command: 'node', args: ['server.mjs'] } },
20
- }));
21
- const configured = readProjectMcpServers(root);
22
- assert.equal(configured.demo.command, 'node');
23
- assert.notEqual(configured.demo.enabled, false);
24
- } finally {
25
- rmSync(root, { recursive: true, force: true });
26
- }
27
- });
28
-
29
11
  test('MCP transport requires encryption except for loopback', () => {
30
12
  assert.equal(
31
13
  normalizeMcpTransportUrl('http://127.0.0.1:3000/mcp', 'http'),
@@ -62,3 +44,37 @@ test('MCP logs and failures redact URL and header credentials', () => {
62
44
  assert.doesNotMatch(scrubbed, new RegExp(secret));
63
45
  assert.match(scrubbed, /redacted/);
64
46
  });
47
+
48
+ test('MCP HTTP auth resolves from environment without storing secret values', () => {
49
+ const headers = resolveMcpHttpHeaders({
50
+ bearer_token_env_var: 'MCP_TOKEN',
51
+ env_http_headers: { 'X-API-Key': 'MCP_API_KEY' },
52
+ headers: { 'X-Static': '${MCP_STATIC}', Authorization: 'Explicit auth' },
53
+ }, {
54
+ MCP_TOKEN: 'bearer-secret',
55
+ MCP_API_KEY: 'key-secret',
56
+ MCP_STATIC: 'expanded',
57
+ });
58
+ assert.deepEqual(headers, {
59
+ Authorization: 'Explicit auth',
60
+ 'X-API-Key': 'key-secret',
61
+ 'X-Static': 'expanded',
62
+ });
63
+ });
64
+
65
+ test('MCP stdio passthrough narrows inherited environment only when configured', () => {
66
+ const env = {
67
+ PATH: '/bin',
68
+ HOME: '/home/test',
69
+ KEEP_ME: 'kept',
70
+ DROP_ME: 'dropped',
71
+ };
72
+ assert.equal(resolveMcpStdioEnvironment({ env: { LOCAL: '${KEEP_ME}' } }, env).DROP_ME, 'dropped');
73
+ const narrowed = resolveMcpStdioEnvironment({
74
+ env_vars: ['KEEP_ME'],
75
+ env: { LOCAL: '${KEEP_ME}' },
76
+ }, env);
77
+ assert.equal(narrowed.KEEP_ME, 'kept');
78
+ assert.equal(narrowed.LOCAL, 'kept');
79
+ assert.equal(narrowed.DROP_ME, undefined);
80
+ });