mulmoclaude 0.6.2 → 0.6.4

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 (182) hide show
  1. package/README.md +26 -0
  2. package/bin/mulmoclaude.js +11 -1
  3. package/client/assets/JsonEditor-D6WBWLoa.js +10 -0
  4. package/client/assets/JsonEditor-Di5xGeZY.css +1 -0
  5. package/client/assets/_plugin-vue_export-helper-BOai-rQB.js +1 -0
  6. package/client/assets/chunk-D8eiyYIV-LcKZGJv5.js +1 -0
  7. package/client/assets/{html2canvas-CDGcmOD3-Bkf2uOth.js → html2canvas-CDGcmOD3-XVrO-eyz.js} +1 -1
  8. package/client/assets/index-CyBr8Mkr.css +2 -0
  9. package/client/assets/index-zZIqEbNX.js +5106 -0
  10. package/client/assets/{index.es-DqtpmBm8-D9mAh_KQ.js → index.es-DqtpmBm8-DHT6q10o.js} +1 -1
  11. package/client/assets/material-symbols-outlined-DtIK7AQn.woff2 +0 -0
  12. package/client/assets/runtime-protocol-vue-D6kcV0wa.js +1 -0
  13. package/client/assets/{runtime-vue-BVUzgYGA.js → runtime-vue-fFYhnNg3.js} +1 -1
  14. package/client/assets/{vue-C8UuIO9J.js → vue-D4w8THF_.js} +1 -1
  15. package/client/assets/vue-i18n-CQbxVmNs.js +3 -0
  16. package/client/assets/vue.runtime.esm-bundler-BTyIdNAI.js +4 -0
  17. package/client/index.html +10 -10
  18. package/package.json +9 -8
  19. package/server/agent/backend/claude-code.ts +34 -0
  20. package/server/agent/backend/fake-echo.ts +370 -0
  21. package/server/agent/backend/index.ts +16 -1
  22. package/server/agent/config.ts +74 -24
  23. package/server/agent/index.ts +104 -80
  24. package/server/agent/mcpFailureMonitor.ts +167 -0
  25. package/server/agent/mcpPreflight.ts +185 -0
  26. package/server/agent/prompt.ts +50 -359
  27. package/server/agent/stdioHttpShim.ts +171 -0
  28. package/server/agent/stream.ts +12 -1
  29. package/server/api/routes/encore.ts +55 -0
  30. package/server/api/routes/files.ts +22 -0
  31. package/server/api/routes/mulmo-script.ts +19 -1
  32. package/server/api/routes/schedulerHandlers.ts +52 -4
  33. package/server/api/routes/sessions.ts +15 -0
  34. package/server/api/routes/skills.ts +263 -0
  35. package/server/build/dispatcher.mjs +299 -0
  36. package/server/encore/INVARIANTS.md +272 -0
  37. package/server/encore/boot.ts +39 -0
  38. package/server/encore/closure.ts +36 -0
  39. package/server/encore/cycle.ts +276 -0
  40. package/server/encore/dispatch.ts +103 -0
  41. package/server/encore/handlers/amend.ts +99 -0
  42. package/server/encore/handlers/appendNote.ts +74 -0
  43. package/server/encore/handlers/defineEncore.ts +42 -0
  44. package/server/encore/handlers/listTickets.ts +107 -0
  45. package/server/encore/handlers/markStepDone.ts +41 -0
  46. package/server/encore/handlers/markTargetSkipped.ts +33 -0
  47. package/server/encore/handlers/query.ts +138 -0
  48. package/server/encore/handlers/recordValues.ts +44 -0
  49. package/server/encore/handlers/resolveNotification.ts +121 -0
  50. package/server/encore/handlers/setup.ts +81 -0
  51. package/server/encore/handlers/shared.ts +137 -0
  52. package/server/encore/handlers/snooze.ts +87 -0
  53. package/server/encore/handlers/startObligationChat.ts +64 -0
  54. package/server/encore/handlers/startSetupChat.ts +50 -0
  55. package/server/encore/lock.ts +61 -0
  56. package/server/encore/notifier.ts +123 -0
  57. package/server/encore/obligation.ts +25 -0
  58. package/server/encore/paths.ts +78 -0
  59. package/server/encore/reconcile.ts +661 -0
  60. package/server/encore/tick.ts +191 -0
  61. package/server/encore/yaml-fm.ts +63 -0
  62. package/server/events/notifications.ts +19 -91
  63. package/server/index.ts +94 -9
  64. package/server/notifier/engine.ts +102 -1
  65. package/server/notifier/macosReminderAdapter.ts +30 -0
  66. package/server/notifier/runtime-api.ts +41 -1
  67. package/server/notifier/types.ts +15 -2
  68. package/server/plugins/runtime.ts +11 -2
  69. package/server/prompts/index.ts +39 -0
  70. package/server/prompts/system/journal-pointer.md +12 -0
  71. package/server/prompts/system/memory-management-atomic.md +33 -0
  72. package/server/prompts/system/memory-management-topic.md +60 -0
  73. package/server/prompts/system/news-concierge.md +24 -0
  74. package/server/prompts/system/sandbox-tools.md +10 -0
  75. package/server/prompts/system/sources-context.md +16 -0
  76. package/server/prompts/system/system.md +91 -0
  77. package/server/system/announceOptionalDeps.ts +57 -0
  78. package/server/system/appVersion.ts +34 -0
  79. package/server/system/config.ts +17 -1
  80. package/server/system/docker.ts +14 -6
  81. package/server/system/env.ts +18 -5
  82. package/server/system/optionalDeps.ts +129 -0
  83. package/server/utils/cli-flags.d.mts +14 -0
  84. package/server/utils/cli-flags.mjs +53 -0
  85. package/server/utils/files/encore-io.ts +111 -0
  86. package/server/utils/time.ts +6 -0
  87. package/server/workspace/helps/business.md +2 -2
  88. package/server/workspace/helps/encore-dsl.md +482 -0
  89. package/server/workspace/helps/index.md +15 -13
  90. package/server/workspace/helps/mulmoscript.md +3 -3
  91. package/server/workspace/helps/sandbox.md +2 -2
  92. package/server/workspace/hooks/dispatcher.ts +7 -5
  93. package/server/workspace/hooks/provision.ts +6 -3
  94. package/server/workspace/paths.ts +13 -4
  95. package/server/workspace/skills/catalog.ts +355 -0
  96. package/server/workspace/skills/external/catalog.ts +283 -0
  97. package/server/workspace/skills/external/clone.ts +129 -0
  98. package/server/workspace/skills/external/id.ts +194 -0
  99. package/server/workspace/skills/external/install.ts +417 -0
  100. package/server/workspace/skills/external/presets.ts +50 -0
  101. package/server/workspace/skills-preset.ts +29 -17
  102. package/server/workspace/workspace.ts +10 -5
  103. package/src/App.vue +37 -8
  104. package/src/components/FileContentRenderer.vue +102 -9
  105. package/src/components/JsonEditor.vue +160 -0
  106. package/src/components/NotificationBell.vue +35 -3
  107. package/src/components/PluginLauncher.vue +20 -41
  108. package/src/components/RightSidebar.vue +19 -0
  109. package/src/components/SettingsMcpTab.vue +58 -11
  110. package/src/components/SettingsModal.vue +22 -1
  111. package/src/components/StackView.vue +10 -1
  112. package/src/components/TodoExplorer.vue +16 -0
  113. package/src/components/todo/TodoKanbanView.vue +34 -6
  114. package/src/composables/useNotifications.ts +21 -1
  115. package/src/config/apiRoutes.ts +0 -6
  116. package/src/config/mcpCatalog.ts +12 -7
  117. package/src/config/mcpTypes.ts +5 -0
  118. package/src/config/roles.ts +52 -15
  119. package/src/config/systemFileDescriptors.ts +12 -0
  120. package/src/lang/de.ts +108 -12
  121. package/src/lang/en.ts +105 -11
  122. package/src/lang/es.ts +106 -11
  123. package/src/lang/fr.ts +106 -11
  124. package/src/lang/ja.ts +104 -11
  125. package/src/lang/ko.ts +105 -11
  126. package/src/lang/pt-BR.ts +106 -11
  127. package/src/lang/zh.ts +103 -11
  128. package/src/main.ts +1 -0
  129. package/src/plugins/_generated/metas.ts +4 -0
  130. package/src/plugins/_generated/registrations.ts +2 -0
  131. package/src/plugins/_generated/server-bindings.ts +5 -0
  132. package/src/plugins/encore/EncoreDashboard.vue +504 -0
  133. package/src/plugins/encore/EncoreRedirect.vue +116 -0
  134. package/src/plugins/encore/View.vue +36 -0
  135. package/src/plugins/encore/defineEncoreDefinition.ts +74 -0
  136. package/src/plugins/encore/defineEncoreMeta.ts +13 -0
  137. package/src/plugins/encore/index.ts +93 -0
  138. package/src/plugins/encore/manageEncoreDefinition.ts +100 -0
  139. package/src/plugins/encore/manageEncoreMeta.ts +36 -0
  140. package/src/plugins/manageSkills/View.vue +832 -30
  141. package/src/plugins/manageSkills/categories.ts +125 -0
  142. package/src/plugins/manageSkills/meta.ts +30 -0
  143. package/src/plugins/markdown/definition.ts +3 -3
  144. package/src/plugins/meta-types.ts +5 -0
  145. package/src/plugins/presentMulmoScript/Preview.vue +3 -3
  146. package/src/plugins/presentMulmoScript/View.vue +157 -33
  147. package/src/plugins/presentMulmoScript/meta.ts +4 -0
  148. package/src/plugins/scheduler/View.vue +45 -9
  149. package/src/plugins/scheduler/calendarDefinition.ts +6 -2
  150. package/src/plugins/scheduler/multiDayHelpers.ts +95 -0
  151. package/src/plugins/skill/View.vue +1 -5
  152. package/src/plugins/spreadsheet/View.vue +3 -3
  153. package/src/plugins/spreadsheet/definition.ts +1 -1
  154. package/src/plugins/textResponse/Preview.vue +14 -1
  155. package/src/plugins/textResponse/View.vue +39 -24
  156. package/src/plugins/wiki/components/WikiPageBody.vue +4 -0
  157. package/src/router/index.ts +11 -0
  158. package/src/router/pageRoutes.ts +1 -0
  159. package/src/types/encore-dsl/at-expression.ts +120 -0
  160. package/src/types/encore-dsl/at-resolver.ts +32 -0
  161. package/src/types/encore-dsl/cadence.ts +289 -0
  162. package/src/types/encore-dsl/schema.ts +288 -0
  163. package/src/types/notification.ts +2 -1
  164. package/src/types/session.ts +6 -0
  165. package/src/types/sse.ts +5 -0
  166. package/src/types/toolCallHistory.ts +7 -0
  167. package/src/utils/agent/eventDispatch.ts +26 -5
  168. package/src/utils/agent/mcpHint.ts +50 -0
  169. package/src/utils/image/htmlSrcAttrs.ts +117 -13
  170. package/src/utils/session/sessionEntries.ts +8 -32
  171. package/client/assets/PluginScopedRoot-YjvQq0Nn.js +0 -3
  172. package/client/assets/chunk-CernVdwh.js +0 -1
  173. package/client/assets/chunk-D8eiyYIV-CAXpUwLd.js +0 -1
  174. package/client/assets/index-BwrlMMHr.js +0 -5005
  175. package/client/assets/index-CvvNuegU.css +0 -2
  176. package/client/assets/material-symbols-outlined-BOZVWuR3.woff2 +0 -0
  177. package/client/assets/runtime-protocol-vue-C1To4M3t.js +0 -1
  178. package/client/assets/vue.runtime.esm-bundler-DQ8Kjjui.js +0 -4
  179. package/server/api/routes/notifications.ts +0 -195
  180. package/server/notifier/legacy-adapters.ts +0 -76
  181. package/server/workspace/hooks/dispatcher.mjs +0 -300
  182. package/src/composables/useSelectedResult.ts +0 -49
