mixdog 1.2.0 → 1.2.1

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 (201) hide show
  1. package/README.md +7 -5
  2. package/package.json +1 -1
  3. package/src/defaults/shipped-history.json +4 -0
  4. package/src/defaults/skills/pptx/SKILL.md +6 -6
  5. package/src/defaults/skills/pptx/references/review.md +6 -4
  6. package/src/defaults/skills/pptx/scripts/source-extract.mjs +1 -1
  7. package/src/defaults/skills/setup/references/actions.md +13 -3
  8. package/src/defaults/skills/visualize/SKILL.md +12 -9
  9. package/src/rules/shared/60-verification.md +3 -1
  10. package/src/rules/shared/70-delivery.md +4 -2
  11. package/src/runtime/agent/orchestrator/cache-mtime.mjs +26 -5
  12. package/src/runtime/agent/orchestrator/config.mjs +10 -4
  13. package/src/runtime/agent/orchestrator/context/collect.mjs +2 -1
  14. package/src/runtime/agent/orchestrator/context/deferred-tools.mjs +11 -5
  15. package/src/runtime/agent/orchestrator/context/skill-catalog.mjs +74 -21
  16. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +86 -8
  17. package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +5 -1
  18. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +65 -7
  19. package/src/runtime/agent/orchestrator/mcp/client.mjs +128 -44
  20. package/src/runtime/agent/orchestrator/mcp/lazy-output-validator.mjs +25 -0
  21. package/src/runtime/agent/orchestrator/mcp/plugin-placeholders.mjs +54 -0
  22. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +108 -17
  23. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +19 -11
  25. package/src/runtime/agent/orchestrator/providers/auto-effort.mjs +9 -0
  26. package/src/runtime/agent/orchestrator/providers/command-code-oauth-login.mjs +337 -0
  27. package/src/runtime/agent/orchestrator/providers/command-code-oauth-tokens.mjs +159 -0
  28. package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +444 -0
  29. package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +7 -4
  31. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +9 -2
  32. package/src/runtime/agent/orchestrator/providers/gemini.mjs +41 -28
  33. package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +7 -1
  34. package/src/runtime/agent/orchestrator/providers/model-proxy-bridge.mjs +1080 -0
  35. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +15 -0
  36. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +35 -0
  37. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +12 -0
  38. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +6 -5
  39. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-compat-models.mjs +6 -2
  41. package/src/runtime/agent/orchestrator/providers/openai-compat-options.mjs +11 -0
  42. package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +2 -1
  43. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +9 -3
  44. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +4 -4
  45. package/src/runtime/agent/orchestrator/providers/registry.mjs +35 -9
  46. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +3 -0
  47. package/src/runtime/agent/orchestrator/runtime-core/extension-scopes.mjs +15 -55
  48. package/src/runtime/agent/orchestrator/runtime-core/goal-control.mjs +15 -2
  49. package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +267 -84
  50. package/src/runtime/agent/orchestrator/runtime-core/skill-tool-loading.mjs +4 -1
  51. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-schema.mjs +21 -0
  52. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +54 -3
  53. package/src/runtime/agent/orchestrator/session/catalog-db/index.mjs +284 -0
  54. package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +268 -0
  55. package/src/runtime/agent/orchestrator/session/catalog-db/sqlite-gate.mjs +27 -0
  56. package/src/runtime/agent/orchestrator/session/catalog-db/worker.mjs +628 -0
  57. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +6 -2
  58. package/src/runtime/agent/orchestrator/session/conversation-import/claude.mjs +184 -0
  59. package/src/runtime/agent/orchestrator/session/conversation-import/codex.mjs +136 -0
  60. package/src/runtime/agent/orchestrator/session/conversation-import/convert.mjs +128 -0
  61. package/src/runtime/agent/orchestrator/session/conversation-import/imported-session.mjs +127 -0
  62. package/src/runtime/agent/orchestrator/session/conversation-import/turns.mjs +101 -0
  63. package/src/runtime/agent/orchestrator/session/explicit-skills.mjs +83 -5
  64. package/src/runtime/agent/orchestrator/session/imported-message.mjs +11 -0
  65. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +2 -1
  66. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +1 -1
  67. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +3 -2
  68. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +6 -4
  69. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +4 -3
  70. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +3 -0
  71. package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +12 -3
  72. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +3 -1
  73. package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +20 -12
  74. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +9 -2
  75. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +4 -0
  76. package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +17 -4
  77. package/src/runtime/agent/orchestrator/session/manager.mjs +0 -1
  78. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +4 -3
  79. package/src/runtime/agent/orchestrator/session/store/listing.mjs +6 -2
  80. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +6 -0
  81. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +817 -0
  82. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +7 -0
  83. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +19 -12
  84. package/src/runtime/agent/orchestrator/session/store-summary-reader-catalog-fixture.mjs +158 -0
  85. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +183 -374
  86. package/src/runtime/agent/orchestrator/session/synthetic-user-envelope.mjs +3 -1
  87. package/src/runtime/agent/orchestrator/session/tool-batch/pre-dispatch.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +21 -2
  89. package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +38 -17
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/invocation-plan.mjs +17 -1
  91. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/spawn-env.mjs +4 -0
  92. package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +2 -2
  93. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +6 -6
  94. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +173 -22
  95. package/src/runtime/agent/orchestrator/tools/builtin/read-single-guards.mjs +8 -4
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +5 -4
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +15 -2
  98. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +13 -10
  99. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +1 -1
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +17 -12
  101. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch/root-resolution.mjs +2 -2
  102. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +1 -1
  103. package/src/runtime/agent/orchestrator/tools/patch/apply-patch/codex-batch.mjs +4 -4
  104. package/src/runtime/agent/orchestrator/tools/patch/apply-patch/route.mjs +2 -2
  105. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -1
  106. package/src/runtime/agent/orchestrator/tools/patch/read-redirects.mjs +34 -30
  107. package/src/runtime/agent/orchestrator/tools/patch/sequence/units.mjs +7 -7
  108. package/src/runtime/agent/orchestrator/tools/patch/sequence.mjs +1 -1
  109. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -0
  110. package/src/runtime/attachments/store.d.mts +3 -0
  111. package/src/runtime/channels/lib/owned-runtime/owned-start.mjs +1 -1
  112. package/src/runtime/channels/lib/webhook/session-dispatch.mjs +16 -4
  113. package/src/runtime/channels/lib/worker-main.mjs +17 -14
  114. package/src/runtime/computer-bridge/linux-readiness.mjs +0 -2
  115. package/src/runtime/effort-judge/judge-client.mjs +56 -18
  116. package/src/runtime/memory/lib/memory.mjs +1 -1
  117. package/src/runtime/memory/lib/session-ingest.mjs +2 -2
  118. package/src/runtime/shared/atomic-file.mjs +25 -4
  119. package/src/runtime/shared/extension-scopes.mjs +5 -3
  120. package/src/runtime/shared/file-lock.mjs +37 -11
  121. package/src/runtime/shared/llm/anthropic-effort.mjs +14 -0
  122. package/src/runtime/shared/llm/model-catalog.mjs +0 -12
  123. package/src/runtime/shared/llm/provider-catalog-registry.mjs +6 -0
  124. package/src/runtime/shared/plugin-manifest.mjs +18 -9
  125. package/src/runtime/shared/pristine-execution-contract.json +9 -2
  126. package/src/runtime/shared/pristine-execution.mjs +28 -4
  127. package/src/runtime/shared/provider-accounts.mjs +124 -20
  128. package/src/runtime/shared/provider-api-key.mjs +12 -0
  129. package/src/runtime/shared/shell-display.mjs +9 -3
  130. package/src/runtime/shared/skill-mentions.d.mts +10 -0
  131. package/src/runtime/shared/skill-mentions.mjs +133 -0
  132. package/src/runtime/shared/skill-script-runtime.mjs +264 -0
  133. package/src/runtime/shared/tool-primitives.mjs +35 -2
  134. package/src/runtime/shared/turn-worktree-snapshot.mjs +25 -5
  135. package/src/runtime/shared/worker-requests.mjs +15 -2
  136. package/src/session-runtime/automation-agents.mjs +8 -2
  137. package/src/session-runtime/boot/apis.mjs +55 -0
  138. package/src/session-runtime/boot/config.mjs +5 -2
  139. package/src/session-runtime/boot/facade.mjs +1 -0
  140. package/src/session-runtime/boot/infrastructure.mjs +29 -18
  141. package/src/session-runtime/conversation-import/discovery.mjs +698 -0
  142. package/src/session-runtime/conversation-import/service.mjs +369 -0
  143. package/src/session-runtime/cwd-plugins/cwd-apply.mjs +10 -5
  144. package/src/session-runtime/cwd-plugins/plugin-status.mjs +47 -42
  145. package/src/session-runtime/mcp-glue/connect.mjs +57 -10
  146. package/src/session-runtime/mcp-glue/plugin-servers.mjs +499 -0
  147. package/src/session-runtime/mcp-glue/server-catalog.mjs +115 -30
  148. package/src/session-runtime/mcp-glue.mjs +22 -2
  149. package/src/session-runtime/new-session-config.mjs +3 -2
  150. package/src/session-runtime/notification-bus.mjs +9 -2
  151. package/src/session-runtime/provider-auth/accounts.mjs +33 -15
  152. package/src/session-runtime/provider-models/web-search-catalog.mjs +5 -1
  153. package/src/session-runtime/provider-readiness.mjs +16 -10
  154. package/src/session-runtime/resource-extension-sync.mjs +20 -5
  155. package/src/session-runtime/resource-mcp-api.mjs +101 -77
  156. package/src/session-runtime/resource-plugins-api.mjs +29 -41
  157. package/src/session-runtime/resource-scope-decorate.mjs +6 -8
  158. package/src/session-runtime/resource-skills-api.mjs +23 -2
  159. package/src/session-runtime/schedule-session-run.mjs +1 -1
  160. package/src/session-runtime/services/channel-session-router.mjs +4 -1
  161. package/src/session-runtime/services/hook-bus/command-handler.mjs +4 -1
  162. package/src/session-runtime/services/hook-bus/handlers.mjs +7 -3
  163. package/src/session-runtime/services/provider-admin.mjs +64 -18
  164. package/src/session-runtime/services/usage-dashboard-model.mjs +3 -0
  165. package/src/session-runtime/setup-tool/executor.mjs +12 -1
  166. package/src/session-runtime/setup-tool/extended-actions.mjs +1 -0
  167. package/src/session-runtime/setup-tool/settings-contract.mjs +1 -0
  168. package/src/session-runtime/setup-tool/tool-defs.mjs +7 -1
  169. package/src/session-runtime/skills-api.mjs +7 -3
  170. package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +1 -0
  171. package/src/session-runtime/webhook-session-run.mjs +16 -4
  172. package/src/skill-script-entry.mjs +36 -0
  173. package/src/standalone/daemon-stored-session-views.mjs +11 -3
  174. package/src/standalone/daemon.mjs +41 -4
  175. package/src/standalone/session-protocol.mjs +5 -0
  176. package/src/standalone/session-service/session-calls/session-action.mjs +6 -1
  177. package/src/tui/App.jsx +9 -1
  178. package/src/tui/app/app-view/floating-panel.jsx +9 -1
  179. package/src/tui/app/app-view.jsx +6 -0
  180. package/src/tui/app/extension-pickers/plugins-pickers.mjs +35 -16
  181. package/src/tui/app/model-options.mjs +2 -0
  182. package/src/tui/app/skill-mention-palette.mjs +88 -0
  183. package/src/tui/app/slash-palette.mjs +23 -7
  184. package/src/tui/app/transcript-row-estimate.mjs +11 -3
  185. package/src/tui/app/use-prompt-draft-flow.mjs +7 -2
  186. package/src/tui/app/use-prompt-state.mjs +5 -0
  187. package/src/tui/components/Message.jsx +22 -1
  188. package/src/tui/components/PromptInput.jsx +21 -3
  189. package/src/tui/components/SlashCommandPalette.jsx +12 -3
  190. package/src/tui/components/TranscriptItem.jsx +9 -1
  191. package/src/tui/components/prompt-input/key-handler.mjs +21 -8
  192. package/src/tui/components/user-message-skills.mjs +38 -0
  193. package/src/tui/dist/index.mjs +507 -171
  194. package/src/tui/session/queue-helpers.mjs +7 -2
  195. package/src/tui/session/session-api/extensions.mjs +4 -2
  196. package/src/tui/session/session-api/settings.mjs +7 -0
  197. package/src/tui/session/session-flow/drain.mjs +14 -4
  198. package/src/tui/session/tui-steering-persist.mjs +25 -9
  199. package/src/tui/session/turn.mjs +17 -0
  200. package/src/runtime/effort-judge/previous-human-request.mjs +0 -162
  201. package/src/runtime/effort-judge/verified-context-input.mjs +0 -10
