one-code-extension 0.1.4 → 0.3.0
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/LICENSE +20 -0
- package/README.md +200 -172
- package/agents/explore.md +9 -6
- package/agents/general-purpose.md +1 -1
- package/agents/plan.md +6 -4
- package/docs/guide/README.md +52 -0
- package/docs/guide/bring-your-claude-code-setup.md +126 -0
- package/docs/guide/configuration.md +171 -0
- package/docs/guide/differences-from-claude-code.md +150 -0
- package/docs/guide/doctor.md +86 -0
- package/docs/guide/hooks.md +109 -0
- package/docs/guide/installation.md +124 -0
- package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
- package/docs/guide/providers-and-models.md +194 -0
- package/docs/guide/reference.md +224 -0
- package/docs/guide/sessions-and-context.md +190 -0
- package/docs/guide/skills-plugins-and-mcp.md +176 -0
- package/docs/guide/subagents-and-workflows.md +193 -0
- package/docs/guide/tasks-and-background-work.md +111 -0
- package/docs/guide/terminal-interface.md +155 -0
- package/docs/guide/tools.md +175 -0
- package/docs/guide/troubleshooting.md +175 -0
- package/extensions/ask-user/index.ts +65 -71
- package/extensions/ask-user/questions.ts +15 -28
- package/extensions/ask-user/widget.ts +533 -0
- package/extensions/auto-mode/actions.ts +9 -26
- package/extensions/auto-mode/classifier-prompt.ts +127 -0
- package/extensions/auto-mode/classifier.ts +305 -227
- package/extensions/auto-mode/config.ts +279 -77
- package/extensions/auto-mode/defaults.ts +17 -60
- package/extensions/auto-mode/instructions.ts +2 -1
- package/extensions/auto-mode/model-picker.ts +53 -31
- package/extensions/auto-mode/model-select.ts +153 -82
- package/extensions/auto-mode/paths.ts +13 -1
- package/extensions/auto-mode/pause.ts +8 -0
- package/extensions/auto-mode/prompt.ts +205 -238
- package/extensions/auto-mode/recoverability.ts +229 -0
- package/extensions/auto-mode/rules.ts +92 -75
- package/extensions/auto-mode/safety-floor.ts +43 -12
- package/extensions/auto-mode/sensitive.ts +11 -0
- package/extensions/auto-mode/setup-run.ts +245 -0
- package/extensions/auto-mode/setup.ts +235 -0
- package/extensions/auto-mode/shell-analysis.ts +332 -26
- package/extensions/auto-mode/transcript.ts +102 -0
- package/extensions/background/index.ts +256 -43
- package/extensions/background/monitor-batch.ts +58 -0
- package/extensions/background/registry.ts +9 -0
- package/extensions/background/wakeup.ts +45 -0
- package/extensions/bash/background.ts +47 -22
- package/extensions/bash/guards.ts +229 -0
- package/extensions/bash/index.ts +81 -19
- package/extensions/branding/index.ts +44 -18
- package/extensions/branding/startup.ts +23 -8
- package/extensions/claude-compat/index.ts +17 -2
- package/extensions/claude-context/index.ts +41 -12
- package/extensions/clear/index.ts +27 -0
- package/extensions/compaction/fit.ts +121 -0
- package/extensions/compaction/index.ts +176 -71
- package/extensions/compaction/prompt.ts +21 -1
- package/extensions/context-budget/budget.ts +61 -0
- package/extensions/context-budget/index.ts +66 -0
- package/extensions/context-management/index.ts +40 -16
- package/extensions/doctor/build.ts +226 -0
- package/extensions/doctor/cli.ts +99 -0
- package/extensions/doctor/compat.ts +532 -0
- package/extensions/doctor/dependencies.ts +186 -0
- package/extensions/doctor/fix-prompt.ts +125 -0
- package/extensions/doctor/index.ts +308 -0
- package/extensions/doctor/models.ts +231 -0
- package/extensions/doctor/presets.ts +208 -0
- package/extensions/doctor/report.ts +189 -0
- package/extensions/doctor/update-lookup.ts +46 -0
- package/extensions/doctor/viewer.ts +124 -0
- package/extensions/effort/index.ts +41 -4
- package/extensions/effort/slider.ts +33 -2
- package/extensions/file-tracker/index.ts +143 -20
- package/extensions/file-tracker/replay.ts +46 -0
- package/extensions/file-tracker/tracker.ts +51 -4
- package/extensions/footer/footer-line.ts +207 -0
- package/extensions/footer/index.ts +152 -0
- package/extensions/footer/pr.ts +53 -0
- package/extensions/hooks/executor.ts +57 -20
- package/extensions/hooks/formatter-notice.ts +76 -0
- package/extensions/hooks/index.ts +314 -73
- package/extensions/hooks/matcher.ts +35 -1
- package/extensions/hooks/plugin-hooks.ts +3 -3
- package/extensions/hooks/protocol.ts +58 -0
- package/extensions/hooks/subagent-bridge.ts +77 -0
- package/extensions/hooks/trust.ts +15 -4
- package/extensions/init/index.ts +26 -0
- package/extensions/init/prompt.ts +40 -0
- package/extensions/interrupted/index.ts +35 -0
- package/extensions/interrupted/line.ts +9 -0
- package/extensions/lib/abort.ts +15 -0
- package/extensions/lib/agent-loader.ts +183 -0
- package/extensions/lib/anthropic-payload.ts +15 -0
- package/extensions/lib/atomic-write.ts +56 -0
- package/extensions/lib/bridge-watch.ts +20 -0
- package/extensions/lib/cache-retention.ts +60 -0
- package/extensions/lib/capability-index.ts +466 -0
- package/extensions/lib/claude-context.ts +318 -27
- package/extensions/lib/claude-settings.ts +64 -0
- package/extensions/lib/consent-preview.ts +30 -0
- package/extensions/lib/deferred.ts +144 -13
- package/extensions/lib/favorites.ts +43 -0
- package/extensions/lib/fetch-timeout.ts +15 -0
- package/extensions/lib/frontmatter.ts +31 -0
- package/extensions/lib/git.ts +44 -2
- package/extensions/lib/hook-gate.ts +90 -0
- package/extensions/lib/interrupt.ts +94 -0
- package/extensions/lib/mcp-overrides.ts +61 -0
- package/extensions/lib/mcp-share.ts +25 -0
- package/extensions/lib/mcp-status.ts +34 -0
- package/extensions/lib/memory.ts +41 -6
- package/extensions/lib/model-facts.generated.json +2480 -0
- package/extensions/lib/model-facts.ts +162 -0
- package/extensions/lib/model-policy.ts +145 -189
- package/extensions/lib/model-tier.ts +480 -64
- package/extensions/lib/model-unusable.ts +37 -0
- package/extensions/lib/notifications.ts +320 -1
- package/extensions/lib/one-code-settings.ts +63 -0
- package/extensions/lib/original-command.ts +71 -0
- package/extensions/lib/package-version.ts +31 -0
- package/extensions/lib/paths.ts +39 -4
- package/extensions/lib/permission-gate.ts +192 -0
- package/extensions/lib/persisted-output.ts +18 -0
- package/extensions/lib/pi-ai-estimate.ts +160 -0
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/plan-mode-channels.ts +11 -0
- package/extensions/lib/plugin-overrides.ts +24 -0
- package/extensions/lib/plugin-root.ts +44 -0
- package/extensions/lib/plugins.ts +233 -54
- package/extensions/lib/prefix-warm-gate.ts +157 -0
- package/extensions/lib/process-tree.ts +56 -0
- package/extensions/lib/reminders.ts +321 -68
- package/extensions/lib/scratchpad.ts +11 -8
- package/extensions/lib/session-lifecycle.ts +28 -0
- package/extensions/lib/settings-channels.ts +14 -0
- package/extensions/lib/shell-tasks.ts +54 -0
- package/extensions/lib/skill-overrides.ts +86 -0
- package/extensions/lib/skill-scan.ts +110 -0
- package/extensions/lib/text-width.ts +267 -0
- package/extensions/lib/tui-render.ts +281 -54
- package/extensions/lib/usage-bus.ts +75 -0
- package/extensions/lib/usage-tracker.ts +67 -0
- package/extensions/lib/worktree-isolation.ts +170 -0
- package/extensions/lsp/client.ts +69 -12
- package/extensions/lsp/format.ts +12 -2
- package/extensions/lsp/index.ts +273 -84
- package/extensions/lsp/install-hints.ts +43 -0
- package/extensions/lsp/keep-alive.ts +19 -0
- package/extensions/lsp/plugin-servers.ts +331 -0
- package/extensions/lsp/watcher.ts +157 -0
- package/extensions/mcp/announce.ts +99 -0
- package/extensions/mcp/client.ts +181 -22
- package/extensions/mcp/config.ts +55 -24
- package/extensions/mcp/index.ts +586 -86
- package/extensions/mcp/oauth/browser.ts +26 -0
- package/extensions/mcp/oauth/callback.ts +104 -0
- package/extensions/mcp/oauth/flow.ts +68 -0
- package/extensions/mcp/oauth/provider.ts +147 -0
- package/extensions/mcp/oauth/store.ts +86 -0
- package/extensions/mcp/panel/keys.ts +34 -0
- package/extensions/mcp/panel/model.ts +62 -0
- package/extensions/mcp/panel/render.ts +141 -0
- package/extensions/mcp/panel/state.ts +108 -0
- package/extensions/mcp/schema.ts +83 -6
- package/extensions/mcp/trust.ts +340 -0
- package/extensions/memory/entries.ts +163 -0
- package/extensions/memory/index.ts +119 -2
- package/extensions/memory/open-external.ts +97 -0
- package/extensions/memory/panel.ts +124 -0
- package/extensions/model-default/index.ts +74 -0
- package/extensions/notebook/index.ts +36 -18
- package/extensions/notebook/notebook.ts +110 -17
- package/extensions/permissions/index.ts +1012 -247
- package/extensions/permissions/matcher.ts +488 -57
- package/extensions/permissions/modes.ts +55 -7
- package/extensions/permissions/project-trust.ts +81 -0
- package/extensions/permissions/protected-paths.ts +16 -3
- package/extensions/permissions/rule-prose.ts +79 -0
- package/extensions/permissions/session-grant.ts +91 -0
- package/extensions/permissions/settings.ts +112 -33
- package/extensions/permissions/subagent-gate.ts +57 -0
- package/extensions/plan-mode/index.ts +143 -33
- package/extensions/plan-mode/reminder.ts +16 -19
- package/extensions/plan-mode/viewer.ts +40 -31
- package/extensions/plugins/counts.ts +68 -0
- package/extensions/plugins/index.ts +496 -32
- package/extensions/plugins/install/install.ts +167 -0
- package/extensions/plugins/install/paths.ts +23 -0
- package/extensions/plugins/install/registry.ts +72 -0
- package/extensions/plugins/marketplace/git.ts +103 -0
- package/extensions/plugins/marketplace/official.ts +36 -0
- package/extensions/plugins/marketplace/parse.ts +94 -0
- package/extensions/plugins/marketplace/registry.ts +60 -0
- package/extensions/plugins/marketplace/sync.ts +146 -0
- package/extensions/plugins/marketplace/types.ts +128 -0
- package/extensions/plugins/panel/keys.ts +52 -0
- package/extensions/plugins/panel/render.ts +315 -0
- package/extensions/plugins/panel/rows.ts +230 -0
- package/extensions/plugins/panel/state.ts +333 -0
- package/extensions/recap/index.ts +215 -0
- package/extensions/recap/prompt.ts +44 -0
- package/extensions/recap/scheduler.ts +87 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +7 -5
- package/extensions/skill/index.ts +360 -34
- package/extensions/skill/invoke.ts +185 -0
- package/extensions/skill/listing.ts +21 -0
- package/extensions/skill/panel/keys.ts +41 -0
- package/extensions/skill/panel/render.ts +91 -0
- package/extensions/skill/panel/state.ts +115 -0
- package/extensions/spinner/index.ts +110 -0
- package/extensions/spinner/line.ts +51 -0
- package/extensions/spinner/verbs.ts +201 -0
- package/extensions/subagents/agents.ts +88 -44
- package/extensions/subagents/default-model.ts +62 -32
- package/extensions/subagents/delegation-steer.ts +23 -0
- package/extensions/subagents/hand-back-review.ts +67 -0
- package/extensions/subagents/index.ts +1545 -346
- package/extensions/subagents/live-runs.ts +293 -0
- package/extensions/subagents/model-select.ts +252 -82
- package/extensions/subagents/outcome.ts +90 -0
- package/extensions/subagents/panel-keys.ts +41 -0
- package/extensions/subagents/panel-render.ts +357 -0
- package/extensions/subagents/panel-widget.ts +330 -0
- package/extensions/subagents/pending-claim.ts +45 -0
- package/extensions/subagents/prose.ts +105 -0
- package/extensions/subagents/runner.ts +674 -0
- package/extensions/subagents/runs.ts +68 -1
- package/extensions/subagents/send-to-main-tool.ts +55 -0
- package/extensions/subagents/session-turns.ts +138 -0
- package/extensions/subagents/shell-panel.ts +198 -0
- package/extensions/subagents/usage.ts +16 -4
- package/extensions/subagents/worktree.ts +13 -0
- package/extensions/system-prompt/environment.ts +5 -3
- package/extensions/system-prompt/git-status.ts +93 -0
- package/extensions/system-prompt/index.ts +23 -1
- package/extensions/system-prompt/template.ts +34 -4
- package/extensions/system-prompt/tiers/common.ts +31 -6
- package/extensions/system-prompt/tiers/low.ts +13 -3
- package/extensions/system-prompt/tiers/mid.ts +21 -8
- package/extensions/system-reminder/index.ts +53 -9
- package/extensions/tasks/index.ts +65 -16
- package/extensions/tasks/store.ts +76 -3
- package/extensions/tool-search/announce.ts +42 -0
- package/extensions/tool-search/index.ts +136 -13
- package/extensions/turn-duration/index.ts +60 -0
- package/extensions/turn-duration/line.ts +22 -0
- package/extensions/turn-duration/span.ts +41 -0
- package/extensions/turn-duration/verbs.ts +24 -0
- package/extensions/web/backends.ts +421 -0
- package/extensions/web/index.ts +178 -21
- package/extensions/web-fetch/index.ts +74 -18
- package/extensions/web-fetch/summarize.ts +1 -1
- package/extensions/workflow/agent-session.ts +182 -112
- package/extensions/workflow/globals.ts +129 -106
- package/extensions/workflow/index.ts +343 -28
- package/extensions/workflow/records.ts +118 -0
- package/extensions/workflow/run-manager.ts +80 -31
- package/extensions/workflow/saved-workflows.ts +2 -1
- package/extensions/workflow/script-worker.mjs +110 -0
- package/extensions/workflow/types.ts +74 -8
- package/extensions/workflow/viewer.ts +562 -0
- package/extensions/workflow/vm-runtime.ts +118 -22
- package/extensions/workflow/widget.ts +126 -18
- package/extensions/worktree/guards.ts +240 -0
- package/extensions/worktree/index.ts +30 -5
- package/extensions/worktree/rewrite.ts +32 -16
- package/package.json +20 -10
- package/skills/code-review/SKILL.md +165 -0
- package/skills/fewer-permission-prompts/SKILL.md +61 -0
- package/skills/security-review/SKILL.md +178 -0
- package/skills/simplify/SKILL.md +70 -0
- package/themes/{one-code-light.json → onecode-light.json} +6 -1
- package/themes/{one-code.json → onecode.json} +6 -1
- package/types/app.d.ts +8 -1
- package/types/community-packages.d.ts +45 -2
- package/docs/decisions/auto-mode.md +0 -526
- package/docs/decisions/branding.md +0 -127
- package/docs/decisions/code-review-remediation.md +0 -106
- package/docs/decisions/compaction.md +0 -140
- package/docs/decisions/distribution.md +0 -168
- package/docs/decisions/hooks.md +0 -64
- package/docs/decisions/lsp.md +0 -58
- package/docs/decisions/mcp.md +0 -104
- package/docs/decisions/memory-state.md +0 -170
- package/docs/decisions/model-policy.md +0 -126
- package/docs/decisions/modes.md +0 -178
- package/docs/decisions/skills-plugins.md +0 -56
- package/docs/decisions/subagents-workflows.md +0 -225
- package/docs/decisions/system-prompt.md +0 -98
- package/docs/decisions/tools.md +0 -407
- package/docs/decisions/tui.md +0 -217
- package/docs/decisions.md +0 -148
- package/extensions/subagents/child.ts +0 -414
- package/extensions/subagents/rpc-turns.ts +0 -123
- package/extensions/todo/index.ts +0 -133
- package/extensions/workflow/permission-gate.ts +0 -71
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /mcp panel state machine (pure).
|
|
3
|
+
*
|
|
4
|
+
* Two views: the server list (cursor over entries; group headings are
|
|
5
|
+
* render-only and never landed on) and the detail view for one server (cursor
|
|
6
|
+
* over its numbered action list). `applyMcpKey` mutates the state in place and
|
|
7
|
+
* returns an effect when a key demands I/O — reconnect, disable, enable,
|
|
8
|
+
* authenticate — which the wiring performs and reflects back through the entries
|
|
9
|
+
* it renders. The wiring passes the current entries so "the selected row" always
|
|
10
|
+
* matches what was drawn (async reconnects reorder the list).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { McpKey } from "./keys.ts";
|
|
14
|
+
import { actionsFor, type McpEntry } from "./model.ts";
|
|
15
|
+
|
|
16
|
+
export interface McpPanelState {
|
|
17
|
+
cursor: number;
|
|
18
|
+
detail?: { name: string; actionCursor: number };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type McpEffect =
|
|
22
|
+
| { kind: "close" }
|
|
23
|
+
| { kind: "reconnect"; entry: McpEntry }
|
|
24
|
+
| { kind: "disable"; entry: McpEntry }
|
|
25
|
+
| { kind: "enable"; entry: McpEntry }
|
|
26
|
+
| { kind: "authenticate"; entry: McpEntry };
|
|
27
|
+
|
|
28
|
+
export function initialMcpState(): McpPanelState {
|
|
29
|
+
return { cursor: 0 };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function clampMcpCursor(state: McpPanelState, entries: McpEntry[]): void {
|
|
33
|
+
state.cursor = entries.length === 0 ? 0 : Math.max(0, Math.min(state.cursor, entries.length - 1));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Resolve the detail view's entry against the current list (rows shift under async work). */
|
|
37
|
+
export function detailEntry(state: McpPanelState, entries: McpEntry[]): McpEntry | undefined {
|
|
38
|
+
return state.detail ? entries.find((entry) => entry.name === state.detail!.name) : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function effectForAction(entry: McpEntry, index: number): McpEffect | undefined {
|
|
42
|
+
const action = actionsFor(entry)[index];
|
|
43
|
+
if (!action) return undefined;
|
|
44
|
+
switch (action.key) {
|
|
45
|
+
case "reconnect":
|
|
46
|
+
return { kind: "reconnect", entry };
|
|
47
|
+
case "disable":
|
|
48
|
+
return { kind: "disable", entry };
|
|
49
|
+
case "enable":
|
|
50
|
+
return { kind: "enable", entry };
|
|
51
|
+
case "authenticate":
|
|
52
|
+
return { kind: "authenticate", entry };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function applyMcpKey(state: McpPanelState, key: McpKey, entries: McpEntry[]): McpEffect | undefined {
|
|
57
|
+
// Detail view.
|
|
58
|
+
if (state.detail) {
|
|
59
|
+
const entry = detailEntry(state, entries);
|
|
60
|
+
if (!entry) {
|
|
61
|
+
// The server vanished (e.g. config changed under an async refresh).
|
|
62
|
+
if (key.kind === "back" || key.kind === "enter") state.detail = undefined;
|
|
63
|
+
if (key.kind === "close") return { kind: "close" };
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
const actions = actionsFor(entry);
|
|
67
|
+
const detail = state.detail;
|
|
68
|
+
detail.actionCursor = Math.max(0, Math.min(detail.actionCursor, actions.length - 1));
|
|
69
|
+
switch (key.kind) {
|
|
70
|
+
case "up":
|
|
71
|
+
detail.actionCursor = Math.max(0, detail.actionCursor - 1);
|
|
72
|
+
return undefined;
|
|
73
|
+
case "down":
|
|
74
|
+
detail.actionCursor = Math.min(actions.length - 1, detail.actionCursor + 1);
|
|
75
|
+
return undefined;
|
|
76
|
+
case "enter":
|
|
77
|
+
return effectForAction(entry, detail.actionCursor);
|
|
78
|
+
case "digit":
|
|
79
|
+
return effectForAction(entry, key.value - 1);
|
|
80
|
+
case "back":
|
|
81
|
+
state.detail = undefined;
|
|
82
|
+
return undefined;
|
|
83
|
+
case "close":
|
|
84
|
+
return { kind: "close" };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// List view.
|
|
89
|
+
clampMcpCursor(state, entries);
|
|
90
|
+
switch (key.kind) {
|
|
91
|
+
case "up":
|
|
92
|
+
state.cursor = Math.max(0, state.cursor - 1);
|
|
93
|
+
return undefined;
|
|
94
|
+
case "down":
|
|
95
|
+
state.cursor = Math.min(Math.max(0, entries.length - 1), state.cursor + 1);
|
|
96
|
+
return undefined;
|
|
97
|
+
case "enter": {
|
|
98
|
+
const entry = entries[state.cursor];
|
|
99
|
+
if (entry) state.detail = { name: entry.name, actionCursor: 0 };
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
case "back":
|
|
103
|
+
case "close":
|
|
104
|
+
return { kind: "close" };
|
|
105
|
+
case "digit":
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
package/extensions/mcp/schema.ts
CHANGED
|
@@ -9,12 +9,23 @@
|
|
|
9
9
|
|
|
10
10
|
import { type TSchema, Type } from "typebox";
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Claude Code's namespacing: `mcp__<server>__<tool>`. Hyphens are KEPT (CC's
|
|
14
|
+
* captures show `mcp__plugin_context7_context7__query-docs`), so a user's
|
|
15
|
+
* existing `mcp__…` permission rules and hook matchers keep matching; anything
|
|
16
|
+
* else outside `[A-Za-z0-9_-]` (a plugin server's `plugin:x:y` colons, dots)
|
|
17
|
+
* becomes `_`, as in CC.
|
|
18
|
+
*/
|
|
13
19
|
export function namespacedToolName(server: string, tool: string): string {
|
|
14
|
-
const clean = (part: string) => part.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
20
|
+
const clean = (part: string) => part.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
15
21
|
return `mcp__${clean(server)}__${clean(tool)}`;
|
|
16
22
|
}
|
|
17
23
|
|
|
24
|
+
/** CC's name for a plugin-provided server: `plugin:<plugin>:<server>`, so two plugins' `github` servers cannot collide with each other or with the user's. */
|
|
25
|
+
export function pluginServerName(pluginName: string, serverName: string): string {
|
|
26
|
+
return `plugin:${pluginName}:${serverName}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
export function parseNamespacedToolName(name: string): { server: string; tool: string } | undefined {
|
|
19
30
|
if (!name.startsWith("mcp__")) return undefined;
|
|
20
31
|
const rest = name.slice("mcp__".length);
|
|
@@ -36,8 +47,26 @@ interface JsonSchema {
|
|
|
36
47
|
[key: string]: unknown;
|
|
37
48
|
}
|
|
38
49
|
|
|
50
|
+
/**
|
|
51
|
+
* A server's tool and parameter descriptions ride every request as
|
|
52
|
+
* deferred-definition bytes with no bound; a malicious or careless server can
|
|
53
|
+
* make each one arbitrarily large. Cap them at registration with a labelled
|
|
54
|
+
* marker (review L2). 4 KB is far more than any real description needs.
|
|
55
|
+
*/
|
|
56
|
+
export const DESCRIPTION_CAP = 4096;
|
|
57
|
+
export function capDescription(text: string | undefined): string | undefined {
|
|
58
|
+
if (typeof text !== "string") return undefined;
|
|
59
|
+
return text.length > DESCRIPTION_CAP ? `${text.slice(0, DESCRIPTION_CAP)}… [truncated, ${text.length} chars]` : text;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** TypeBox options carrying a (capped) description, or none. */
|
|
63
|
+
function descriptionOptions(schema: JsonSchema): Record<string, unknown> {
|
|
64
|
+
const capped = capDescription(schema.description);
|
|
65
|
+
return capped ? { description: capped } : {};
|
|
66
|
+
}
|
|
67
|
+
|
|
39
68
|
function convertLeaf(schema: JsonSchema): TSchema | undefined {
|
|
40
|
-
const options = schema
|
|
69
|
+
const options = descriptionOptions(schema);
|
|
41
70
|
const type = Array.isArray(schema.type) ? schema.type.find((t) => t !== "null") : schema.type;
|
|
42
71
|
|
|
43
72
|
switch (type) {
|
|
@@ -67,7 +96,7 @@ function convertLeaf(schema: JsonSchema): TSchema | undefined {
|
|
|
67
96
|
export function jsonSchemaToTypeBox(schema: JsonSchema | undefined): TSchema {
|
|
68
97
|
if (!schema || typeof schema !== "object") return Type.Unknown();
|
|
69
98
|
|
|
70
|
-
const options = schema
|
|
99
|
+
const options = descriptionOptions(schema);
|
|
71
100
|
|
|
72
101
|
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
|
|
73
102
|
const literals = schema.enum
|
|
@@ -137,7 +166,53 @@ export function describeResourceContents(contents: McpResourceContents[] | undef
|
|
|
137
166
|
return parts.join("\n").trim();
|
|
138
167
|
}
|
|
139
168
|
|
|
140
|
-
|
|
169
|
+
/** Decoded size a single image block may occupy before it is refused. */
|
|
170
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A server names an image's `mimeType`, but the provider decodes the bytes: an
|
|
174
|
+
* undecodable block (a truncated PNG, a base64 string with a wrong `mimeType`)
|
|
175
|
+
* makes the provider answer 400 "Could not process image" on that request and
|
|
176
|
+
* on every later one, since the block stays in the history. So the bytes are
|
|
177
|
+
* checked against a small magic-number allow-list here and `mimeType` is
|
|
178
|
+
* derived from them, never trusted; a block that fails becomes a text note.
|
|
179
|
+
*/
|
|
180
|
+
/** Base64 length that decodes to just over the byte ceiling (4 base64 chars per 3 bytes). */
|
|
181
|
+
const MAX_IMAGE_BASE64_LENGTH = Math.ceil((MAX_IMAGE_BYTES * 4) / 3);
|
|
182
|
+
|
|
183
|
+
export function validateImageData(
|
|
184
|
+
data: string,
|
|
185
|
+
claimedMime: string | undefined,
|
|
186
|
+
): { ok: true; mimeType: string } | { ok: false; reason: string; bytes: number } {
|
|
187
|
+
// Bound and sniff from the base64 string itself: decoding the whole payload
|
|
188
|
+
// (up to ~6.7 MB) just to read a 12-byte magic number and a length is pure
|
|
189
|
+
// waste — the block ships as the original base64 string regardless. Cap on
|
|
190
|
+
// the string length, then decode only a 24-char (4-aligned) prefix to sniff.
|
|
191
|
+
if (data.length === 0) return { ok: false, reason: "empty image data", bytes: 0 };
|
|
192
|
+
const approxBytes = Math.floor((data.length * 3) / 4);
|
|
193
|
+
if (data.length > MAX_IMAGE_BASE64_LENGTH) {
|
|
194
|
+
return { ok: false, reason: `image is ~${approxBytes} bytes, over the ${MAX_IMAGE_BYTES}-byte ceiling`, bytes: approxBytes };
|
|
195
|
+
}
|
|
196
|
+
const head = Buffer.from(data.slice(0, 24), "base64");
|
|
197
|
+
if (head.length === 0) return { ok: false, reason: "empty after base64 decode", bytes: 0 };
|
|
198
|
+
const mime = sniffImageMime(head);
|
|
199
|
+
if (!mime) return { ok: false, reason: `unrecognised image format (claimed ${claimedMime ?? "none"})`, bytes: approxBytes };
|
|
200
|
+
return { ok: true, mimeType: mime };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Magic-number sniff for the formats Anthropic accepts. Returns undefined for anything else. */
|
|
204
|
+
function sniffImageMime(buf: Buffer): string | undefined {
|
|
205
|
+
if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return "image/png";
|
|
206
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "image/jpeg";
|
|
207
|
+
if (buf.length >= 6 && buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x38) return "image/gif";
|
|
208
|
+
if (buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") return "image/webp";
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function describeContent(
|
|
213
|
+
blocks: McpContentBlock[] | undefined,
|
|
214
|
+
serverName?: string,
|
|
215
|
+
): {
|
|
141
216
|
text: string;
|
|
142
217
|
images: Array<{ data: string; mimeType: string }>;
|
|
143
218
|
} {
|
|
@@ -148,7 +223,9 @@ export function describeContent(blocks: McpContentBlock[] | undefined): {
|
|
|
148
223
|
if (block.type === "text" && typeof block.text === "string") {
|
|
149
224
|
texts.push(block.text);
|
|
150
225
|
} else if (block.type === "image" && block.data) {
|
|
151
|
-
|
|
226
|
+
const checked = validateImageData(block.data, block.mimeType);
|
|
227
|
+
if (checked.ok) images.push({ data: block.data, mimeType: checked.mimeType });
|
|
228
|
+
else texts.push(`[image content${serverName ? ` from ${serverName}` : ""} could not be decoded: ${checked.reason}, ${checked.bytes} bytes]`);
|
|
152
229
|
} else if (block.type === "resource" && block.resource) {
|
|
153
230
|
const { text, uri } = block.resource;
|
|
154
231
|
texts.push(text ?? `[resource ${uri ?? "(no uri)"}]`);
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project `.mcp.json` consent. A checked-in `.mcp.json` names commands that
|
|
3
|
+
* run on the user's machine the moment the repo is opened, so its servers
|
|
4
|
+
* connect only after the user approves them — Claude Code prompts "New MCP
|
|
5
|
+
* server found in .mcp.json" and remembers the answer in
|
|
6
|
+
* `enabledMcpjsonServers` / `disabledMcpjsonServers` /
|
|
7
|
+
* `enableAllProjectMcpServers`. One Code honours those three keys READ-ONLY
|
|
8
|
+
* (user and `.claude/settings.local.json` scopes — never the checked-in
|
|
9
|
+
* project `settings.json`, which would let a repo approve itself) and keeps its
|
|
10
|
+
* own answers under `~/.onecode`, keyed to a hash of each server's config so a
|
|
11
|
+
* changed command re-prompts (CC keys by name alone). Servers from
|
|
12
|
+
* `~/.claude.json` and plugins never prompt: the user wrote the first, and
|
|
13
|
+
* installing a plugin was the consent. Servers from `.claude/settings.local.json`
|
|
14
|
+
* ARE prompted (that file can be checked in — `git clone && onecode` would
|
|
15
|
+
* otherwise spawn its servers with no consent, review H1); CC never reads
|
|
16
|
+
* `mcpServers` from that file at all. Its `enableAll*`/`enabledMcpjsonServers`
|
|
17
|
+
* policy keys are honoured only when the file is not git-tracked, for the same
|
|
18
|
+
* reason.
|
|
19
|
+
*
|
|
20
|
+
* A "No" is persisted as a project-scope disable (`lib/mcp-overrides.ts`), so
|
|
21
|
+
* the server shows as disabled in `/mcp` and Enable there brings it back —
|
|
22
|
+
* that click is the consent, recorded here. Escape declines for this session
|
|
23
|
+
* only. Same shape as `hooks/trust.ts`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { execFile } from "node:child_process";
|
|
27
|
+
import { createHash } from "node:crypto";
|
|
28
|
+
import { promisify } from "node:util";
|
|
29
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
30
|
+
import { basename, dirname, join } from "node:path";
|
|
31
|
+
import { readSettingsFile, settingsPaths } from "../lib/claude-settings.ts";
|
|
32
|
+
import { boundConsentItems } from "../lib/consent-preview.ts";
|
|
33
|
+
import { oneCodeStateDir } from "../lib/paths.ts";
|
|
34
|
+
import type { McpServer } from "./config.ts";
|
|
35
|
+
|
|
36
|
+
const execFileAsync = promisify(execFile);
|
|
37
|
+
|
|
38
|
+
interface ProjectApprovals {
|
|
39
|
+
/** "Use this and all future MCP servers in this project" was chosen. */
|
|
40
|
+
enableAll?: boolean;
|
|
41
|
+
servers: Record<string, { configHash: string; approvedAt: string }>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ApprovalStore {
|
|
45
|
+
version: 1;
|
|
46
|
+
projects: Record<string, ProjectApprovals>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function approvalStorePath(): string {
|
|
50
|
+
return join(oneCodeStateDir(), "mcp", "project-approvals.json");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A server whose config a repository can ship (a project `.mcp.json` or a
|
|
55
|
+
* checked-in `.claude/settings.local.json`) — so it is consent-gated. A
|
|
56
|
+
* plugin's `.mcp.json` (also named `.mcp.json`) is excluded: installing the
|
|
57
|
+
* plugin was the consent.
|
|
58
|
+
*/
|
|
59
|
+
export function isProjectScopedServer(server: McpServer, pluginConfigPaths: ReadonlySet<string>): boolean {
|
|
60
|
+
if (pluginConfigPaths.has(server.source)) return false;
|
|
61
|
+
const base = basename(server.source);
|
|
62
|
+
return base === ".mcp.json" || base === "settings.local.json";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The directory the `.mcp.json` lives in — approvals are per config file, not per cwd. */
|
|
66
|
+
export function projectRootOf(server: McpServer): string {
|
|
67
|
+
return dirname(server.source);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function canonicalize(value: unknown): unknown {
|
|
71
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
72
|
+
if (value && typeof value === "object") {
|
|
73
|
+
return Object.fromEntries(
|
|
74
|
+
Object.entries(value as Record<string, unknown>)
|
|
75
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
76
|
+
.map(([key, inner]) => [key, canonicalize(inner)]),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Everything that decides what the server runs or talks to; `source`/`missingEnv` are not part of it. */
|
|
83
|
+
export function hashServerConfig(server: McpServer): string {
|
|
84
|
+
const material =
|
|
85
|
+
server.kind === "stdio"
|
|
86
|
+
? { kind: "stdio", command: server.command, args: server.args, env: server.env ?? {} }
|
|
87
|
+
: { kind: "http", url: server.url, headers: server.headers ?? {} };
|
|
88
|
+
return createHash("sha256").update(JSON.stringify(canonicalize(material))).digest("hex");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Claude Code's own answers, read from its settings (user + local scopes only). */
|
|
92
|
+
export interface ClaudeMcpjsonPolicy {
|
|
93
|
+
enableAll: boolean;
|
|
94
|
+
enabled: Set<string>;
|
|
95
|
+
disabled: Set<string>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function stringArray(value: unknown): string[] {
|
|
99
|
+
return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether `.claude/settings.local.json`'s policy keys may be trusted: true ONLY
|
|
104
|
+
* when git ran to a verdict and the file is not a tracked file (untracked in a
|
|
105
|
+
* repo, or no repo at all — either way not something a `git clone` shipped).
|
|
106
|
+
* FAILS CLOSED: git missing, a timeout, or any result we cannot interpret
|
|
107
|
+
* returns false, so a checked-in file that we could not prove untracked is not
|
|
108
|
+
* trusted (review H1 — a fail-open here silently re-opens the self-approval
|
|
109
|
+
* hole). Async so it never blocks the event loop from the connect path
|
|
110
|
+
* (findings §15: never do slow synchronous work on the startup path).
|
|
111
|
+
*/
|
|
112
|
+
async function localPolicyFileIsTrusted(cwd: string, path: string): Promise<boolean> {
|
|
113
|
+
try {
|
|
114
|
+
await execFileAsync("git", ["ls-files", "--error-unmatch", "--", path], { cwd, timeout: 5_000 });
|
|
115
|
+
return false; // exit 0 → tracked → do not trust its policy keys
|
|
116
|
+
} catch (error) {
|
|
117
|
+
// execFile rejects with `code` = the numeric exit code on a non-zero exit
|
|
118
|
+
// (1 = untracked in a repo, 128 = not a repo — both mean "not tracked, safe
|
|
119
|
+
// to trust"); a spawn failure gives a string code (e.g. "ENOENT") and a
|
|
120
|
+
// timeout gives a non-numeric code — those are "could not decide" → closed.
|
|
121
|
+
return typeof (error as { code?: unknown }).code === "number";
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function readClaudeMcpjsonPolicy(cwd: string, home: string): Promise<ClaudeMcpjsonPolicy> {
|
|
126
|
+
const paths = settingsPaths(cwd, home);
|
|
127
|
+
const policy: ClaudeMcpjsonPolicy = { enableAll: false, enabled: new Set(), disabled: new Set() };
|
|
128
|
+
// Deliberately not `paths.project`: a checked-in settings.json granting
|
|
129
|
+
// enableAllProjectMcpServers would be the repo approving its own servers.
|
|
130
|
+
// The local file is read only when it is provably not git-tracked (an
|
|
131
|
+
// untracked, user-authored file), for the same reason — a checked-in
|
|
132
|
+
// settings.local.json would otherwise approve the repo's own .mcp.json
|
|
133
|
+
// servers (review H1).
|
|
134
|
+
const sources = [paths.user];
|
|
135
|
+
if (await localPolicyFileIsTrusted(cwd, paths.local)) sources.push(paths.local);
|
|
136
|
+
for (const path of sources) {
|
|
137
|
+
const file = readSettingsFile(path);
|
|
138
|
+
if (!file) continue;
|
|
139
|
+
if (typeof file.enableAllProjectMcpServers === "boolean") policy.enableAll = file.enableAllProjectMcpServers;
|
|
140
|
+
for (const name of stringArray(file.enabledMcpjsonServers)) policy.enabled.add(name);
|
|
141
|
+
for (const name of stringArray(file.disabledMcpjsonServers)) policy.disabled.add(name);
|
|
142
|
+
}
|
|
143
|
+
return policy;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readStore(storePath: string): ApprovalStore {
|
|
147
|
+
try {
|
|
148
|
+
const existing = JSON.parse(readFileSync(storePath, "utf-8")) as ApprovalStore;
|
|
149
|
+
if (existing && typeof existing === "object" && existing.projects) return existing;
|
|
150
|
+
} catch {
|
|
151
|
+
// Fresh store.
|
|
152
|
+
}
|
|
153
|
+
return { version: 1, projects: {} };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function writeStore(storePath: string, store: ApprovalStore): void {
|
|
157
|
+
try {
|
|
158
|
+
mkdirSync(dirname(storePath), { recursive: true });
|
|
159
|
+
writeFileSync(storePath, `${JSON.stringify(store, null, "\t")}\n`, { mode: 0o600 });
|
|
160
|
+
} catch {
|
|
161
|
+
// Approval still holds for this session; it will just re-prompt next run.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function readStoredApproval(projectRoot: string, storePath = approvalStorePath()): ProjectApprovals | undefined {
|
|
166
|
+
return readStore(storePath).projects[projectRoot];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Record consent for one server's current config. */
|
|
170
|
+
export function persistApproval(projectRoot: string, server: McpServer, storePath = approvalStorePath()): void {
|
|
171
|
+
const store = readStore(storePath);
|
|
172
|
+
const project = (store.projects[projectRoot] ??= { servers: {} });
|
|
173
|
+
project.servers[server.name] = { configHash: hashServerConfig(server), approvedAt: new Date().toISOString() };
|
|
174
|
+
writeStore(storePath, store);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Record "this and all future servers in this project". */
|
|
178
|
+
export function persistEnableAll(projectRoot: string, storePath = approvalStorePath()): void {
|
|
179
|
+
const store = readStore(storePath);
|
|
180
|
+
const project = (store.projects[projectRoot] ??= { servers: {} });
|
|
181
|
+
project.enableAll = true;
|
|
182
|
+
writeStore(storePath, store);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export const CHOICE_ALL = "Use this and all future MCP servers in this project";
|
|
186
|
+
export const CHOICE_THIS = "Use this MCP server";
|
|
187
|
+
export const CHOICE_THESE = "Use these MCP servers";
|
|
188
|
+
export const CHOICE_NO = "No";
|
|
189
|
+
|
|
190
|
+
/** Claude Code's dialog title for one or several newly found servers. */
|
|
191
|
+
export function promptTitle(names: string[]): string {
|
|
192
|
+
return names.length === 1
|
|
193
|
+
? `New MCP server found in .mcp.json: ${names[0]}`
|
|
194
|
+
: `${names.length} new MCP servers found in .mcp.json`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* One line per server naming what would run, for the dialog body. Commands and
|
|
199
|
+
* URLs are shown in full (a truncated one hides where a padded attack lives),
|
|
200
|
+
* env values are never echoed, but the variable NAMES a server's
|
|
201
|
+
* command/args/env/headers reference are listed (from `referencedEnv`, captured
|
|
202
|
+
* before expansion) — approving a URL should not silently ship a credential the
|
|
203
|
+
* header interpolates (review M5).
|
|
204
|
+
*/
|
|
205
|
+
export function describeServers(servers: McpServer[]): string {
|
|
206
|
+
const lines = servers.map((server) => {
|
|
207
|
+
const what =
|
|
208
|
+
server.kind === "stdio"
|
|
209
|
+
? `${server.name}: ${[server.command, ...server.args].join(" ")}`
|
|
210
|
+
: `${server.name}: ${server.url}`;
|
|
211
|
+
const vars = server.referencedEnv ?? [];
|
|
212
|
+
const headerNames = server.kind === "http" ? Object.keys(server.headers ?? {}) : [];
|
|
213
|
+
const notes = [
|
|
214
|
+
vars.length ? `uses ${vars.map((v) => `$${v}`).join(", ")}` : "",
|
|
215
|
+
headerNames.length ? `headers: ${headerNames.join(", ")}` : "",
|
|
216
|
+
].filter(Boolean);
|
|
217
|
+
return notes.length ? `${what}\n (${notes.join("; ")})` : what;
|
|
218
|
+
});
|
|
219
|
+
// Bound the modal: a very long command/url or many servers cannot flood it.
|
|
220
|
+
return boundConsentItems(lines, "review .mcp.json before approving");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface McpTrustDeps {
|
|
224
|
+
hasUI: boolean;
|
|
225
|
+
/** `ctx.ui.select` — returns the chosen option, or undefined on Escape. */
|
|
226
|
+
select: (title: string, options: string[]) => Promise<string | undefined>;
|
|
227
|
+
notify: (message: string) => void;
|
|
228
|
+
/** Persist an explicit "No" (project-scope disable), so it does not re-prompt every start. */
|
|
229
|
+
disable: (server: McpServer) => void;
|
|
230
|
+
storePath?: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface McpTrustOutcome {
|
|
234
|
+
/** Servers that may connect (includes every non-project-`.mcp.json` server). */
|
|
235
|
+
approved: McpServer[];
|
|
236
|
+
/** Project `.mcp.json` servers that must not connect, with why. */
|
|
237
|
+
withheld: { server: McpServer; reason: "declined" | "not-approved" | "disabled-by-claude-settings" }[];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Process-lifetime memory per project+server+hash, so /clear (a new session_start) does not re-ask a "No". */
|
|
241
|
+
const sessionDecisions = new Map<string, boolean>();
|
|
242
|
+
const pendingPrompts = new Map<string, Promise<string | undefined>>();
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Split servers into those that may connect and those withheld, prompting
|
|
246
|
+
* once (one dialog for every new project server) when there is a UI.
|
|
247
|
+
*/
|
|
248
|
+
export async function approveMcpServers(
|
|
249
|
+
servers: McpServer[],
|
|
250
|
+
pluginConfigPaths: ReadonlySet<string>,
|
|
251
|
+
cwd: string,
|
|
252
|
+
home: string,
|
|
253
|
+
deps: McpTrustDeps,
|
|
254
|
+
): Promise<McpTrustOutcome> {
|
|
255
|
+
const approved: McpServer[] = [];
|
|
256
|
+
const withheld: McpTrustOutcome["withheld"] = [];
|
|
257
|
+
const pending: McpServer[] = [];
|
|
258
|
+
// Reading CC's policy shells out to git (localPolicyFileIsTrusted), so only
|
|
259
|
+
// do it when a server actually needs consent — a session with only
|
|
260
|
+
// user-scope or plugin servers must not pay a git spawn at connect.
|
|
261
|
+
const anyProjectScoped = servers.some((s) => isProjectScopedServer(s, pluginConfigPaths));
|
|
262
|
+
const claude: ClaudeMcpjsonPolicy = anyProjectScoped
|
|
263
|
+
? await readClaudeMcpjsonPolicy(cwd, home)
|
|
264
|
+
: { enableAll: false, enabled: new Set<string>(), disabled: new Set<string>() };
|
|
265
|
+
|
|
266
|
+
for (const server of servers) {
|
|
267
|
+
if (!isProjectScopedServer(server, pluginConfigPaths)) {
|
|
268
|
+
approved.push(server);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (claude.disabled.has(server.name)) {
|
|
272
|
+
withheld.push({ server, reason: "disabled-by-claude-settings" });
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (claude.enableAll || claude.enabled.has(server.name)) {
|
|
276
|
+
approved.push(server);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const root = projectRootOf(server);
|
|
280
|
+
const hash = hashServerConfig(server);
|
|
281
|
+
const stored = readStoredApproval(root, deps.storePath);
|
|
282
|
+
if (stored?.enableAll || stored?.servers[server.name]?.configHash === hash) {
|
|
283
|
+
approved.push(server);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const remembered = sessionDecisions.get(`${root}:${server.name}:${hash}`);
|
|
287
|
+
if (remembered === true) approved.push(server);
|
|
288
|
+
else if (remembered === false) withheld.push({ server, reason: "declined" });
|
|
289
|
+
else pending.push(server);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (pending.length === 0) return { approved, withheld };
|
|
293
|
+
|
|
294
|
+
if (!deps.hasUI) {
|
|
295
|
+
for (const server of pending) withheld.push({ server, reason: "not-approved" });
|
|
296
|
+
deps.notify(
|
|
297
|
+
`Skipped ${pending.length} MCP server${pending.length === 1 ? "" : "s"} from .mcp.json (${pending.map((s) => s.name).join(", ")}): not yet approved and no UI to ask. Approve once in an interactive session, or set enabledMcpjsonServers in .claude/settings.local.json.`,
|
|
298
|
+
);
|
|
299
|
+
return { approved, withheld };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// One dialog for the batch; concurrent callers (a reconnect racing startup) share it.
|
|
303
|
+
const names = pending.map((s) => s.name);
|
|
304
|
+
const key = pending.map((s) => `${projectRootOf(s)}:${s.name}:${hashServerConfig(s)}`).join("|");
|
|
305
|
+
let prompt = pendingPrompts.get(key);
|
|
306
|
+
if (!prompt) {
|
|
307
|
+
const useLabel = pending.length === 1 ? CHOICE_THIS : CHOICE_THESE;
|
|
308
|
+
prompt = deps
|
|
309
|
+
.select(`${promptTitle(names)}\n\n${describeServers(pending)}\n\nThese run on your machine.`, [CHOICE_ALL, useLabel, CHOICE_NO])
|
|
310
|
+
.finally(() => pendingPrompts.delete(key));
|
|
311
|
+
pendingPrompts.set(key, prompt);
|
|
312
|
+
}
|
|
313
|
+
const choice = await prompt;
|
|
314
|
+
|
|
315
|
+
for (const server of pending) {
|
|
316
|
+
const root = projectRootOf(server);
|
|
317
|
+
const decisionKey = `${root}:${server.name}:${hashServerConfig(server)}`;
|
|
318
|
+
if (choice === CHOICE_ALL || choice === CHOICE_THIS || choice === CHOICE_THESE) {
|
|
319
|
+
sessionDecisions.set(decisionKey, true);
|
|
320
|
+
if (choice === CHOICE_ALL) persistEnableAll(root, deps.storePath);
|
|
321
|
+
persistApproval(root, server, deps.storePath);
|
|
322
|
+
approved.push(server);
|
|
323
|
+
} else {
|
|
324
|
+
sessionDecisions.set(decisionKey, false);
|
|
325
|
+
withheld.push({ server, reason: "declined" });
|
|
326
|
+
// An explicit "No" is remembered as a disable; Escape is this session only.
|
|
327
|
+
if (choice === CHOICE_NO) deps.disable(server);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (choice !== CHOICE_ALL && choice !== CHOICE_THIS && choice !== CHOICE_THESE) {
|
|
331
|
+
deps.notify(`MCP server${pending.length === 1 ? "" : "s"} from .mcp.json not connected (${names.join(", ")}); enable in /mcp.`);
|
|
332
|
+
}
|
|
333
|
+
return { approved, withheld };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Test seam. */
|
|
337
|
+
export function resetMcpTrustSessionState(): void {
|
|
338
|
+
sessionDecisions.clear();
|
|
339
|
+
pendingPrompts.clear();
|
|
340
|
+
}
|