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.
- package/README.md +26 -0
- package/bin/mulmoclaude.js +11 -1
- package/client/assets/JsonEditor-D6WBWLoa.js +10 -0
- package/client/assets/JsonEditor-Di5xGeZY.css +1 -0
- package/client/assets/_plugin-vue_export-helper-BOai-rQB.js +1 -0
- package/client/assets/chunk-D8eiyYIV-LcKZGJv5.js +1 -0
- package/client/assets/{html2canvas-CDGcmOD3-Bkf2uOth.js → html2canvas-CDGcmOD3-XVrO-eyz.js} +1 -1
- package/client/assets/index-CyBr8Mkr.css +2 -0
- package/client/assets/index-zZIqEbNX.js +5106 -0
- package/client/assets/{index.es-DqtpmBm8-D9mAh_KQ.js → index.es-DqtpmBm8-DHT6q10o.js} +1 -1
- package/client/assets/material-symbols-outlined-DtIK7AQn.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-D6kcV0wa.js +1 -0
- package/client/assets/{runtime-vue-BVUzgYGA.js → runtime-vue-fFYhnNg3.js} +1 -1
- package/client/assets/{vue-C8UuIO9J.js → vue-D4w8THF_.js} +1 -1
- package/client/assets/vue-i18n-CQbxVmNs.js +3 -0
- package/client/assets/vue.runtime.esm-bundler-BTyIdNAI.js +4 -0
- package/client/index.html +10 -10
- package/package.json +9 -8
- package/server/agent/backend/claude-code.ts +34 -0
- package/server/agent/backend/fake-echo.ts +370 -0
- package/server/agent/backend/index.ts +16 -1
- package/server/agent/config.ts +74 -24
- package/server/agent/index.ts +104 -80
- package/server/agent/mcpFailureMonitor.ts +167 -0
- package/server/agent/mcpPreflight.ts +185 -0
- package/server/agent/prompt.ts +50 -359
- package/server/agent/stdioHttpShim.ts +171 -0
- package/server/agent/stream.ts +12 -1
- package/server/api/routes/encore.ts +55 -0
- package/server/api/routes/files.ts +22 -0
- package/server/api/routes/mulmo-script.ts +19 -1
- package/server/api/routes/schedulerHandlers.ts +52 -4
- package/server/api/routes/sessions.ts +15 -0
- package/server/api/routes/skills.ts +263 -0
- package/server/build/dispatcher.mjs +299 -0
- package/server/encore/INVARIANTS.md +272 -0
- package/server/encore/boot.ts +39 -0
- package/server/encore/closure.ts +36 -0
- package/server/encore/cycle.ts +276 -0
- package/server/encore/dispatch.ts +103 -0
- package/server/encore/handlers/amend.ts +99 -0
- package/server/encore/handlers/appendNote.ts +74 -0
- package/server/encore/handlers/defineEncore.ts +42 -0
- package/server/encore/handlers/listTickets.ts +107 -0
- package/server/encore/handlers/markStepDone.ts +41 -0
- package/server/encore/handlers/markTargetSkipped.ts +33 -0
- package/server/encore/handlers/query.ts +138 -0
- package/server/encore/handlers/recordValues.ts +44 -0
- package/server/encore/handlers/resolveNotification.ts +121 -0
- package/server/encore/handlers/setup.ts +81 -0
- package/server/encore/handlers/shared.ts +137 -0
- package/server/encore/handlers/snooze.ts +87 -0
- package/server/encore/handlers/startObligationChat.ts +64 -0
- package/server/encore/handlers/startSetupChat.ts +50 -0
- package/server/encore/lock.ts +61 -0
- package/server/encore/notifier.ts +123 -0
- package/server/encore/obligation.ts +25 -0
- package/server/encore/paths.ts +78 -0
- package/server/encore/reconcile.ts +661 -0
- package/server/encore/tick.ts +191 -0
- package/server/encore/yaml-fm.ts +63 -0
- package/server/events/notifications.ts +19 -91
- package/server/index.ts +94 -9
- package/server/notifier/engine.ts +102 -1
- package/server/notifier/macosReminderAdapter.ts +30 -0
- package/server/notifier/runtime-api.ts +41 -1
- package/server/notifier/types.ts +15 -2
- package/server/plugins/runtime.ts +11 -2
- package/server/prompts/index.ts +39 -0
- package/server/prompts/system/journal-pointer.md +12 -0
- package/server/prompts/system/memory-management-atomic.md +33 -0
- package/server/prompts/system/memory-management-topic.md +60 -0
- package/server/prompts/system/news-concierge.md +24 -0
- package/server/prompts/system/sandbox-tools.md +10 -0
- package/server/prompts/system/sources-context.md +16 -0
- package/server/prompts/system/system.md +91 -0
- package/server/system/announceOptionalDeps.ts +57 -0
- package/server/system/appVersion.ts +34 -0
- package/server/system/config.ts +17 -1
- package/server/system/docker.ts +14 -6
- package/server/system/env.ts +18 -5
- package/server/system/optionalDeps.ts +129 -0
- package/server/utils/cli-flags.d.mts +14 -0
- package/server/utils/cli-flags.mjs +53 -0
- package/server/utils/files/encore-io.ts +111 -0
- package/server/utils/time.ts +6 -0
- package/server/workspace/helps/business.md +2 -2
- package/server/workspace/helps/encore-dsl.md +482 -0
- package/server/workspace/helps/index.md +15 -13
- package/server/workspace/helps/mulmoscript.md +3 -3
- package/server/workspace/helps/sandbox.md +2 -2
- package/server/workspace/hooks/dispatcher.ts +7 -5
- package/server/workspace/hooks/provision.ts +6 -3
- package/server/workspace/paths.ts +13 -4
- package/server/workspace/skills/catalog.ts +355 -0
- package/server/workspace/skills/external/catalog.ts +283 -0
- package/server/workspace/skills/external/clone.ts +129 -0
- package/server/workspace/skills/external/id.ts +194 -0
- package/server/workspace/skills/external/install.ts +417 -0
- package/server/workspace/skills/external/presets.ts +50 -0
- package/server/workspace/skills-preset.ts +29 -17
- package/server/workspace/workspace.ts +10 -5
- package/src/App.vue +37 -8
- package/src/components/FileContentRenderer.vue +102 -9
- package/src/components/JsonEditor.vue +160 -0
- package/src/components/NotificationBell.vue +35 -3
- package/src/components/PluginLauncher.vue +20 -41
- package/src/components/RightSidebar.vue +19 -0
- package/src/components/SettingsMcpTab.vue +58 -11
- package/src/components/SettingsModal.vue +22 -1
- package/src/components/StackView.vue +10 -1
- package/src/components/TodoExplorer.vue +16 -0
- package/src/components/todo/TodoKanbanView.vue +34 -6
- package/src/composables/useNotifications.ts +21 -1
- package/src/config/apiRoutes.ts +0 -6
- package/src/config/mcpCatalog.ts +12 -7
- package/src/config/mcpTypes.ts +5 -0
- package/src/config/roles.ts +52 -15
- package/src/config/systemFileDescriptors.ts +12 -0
- package/src/lang/de.ts +108 -12
- package/src/lang/en.ts +105 -11
- package/src/lang/es.ts +106 -11
- package/src/lang/fr.ts +106 -11
- package/src/lang/ja.ts +104 -11
- package/src/lang/ko.ts +105 -11
- package/src/lang/pt-BR.ts +106 -11
- package/src/lang/zh.ts +103 -11
- package/src/main.ts +1 -0
- package/src/plugins/_generated/metas.ts +4 -0
- package/src/plugins/_generated/registrations.ts +2 -0
- package/src/plugins/_generated/server-bindings.ts +5 -0
- package/src/plugins/encore/EncoreDashboard.vue +504 -0
- package/src/plugins/encore/EncoreRedirect.vue +116 -0
- package/src/plugins/encore/View.vue +36 -0
- package/src/plugins/encore/defineEncoreDefinition.ts +74 -0
- package/src/plugins/encore/defineEncoreMeta.ts +13 -0
- package/src/plugins/encore/index.ts +93 -0
- package/src/plugins/encore/manageEncoreDefinition.ts +100 -0
- package/src/plugins/encore/manageEncoreMeta.ts +36 -0
- package/src/plugins/manageSkills/View.vue +832 -30
- package/src/plugins/manageSkills/categories.ts +125 -0
- package/src/plugins/manageSkills/meta.ts +30 -0
- package/src/plugins/markdown/definition.ts +3 -3
- package/src/plugins/meta-types.ts +5 -0
- package/src/plugins/presentMulmoScript/Preview.vue +3 -3
- package/src/plugins/presentMulmoScript/View.vue +157 -33
- package/src/plugins/presentMulmoScript/meta.ts +4 -0
- package/src/plugins/scheduler/View.vue +45 -9
- package/src/plugins/scheduler/calendarDefinition.ts +6 -2
- package/src/plugins/scheduler/multiDayHelpers.ts +95 -0
- package/src/plugins/skill/View.vue +1 -5
- package/src/plugins/spreadsheet/View.vue +3 -3
- package/src/plugins/spreadsheet/definition.ts +1 -1
- package/src/plugins/textResponse/Preview.vue +14 -1
- package/src/plugins/textResponse/View.vue +39 -24
- package/src/plugins/wiki/components/WikiPageBody.vue +4 -0
- package/src/router/index.ts +11 -0
- package/src/router/pageRoutes.ts +1 -0
- package/src/types/encore-dsl/at-expression.ts +120 -0
- package/src/types/encore-dsl/at-resolver.ts +32 -0
- package/src/types/encore-dsl/cadence.ts +289 -0
- package/src/types/encore-dsl/schema.ts +288 -0
- package/src/types/notification.ts +2 -1
- package/src/types/session.ts +6 -0
- package/src/types/sse.ts +5 -0
- package/src/types/toolCallHistory.ts +7 -0
- package/src/utils/agent/eventDispatch.ts +26 -5
- package/src/utils/agent/mcpHint.ts +50 -0
- package/src/utils/image/htmlSrcAttrs.ts +117 -13
- package/src/utils/session/sessionEntries.ts +8 -32
- package/client/assets/PluginScopedRoot-YjvQq0Nn.js +0 -3
- package/client/assets/chunk-CernVdwh.js +0 -1
- package/client/assets/chunk-D8eiyYIV-CAXpUwLd.js +0 -1
- package/client/assets/index-BwrlMMHr.js +0 -5005
- package/client/assets/index-CvvNuegU.css +0 -2
- package/client/assets/material-symbols-outlined-BOZVWuR3.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-C1To4M3t.js +0 -1
- package/client/assets/vue.runtime.esm-bundler-DQ8Kjjui.js +0 -4
- package/server/api/routes/notifications.ts +0 -195
- package/server/notifier/legacy-adapters.ts +0 -76
- package/server/workspace/hooks/dispatcher.mjs +0 -300
- package/src/composables/useSelectedResult.ts +0 -49
package/server/agent/index.ts
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|