@@ -3,6 +3,8 @@
3
3
  * kind, URL policy, env expansion, stdio environment, HTTP headers, timeouts,
4
4
  * and the log scrubbing that keeps credentials out of connection messages.
5
5
  */
6
+ import { createHash } from 'node:crypto';
7
+ import { rememberMcpToolIdentity } from '../../../shared/tool-primitives.mjs';
6
8
 
7
9
  /** Known auto-detect targets: the discovery-advert service name and the MCP
8
10
  * endpoint path on its port.
@@ -108,30 +110,106 @@ export function scrubMcpConnectionMessage(value, cfg = {}) {
108
110
  }
109
111
 
110
112
  /**
111
- * Expand `${VAR}` and `${env:VAR}` references in string values using the
112
- * provided env map (defaults to process.env). Recurses into arrays/objects.
113
- * Unknown vars expand to an empty string. No shell execution.
113
+ * Expand `${VAR}`, `${env:VAR}` and `${VAR:-default}` references in string
114
+ * values using the provided env map (defaults to process.env). Recurses into
115
+ * arrays/objects. The default (possibly empty) applies only when the variable
116
+ * is unset, and runs to the first `}`. An unset variable without a default
117
+ * expands to an empty string and, when `missing` (a Set) is given, its name is
118
+ * recorded there. No shell execution.
114
119
  */