@@ -53,98 +53,122 @@ export async function* runAgent({
53
53
 
54
54
  // Per-invocation read so Settings UI changes apply without a server restart.
55
55
  const userMcpRaw = loadMcpConfig().mcpServers;
56
- const userServers = prepareUserServers(userMcpRaw, useDocker, workspacePath);
57
- const hasUserServers = Object.keys(userServers).length > 0;
58
- const hasMcp = activePlugins.length > 0 || hasUserServers;
56
+ // `prepareUserServers` may spawn host-side stdio→HTTP gateways for
57
+ // opted-in servers (#1421 Phase B); `mcpShims` MUST be torn down
58
+ // in the finally below or host processes / ports leak.
59
+ const { servers: userServers, shims: mcpShims } = await prepareUserServers(userMcpRaw, useDocker, workspacePath);
59
60
 
60
- // Catches the "catalog entry pinned to a non-existent npm package" failure where the MCP subprocess never starts and
61
- // Claude silently falls back to WebSearch. Fire-and-forget; per-package cache amortizes the network round-trip.
62
- validateStdioPackages(userServers).catch(() => {});
63
-
64
- // macOS sandbox: refresh from Keychain so expired OAuth tokens get replaced transparently.
65
- if (useDocker && process.platform === "darwin") {
66
- await refreshCredentials();
67
- }
68
-
69
- // Pre-load memory once (atomic vs topic format chosen inside
70
- // `loadMemorySnapshot`) so prompt assembly itself stays sync.
71
- const memorySnapshot = await loadMemorySnapshot(workspacePath);
72
- const fullSystemPrompt = buildSystemPrompt({
73
- role,
74
- workspacePath: useDocker ? CONTAINER_WORKSPACE_PATH : workspacePath,
75
- useDocker,
76
- userTimezone,
77
- memorySnapshot,
78
- });
61
+ // Shims are live host processes the moment `prepareUserServers`
62
+ // returns. Wrap *all* subsequent setup (credential refresh, memory
63
+ // /prompt prep, MCP config write) so a throw before `runAgent` still
64
+ // tears them down — otherwise host processes / ports leak for the
65
+ // rest of the session.
66
+ try {
67
+ const hasUserServers = Object.keys(userServers).length > 0;
68
+ const hasMcp = activePlugins.length > 0 || hasUserServers;
79
69
 
80
- // --debug: dump the full system prompt on the first message of each session.
81
- if (!claudeSessionId && process.argv.includes("--debug")) {
82
- log.info("agent", `system prompt for new session:\n${fullSystemPrompt}`);
83
- }
70
+ // Catches the "catalog entry pinned to a non-existent npm package" failure where the MCP subprocess never starts and
71
+ // Claude silently falls back to WebSearch. Fire-and-forget; per-package cache amortizes the network round-trip.
72
+ validateStdioPackages(userServers).catch(() => {});
84
73
 
85
- const mcpPaths = resolveMcpConfigPaths({
86
- workspacePath,
87
- sessionId,
88
- useDocker,
89
- });
90
- if (useDocker) {
91
- await mkdir(dirname(mcpPaths.hostPath), { recursive: true });
92
- }
74
+ // macOS sandbox: refresh from Keychain so expired OAuth tokens get replaced transparently.
75
+ if (useDocker && process.platform === "darwin") {
76
+ await refreshCredentials();
77
+ }
93
78
 
94
- // Surfaced in the --debug spawn log so developers can verify Settings UI changes reach Claude Code.
95
- let mcpServerNames: string[] = [];
96
- if (hasMcp) {
97
- const mcpConfig = buildMcpConfig({
98
- chatSessionId: sessionId,
99
- port,
100
- activePlugins,
79
+ // Pre-load memory once (atomic vs topic format chosen inside
80
+ // `loadMemorySnapshot`) so prompt assembly itself stays sync.
81
+ const memorySnapshot = await loadMemorySnapshot(workspacePath);
82
+ const fullSystemPrompt = buildSystemPrompt({
83
+ role,
84
+ workspacePath: useDocker ? CONTAINER_WORKSPACE_PATH : workspacePath,
101
85
  useDocker,
102
- userServers,
86
+ userTimezone,
87
+ memorySnapshot,
103
88
  });
104
- mcpServerNames = Object.keys(mcpConfig.mcpServers).sort();
105
- // Atomic so a concurrent claude spawn can't pick up a half-written file (they share the path under the session dir).
106
- await writeJsonAtomic(mcpPaths.hostPath, mcpConfig);
107
- }
108
89
 
109
- // Per-invocation read so allowedTools / MCP-server changes apply without a server restart.
110
- const settings = loadSettings();
111
- const userServerAllowedTools = userServerAllowedToolNames(userServers, useDocker);
90
+ // --debug: dump the full system prompt on the first message of each session.
91
+ if (!claudeSessionId && process.argv.includes("--debug")) {
92
+ log.info("agent", `system prompt for new session:\n${fullSystemPrompt}`);
93
+ }
112
94
 
113
- // Boolean presence flags only — never write raw sessionId into long-lived log sinks.
114
- const backend = getActiveBackend();
115
- const spawnLog: Record<string, unknown> = {
116
- backend: backend.id,
117
- roleId: role.id,
118
- useDocker,
119
- hasMcp,
120
- resumed: Boolean(claudeSessionId),
121
- hasSessionId: Boolean(sessionId),
122
- };
123
- // --debug only: kept off the default log to avoid leaking user MCP server names into long-lived sinks.
124
- if (process.argv.includes("--debug") && hasMcp) {
125
- spawnLog.mcpServers = mcpServerNames;
126
- }
127
- log.info("agent", "spawning agent", spawnLog);
128
-
129
- try {
130
- yield* backend.runAgent({
131
- systemPrompt: fullSystemPrompt,
132
- message,
133
- role,
95
+ const mcpPaths = resolveMcpConfigPaths({
134
96
  workspacePath,
135
97
  sessionId,
136
- port,
137
- sessionToken: claudeSessionId,
138
- attachments,
139
- activePlugins,
140
- mcpConfigPath: hasMcp ? mcpPaths.argPath : undefined,
141
- extraAllowedTools: [...settings.extraAllowedTools, ...userServerAllowedTools],
142
- effortLevel: settings.effortLevel,
143
- abortSignal,
144
- userTimezone,
145
98
  useDocker,
146
99
  });
100
+ if (useDocker) {
101
+ await mkdir(dirname(mcpPaths.hostPath), { recursive: true });
102
+ }
103
+
104
+ // Surfaced in the --debug spawn log so developers can verify Settings UI changes reach Claude Code.
105
+ let mcpServerNames: string[] = [];
106
+ if (hasMcp) {
107
+ const mcpConfig = buildMcpConfig({
108
+ chatSessionId: sessionId,
109
+ port,
110
+ activePlugins,
111
+ useDocker,
112
+ userServers,
113
+ });
114
+ mcpServerNames = Object.keys(mcpConfig.mcpServers).sort();
115
+ // Atomic so a concurrent claude spawn can't pick up a half-written file (they share the path under the session dir).
116
+ await writeJsonAtomic(mcpPaths.hostPath, mcpConfig);
117
+ }
118
+
119
+ // Per-invocation read so allowedTools / MCP-server changes apply without a server restart.
120
+ const settings = loadSettings();
121
+ const userServerAllowedTools = userServerAllowedToolNames(userServers, useDocker);
122
+
123
+ // Boolean presence flags only — never write raw sessionId into long-lived log sinks.
124
+ const backend = getActiveBackend();
125
+ const spawnLog: Record<string, unknown> = {
126
+ backend: backend.id,
127
+ roleId: role.id,
128
+ useDocker,
129
+ hasMcp,
130
+ resumed: Boolean(claudeSessionId),
131
+ hasSessionId: Boolean(sessionId),
132
+ };
133
+ // --debug only: kept off the default log to avoid leaking user MCP server names into long-lived sinks.
134
+ if (process.argv.includes("--debug") && hasMcp) {
135
+ spawnLog.mcpServers = mcpServerNames;
136
+ }
137
+ log.info("agent", "spawning agent", spawnLog);
138
+
139
+ try {
140
+ yield* backend.runAgent({
141
+ systemPrompt: fullSystemPrompt,
142
+ message,
143
+ role,
144
+ workspacePath,
145
+ sessionId,
146
+ port,
147
+ sessionToken: claudeSessionId,
148
+ attachments,
149
+ activePlugins,
150
+ mcpConfigPath: hasMcp ? mcpPaths.argPath : undefined,
151
+ extraAllowedTools: [...settings.extraAllowedTools, ...userServerAllowedTools],
152
+ effortLevel: settings.effortLevel,
153
+ abortSignal,
154
+ userTimezone,
155
+ useDocker,
156
+ });
157
+ } finally {
158
+ if (hasMcp) unlink(mcpPaths.hostPath).catch(() => {});
159
+ }
147
160
  } finally {
148
- if (hasMcp) unlink(mcpPaths.hostPath).catch(() => {});
161
+ // Tear down any host-side stdio→HTTP shims (#1421 Phase B)
162
+ // real child processes holding ports. This outer finally also
163
+ // covers a throw during setup (before `runAgent`), which is the
164
+ // main leak risk of the opt-in escape hatch.
165
+ for (const shim of mcpShims) {
166
+ try {
167
+ shim.close();
168
+ } catch {
169
+ // close() is best-effort + idempotent; never let a teardown
170
+ // failure mask the turn's real outcome.
171
+ }
172
+ }
149
173
  }
150
174
  }
@@ -0,0 +1,167 @@
1
+ // Runtime failure monitor for external MCP servers (#1353).
2
+ //
3
+ // `mcpPreflight.ts` (#1352) handles the static case: servers that
4
+ // can't even start because of missing required config. This module
5
+ // handles the dynamic case: servers that start OK but fail to answer
6
+ // tool calls (API key rotated, upstream down, OAuth scope wrong …).
7
+ //
8
+ // Signal source is `tool_result.is_error: true` from Claude Code's
9
+ // stream-json. `stream.ts` now forwards that flag as
10
+ // `AgentEvent.toolCallResult.isError`; this monitor consumes those
11
+ // events, attributes errors to the originating MCP server (parsed
12
+ // from `mcp__<server>__<tool>` names cached at toolCall time), and
13
+ // fires a single warn + bell notification per server once a
14
+ // consecutive-failure threshold is crossed.
15
+ //
16
+ // What the monitor intentionally does NOT do:
17
+ // - Restart the MCP server (operator's call).
18
+ // - Capture MCP subprocess stderr (Claude Agent SDK holds that —
19
+ // out of scope for #1353).
20
+ // - Auto-dismiss the bell entry when calls recover. The notification
21
+ // engine has no dismissal API exposed yet; future work can wire
22
+ // it once that lands. For now: bell stays until user dismisses.
23
+
24
+ import { EVENT_TYPES } from "../../src/types/events.js";
25
+ import { NOTIFICATION_ACTION_TYPES, NOTIFICATION_PRIORITIES } from "../../src/types/notification.js";
26
+ import { publishNotification } from "../events/notifications.js";
27
+ import { log } from "../system/logger/index.js";
28
+
29
+ export const MCP_FAILURE_THRESHOLD = 3;
30
+
31
+ // Server-id contract — mirrors `isMcpServerId` in
32
+ // `server/system/config.ts`. Single `_` is allowed; consecutive
33
+ // `__` is forbidden because it would collide with the
34
+ // `mcp__<server>__<tool>` delimiter and make the parser ambiguous
35
+ // (Codex iter-2 on #1356). Both ends agreeing on this shape is
36
+ // what lets the monitor attribute failures back to the right
37
+ // server entry in `mcp.json`.
38
+ const MCP_SERVER_ID_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
39
+ const MCP_PREFIX = "mcp__";
40
+ const MCP_DELIM = "__";
41
+
42
+ function isValidServerId(value: string): boolean {
43
+ return MCP_SERVER_ID_PATTERN.test(value) && !value.includes("__");
44
+ }
45
+
46
+ /** Minimal AgentEvent surface the monitor needs. Defined locally to
47
+ * avoid a circular import; structurally matches the relevant fields
48
+ * on the real `AgentEvent` union. */
49
+ interface TrackableEvent {
50
+ type: string;
51
+ toolUseId?: string;
52
+ toolName?: string;
53
+ content?: string;
54
+ isError?: boolean;
55
+ }
56
+
57
+ interface ServerStats {
58
+ consecutiveFailures: number;
59
+ totalFailures: number;
60
+ totalCalls: number;
61
+ }
62
+
63
+ interface NotificationSink {
64
+ publish: typeof publishNotification;
65
+ warn: (event: string, message: string, data?: Record<string, unknown>) => void;
66
+ }
67
+
68
+ const defaultSink: NotificationSink = {
69
+ publish: publishNotification,
70
+ warn: (event, message, data) => log.warn(event, message, data),
71
+ };
72
+
73
+ /** Pure helper: returns the server id encoded in an MCP tool name,
74
+ * or `null` for non-MCP tools.
75
+ *
76
+ * Parses by string-split rather than a single regex so:
77
+ * - server ids containing `_` (allowed by `isMcpServerId`, e.g.
78
+ * `a1_b2`) attribute correctly. The first `__` after the
79
+ * `mcp__` prefix is treated as the server↔tool delimiter, so
80
+ * `mcp__a1_b2__do_thing` resolves to server `"a1_b2"`,
81
+ * tool-part `"do_thing"`.
82
+ * - no regex backtracking surface (Codex flagged ReDoS on the
83
+ * previous `[^_]+(?:_[^_]+)*` form; this fix uses split + a
84
+ * simple per-character validator instead). */
85
+ export function mcpServerFromToolName(toolName: string): string | null {
86
+ if (!toolName.startsWith(MCP_PREFIX)) return null;
87
+ const rest = toolName.slice(MCP_PREFIX.length);
88
+ const delim = rest.indexOf(MCP_DELIM);
89
+ if (delim <= 0) return null;
90
+ const serverId = rest.slice(0, delim);
91
+ // The tool-part is everything after the delimiter; it can carry
92
+ // `__` of its own (some MCP authors use `__` in tool names) — we
93
+ // only care that something is there.
94
+ if (rest.length <= delim + MCP_DELIM.length) return null;
95
+ return isValidServerId(serverId) ? serverId : null;
96
+ }
97
+
98
+ /** Build a session-scoped monitor. Returns the same shape as
99
+ * `createMcpTracker` so the backend wires both in the same loop.
100
+ *
101
+ * `sink` is injectable for tests so we don't need to mock the
102
+ * notification engine / logger globally. */
103
+ export function createMcpFailureMonitor(opts: { sink?: NotificationSink; threshold?: number } = {}): {
104
+ track: (event: TrackableEvent) => void;
105
+ } {
106
+ const sink = opts.sink ?? defaultSink;
107
+ const threshold = opts.threshold ?? MCP_FAILURE_THRESHOLD;
108
+ const toolUseIdToServer = new Map<string, string>();
109
+ const stats = new Map<string, ServerStats>();
110
+ const notified = new Set<string>();
111
+
112
+ function recordCall(toolUseId: string, toolName: string): void {
113
+ const server = mcpServerFromToolName(toolName);
114
+ if (server === null) return;
115
+ toolUseIdToServer.set(toolUseId, server);
116
+ }
117
+
118
+ function recordResult(toolUseId: string, isError: boolean): void {
119
+ const server = toolUseIdToServer.get(toolUseId);
120
+ if (server === undefined) return; // not an MCP call (or orphan result)
121
+ toolUseIdToServer.delete(toolUseId);
122
+ const entry = stats.get(server) ?? { consecutiveFailures: 0, totalFailures: 0, totalCalls: 0 };
123
+ entry.totalCalls += 1;
124
+ if (isError) {
125
+ entry.consecutiveFailures += 1;
126
+ entry.totalFailures += 1;
127
+ if (entry.consecutiveFailures >= threshold && !notified.has(server)) {
128
+ notified.add(server);
129
+ emitFailureNotice(server, entry, sink);
130
+ }
131
+ } else {
132
+ entry.consecutiveFailures = 0;
133
+ }
134
+ stats.set(server, entry);
135
+ }
136
+
137
+ return {
138
+ track(event: TrackableEvent): void {
139
+ if (event.type === EVENT_TYPES.toolCall && typeof event.toolUseId === "string" && typeof event.toolName === "string") {
140
+ recordCall(event.toolUseId, event.toolName);
141
+ } else if (event.type === EVENT_TYPES.toolCallResult && typeof event.toolUseId === "string") {
142
+ recordResult(event.toolUseId, event.isError === true);
143
+ }
144
+ },
145
+ };
146
+ }
147
+
148
+ function emitFailureNotice(server: string, entry: ServerStats, sink: NotificationSink): void {
149
+ const message = `MCP server ${server} returned errors on ${String(entry.consecutiveFailures)} consecutive tool calls; check API key, network, or upstream service health.`;
150
+ sink.warn("mcp", "subprocess appears broken — consecutive tool errors crossed threshold", {
151
+ server,
152
+ consecutiveFailures: entry.consecutiveFailures,
153
+ totalFailures: entry.totalFailures,
154
+ totalCalls: entry.totalCalls,
155
+ });
156
+ sink.publish({
157
+ // Deterministic id so the notification engine's legacyId dedup
158
+ // matches across restarts — bell entries from previous boots
159
+ // for the same broken server don't pile up.
160
+ id: `mcp-failure-${server}`,
161
+ kind: "system",
162
+ title: "MCP server failing",
163
+ body: message,
164
+ action: { type: NOTIFICATION_ACTION_TYPES.none },
165
+ priority: NOTIFICATION_PRIORITIES.high,
166
+ });
167
+ }
@@ -0,0 +1,185 @@
1
+ // Boot-time + per-agent-run preflight for external MCP servers
2
+ // (#1352).
3
+ //
4
+ // Built-in MCP-only tools have always done this via
5
+ // `isMcpToolEnabled` + `logMcpStatus` (server/index.ts:750) — when an
6
+ // env var listed in `requiredEnv` is unset, the tool drops out of
7
+ // the list and the operator sees an info log explaining why. External
8
+ // MCP servers (the `mcp.json` ones — Notion / GitHub / Linear /…)
9
+ // had no equivalent, so a half-configured catalog entry would still
10
+ // spawn a subprocess and every tool call would fail silently with
11
+ // 401. This module is the parity fix.
12
+ //
13
+ // The catalog (`src/config/mcpCatalog.ts`) declares which config
14
+ // fields are `required: true`. The user's saved `mcp.json` holds
15
+ // resolved values. Cross-referencing the two tells us which servers
16
+ // are ready to boot and which should be excluded from the config
17
+ // handed to Claude Code.
18
+
19
+ import type { McpServerSpec } from "../system/config.js";
20
+ import { findCatalogEntry, requiredKeysOf, type McpCatalogEntry } from "../../src/config/mcpCatalog.js";
21
+ import { log } from "../system/logger/index.js";
22
+
23
+ export interface McpPreflightResult {
24
+ /** Servers that passed preflight, keyed by the same id used in
25
+ * the input. Safe to pass straight into `prepareUserServers` /
26
+ * `buildMcpConfig`. */
27
+ ready: Record<string, McpServerSpec>;
28
+ /** Servers excluded by preflight, with the catalog field keys
29
+ * whose values were unset / unresolved. */
30
+ skipped: { serverId: string; missing: string[] }[];
31
+ }
32
+
33
+ const PLACEHOLDER_PATTERN = /\$\{([A-Z0-9_]+)\}/g;
34
+ const SINGLE_PLACEHOLDER = /^\$\{([A-Z0-9_]+)\}$/;
35
+
36
+ /** Returns the catalog field keys whose values are unresolved in
37
+ * the user's saved spec — `""`, missing, or still carrying a
38
+ * `${KEY}` placeholder.
39
+ *
40
+ * Mapping goes: catalog `configSchema[].key` → spec env key, via
41
+ * the catalog template's env value. E.g. catalog template
42
+ * `env: { NOTION_TOKEN: "${NOTION_API_KEY}" }` binds the field
43
+ * `NOTION_API_KEY` to the env key `NOTION_TOKEN`. We then check
44
+ * the user's saved spec's `env.NOTION_TOKEN`.
45
+ *
46
+ * HTTP-type catalog entries currently have no required fields
47
+ * (deepwiki is empty) — they fall through with `[]`. When a
48
+ * required HTTP header lands in the catalog, extend this helper. */
49
+ export function findMissingRequiredEnv(entry: McpCatalogEntry, spec: McpServerSpec): string[] {
50
+ // Transport mismatch (e.g. catalog stdio entry but user pointed
51
+ // the same id at an HTTP URL) means the catalog's env template
52
+ // doesn't apply to this user spec — see `preflightUserServers`'s
53
+ // header comment for the rationale. Guard here too so callers that
54
+ // skip the wrapper still get the correct answer.
55
+ if (entry.spec.type !== spec.type) return [];
56
+ if (entry.spec.type !== "stdio" || !entry.spec.env) return [];
57
+ const fieldToEnvKey = buildFieldToEnvKeyMap(entry.spec.env);
58
+ const userEnv = spec.type === "stdio" ? spec.env : undefined;
59
+ const required = requiredKeysOf(entry);
60
+ const missing: string[] = [];
61
+ for (const fieldKey of required) {
62
+ const envKey = fieldToEnvKey.get(fieldKey);
63
+ if (envKey === undefined) continue;
64
+ const value = userEnv?.[envKey];
65
+ if (!isResolved(value)) missing.push(fieldKey);
66
+ }
67
+ return missing;
68
+ }
69
+
70
+ function buildFieldToEnvKeyMap(templateEnv: Record<string, string>): Map<string, string> {
71
+ const out = new Map<string, string>();
72
+ for (const [envKey, value] of Object.entries(templateEnv)) {
73
+ const match = SINGLE_PLACEHOLDER.exec(value);
74
+ if (match) out.set(match[1], envKey);
75
+ }
76
+ return out;
77
+ }
78
+
79
+ function isResolved(value: string | undefined): boolean {
80
+ if (typeof value !== "string") return false;
81
+ // Trim before the empty check — `" "` (whitespace-only) is just
82
+ // as misconfigured as `""` and would otherwise let preflight
83
+ // greenlight a server that can't actually authenticate (Codex
84
+ // review on #1355).
85
+ if (value.trim().length === 0) return false;
86
+ PLACEHOLDER_PATTERN.lastIndex = 0;
87
+ return !PLACEHOLDER_PATTERN.test(value);
88
+ }
89
+
90
+ /** Filter user MCP servers by checking the catalog's required
91
+ * fields. Servers without a catalog match (= user-added custom
92
+ * servers) pass through — we have no metadata to validate them
93
+ * against.
94
+ *
95
+ * Two other shapes also pass through unvalidated:
96
+ *
97
+ * - `enabled: false` entries (CodeRabbit review on #1355). They're
98
+ * intentionally disabled by the user; running them through
99
+ * preflight produces spurious "missing required config" warnings
100
+ * AND skews the boot summary's `started` count. The downstream
101
+ * `prepareUserServers` already drops disabled entries before
102
+ * spawning anything, so we just forward them.
103
+ *
104
+ * - Type-mismatched catalog hits. If the user's mcp.json has
105
+ * `gmail: { type: "http", url: ... }` but the catalog's `gmail`
106
+ * entry is `type: "stdio"` with env templates, the catalog's
107
+ * requirement list doesn't apply to the user's spec — they've
108
+ * pointed `gmail` at a different transport, effectively making it
109
+ * a custom server. Treat as custom (no preflight) rather than
110
+ * false-flagging missing env. */
111
+ export function preflightUserServers(userServers: Record<string, McpServerSpec> | undefined | null): McpPreflightResult {
112
+ const ready: Record<string, McpServerSpec> = {};
113
+ const skipped: McpPreflightResult["skipped"] = [];
114
+ // Defensive default (Sourcery review on #1355): a malformed
115
+ // mcp.json — or a future refactor that nulls `mcpServers` — would
116
+ // otherwise throw `Object.entries(null)` at boot.
117
+ for (const [serverId, spec] of Object.entries(userServers ?? {})) {
118
+ if (spec.enabled === false) {
119
+ ready[serverId] = spec;
120
+ continue;
121
+ }
122
+ const entry = findCatalogEntry(serverId);
123
+ if (entry === null || entry.spec.type !== spec.type) {
124
+ ready[serverId] = spec;
125
+ continue;
126
+ }
127
+ const missing = findMissingRequiredEnv(entry, spec);
128
+ if (missing.length > 0) {
129
+ skipped.push({ serverId, missing: missing.sort() });
130
+ continue;
131
+ }
132
+ ready[serverId] = spec;
133
+ }
134
+ return { ready, skipped };
135
+ }
136
+
137
+ // Snapshot of the previous run's skipped set so per-agent-run logging
138
+ // only fires on state changes. Boot-time logging always fires (clean
139
+ // startup signal) and seeds the snapshot for subsequent runs.
140
+ //
141
+ // The earlier shape — a monotonic Set that only ever grew — would
142
+ // swallow a `missing → fixed → missing again` regression: the second
143
+ // "missing" emitted no warning because the key had already been
144
+ // logged on the first one (Codex review on #1355). Snapshot diffing
145
+ // fixes that without losing the dedup property: identical state
146
+ // across turns still logs at most once.
147
+ let lastSkippedKeys = new Set<string>();
148
+
149
+ function dedupKey(entry: { serverId: string; missing: string[] }): string {
150
+ return `${entry.serverId}:${entry.missing.join(",")}`;
151
+ }
152
+
153
+ /** Emit structured logs for the preflight outcome.
154
+ * - `source: "boot"` — runs once at startup; always logs and
155
+ * seeds the snapshot.
156
+ * - `source: "agent-run"` — runs per agent invocation; logs only
157
+ * entries that are new vs the previous run's snapshot. A server
158
+ * that re-enters a broken state after being fixed will log again
159
+ * because the key is absent from the snapshot. */
160
+ export function logPreflightResult(result: McpPreflightResult, source: "boot" | "agent-run"): void {
161
+ const isBoot = source === "boot";
162
+ const currentKeys = new Set(result.skipped.map(dedupKey));
163
+ for (const entry of result.skipped) {
164
+ const key = dedupKey(entry);
165
+ if (!isBoot && lastSkippedKeys.has(key)) continue;
166
+ log.warn("mcp", "preflight: skipping server — missing required config", {
167
+ source,
168
+ serverId: entry.serverId,
169
+ missing: entry.missing,
170
+ });
171
+ }
172
+ lastSkippedKeys = currentKeys;
173
+ if (isBoot) {
174
+ log.info("mcp", "preflight summary", {
175
+ started: Object.keys(result.ready).length,
176
+ skipped: result.skipped.length,
177
+ });
178
+ }
179
+ }
180
+
181
+ /** Test seam — reset the snapshot between tests so each case sees a
182
+ * fresh logging state. */
183
+ export function _resetPreflightLogCache(): void {
184
+ lastSkippedKeys = new Set();
185
+ }