115
- export function expandEnvVars(value, env = process.env) {
120
+ export function expandEnvVars(value, env = process.env, missing) {
116
121
  if (typeof value === 'string') {
117
- return value.replace(/\$\{(?:env:)?([A-Za-z_][A-Za-z0-9_]*)\}/g, (_m, name) => {
122
+ return value.replace(/\$\{(?:env:)?([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_m, name, fallback) => {
118
123
  const v = env?.[name];
119
- return v == null ? '' : String(v);
124
+ if (v != null) return String(v);
125
+ if (fallback !== undefined) return fallback;
126
+ missing?.add(name);
127
+ return '';
120
128
  });
121
129
  }
122
130
  if (Array.isArray(value)) {
123
- return value.map((v) => expandEnvVars(v, env));
131
+ return value.map((v) => expandEnvVars(v, env, missing));
124
132
  }
125
133
  if (value && typeof value === 'object') {
126
134
  const out = {};
127
135
  for (const [k, v] of Object.entries(value)) {
128
- out[k] = expandEnvVars(v, env);
136
+ out[k] = expandEnvVars(v, env, missing);
129
137
  }
130
138
  return out;
131
139
  }
132
140
  return value;
133
141
  }
134
142
 
143
+ /**
144
+ * Names (deduplicated, first-seen order) of variables a server config needs
145
+ * that are unset: `${VAR}` references without a default in command, args,
146
+ * env, url, headers and cwd, plus the variables named directly by
147
+ * `bearer_token_env_var`, `env_http_headers` values and `env_vars`. Names
148
+ * only — never values.
149
+ */
150
+ export function findMissingMcpEnvVars(cfg = {}, env = process.env) {
151
+ const missing = new Set();
152
+ expandEnvVars([cfg.command, cfg.args, cfg.env, cfg.url, cfg.headers, cfg.cwd], env, missing);
153
+ const named = [
154
+ cfg.bearer_token_env_var,
155
+ ...(cfg.env_http_headers && typeof cfg.env_http_headers === 'object' && !Array.isArray(cfg.env_http_headers)
156
+ ? Object.values(cfg.env_http_headers)
157
+ : []),
158
+ ...(Array.isArray(cfg.env_vars) ? cfg.env_vars : []),
159
+ ];
160
+ for (const value of named) {
161
+ const name = typeof value === 'string' ? value.trim() : '';
162
+ if (name && envValue(env, name) == null) missing.add(name);
163
+ }
164
+ return [...missing];
165
+ }
166
+
167
+ // Windows variable names are case-insensitive.
168
+ function envValue(env, name) {
169
+ if (process.platform !== 'win32') return env?.[name];
170
+ const actual = Object.keys(env || {}).find((key) => key.toLowerCase() === name.toLowerCase());
171
+ return actual ? env[actual] : undefined;
172
+ }
173
+
174
+ // Provider tool names: at most 64 characters of [A-Za-z0-9_-] (the strictest
175
+ // limit among the providers Mixdog talks to).
176
+ const PROVIDER_TOOL_NAME_MAX = 64;
177
+ const PROVIDER_TOOL_NAME_RE = /^[A-Za-z0-9_-]+$/;
178
+ const KEPT_SERVER_PART_MAX = 40;
179
+
180
+ /**
181
+ * `mcp__<server>__<tool>` when that already fits provider limits (unchanged
182
+ * for every name that fits today). Otherwise a deterministic short form:
183
+ * the server part kept when it is safe (sanitized and cut short when not),
184
+ * the tool part sanitized and cut, and a hash of the full name appended so
185
+ * distinct tools never meet. Callers keep the original tool name alongside
186
+ * to dispatch the call.
187
+ */
188
+ export function providerSafeMcpToolName(serverName, toolName) {
189
+ const server = String(serverName || '');
190
+ const tool = String(toolName || '');
191
+ const full = `mcp__${server}__${tool}`;
192
+ if (full.length <= PROVIDER_TOOL_NAME_MAX && PROVIDER_TOOL_NAME_RE.test(full)) return full;
193
+ const hash = createHash('sha1').update(full).digest('hex').slice(0, 8);
194
+ const serverPart =
195
+ /^[A-Za-z0-9-]+(?:_[A-Za-z0-9-]+)*$/.test(server) && server.length <= KEPT_SERVER_PART_MAX
196
+ ? server
197
+ : server
198
+ .replace(/[^A-Za-z0-9-]+/g, '-')
199
+ .replace(/^-+|-+$/g, '')
200
+ .slice(0, 24)
201
+ .replace(/-+$/, '') || 'server';
202
+ const room = PROVIDER_TOOL_NAME_MAX - `mcp__${serverPart}__`.length - hash.length - 1;
203
+ const toolPart = tool
204
+ .replace(/[^A-Za-z0-9_-]+/g, '_')
205
+ .slice(0, Math.max(0, room))
206
+ .replace(/_+$/, '');
207
+ const safe = `mcp__${serverPart}__${toolPart ? `${toolPart}_` : ''}${hash}`;
208
+ // Name-only consumers in this process (display, guards) resolve it back.
209
+ rememberMcpToolIdentity(safe, server, tool);
210
+ return safe;
211
+ }
212
+
135
213
  const DEFAULT_STDIO_ENV_KEYS =
136
214
  process.platform === 'win32'
137
215
  ? [
@@ -15,12 +15,15 @@ const MAX_MCP_TOOL_PAGES = 100;
15
15
  * Every tool of a server: follows `nextCursor` until it is exhausted, repeats,
16
16
  * or MAX_MCP_TOOL_PAGES is reached. A page with an invalid shape is returned
17
17
  * as-is so the caller's shape validation reports it.
18
+ * The SDK rebuilds its output validators from each page alone, so after a
19
+ * paginated listing they are rebuilt once from every page's tools.
18
20
  */
19
21
  export async function listAllMcpTools(client, requestOptions = undefined) {
20
22
  const tools = [];
21
23
  const seen = new Set();
22
24
  let cursor = null;
23
- for (let page = 0; page < MAX_MCP_TOOL_PAGES; page += 1) {
25
+ let pages = 0;
26
+ for (; pages < MAX_MCP_TOOL_PAGES; pages += 1) {
24
27
  const result = await client.listTools(cursor ? { cursor } : undefined, requestOptions);
25
28
  if (!result || !Array.isArray(result.tools)) return result;
26
29
  tools.push(...result.tools);
@@ -29,6 +32,7 @@ export async function listAllMcpTools(client, requestOptions = undefined) {
29
32
  seen.add(next);
30
33
  cursor = next;
31
34
  }
35
+ if (pages > 0) client.cacheToolMetadata?.(tools);
32
36
  return { tools };
33
37
  }
34
38
 
@@ -7,6 +7,7 @@ import { readServicePort } from '../../../shared/service-discovery.mjs';
7
7
  import {
8
8
  AUTO_DETECT_PORTS,
9
9
  expandEnvVars,
10
+ findMissingMcpEnvVars,
10
11
  mcpUrlForLog,
11
12
  normalizeMcpTransportUrl,
12
13
  resolveMcpHttpHeaders,
@@ -48,11 +49,58 @@ function requestInitFor(cfg) {
48
49
  return headers && Object.keys(headers).length > 0 ? { requestInit: { headers } } : undefined;
49
50
  }
50
51
 
51
- /** Returns { transport, autoDetectAdvert, stderrTail } — stderrTail() is the
52
- * last ~4 KB of a stdio server's (scrubbed) stderr, '' for remote transports. */
53
- export function createMcpTransport({ name, cfg, sdk, log }) {
52
+ // The SDK's WebSocket transport opens its socket without a way to set request
53
+ // headers. This subclass changes only socket creation: Node's WebSocket takes
54
+ // `{ protocols, headers }`; the guard and event wiring match the stock start().
55
+ function createHeaderWebSocketTransport(Base, messageSchema, url, headers) {
56
+ return new (class extends Base {
57
+ start() {
58
+ if (this._socket) {
59
+ throw new Error(
60
+ 'WebSocketClientTransport already started! If using Client class, note that connect() calls start() automatically.'
61
+ );
62
+ }
63
+ return new Promise((resolve, reject) => {
64
+ this._socket = new globalThis.WebSocket(this._url, { protocols: 'mcp', headers });
65
+ this._socket.onerror = (event) => {
66
+ const error = 'error' in event ? event.error : new Error(`WebSocket error: ${JSON.stringify(event)}`);
67
+ reject(error);
68
+ this.onerror?.(error);
69
+ };
70
+ this._socket.onopen = () => resolve();
71
+ this._socket.onclose = () => this.onclose?.();
72
+ this._socket.onmessage = (event) => {
73
+ let message;
74
+ try {
75
+ message = messageSchema.parse(JSON.parse(event.data));
76
+ } catch (error) {
77
+ this.onerror?.(error);
78
+ return;
79
+ }
80
+ this.onmessage?.(message);
81
+ };
82
+ });
83
+ }
84
+ })(url);
85
+ }
86
+ /** Returns { transport, autoDetectAdvert, stderrTail, warnings } — stderrTail()
87
+ * is the last ~4 KB of a stdio server's (scrubbed) stderr, '' for remote
88
+ * transports. `strictEnv` (the caller passes it for plugin-declared servers)
89
+ * makes unset variables without a default fail the server; otherwise they
90
+ * expand to empty and are reported in `warnings` (names only). */
91
+ export function createMcpTransport({ name, cfg, sdk, log, strictEnv }) {
54
92
  const { StdioClientTransport, StreamableHTTPClientTransport, SSEClientTransport, WebSocketClientTransport } = sdk;
55
93
  const kind = resolveMcpTransportKind(cfg);
94
+ const warnings = [];
95
+ if (kind !== 'autoDetect') {
96
+ const missing = findMissingMcpEnvVars(cfg);
97
+ if (missing.length) {
98
+ const message = `missing environment variable(s): ${missing.join(', ')}`;
99
+ if (strictEnv === true) throw new Error(message);
100
+ warnings.push(message);
101
+ log(`[mcp-client] "${name}": ${message}\n`);
102
+ }
103
+ }
56
104
  if (kind === 'autoDetect') {
57
105
  const { url, advert } = resolveAutoDetectUrl(name, cfg);
58
106
  log(`[mcp-client] Connecting "${name}" via autoDetect HTTP: ${url}\n`);
@@ -60,6 +108,7 @@ export function createMcpTransport({ name, cfg, sdk, log }) {
60
108
  transport: new StreamableHTTPClientTransport(new URL(url)),
61
109
  autoDetectAdvert: advert,
62
110
  stderrTail: noStderrTail,
111
+ warnings,
63
112
  };
64
113
  }
65
114
  if (kind === 'http' || kind === 'sse') {
@@ -71,19 +120,28 @@ export function createMcpTransport({ name, cfg, sdk, log }) {
71
120
  transport: opts ? new Transport(new URL(url), opts) : new Transport(new URL(url)),
72
121
  autoDetectAdvert: null,
73
122
  stderrTail: noStderrTail,
123
+ warnings,
74
124
  };
75
125
  }
76
126
  if (kind === 'ws') {
77
- // WebSocketClientTransport ctor takes only a URL; headers are ignored.
78
127
  const url = normalizeMcpTransportUrl(expandEnvVars(String(cfg.url ?? '')), 'ws');
128
+ const opts = requestInitFor(cfg);
79
129
  log(`[mcp-client] Connecting "${name}" via WebSocket: ${mcpUrlForLog(url)}\n`);
80
- return { transport: new WebSocketClientTransport(new URL(url)), autoDetectAdvert: null, stderrTail: noStderrTail };
130
+ const transport = opts
131
+ ? createHeaderWebSocketTransport(
132
+ WebSocketClientTransport,
133
+ sdk.JSONRPCMessageSchema,
134
+ new URL(url),
135
+ opts.requestInit.headers
136
+ )
137
+ : new WebSocketClientTransport(new URL(url));
138
+ return { transport, autoDetectAdvert: null, stderrTail: noStderrTail, warnings };
81
139
  }
82
140
  if (kind === 'stdio') {
83
141
  const transport = new StdioClientTransport({
84
142
  command: expandEnvVars(String(cfg.command ?? '')),
85
143
  args: Array.isArray(cfg.args) ? expandEnvVars(cfg.args) : cfg.args,
86
- cwd: cfg.cwd,
144
+ cwd: cfg.cwd == null ? cfg.cwd : expandEnvVars(String(cfg.cwd)),
87
145
  env: resolveMcpStdioEnvironment(cfg),
88
146
  stderr: cfg.stderr ?? 'pipe',
89
147
  });
@@ -93,7 +151,7 @@ export function createMcpTransport({ name, cfg, sdk, log }) {
93
151
  tail = (tail + text).slice(-MCP_STDERR_TAIL_CHARS);
94
152
  log(`[mcp:${name}:stderr] ${text}`);
95
153
  });
96
- return { transport, autoDetectAdvert: null, stderrTail: () => tail.trim() };
154
+ return { transport, autoDetectAdvert: null, stderrTail: () => tail.trim(), warnings };
97
155
  }
98
156
  throw new Error(
99
157
  `Invalid config for "${name}": need autoDetect, type (stdio/http/sse/ws), url (http), or command (stdio)`
@@ -22,7 +22,13 @@ import { createKeyedSingleflight } from './reconnect-singleflight.mjs';
22
22
  import { createOwnerFairGate } from '../../../shared/owner-fair-gate.mjs';
23
23
  import { currentToolExecutionOwner } from '../../../shared/tool-execution-owner.mjs';
24
24
  import { positiveInt } from '../../../shared/numbers.mjs';
25
- import { resolveMcpRequestTimeouts, resolveMcpTransportKind, scrubMcpConnectionMessage } from './client-config.mjs';
25
+ import {
26
+ providerSafeMcpToolName,
27
+ resolveMcpRequestTimeouts,
28
+ resolveMcpTransportKind,
29
+ scrubMcpConnectionMessage,
30
+ } from './client-config.mjs';
31
+ import { createLazyOutputValidator } from './lazy-output-validator.mjs';
26
32
  import { appendMcpStderrTail, createMcpTransport } from './client-transport.mjs';
27
33
  import { listAllMcpTools, runBoundedHandshake } from './client-handshake.mjs';
28
34
 
@@ -50,6 +56,8 @@ const MCP_TOOL_NAME_RE = /^mcp__(.+?)__(.+)$/;
50
56
  const _knownMcpScopes = new Set([DEFAULT_MCP_SCOPE_ID]);
51
57
  const _connectAbortGenerations = new Map();
52
58
  const _pendingConnects = new Set();
59
+ // scopeId -> root path advertised by that scope's scope-following servers.
60
+ const _scopeRoots = new Map();
53
61
  let mcpSdkPromise = null;
54
62
  // Memo for mcpToolHasField(name, field) — keyed by `${scopeId}|${toolName}|${field}`.
55
63
  // The lookup (regex parse + servers Map get + tools.find + schema property
@@ -58,8 +66,14 @@ let mcpSdkPromise = null;
58
66
  // mutation point (connectServer / disconnectAll) so a stale positive or
59
67
  // negative can never survive a tools-list change.
60
68
  const _mcpToolFieldMemo = new Map();
69
+ let _mcpToolGeneration = 0;
61
70
  function _invalidateMcpToolFieldMemo() {
62
71
  _mcpToolFieldMemo.clear();
72
+ _mcpToolGeneration += 1;
73
+ }
74
+ /** Read-only counter bumped on every MCP tool registry mutation. */
75
+ export function getMcpToolGeneration() {
76
+ return _mcpToolGeneration;
63
77
  }
64
78
  function normalizeMcpScopeId(value) {
65
79
  const raw = value && typeof value === 'object' ? value.scopeId : value;
@@ -98,12 +112,15 @@ async function loadMcpSdk() {
98
112
  import('@modelcontextprotocol/sdk/client/sse.js'),
99
113
  import('@modelcontextprotocol/sdk/client/websocket.js'),
100
114
  import('@modelcontextprotocol/sdk/types.js'),
101
- ]).then(([clientMod, stdioMod, httpMod, sseMod, wsMod, typesMod]) => ({
115
+ import('@modelcontextprotocol/sdk/validation/ajv'),
116
+ ]).then(([clientMod, stdioMod, httpMod, sseMod, wsMod, typesMod, ajvMod]) => ({
102
117
  Client: clientMod.Client,
118
+ AjvJsonSchemaValidator: ajvMod.AjvJsonSchemaValidator,
103
119
  StdioClientTransport: stdioMod.StdioClientTransport,
104
120
  StreamableHTTPClientTransport: httpMod.StreamableHTTPClientTransport,
105
121
  SSEClientTransport: sseMod.SSEClientTransport,
106
122
  WebSocketClientTransport: wsMod.WebSocketClientTransport,
123
+ JSONRPCMessageSchema: typesMod.JSONRPCMessageSchema,
107
124
  ListRootsRequestSchema: typesMod.ListRootsRequestSchema,
108
125
  ToolListChangedNotificationSchema: typesMod.ToolListChangedNotificationSchema,
109
126
  }));
@@ -163,8 +180,8 @@ function mcpFeatureTools(serverName, capabilities, protocolTools) {
163
180
  const tools = [];
164
181
  const used = new Set(protocolTools.map((tool) => tool.name));
165
182
  const add = (leaf, description, properties, required, operation) => {
166
- let name = `mcp__${serverName}__${leaf}`;
167
- while (used.has(name)) name += '_';
183
+ let name = providerSafeMcpToolName(serverName, leaf);
184
+ for (let suffix = '_'; used.has(name); suffix += '_') name = providerSafeMcpToolName(serverName, leaf + suffix);
168
185
  used.add(name);
169
186
  tools.push({
170
187
  name,
@@ -238,6 +255,7 @@ export function getMcpServerStatus(scopeId = DEFAULT_MCP_SCOPE_ID) {
238
255
  ),
239
256
  }
240
257
  : {}),
258
+ warnings: Array.isArray(server.warnings) ? server.warnings : [],
241
259
  toolCount: Array.isArray(server.tools) ? server.tools.length : 0,
242
260
  tools: (server.tools || []).map((tool) => ({
243
261
  name: tool.name,
@@ -409,11 +427,12 @@ export async function reconnectSingleMcpServer(scopeId, name, cfg = undefined) {
409
427
  export async function executeMcpTool(name, args, options = {}) {
410
428
  const match = name.match(MCP_TOOL_NAME_RE);
411
429
  if (!match) throw new Error(`Not an MCP tool name: ${name}`);
412
- const [, serverName, toolName] = match;
413
430
  const scopeId = normalizeMcpScopeId(options);
414
- const server = scopedServer(scopeId, serverName);
415
- if (!server) throw new Error(`MCP server "${serverName}" not connected`);
416
- const definition = (server.tools || []).find((tool) => tool.name === name);
431
+ const target = resolveMcpToolTarget(scopeId, name);
432
+ if (!target) throw new Error(`MCP server "${match[1]}" not connected`);
433
+ const { server, definition } = target;
434
+ const serverName = server.name;
435
+ const toolName = definition?.mcpToolName || match[2];
417
436
  const callSignal = options?.signal || null;
418
437
  const dispatch = (target) =>
419
438
  definition?.mcpOperation
@@ -1022,12 +1041,7 @@ export function isMcpTool(name) {
1022
1041
  }
1023
1042
  /** True when the prefixed name exists on a connected MCP server. */
1024
1043
  export function isRegisteredMcpTool(name, scopeId = DEFAULT_MCP_SCOPE_ID) {
1025
- const match = name.match(MCP_TOOL_NAME_RE);
1026
- if (!match) return false;
1027
- const [, serverName] = match;
1028
- const server = scopedServer(scopeId, serverName);
1029
- if (!server || !Array.isArray(server.tools)) return false;
1030
- return server.tools.some((t) => t?.name === name);
1044
+ return Boolean(resolveMcpToolTarget(scopeId, name)?.definition);
1031
1045
  }
1032
1046
  /**
1033
1047
  * Check whether the inputSchema for an MCP tool declares the given top-level
@@ -1040,9 +1054,7 @@ export function mcpToolHasField(name, field, scopeId = DEFAULT_MCP_SCOPE_ID) {
1040
1054
  const memoKey = `${normalizedScopeId}|${name}|${field}`;
1041
1055
  const memoized = _mcpToolFieldMemo.get(memoKey);
1042
1056
  if (memoized !== undefined) return memoized;
1043
- const match = name.match(MCP_TOOL_NAME_RE);
1044
- const server = match ? scopedServer(normalizedScopeId, match[1]) : null;
1045
- const tool = server ? server.tools.find((t) => t.name === name) : null;
1057
+ const tool = resolveMcpToolTarget(normalizedScopeId, name)?.definition;
1046
1058
  const props = tool?.inputSchema?.properties;
1047
1059
  const result = Boolean(props && Object.hasOwn(props, field));
1048
1060
  _mcpToolFieldMemo.set(memoKey, result);
@@ -1163,8 +1175,14 @@ async function connectServer(
1163
1175
  // abort before creating a transport/child at all.
1164
1176
  throw new Error(`MCP server "${name}" connect aborted by shutdown`);
1165
1177
  }
1166
- const client = createMcpClient(sdk, name, cfg);
1167
- const { transport, autoDetectAdvert, stderrTail } = createMcpTransport({ name, cfg, sdk, log: mcpLog });
1178
+ const client = createMcpClient(sdk, name, cfg, scopeId);
1179
+ const { transport, autoDetectAdvert, stderrTail, warnings } = createMcpTransport({
1180
+ name,
1181
+ cfg,
1182
+ sdk,
1183
+ log: mcpLog,
1184
+ strictEnv: cfg?._mixdogStrictEnv === true,
1185
+ });
1168
1186
  const liveness = trackMcpLiveness(client, name, cfg, stderrTail);
1169
1187
  const pending = { scopeId, name, client, transport };
1170
1188
  _pendingConnects.add(pending);
@@ -1195,7 +1213,7 @@ async function connectServer(
1195
1213
  }
1196
1214
  throw new Error(`MCP server "${name}" connect aborted by shutdown`);
1197
1215
  }
1198
- const tools = mcpServerTools(name, capabilities, toolsResult.tools);
1216
+ const tools = mcpServerTools(name, capabilities, toolsResult.tools, cfg);
1199
1217
  const registryKey = mcpServerRegistryKey(scopeId, name);
1200
1218
  servers.set(registryKey, {
1201
1219
  scopeId,
@@ -1209,6 +1227,7 @@ async function connectServer(
1209
1227
  capabilities,
1210
1228
  liveness,
1211
1229
  stderrTail,
1230
+ warnings: warnings || [],
1212
1231
  generation: genAtStart,
1213
1232
  });
1214
1233
  if (capabilities?.tools?.listChanged && sdk.ToolListChangedNotificationSchema) {
@@ -1250,16 +1269,25 @@ function trackMcpLiveness(client, name, cfg, stderrTail) {
1250
1269
  return liveness;
1251
1270
  }
1252
1271
 
1253
- /** The SDK client for one server, advertising the project root when known. */
1254
- function createMcpClient(sdk, name, cfg) {
1255
- const projectRoot = String(cfg?._mixdogProjectRoot || cfg?.cwd || '').trim();
1272
+ /** The SDK client for one server, advertising the project root when known.
1273
+ * A config marked `_mixdogRootsFollowScope` advertises its scope's current
1274
+ * root (setMcpScopeRoot) and is told when it changes. */
1275
+ function createMcpClient(sdk, name, cfg, scopeId = DEFAULT_MCP_SCOPE_ID) {
1276
+ const followScope = cfg?._mixdogRootsFollowScope === true;
1277
+ const fixedRoot = String(cfg?._mixdogProjectRoot || cfg?.cwd || '').trim();
1278
+ const currentRoot = () => (followScope ? _scopeRoots.get(normalizeMcpScopeId(scopeId)) || fixedRoot : fixedRoot);
1256
1279
  const client = new sdk.Client(
1257
1280
  { name: `mixdog-agent/${name}`, version: '1.0.0' },
1258
- { capabilities: projectRoot ? { roots: { listChanged: false } } : {} }
1281
+ {
1282
+ capabilities: followScope || fixedRoot ? { roots: { listChanged: followScope } } : {},
1283
+ jsonSchemaValidator: createLazyOutputValidator(sdk.AjvJsonSchemaValidator),
1284
+ }
1259
1285
  );
1260
- if (projectRoot && sdk.ListRootsRequestSchema) {
1286
+ if ((followScope || fixedRoot) && sdk.ListRootsRequestSchema) {
1261
1287
  client.setRequestHandler(sdk.ListRootsRequestSchema, async () => {
1262
- const rootPath = resolve(projectRoot);
1288
+ const root = currentRoot();
1289
+ if (!root) return { roots: [] };
1290
+ const rootPath = resolve(root);
1263
1291
  return {
1264
1292
  roots: [
1265
1293
  {
@@ -1273,15 +1301,62 @@ function createMcpClient(sdk, name, cfg) {
1273
1301
  return client;
1274
1302
  }
1275
1303
 
1276
- /** Protocol tools under the `mcp__<server>__` prefix plus the feature tools. */
1277
- function mcpServerTools(name, capabilities, rawTools) {
1278
- const protocolTools = (rawTools || []).map((t) => ({
1279
- name: `mcp__${name}__${t.name}`,
1280
- description: t.description || '',
1281
- inputSchema: t.inputSchema || { type: 'object', properties: {} },
1282
- ...(t.annotations && typeof t.annotations === 'object' ? { annotations: t.annotations } : {}),
1283
- }));
1284
- return [...protocolTools, ...mcpFeatureTools(name, capabilities, protocolTools)];
1304
+ /**
1305
+ * Set the root advertised by `scopeId`'s scope-following servers (plugin
1306
+ * servers, whose roots track the session cwd) and notify the connected ones
1307
+ * when it changed. No reconnect: the connection and its tools stay as they are.
1308
+ */
1309
+ export function setMcpScopeRoot(scopeId, root) {
1310
+ const normalized = normalizeMcpScopeId(scopeId);
1311
+ const next = String(root || '').trim();
1312
+ if ((_scopeRoots.get(normalized) || '') === next) return;
1313
+ if (next) _scopeRoots.set(normalized, next);
1314
+ else _scopeRoots.delete(normalized);
1315
+ for (const [, server] of scopedServerEntries(normalized)) {
1316
+ if (server.cfg?._mixdogRootsFollowScope !== true) continue;
1317
+ Promise.resolve()
1318
+ .then(() => server.client?.sendRootsListChanged?.())
1319
+ .catch(() => {});
1320
+ }
1321
+ }
1322
+
1323
+ /** The connected server and tool definition a prefixed tool name refers to.
1324
+ * The name's server part finds it directly; a name shortened to fit provider
1325
+ * limits is found by scanning this scope's servers. A known server without
1326
+ * the tool still resolves (definition null) so the call reaches the server. */
1327
+ function resolveMcpToolTarget(scopeId, name) {
1328
+ const match = MCP_TOOL_NAME_RE.exec(String(name || ''));
1329
+ if (!match) return null;
1330
+ const direct = scopedServer(scopeId, match[1]);
1331
+ const directTool = direct?.tools?.find((tool) => tool.name === name);
1332
+ if (directTool) return { server: direct, definition: directTool };
1333
+ for (const [, server] of scopedServerEntries(scopeId)) {
1334
+ const definition = server.tools?.find((tool) => tool.name === name);
1335
+ if (definition) return { server, definition };
1336
+ }
1337
+ return direct ? { server: direct, definition: null } : null;
1338
+ }
1339
+
1340
+ /** Protocol tools under the `mcp__<server>__` prefix plus the feature tools.
1341
+ * Names that would exceed provider limits are shortened deterministically
1342
+ * (`providerSafeMcpToolName`); a tool whose name was shortened carries the
1343
+ * server's own tool name as `mcpToolName` and its server as `mcpServerName`.
1344
+ * A plugin-declared server's tools carry its owner as `mcpPluginId`, so
1345
+ * scope filtering never has to infer ownership from the server name. */
1346
+ function mcpServerTools(name, capabilities, rawTools, cfg) {
1347
+ const protocolTools = (rawTools || []).map((t) => {
1348
+ const safeName = providerSafeMcpToolName(name, t.name);
1349
+ return {
1350
+ name: safeName,
1351
+ ...(safeName === `mcp__${name}__${t.name}` ? {} : { mcpToolName: t.name, mcpServerName: name }),
1352
+ description: t.description || '',
1353
+ inputSchema: t.inputSchema || { type: 'object', properties: {} },
1354
+ ...(t.annotations && typeof t.annotations === 'object' ? { annotations: t.annotations } : {}),
1355
+ };
1356
+ });
1357
+ const tools = [...protocolTools, ...mcpFeatureTools(name, capabilities, protocolTools)];
1358
+ const pluginId = String(cfg?._mixdogPlugin?.id || '').trim();
1359
+ return pluginId ? tools.map((tool) => ({ ...tool, mcpPluginId: pluginId })) : tools;
1285
1360
  }
1286
1361
 
1287
1362
  // tools/list_changed refreshes run one at a time per server connection, so an
@@ -1304,7 +1379,7 @@ async function runToolRefresh(registryKey, live, name, cfg, capabilities) {
1304
1379
  throw new Error('ListTools returned invalid shape: missing or non-array tools field');
1305
1380
  }
1306
1381
  if (servers.get(registryKey) !== live) return;
1307
- live.tools = mcpServerTools(name, capabilities, refreshed.tools);
1382
+ live.tools = mcpServerTools(name, capabilities, refreshed.tools, cfg);
1308
1383
  _invalidateMcpToolFieldMemo();
1309
1384
  } catch (error) {
1310
1385
  mcpLog(
@@ -1317,19 +1392,28 @@ async function runToolRefresh(registryKey, live, name, cfg, capabilities) {
1317
1392
  export function _registerMcpServerForTest(scopeId, name, rawTools = [], options = {}) {
1318
1393
  const normalizedScopeId = normalizeMcpScopeId(scopeId);
1319
1394
  const registryKey = mcpServerRegistryKey(normalizedScopeId, name);
1320
- const tools = rawTools.map((tool) => ({
1321
- ...tool,
1322
- name: String(tool?.name || '').startsWith('mcp__')
1323
- ? String(tool.name)
1324
- : `mcp__${name}__${String(tool?.name || '')}`,
1325
- inputSchema: tool?.inputSchema || { type: 'object', properties: {} },
1326
- }));
1395
+ const tools = rawTools.map((tool) => {
1396
+ const prefixed = String(tool?.name || '').startsWith('mcp__');
1397
+ const leaf = String(tool?.name || '');
1398
+ const safeName = prefixed ? leaf : providerSafeMcpToolName(name, leaf);
1399
+ const shortened = !prefixed && safeName !== `mcp__${name}__${leaf}`;
1400
+ return {
1401
+ ...tool,
1402
+ name: safeName,
1403
+ ...(shortened ? { mcpToolName: leaf, mcpServerName: name } : {}),
1404
+ inputSchema: tool?.inputSchema || { type: 'object', properties: {} },
1405
+ };
1406
+ });
1327
1407
  const capabilities = options.capabilities || {};
1408
+ const pluginId = String(options.cfg?._mixdogPlugin?.id || '').trim();
1328
1409
  const server = {
1329
1410
  scopeId: normalizedScopeId,
1330
1411
  registryKey,
1331
1412
  name,
1332
- tools: [...tools, ...mcpFeatureTools(name, capabilities, tools)],
1413
+ tools: [...tools, ...mcpFeatureTools(name, capabilities, tools)].map((tool) =>
1414
+ pluginId ? { ...tool, mcpPluginId: pluginId } : tool
1415
+ ),
1416
+ warnings: options.warnings || [],
1333
1417
  cfg: options.cfg || {},
1334
1418
  instructions: options.instructions || '',
1335
1419
  capabilities,
@@ -0,0 +1,25 @@
1
+ // The MCP SDK Client compiles every tool's outputSchema with Ajv inside
2
+ // listTools(), synchronously: a server listing ~180 tools held the daemon's
3
+ // event loop for 1-2 s on every connect. This validator compiles each schema
4
+ // on the tool's first structured result instead, and creates the SDK's Ajv
5
+ // validator (same options and error text) on first use too.
6
+ // A schema declaring an `$id` anywhere registers it in the client's Ajv, and a
7
+ // schema referencing anything outside itself resolves against what is
8
+ // registered by then (failing the listing when it is not); both still compile
9
+ // when listed, in listing order, exactly as the SDK does.
10
+ const LISTING_ORDER_SCHEMA = /"\$id"|"\$ref":"(?!#)/;
11
+
12
+ export function createLazyOutputValidator(AjvJsonSchemaValidator) {
13
+ let provider = null;
14
+ const shared = () => (provider ??= new AjvJsonSchemaValidator());
15
+ return {
16
+ getValidator(schema) {
17
+ if (LISTING_ORDER_SCHEMA.test(JSON.stringify(schema))) return shared().getValidator(schema);
18
+ let validate = null;
19
+ return (input) => {
20
+ validate ??= shared().getValidator(schema);
21
+ return validate(input);
22
+ };
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * mcp/plugin-placeholders.mjs — plugin root/data placeholders in an MCP server
3
+ * config. Pure: no filesystem access; the caller supplies the directories.
4
+ */
5
+
6
+ const ROOT_NAMES = ['CLAUDE_PLUGIN_ROOT', 'CODEX_PLUGIN_ROOT', 'PLUGIN_ROOT', 'MIXDOG_PLUGIN_ROOT'];
7
+ const DATA_NAMES = ['CLAUDE_PLUGIN_DATA', 'PLUGIN_DATA', 'MIXDOG_PLUGIN_DATA'];
8
+ const PLACEHOLDER_RE = new RegExp(`\\$\\{(${[...ROOT_NAMES, ...DATA_NAMES].join('|')})\\}`, 'g');
9
+ const CONFIG_FIELDS = ['command', 'args', 'env', 'url', 'headers', 'cwd'];
10
+
11
+ // Substituted paths use forward slashes on Windows so a path that lands in a
12
+ // command line or argument is not read as backslash escapes; Windows accepts
13
+ // them everywhere.
14
+ function normalizePath(path, platform) {
15
+ return platform === 'win32' ? path.replace(/\\/g, '/') : path;
16
+ }
17
+
18
+ function substitute(value, replacements) {
19
+ if (typeof value === 'string') {
20
+ // Function replacement: `$` sequences in paths are inserted literally.
21
+ return value.replace(PLACEHOLDER_RE, (match, name) => replacements.get(name) ?? match);
22
+ }
23
+ if (Array.isArray(value)) return value.map((v) => substitute(v, replacements));
24
+ if (value && typeof value === 'object') {
25
+ const out = {};
26
+ for (const [k, v] of Object.entries(value)) out[k] = substitute(v, replacements);
27
+ return out;
28
+ }
29
+ return value;
30
+ }
31
+
32
+ /**
33
+ * Returns a copy of `config` with plugin root/data placeholders replaced in
34
+ * command, args, env, url, headers and cwd. A placeholder whose directory is
35
+ * not supplied is left as written. Run before ordinary env expansion.
36
+ */
37
+ export function resolvePluginPlaceholders(config, { root, data, platform = process.platform } = {}) {
38
+ const replacements = new Map();
39
+ if (root) for (const name of ROOT_NAMES) replacements.set(name, normalizePath(String(root), platform));
40
+ if (data) for (const name of DATA_NAMES) replacements.set(name, normalizePath(String(data), platform));
41
+ const out = { ...config };
42
+ for (const field of CONFIG_FIELDS) {
43
+ if (out[field] !== undefined) out[field] = substitute(out[field], replacements);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /** Environment variables a plugin-owned server process receives (raw paths). */
49
+ export function pluginServerEnv({ root, data } = {}) {
50
+ const env = {};
51
+ if (root) for (const name of ['CLAUDE_PLUGIN_ROOT', 'MIXDOG_PLUGIN_ROOT', 'PLUGIN_ROOT']) env[name] = String(root);
52
+ if (data) for (const name of ['CLAUDE_PLUGIN_DATA', 'MIXDOG_PLUGIN_DATA', 'PLUGIN_DATA']) env[name] = String(data);
53
+ return env;
54
+ }